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