first commit
This commit is contained in:
commit
25ab7a4f58
2
.codex/config.toml
Normal file
2
.codex/config.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[mcp_servers.idea]
|
||||
url = "http://127.0.0.1:64342/stream"
|
||||
10
.env.example
Normal file
10
.env.example
Normal file
@ -0,0 +1,10 @@
|
||||
DB_URL=jdbc:postgresql://localhost:5432/postgres
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=root
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
|
||||
# Optional overrides used by DatabaseConnectionTest:
|
||||
TEST_DB_URL=jdbc:postgresql://localhost:5432/postgres
|
||||
TEST_DB_USERNAME=root
|
||||
TEST_DB_PASSWORD=root
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
.kotlin
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
.idea/libraries/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
8
.idea/encodings.xml
generated
Normal file
8
.idea/encodings.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/kotlin" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/kotlinc.xml
generated
Normal file
7
.idea/kotlinc.xml
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="externalSystemId" value="Maven" />
|
||||
<option name="version" value="2.4.0" />
|
||||
</component>
|
||||
</project>
|
||||
14
.idea/misc.xml
generated
Normal file
14
.idea/misc.xml
generated
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/pom.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
14
.idea/runConfigurations/MainKt.xml
generated
Normal file
14
.idea/runConfigurations/MainKt.xml
generated
Normal file
@ -0,0 +1,14 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="MainKt" type="Application" factoryName="Application" nameIsGenerated="true">
|
||||
<envs>
|
||||
<env name="DB_URL" value="jdbc:postgresql://localhost:5432/postgres" />
|
||||
<env name="DB_USERNAME" value="root" />
|
||||
<env name="DB_PASSWORD" value="root" />
|
||||
</envs>
|
||||
<option name="MAIN_CLASS_NAME" value="MainKt" />
|
||||
<module name="bolts" />
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Project Rules
|
||||
|
||||
## General
|
||||
- Follow the existing code style and patterns.
|
||||
- Use npm for running project commands.
|
||||
- Keep code in TypeScript unless migration is required.
|
||||
|
||||
## Frontend Stack
|
||||
- Framework: Vue 3 + Vite
|
||||
- UI Library: Vuetify0
|
||||
- Enabled Features: ESLint, Vue Router, UnoCSS
|
||||
|
||||
## Backend Stack
|
||||
- Language: Java
|
||||
- Server: Javalin
|
||||
- Persistence: Postgres with JDBC
|
||||
- Maven for dependency management
|
||||
|
||||
|
||||
|
||||
## Tests
|
||||
Use JUnit 5 for backend unit test
|
||||
Use Playwright for frontend end-to-end tests
|
||||
34
frontend/.gitignore
vendored
Normal file
34
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
79
frontend/README.md
Normal file
79
frontend/README.md
Normal file
@ -0,0 +1,79 @@
|
||||
# frontend
|
||||
|
||||
Scaffolded with Vuetify CLI.
|
||||
|
||||
## ❗️ Documentation
|
||||
|
||||
- Primary docs: https://0.vuetifyjs.com/
|
||||
- Getting started guide: https://0.vuetifyjs.com/guide
|
||||
- Community support: https://community.vuetifyjs.com/
|
||||
- Issue tracker: https://issues.vuetifyjs.com/
|
||||
|
||||
## 🧱 Stack
|
||||
|
||||
- Framework: Vue 3 + Vite
|
||||
- UI Library: Vuetify0
|
||||
- Language: TypeScript
|
||||
- Package manager: npm
|
||||
|
||||
## 🧭 Start Here
|
||||
|
||||
- Main entry: `src/main.ts`
|
||||
- Main app component: `src/App.vue`
|
||||
- Main styles: `src/styles/`
|
||||
- Plugin setup: `src/plugins/`
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
- `src/main.ts` — application entry point
|
||||
- `src/App.vue` — root component
|
||||
- `src/components/` — reusable Vue components
|
||||
- `src/plugins/` — plugin registration and setup
|
||||
- `src/styles/` — global styles and theme settings
|
||||
- `public/` — static public files
|
||||
|
||||
## ✨ Enabled Features
|
||||
|
||||
- ESLint
|
||||
- Vue Router
|
||||
- UnoCSS
|
||||
|
||||
## 💿 Install
|
||||
|
||||
Use your selected package manager (npm) to install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🏗️ Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🧪 Available Scripts
|
||||
|
||||
- `npm run dev`
|
||||
- `npm run build`
|
||||
- `npm run preview`
|
||||
- `npm run build-only`
|
||||
- `npm run type-check`
|
||||
- `npm run lint`
|
||||
- `npm run lint:fix`
|
||||
|
||||
## 💪 Support Vuetify Development
|
||||
|
||||
This project uses Vuetify0 - an MIT licensed Open Source project. We are glad to welcome contributors and any support for ongoing development:
|
||||
|
||||
- Contribute to Vuetify and ecosystem projects: https://github.com/vuetifyjs
|
||||
- Request enterprise support: https://support.vuetifyjs.com/
|
||||
- Sponsor on GitHub: https://github.com/sponsors/vuetifyjs
|
||||
- Support on Open Collective: https://opencollective.com/vuetify
|
||||
2
frontend/env.d.ts
vendored
Normal file
2
frontend/env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-vue-layouts-next/client" />
|
||||
5
frontend/eslint.config.js
Normal file
5
frontend/eslint.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
import vuetify from 'eslint-config-vuetify'
|
||||
|
||||
export default vuetify({
|
||||
ts: true,
|
||||
})
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/png" href="/0.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vuetify0 - Headless Components for Vue 3</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
6218
frontend/package-lock.json
generated
Normal file
6218
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.2.10",
|
||||
"@vuetify/v0": "^1.0.0-beta.1",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@tsconfig/node22": "^22.0.5",
|
||||
"@types/node": "^24.12.0",
|
||||
"@unocss/transformer-directives": "^66.6.6",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-vuetify": "^4.3.4",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"typescript": "~5.9.3",
|
||||
"unocss": "^66.6.6",
|
||||
"unplugin-fonts": "^2.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
25
frontend/playwright.config.ts
Normal file
25
frontend/playwright.config.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
BIN
frontend/public/0.png
Normal file
BIN
frontend/public/0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
42
frontend/src/App.vue
Normal file
42
frontend/src/App.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-hidden="true" class="page-glow" />
|
||||
|
||||
<main class="min-h-screen p-5 flex items-center">
|
||||
<router-view />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
font-family: Roboto, sans-serif;
|
||||
color: var(--v0-on-background);
|
||||
background: var(--v0-background);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page-glow {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 15% 5%, color-mix(in srgb, var(--v0-primary) 22%, transparent), transparent 38%),
|
||||
radial-gradient(circle at 88% 88%, color-mix(in srgb, var(--v0-info) 14%, transparent), transparent 35%);
|
||||
}
|
||||
</style>
|
||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
6
frontend/src/assets/logo.svg
Normal file
6
frontend/src/assets/logo.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
|
||||
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
|
||||
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
|
||||
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
93
frontend/src/components/HelloWorld.vue
Normal file
93
frontend/src/components/HelloWorld.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { Selection } from '@vuetify/v0'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const components = [
|
||||
{ id: 'selection', label: 'Selection' },
|
||||
{ id: 'dialog', label: 'Dialog' },
|
||||
{ id: 'popover', label: 'Popover' },
|
||||
]
|
||||
|
||||
const selected = ref<string[]>(['selection'])
|
||||
|
||||
const links = [
|
||||
{ label: 'Getting Started', href: 'https://0.vuetifyjs.com/introduction/getting-started' },
|
||||
{ label: 'Guide', href: 'https://0.vuetifyjs.com/guide' },
|
||||
{ label: 'Components', href: 'https://0.vuetifyjs.com/components' },
|
||||
{ label: 'Composables', href: 'https://0.vuetifyjs.com/composables' },
|
||||
{ label: 'MCP Server', href: 'https://0.vuetifyjs.com/guide/vuetify-mcp' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto justify-self-center">
|
||||
<!-- Hero -->
|
||||
<div class="text-center mb-12">
|
||||
<img
|
||||
alt="Vuetify0 logo"
|
||||
class="mx-auto mb-4"
|
||||
height="80"
|
||||
src="https://cdn.vuetifyjs.com/docs/images/one/logos/vzero.svg"
|
||||
width="92"
|
||||
>
|
||||
<h1 class="text-4xl font-bold text-on-background mb-3">
|
||||
Vuetify0
|
||||
</h1>
|
||||
<p class="text-on-background opacity-60">
|
||||
Headless components for Vue 3
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="flex flex-wrap justify-center items-center gap-x-2 gap-y-1 mb-8">
|
||||
<template v-for="(link, index) in links" :key="link.label">
|
||||
<a
|
||||
class="text-sm text-primary hover:underline"
|
||||
:href="link.href"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ link.label }}<span class="sr-only"> (opens in new tab)</span>
|
||||
</a>
|
||||
<span v-if="index < links.length - 1" class="text-on-surface opacity-30">•</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Interactive Demo -->
|
||||
<div class="rounded-xl border border-subtle bg-surface p-6">
|
||||
<div class="text-xs font-medium text-on-surface opacity-50 uppercase tracking-wide mb-4">
|
||||
Live Demo — Selection
|
||||
</div>
|
||||
|
||||
<Selection.Root v-slot="{ attrs }" v-model="selected" multiple>
|
||||
<div aria-label="Select components" v-bind="attrs" class="flex gap-3 mb-4">
|
||||
<Selection.Item
|
||||
v-for="item in components"
|
||||
:key="item.id"
|
||||
v-slot="{ isSelected, toggle }"
|
||||
:value="item.id"
|
||||
>
|
||||
<button
|
||||
:aria-selected="isSelected"
|
||||
class="flex-1 px-4 py-3 rounded-lg border font-medium transition-colors"
|
||||
:class="isSelected
|
||||
? 'bg-primary text-on-primary border-primary'
|
||||
: 'bg-surface-tint hover:bg-surface-variant border-subtle'"
|
||||
role="option"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
@keydown.enter.prevent="toggle"
|
||||
@keydown.space.prevent="toggle"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</Selection.Item>
|
||||
</div>
|
||||
|
||||
<p aria-live="polite" class="text-sm text-on-surface opacity-50" role="status">
|
||||
Selected: <span class="font-mono">{{ selected.length > 0 ? selected.join(', ') : 'none' }}</span>
|
||||
</p>
|
||||
</Selection.Root>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
35
frontend/src/components/README.md
Normal file
35
frontend/src/components/README.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Components
|
||||
|
||||
Vue template files in this folder are automatically imported.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
|
||||
|
||||
The following example assumes a component located at `src/components/MyComponent.vue`:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<MyComponent />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
</script>
|
||||
```
|
||||
|
||||
When your template is rendered, the component's import will automatically be inlined, which renders to this:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<MyComponent />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MyComponent from '@/components/MyComponent.vue'
|
||||
</script>
|
||||
```
|
||||
24
frontend/src/main.ts
Normal file
24
frontend/src/main.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* main.ts
|
||||
*
|
||||
* Bootstraps Vuetify and other plugins then mounts the App`
|
||||
*/
|
||||
|
||||
// Composables
|
||||
import { createApp } from 'vue'
|
||||
|
||||
// Plugins
|
||||
import { registerPlugins } from '@/plugins'
|
||||
|
||||
// Components
|
||||
import App from './App.vue'
|
||||
|
||||
// Styles
|
||||
import 'virtual:uno.css'
|
||||
import 'unfonts.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
registerPlugins(app)
|
||||
|
||||
app.mount('#app')
|
||||
329
frontend/src/pages/index.vue
Normal file
329
frontend/src/pages/index.vue
Normal file
@ -0,0 +1,329 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface LoginResponse {
|
||||
username: string
|
||||
}
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const authenticatedUser = ref('')
|
||||
|
||||
async function login () {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
})
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
errorMessage.value = 'Invalid username or password'
|
||||
return
|
||||
}
|
||||
|
||||
const result = await response.json() as LoginResponse
|
||||
authenticatedUser.value = result.username
|
||||
password.value = ''
|
||||
} catch {
|
||||
errorMessage.value = 'Unable to reach the server. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset () {
|
||||
authenticatedUser.value = ''
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="login-shell">
|
||||
<div class="brand">
|
||||
<div aria-hidden="true" class="brand-mark">
|
||||
B
|
||||
</div>
|
||||
|
||||
<span>Bolts</span>
|
||||
</div>
|
||||
|
||||
<div v-if="authenticatedUser" class="success-panel" role="status">
|
||||
<div aria-hidden="true" class="success-icon">
|
||||
✓
|
||||
</div>
|
||||
|
||||
<p class="eyebrow">
|
||||
Signed in
|
||||
</p>
|
||||
|
||||
<h1>Welcome, {{ authenticatedUser }}</h1>
|
||||
|
||||
<p class="supporting">
|
||||
You’re securely connected to your Bolts workspace.
|
||||
</p>
|
||||
|
||||
<button class="secondary-button" type="button" @click="reset">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-else class="login-card" @submit.prevent="login">
|
||||
<div class="card-heading">
|
||||
<p class="eyebrow">
|
||||
Welcome back
|
||||
</p>
|
||||
|
||||
<h1>Sign in to your account</h1>
|
||||
|
||||
<p class="supporting">
|
||||
Enter your credentials to continue to Bolts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
|
||||
<input
|
||||
id="username"
|
||||
v-model.trim="username"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
name="username"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
|
||||
<div class="password-field">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
autocomplete="current-password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
>
|
||||
|
||||
<button
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||
class="visibility-button"
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
{{ showPassword ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error-message" role="alert">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<button class="primary-button" :disabled="loading" type="submit">
|
||||
{{ loading ? 'Signing in…' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="footer-copy">
|
||||
Protected access · Bolts
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-shell {
|
||||
width: min(100%, 28rem);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
place-items: center;
|
||||
border-radius: 0.7rem;
|
||||
color: var(--v0-on-primary);
|
||||
background: var(--v0-primary);
|
||||
}
|
||||
|
||||
.login-card,
|
||||
.success-panel {
|
||||
padding: 2.25rem;
|
||||
border: 1px solid var(--v0-divider);
|
||||
border-radius: 1.25rem;
|
||||
background: color-mix(in srgb, var(--v0-surface) 94%, transparent);
|
||||
box-shadow: 0 24px 70px rgb(0 0 0 / 24%);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.55rem;
|
||||
color: var(--v0-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.supporting {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--v0-on-surface-variant);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-top: 1.15rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
padding: 0 0.9rem;
|
||||
border: 1px solid var(--v0-divider);
|
||||
border-radius: 0.7rem;
|
||||
outline: none;
|
||||
color: var(--v0-on-surface);
|
||||
background: var(--v0-surface-variant);
|
||||
transition: border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--v0-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--v0-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--v0-on-surface-variant);
|
||||
}
|
||||
|
||||
.password-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-field input {
|
||||
padding-right: 4.5rem;
|
||||
}
|
||||
|
||||
.visibility-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
padding: 0.25rem;
|
||||
border: 0;
|
||||
color: var(--v0-primary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--v0-error);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
margin-top: 1.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.7rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
color: var(--v0-on-primary);
|
||||
background: var(--v0-primary);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid var(--v0-divider);
|
||||
color: var(--v0-on-surface);
|
||||
background: var(--v0-surface-variant);
|
||||
}
|
||||
|
||||
.success-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
display: grid;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
margin: 0 auto 1.25rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--v0-on-success);
|
||||
background: var(--v0-success);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.footer-copy {
|
||||
margin: 1.5rem 0 0;
|
||||
color: var(--v0-on-surface-variant);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card,
|
||||
.success-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
frontend/src/plugins/README.md
Normal file
3
frontend/src/plugins/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Plugins
|
||||
|
||||
Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.
|
||||
17
frontend/src/plugins/index.ts
Normal file
17
frontend/src/plugins/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import router from '../router';
|
||||
/**
|
||||
* plugins/index.ts
|
||||
*
|
||||
* Automatically included in `./src/main.ts`
|
||||
*/
|
||||
|
||||
// Types
|
||||
import type { App } from 'vue'
|
||||
|
||||
// Plugins
|
||||
import vuetify from './vuetify'
|
||||
|
||||
export function registerPlugins (app: App) {
|
||||
app.use(vuetify)
|
||||
app.use(router);
|
||||
}
|
||||
58
frontend/src/plugins/vuetify.ts
Normal file
58
frontend/src/plugins/vuetify.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { createThemePlugin } from '@vuetify/v0'
|
||||
|
||||
export default createThemePlugin({
|
||||
default: 'dark',
|
||||
target: 'html',
|
||||
themes: {
|
||||
light: {
|
||||
dark: false,
|
||||
colors: {
|
||||
'primary': '#3b82f6',
|
||||
'secondary': '#64748b',
|
||||
'error': '#ef4444',
|
||||
'info': '#1867c0',
|
||||
'success': '#22c55e',
|
||||
'warning': '#f59e0b',
|
||||
'background': '#f5f5f5',
|
||||
'surface': '#ffffff',
|
||||
'surface-tint': '#f5f5f5',
|
||||
'surface-variant': '#eeeeee',
|
||||
'divider': '#e0e0e0',
|
||||
'on-primary': '#ffffff',
|
||||
'on-secondary': '#ffffff',
|
||||
'on-error': '#ffffff',
|
||||
'on-info': '#ffffff',
|
||||
'on-success': '#ffffff',
|
||||
'on-warning': '#1a1a1a',
|
||||
'on-background': '#212121',
|
||||
'on-surface': '#212121',
|
||||
'on-surface-variant': '#666666',
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
dark: true,
|
||||
colors: {
|
||||
'primary': '#c4b5fd',
|
||||
'secondary': '#94a3b8',
|
||||
'error': '#f87171',
|
||||
'info': '#38bdf8',
|
||||
'success': '#4ade80',
|
||||
'warning': '#fb923c',
|
||||
'background': '#121212',
|
||||
'surface': '#1a1a1a',
|
||||
'surface-tint': '#2a2a2a',
|
||||
'surface-variant': '#1e1e1e',
|
||||
'divider': '#404040',
|
||||
'on-primary': '#1a1a1a',
|
||||
'on-secondary': '#1a1a1a',
|
||||
'on-error': '#1a1a1a',
|
||||
'on-info': '#1a1a1a',
|
||||
'on-success': '#1a1a1a',
|
||||
'on-warning': '#1a1a1a',
|
||||
'on-background': '#e0e0e0',
|
||||
'on-surface': '#e0e0e0',
|
||||
'on-surface-variant': '#a0a0a0',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
21
frontend/src/router/index.ts
Normal file
21
frontend/src/router/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* router/index.ts
|
||||
*
|
||||
* Manual routes for ./src/pages/*.vue
|
||||
*/
|
||||
|
||||
// Composables
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Index from '@/pages/index.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: Index,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
22
frontend/tests/login.spec.ts
Normal file
22
frontend/tests/login.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('shows an error for invalid credentials', async ({ page }) => {
|
||||
await page.getByLabel('Username').fill('admin')
|
||||
await page.getByRole('textbox', { name: 'Password', exact: true }).fill('wrong-password')
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await expect(page.getByRole('alert')).toHaveText('Invalid username or password')
|
||||
})
|
||||
|
||||
test('logs in with the initialized admin user', async ({ page }) => {
|
||||
await page.getByLabel('Username').fill('admin')
|
||||
await page.getByRole('textbox', { name: 'Password', exact: true }).fill('admin')
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Welcome, admin' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
|
||||
})
|
||||
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/__tests__/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
"plugins": [
|
||||
"vue-router/volar/sfc-typed-router",
|
||||
"vue-router/volar/sfc-route-blocks"
|
||||
]
|
||||
}
|
||||
}
|
||||
11
frontend/tsconfig.json
Normal file
11
frontend/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
frontend/tsconfig.node.json
Normal file
19
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
67
frontend/unocss.config.ts
Normal file
67
frontend/unocss.config.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { defineConfig, presetWind4 } from 'unocss'
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetWind4(),
|
||||
],
|
||||
// Wind4 uses color-mix with oklch - opacity modifiers (bg-surface/50)
|
||||
// don't work with CSS variables. Use color-mix shortcuts instead.
|
||||
shortcuts: {
|
||||
'bg-glass-surface': '[background:color-mix(in_srgb,var(--v0-surface)_70%,transparent)] backdrop-blur-12',
|
||||
'border-subtle': '[border-color:color-mix(in_srgb,var(--v0-divider)_50%,transparent)]',
|
||||
'sr-only': 'absolute w-1px h-1px p-0 -m-1px overflow-hidden whitespace-nowrap border-0',
|
||||
},
|
||||
preflights: [
|
||||
{
|
||||
getCSS: () => `
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--v0-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
theme: {
|
||||
colors: {
|
||||
'primary': 'var(--v0-primary)',
|
||||
'secondary': 'var(--v0-secondary)',
|
||||
'error': 'var(--v0-error)',
|
||||
'info': 'var(--v0-info)',
|
||||
'success': 'var(--v0-success)',
|
||||
'warning': 'var(--v0-warning)',
|
||||
'background': 'var(--v0-background)',
|
||||
'surface': 'var(--v0-surface)',
|
||||
'surface-tint': 'var(--v0-surface-tint)',
|
||||
'surface-variant': 'var(--v0-surface-variant)',
|
||||
'divider': 'var(--v0-divider)',
|
||||
'on-primary': 'var(--v0-on-primary)',
|
||||
'on-secondary': 'var(--v0-on-secondary)',
|
||||
'on-error': 'var(--v0-on-error)',
|
||||
'on-info': 'var(--v0-on-info)',
|
||||
'on-success': 'var(--v0-on-success)',
|
||||
'on-warning': 'var(--v0-on-warning)',
|
||||
'on-background': 'var(--v0-on-background)',
|
||||
'on-surface': 'var(--v0-on-surface)',
|
||||
'on-surface-variant': 'var(--v0-on-surface-variant)',
|
||||
},
|
||||
},
|
||||
})
|
||||
31
frontend/vite.config.mts
Normal file
31
frontend/vite.config.mts
Normal file
@ -0,0 +1,31 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import UnoCSS from 'unocss/vite'
|
||||
import Fonts from 'unplugin-fonts/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [Vue(), Fonts({
|
||||
fontsource: {
|
||||
families: [
|
||||
{
|
||||
name: 'Roboto',
|
||||
weights: [100, 300, 400, 500, 700, 900],
|
||||
styles: ['normal', 'italic'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}), UnoCSS()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('src', import.meta.url)),
|
||||
},
|
||||
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:7070',
|
||||
},
|
||||
},
|
||||
})
|
||||
133
pom.xml
Normal file
133
pom.xml
Normal file
@ -0,0 +1,133 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>dev.mduchene</groupId>
|
||||
<artifactId>bolts</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<kotlin.code.style>official</kotlin.code.style>
|
||||
<kotlin.compiler.jvmTarget>1.8</kotlin.compiler.jvmTarget>
|
||||
<kotlin.version>2.4.0</kotlin.version>
|
||||
<application.mainClass>MainKt</application.mainClass>
|
||||
<jib.version>3.5.2</jib.version>
|
||||
<jib.from.image>eclipse-temurin:21-jre</jib.from.image>
|
||||
<jib.to.image>${project.artifactId}:${project.version}</jib.to.image>
|
||||
</properties>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>mavenCentral</id>
|
||||
<url>https://repo1.maven.org/maven2/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-plugin</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>compile</id>
|
||||
<phase>compile</phase>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>test-compile</id>
|
||||
<phase>test-compile</phase>
|
||||
<goals>
|
||||
<goal>test-compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.22.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>2.22.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>1.6.0</version>
|
||||
<configuration>
|
||||
<mainClass>${application.mainClass}</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.google.cloud.tools</groupId>
|
||||
<artifactId>jib-maven-plugin</artifactId>
|
||||
<version>${jib.version}</version>
|
||||
<configuration>
|
||||
<from>
|
||||
<image>${jib.from.image}</image>
|
||||
</from>
|
||||
<to>
|
||||
<image>${jib.to.image}</image>
|
||||
</to>
|
||||
<container>
|
||||
<mainClass>${application.mainClass}</mainClass>
|
||||
<ports>
|
||||
<port>7070</port>
|
||||
</ports>
|
||||
</container>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>7.2.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>2.0.17</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mindrot</groupId>
|
||||
<artifactId>jbcrypt</artifactId>
|
||||
<version>0.4</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-test-junit5</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
51
src/main/kotlin/Main.kt
Normal file
51
src/main/kotlin/Main.kt
Normal file
@ -0,0 +1,51 @@
|
||||
import dev.mduchene.bolts.persistence.Database
|
||||
import dev.mduchene.bolts.persistence.DatabaseConfig
|
||||
import dev.mduchene.bolts.user.LoginService
|
||||
import dev.mduchene.bolts.user.UserRepository
|
||||
import io.javalin.Javalin
|
||||
import io.javalin.http.HttpStatus
|
||||
|
||||
fun main() {
|
||||
val database = Database(DatabaseConfig.fromEnvironment())
|
||||
database.initialize()
|
||||
val loginService = LoginService(UserRepository(database))
|
||||
loginService.initializeAdmin()
|
||||
|
||||
Javalin.create { config ->
|
||||
config.routes.apply {
|
||||
get("/") { ctx -> ctx.result("Hello World") }
|
||||
post("/api/login") { ctx ->
|
||||
val username = ctx.formParam("username").orEmpty()
|
||||
val password = ctx.formParam("password").orEmpty()
|
||||
|
||||
if (loginService.authenticate(username, password)) {
|
||||
ctx.contentType("application/json")
|
||||
.result("""{"username":"${username.toJsonString()}"}""")
|
||||
} else {
|
||||
ctx.status(HttpStatus.UNAUTHORIZED)
|
||||
.contentType("application/json")
|
||||
.result("""{"message":"Invalid username or password"}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(7070)
|
||||
}
|
||||
|
||||
private fun String.toJsonString() = buildString {
|
||||
for (character in this@toJsonString) {
|
||||
when (character) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
'\b' -> append("\\b")
|
||||
'\u000C' -> append("\\f")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
else -> if (character.code < 0x20) {
|
||||
append("\\u%04x".format(character.code))
|
||||
} else {
|
||||
append(character)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt
Normal file
27
src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt
Normal file
@ -0,0 +1,27 @@
|
||||
package dev.mduchene.bolts.persistence
|
||||
|
||||
import java.sql.Connection
|
||||
import java.sql.DriverManager
|
||||
|
||||
class Database(private val config: DatabaseConfig) {
|
||||
fun getConnection(): Connection =
|
||||
DriverManager.getConnection(config.url, config.username, config.password)
|
||||
|
||||
fun initialize() {
|
||||
getConnection().use { connection ->
|
||||
connection.createStatement().use { statement ->
|
||||
statement.execute(CREATE_USERS_TABLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CREATE_USERS_TABLE = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package dev.mduchene.bolts.persistence
|
||||
|
||||
data class DatabaseConfig(
|
||||
val url: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
) {
|
||||
init {
|
||||
require(url.isNotBlank()) { "Database URL must not be blank" }
|
||||
require(username.isNotBlank()) { "Database username must not be blank" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromEnvironment(environment: Map<String, String> = System.getenv()) = DatabaseConfig(
|
||||
url = environment.required("DB_URL"),
|
||||
username = environment.required("DB_USERNAME"),
|
||||
password = environment.required("DB_PASSWORD"),
|
||||
)
|
||||
|
||||
private fun Map<String, String>.required(name: String): String =
|
||||
this[name]?.takeIf { it.isNotBlank() }
|
||||
?: error("Missing required environment variable: $name")
|
||||
}
|
||||
}
|
||||
24
src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt
Normal file
24
src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt
Normal file
@ -0,0 +1,24 @@
|
||||
package dev.mduchene.bolts.user
|
||||
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
class LoginService(
|
||||
private val users: UserRepository,
|
||||
private val environment: Map<String, String> = System.getenv(),
|
||||
) {
|
||||
fun initializeAdmin() {
|
||||
val username = environment.valueOrDefault("ADMIN_USERNAME", "admin")
|
||||
if (users.findByUsername(username) == null) {
|
||||
val password = environment.valueOrDefault("ADMIN_PASSWORD", "admin")
|
||||
users.create(username, BCrypt.hashpw(password, BCrypt.gensalt()))
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(username: String, password: String): Boolean {
|
||||
val user = users.findByUsername(username) ?: return false
|
||||
return BCrypt.checkpw(password, user.passwordHash)
|
||||
}
|
||||
|
||||
private fun Map<String, String>.valueOrDefault(name: String, default: String) =
|
||||
this[name]?.takeIf { it.isNotBlank() } ?: default
|
||||
}
|
||||
7
src/main/kotlin/dev/mduchene/bolts/user/User.kt
Normal file
7
src/main/kotlin/dev/mduchene/bolts/user/User.kt
Normal file
@ -0,0 +1,7 @@
|
||||
package dev.mduchene.bolts.user
|
||||
|
||||
data class User(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
val passwordHash: String,
|
||||
)
|
||||
48
src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt
Normal file
48
src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt
Normal file
@ -0,0 +1,48 @@
|
||||
package dev.mduchene.bolts.user
|
||||
|
||||
import dev.mduchene.bolts.persistence.Database
|
||||
import java.sql.ResultSet
|
||||
|
||||
class UserRepository(private val database: Database) {
|
||||
fun create(username: String, passwordHash: String): User {
|
||||
val sql = """
|
||||
INSERT INTO users (username, password_hash)
|
||||
VALUES (?, ?)
|
||||
RETURNING id, username, password_hash
|
||||
""".trimIndent()
|
||||
|
||||
database.getConnection().use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
statement.setString(1, username)
|
||||
statement.setString(2, passwordHash)
|
||||
statement.executeQuery().use { result ->
|
||||
check(result.next()) { "User insert returned no row" }
|
||||
return result.toUser()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun findByUsername(username: String): User? {
|
||||
val sql = """
|
||||
SELECT id, username, password_hash
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
""".trimIndent()
|
||||
|
||||
database.getConnection().use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
statement.setString(1, username)
|
||||
statement.executeQuery().use { result ->
|
||||
return if (result.next()) result.toUser() else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ResultSet.toUser() = User(
|
||||
id = getLong("id"),
|
||||
username = getString("username"),
|
||||
passwordHash = getString("password_hash"),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package dev.mduchene.bolts.persistence
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DatabaseConnectionTest {
|
||||
@Test
|
||||
fun `connects to Postgres`() {
|
||||
val database = Database(
|
||||
DatabaseConfig(
|
||||
url = environmentOrDefault("TEST_DB_URL", "jdbc:postgresql://localhost:5432/postgres"),
|
||||
username = environmentOrDefault("TEST_DB_USERNAME", "root"),
|
||||
password = environmentOrDefault("TEST_DB_PASSWORD", "root"),
|
||||
),
|
||||
)
|
||||
|
||||
database.getConnection().use { connection ->
|
||||
connection.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT 1").use { result ->
|
||||
assertTrue(result.next())
|
||||
assertEquals(1, result.getInt(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun environmentOrDefault(name: String, defaultValue: String): String =
|
||||
System.getenv(name)?.takeIf { it.isNotBlank() } ?: defaultValue
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user