WP-01: authentication, user management and application shell
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:
@ -1,5 +1,8 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import Toast from './components/Toast.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
62
frontend/src/api/client.ts
Normal file
62
frontend/src/api/client.ts
Normal 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
28
frontend/src/api/types.ts
Normal 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
|
||||
}
|
||||
49
frontend/src/components/AppShell.vue
Normal file
49
frontend/src/components/AppShell.vue
Normal 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>
|
||||
18
frontend/src/components/Toast.vue
Normal file
18
frontend/src/components/Toast.vue
Normal 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>
|
||||
104
frontend/src/components/UserForm.vue
Normal file
104
frontend/src/components/UserForm.vue
Normal 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>
|
||||
6
frontend/src/pages/DashboardPage.vue
Normal file
6
frontend/src/pages/DashboardPage.vue
Normal 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>
|
||||
73
frontend/src/pages/LoginPage.vue
Normal file
73
frontend/src/pages/LoginPage.vue
Normal 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>
|
||||
8
frontend/src/pages/PlaceholderPage.vue
Normal file
8
frontend/src/pages/PlaceholderPage.vue
Normal 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>
|
||||
178
frontend/src/pages/UsersPage.vue
Normal file
178
frontend/src/pages/UsersPage.vue
Normal 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>
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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 }> = []
|
||||
|
||||
43
frontend/src/stores/auth.ts
Normal file
43
frontend/src/stores/auth.ts
Normal 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 }
|
||||
})
|
||||
23
frontend/src/stores/toast.ts
Normal file
23
frontend/src/stores/toast.ts
Normal 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),
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user