Wide tables stretched the whole page because the shell's main is a flex item without min-width:0 and no table had a scroll container, so the header and the summary tiles scrolled out of view. Every table now scrolls inside its own overflow-x-auto container, the sidebar no longer shrinks, and long CVE ids, versions, targets and image references are kept on one line or truncated with a title tooltip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
5.5 KiB
Vue
181 lines
5.5 KiB
Vue
<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>
|
|
<div v-else data-testid="users-scroll" class="overflow-x-auto">
|
|
<table 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>
|
|
|
|
<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>
|