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

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