first commit

This commit is contained in:
Maxime Duchêne-Savard 2026-07-28 14:32:08 -04:00
commit 25ab7a4f58
43 changed files with 7689 additions and 0 deletions

2
.codex/config.toml Normal file
View File

@ -0,0 +1,2 @@
[mcp_servers.idea]
url = "http://127.0.0.1:64342/stream"

10
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-vue-layouts-next/client" />

View File

@ -0,0 +1,5 @@
import vuetify from 'eslint-config-vuetify'
export default vuetify({
ts: true,
})

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View 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"
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

42
frontend/src/App.vue Normal file
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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

View 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>

View 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
View 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')

View 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">
Youre 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>

View 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.

View 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);
}

View 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',
},
},
},
})

View 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

View 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()
})

View 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
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View 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
View 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
View 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
View 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
View 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)
}
}
}
}

View 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
)
"""
}
}

View File

@ -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")
}
}

View 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
}

View File

@ -0,0 +1,7 @@
package dev.mduchene.bolts.user
data class User(
val id: Long,
val username: String,
val passwordHash: String,
)

View 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"),
)
}

View File

@ -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
}