WP-01: authentication, user management and application shell
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Argon2id password hashing, JWT access tokens with rotating HttpOnly refresh
cookies and reuse detection, login rate limiting, auth audit log, bootstrap
admin, admin-only user CRUD and password reset, OpenAPI spec. Vue login page,
auth store with automatic token refresh, route guards, sidebar shell with
toasts and placeholder pages, user management page.

All tests green: 40 backend, 12 Vitest, 4 Playwright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 21:37:14 +02:00
parent 83aa500f5d
commit f1136fdf3d
32 changed files with 1899 additions and 48 deletions

View File

@ -1,5 +1,8 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import Toast from './components/Toast.vue'
</script>
<template>
<Toast />
<RouterView />
</template>

View File

@ -0,0 +1,62 @@
import { useAuthStore } from '../stores/auth'
import type { TokenResponse } from './types'
export class ApiError extends Error {
status: number
code: string
constructor(status: number, code: string, message: string) {
super(message)
this.status = status
this.code = code
}
}
async function parse(res: Response): Promise<unknown> {
if (res.status === 204) return undefined
const text = await res.text()
return text ? JSON.parse(text) : undefined
}
async function toError(res: Response): Promise<ApiError> {
const body = (await parse(res).catch(() => undefined)) as
{ error?: string; message?: string } | undefined
return new ApiError(res.status, body?.error ?? 'unknown', body?.message ?? res.statusText)
}
/** Refresh the access token via the HttpOnly cookie. Returns false if it failed. */
export async function refreshAccessToken(): Promise<boolean> {
const auth = useAuthStore()
const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin' })
if (!res.ok) {
auth.clear()
return false
}
const data = (await parse(res)) as TokenResponse
auth.setSession(data)
return true
}
async function request<T>(method: string, path: string, body?: unknown, retry = true): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = {}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (auth.accessToken) headers['Authorization'] = `Bearer ${auth.accessToken}`
const res = await fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
})
if (res.status === 401 && retry && !path.startsWith('/api/auth/')) {
if (await refreshAccessToken()) return request<T>(method, path, body, false)
throw new ApiError(401, 'unauthorized', 'session expired')
}
if (!res.ok) throw await toError(res)
return (await parse(res)) as T
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
}

28
frontend/src/api/types.ts Normal file
View File

@ -0,0 +1,28 @@
export type Role = 'admin' | 'user'
export interface User {
id: string
email: string
display_name: string
role: Role
is_active: boolean
created_at?: string
}
export interface TokenResponse {
access_token: string
user: User
}
export interface CreateUserPayload {
email: string
display_name: string
password: string
role: Role
}
export interface UpdateUserPayload {
display_name?: string
role?: Role
is_active?: boolean
}

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const router = useRouter()
const nav = [
{ to: '/', label: 'Dashboard' },
{ to: '/updates', label: 'Updates' },
{ to: '/vulnerabilities', label: 'Vulnerabilities' },
{ to: '/backups', label: 'Backups' },
{ to: '/users', label: 'Users', admin: true },
{ to: '/settings', label: 'Settings' },
]
async function signOut() {
await auth.logout()
router.push('/login')
}
</script>
<template>
<div class="flex min-h-screen bg-gray-50 text-gray-900">
<aside class="flex w-56 flex-col border-r border-gray-200 bg-white">
<div class="px-5 py-4 text-lg font-semibold">SoftVisor Monitoring</div>
<nav class="flex-1 space-y-1 px-3">
<template v-for="item in nav" :key="item.to">
<RouterLink
v-if="!item.admin || auth.isAdmin"
:to="item.to"
class="block rounded-md px-3 py-2 text-sm hover:bg-gray-100"
active-class="bg-gray-100 font-medium"
:exact-active-class="item.to === '/' ? 'bg-gray-100 font-medium' : undefined"
>
{{ item.label }}
</RouterLink>
</template>
</nav>
<div class="border-t border-gray-200 px-5 py-4 text-sm">
<div class="truncate font-medium">{{ auth.user?.display_name }}</div>
<div class="truncate text-gray-500">{{ auth.user?.email }}</div>
<button class="mt-2 text-blue-600 hover:underline" @click="signOut">Sign out</button>
</div>
</aside>
<main class="flex-1 p-8">
<RouterView />
</main>
</div>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import { useToastStore } from '../stores/toast'
const toasts = useToastStore()
</script>
<template>
<div class="fixed right-4 top-4 z-50 space-y-2">
<div
v-for="t in toasts.items"
:key="t.id"
role="status"
class="rounded-md px-4 py-2 text-sm text-white shadow"
:class="t.kind === 'error' ? 'bg-red-600' : 'bg-green-600'"
>
{{ t.message }}
</div>
</div>
</template>

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { CreateUserPayload, Role, UpdateUserPayload, User } from '../api/types'
const props = defineProps<{ mode: 'create' | 'edit'; user?: User; busy?: boolean }>()
const emit = defineEmits<{ submit: [payload: CreateUserPayload | UpdateUserPayload]; cancel: [] }>()
const email = ref(props.user?.email ?? '')
const displayName = ref(props.user?.display_name ?? '')
const password = ref('')
const role = ref<Role>(props.user?.role ?? 'user')
const isActive = ref(props.user?.is_active ?? true)
const error = ref('')
function submit() {
error.value = ''
if (props.mode === 'create') {
if (password.value.length < 12) {
error.value = 'Password must have at least 12 characters'
return
}
emit('submit', {
email: email.value,
display_name: displayName.value,
password: password.value,
role: role.value,
})
} else {
emit('submit', { display_name: displayName.value, role: role.value, is_active: isActive.value })
}
}
</script>
<template>
<form class="space-y-4" @submit.prevent="submit">
<div v-if="mode === 'create'">
<label for="uf-email" class="block text-sm font-medium">Email</label>
<input
id="uf-email"
v-model="email"
name="email"
type="email"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label for="uf-name" class="block text-sm font-medium">Display name</label>
<input
id="uf-name"
v-model="displayName"
name="display_name"
type="text"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div v-if="mode === 'create'">
<label for="uf-password" class="block text-sm font-medium">Password</label>
<input
id="uf-password"
v-model="password"
name="password"
type="password"
required
autocomplete="new-password"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label for="uf-role" class="block text-sm font-medium">Role</label>
<select
id="uf-role"
v-model="role"
name="role"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<label v-if="mode === 'edit'" class="flex items-center gap-2 text-sm">
<input v-model="isActive" name="is_active" type="checkbox" />
Active
</label>
<p v-if="error" role="alert" class="text-sm text-red-600">{{ error }}</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="emit('cancel')"
>
Cancel
</button>
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{{ mode === 'create' ? 'Create' : 'Save' }}
</button>
</div>
</form>
</template>

View File

@ -0,0 +1,6 @@
<template>
<h1 class="text-2xl font-semibold">Dashboard</h1>
<p class="mt-2 text-gray-600">
Server overview, update status, vulnerabilities and backups will appear here.
</p>
</template>

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const email = ref('')
const password = ref('')
const error = ref('')
const busy = ref(false)
async function submit() {
error.value = ''
busy.value = true
try {
await auth.login(email.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
router.push(redirect)
} catch (e) {
const code = (e as { code?: string }).code
error.value =
code === 'invalid_credentials'
? 'Invalid email or password'
: code === 'inactive_user'
? 'This account is deactivated'
: code === 'rate_limited'
? 'Too many attempts, please wait a minute'
: 'Login failed, please try again'
} finally {
busy.value = false
}
}
</script>
<template>
<main class="flex min-h-screen items-center justify-center bg-gray-50">
<form class="w-full max-w-sm space-y-4 rounded-lg bg-white p-8 shadow" @submit.prevent="submit">
<h1 class="text-xl font-semibold">SoftVisor Monitoring</h1>
<div>
<label for="email" class="block text-sm font-medium">Email</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="username"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium">Password</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="current-password"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<p v-if="error" role="alert" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="busy"
class="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
Sign in
</button>
</form>
</main>
</template>

View File

@ -0,0 +1,8 @@
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<h1 class="text-2xl font-semibold">{{ title }}</h1>
<p class="mt-2 text-gray-600">Not implemented yet.</p>
</template>

View File

@ -0,0 +1,178 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, ApiError } from '../api/client'
import type { CreateUserPayload, UpdateUserPayload, User } from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
import UserForm from '../components/UserForm.vue'
import StatusBadge from '../components/StatusBadge.vue'
const auth = useAuthStore()
const toast = useToastStore()
const users = ref<User[]>([])
const loading = ref(true)
const busy = ref(false)
const dialog = ref<
{ mode: 'create' } | { mode: 'edit'; user: User } | { mode: 'password'; user: User } | null
>(null)
const newPassword = ref('')
async function load() {
loading.value = true
try {
users.value = await api.get<User[]>('/api/users')
} finally {
loading.value = false
}
}
onMounted(() => {
if (auth.isAdmin) load()
})
function fail(e: unknown) {
toast.error(e instanceof ApiError ? e.message : 'Request failed')
}
async function submit(payload: CreateUserPayload | UpdateUserPayload) {
if (!dialog.value || dialog.value.mode === 'password') return
busy.value = true
try {
if (dialog.value.mode === 'create') {
await api.post('/api/users', payload)
toast.success('User created')
} else {
await api.patch(`/api/users/${dialog.value.user.id}`, payload)
toast.success('User updated')
}
dialog.value = null
await load()
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
async function resetPassword() {
if (!dialog.value || dialog.value.mode !== 'password') return
busy.value = true
try {
await api.post(`/api/users/${dialog.value.user.id}/password`, { password: newPassword.value })
toast.success('Password reset')
dialog.value = null
newPassword.value = ''
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
</script>
<template>
<div v-if="!auth.isAdmin">
<h1 class="text-2xl font-semibold">Users</h1>
<p class="mt-2 text-gray-600">You do not have permission to manage users.</p>
</div>
<div v-else>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Users</h1>
<button
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
@click="dialog = { mode: 'create' }"
>
New user
</button>
</div>
<p v-if="loading" class="mt-6 text-gray-500">Loading…</p>
<table v-else class="mt-6 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">Email</th>
<th>Name</th>
<th>Role</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id" class="border-b border-gray-100">
<td class="py-2">{{ u.email }}</td>
<td>{{ u.display_name }}</td>
<td class="capitalize">{{ u.role }}</td>
<td>
<StatusBadge
:status="u.is_active ? 'ok' : 'error'"
:label="u.is_active ? 'active' : 'inactive'"
/>
</td>
<td class="space-x-3 text-right">
<button
class="text-blue-600 hover:underline"
@click="dialog = { mode: 'edit', user: u }"
>
Edit
</button>
<button
class="text-blue-600 hover:underline"
@click="dialog = { mode: 'password', user: u }"
>
Reset password
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="dialog" class="fixed inset-0 flex items-center justify-center bg-black/30">
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-lg" role="dialog">
<template v-if="dialog.mode === 'password'">
<h2 class="mb-4 text-lg font-semibold">Reset password for {{ dialog.user.email }}</h2>
<form class="space-y-4" @submit.prevent="resetPassword">
<div>
<label for="np" class="block text-sm font-medium">New password</label>
<input
id="np"
v-model="newPassword"
type="password"
required
minlength="12"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="dialog = null"
>
Cancel
</button>
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white"
>
Reset
</button>
</div>
</form>
</template>
<template v-else>
<h2 class="mb-4 text-lg font-semibold">
{{ dialog.mode === 'create' ? 'New user' : 'Edit user' }}
</h2>
<UserForm
:key="dialog.mode === 'edit' ? dialog.user.id : 'new'"
:mode="dialog.mode"
:user="dialog.mode === 'edit' ? dialog.user : undefined"
:busy="busy"
@submit="submit"
@cancel="dialog = null"
/>
</template>
</div>
</div>
</div>
</template>

View File

@ -1,7 +1,44 @@
import { createRouter, createWebHistory } from 'vue-router'
import HealthPage from './pages/HealthPage.vue'
import { useAuthStore } from './stores/auth'
import { refreshAccessToken } from './api/client'
import AppShell from './components/AppShell.vue'
import LoginPage from './pages/LoginPage.vue'
import DashboardPage from './pages/DashboardPage.vue'
import UsersPage from './pages/UsersPage.vue'
import PlaceholderPage from './pages/PlaceholderPage.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: HealthPage }],
routes: [
{ path: '/login', component: LoginPage, meta: { public: true } },
{
path: '/',
component: AppShell,
children: [
{ path: '', name: 'dashboard', component: DashboardPage },
{ path: 'updates', component: PlaceholderPage, props: { title: 'Updates' } },
{
path: 'vulnerabilities',
component: PlaceholderPage,
props: { title: 'Vulnerabilities' },
},
{ path: 'backups', component: PlaceholderPage, props: { title: 'Backups' } },
{ path: 'users', component: UsersPage, meta: { admin: true } },
{ path: 'settings', component: PlaceholderPage, props: { title: 'Settings' } },
],
},
],
})
let sessionRestored = false
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (!sessionRestored) {
sessionRestored = true
if (!auth.isAuthenticated) await refreshAccessToken().catch(() => false)
}
if (to.meta.public) return auth.isAuthenticated ? '/' : true
if (!auth.isAuthenticated) return { path: '/login', query: { redirect: to.fullPath } }
return true
})

View File

@ -2,7 +2,9 @@ import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from './auth'
import { api } from '../api/client'
const admin = { id: '1', email: 'a@x.de', display_name: 'A', role: 'admin', is_active: true }
import type { User } from '../api/types'
const admin: User = { id: '1', email: 'a@x.de', display_name: 'A', role: 'admin', is_active: true }
function mockFetch(responses: Array<{ status: number; body?: unknown }>) {
const calls: Array<{ url: string; init: RequestInit }> = []

View File

@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { api, ApiError } from '../api/client'
import type { TokenResponse, User } from '../api/types'
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string | null>(null)
const user = ref<User | null>(null)
const isAuthenticated = computed(() => user.value !== null)
const isAdmin = computed(() => user.value?.role === 'admin')
function setSession(data: TokenResponse) {
accessToken.value = data.access_token
user.value = data.user
}
function clear() {
accessToken.value = null
user.value = null
}
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ email, password }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new ApiError(res.status, body.error ?? 'unknown', body.message ?? '')
setSession(body as TokenResponse)
}
async function logout() {
try {
await api.post('/api/auth/logout')
} finally {
clear()
}
}
return { accessToken, user, isAuthenticated, isAdmin, setSession, clear, login, logout }
})

View File

@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Toast {
id: number
kind: 'success' | 'error'
message: string
}
export const useToastStore = defineStore('toast', () => {
const items = ref<Toast[]>([])
let next = 1
function push(kind: Toast['kind'], message: string) {
const id = next++
items.value.push({ id, kind, message })
setTimeout(() => (items.value = items.value.filter((t) => t.id !== id)), 4000)
}
return {
items,
success: (m: string) => push('success', m),
error: (m: string) => push('error', m),
}
})