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:
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>
|
||||
Reference in New Issue
Block a user