Files
Hoard/GUI/src/routes/admin/AdminUsers.vue
T
Jonas 178bc8731e Add admin user creation & must-change flag
Add server and UI support for creating admin users and forcing password change. API: introduce CreateUserRequest contract and add CreateNewAppUser endpoint in AppUserController; extend ChangeUserRequest with MustChangePassword and handle role assignment and detailed error responses (409/422/400). Frontend: new CreateUserDialog component, integrate it into AdminUsers list, and add createAdminUser service with CreateAdminUserError and payload handling; include mustChangePassword in update payloads and EditUserDialog. UI polish: enhanced app banner enter/leave animations in Layout.vue and add auto-dismiss timers/cleanup to appBanners store to limit and auto-remove banners.
2026-05-03 15:56:28 +02:00

674 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { fetchAdminUsers, type AdminUser } from '@/services/adminUsers'
import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
import StatusPill from '@/components/ui/StatusPill.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import CreateUserDialog from '@/components/admin/CreateUserDialog.vue'
const router = useRouter()
const appBannersStore = useAppBannersStore()
const isLoading = ref(true)
const errorMessage = ref('')
const users = ref<AdminUser[]>([])
const searchQuery = ref('')
const isCreateDialogOpen = ref(false)
const hasUsers = computed(() => users.value.length > 0)
const activeUserCount = computed(() => users.value.filter((user) => user.isActive).length)
const passwordChangeCount = computed(
() => users.value.filter((user) => user.mustChangePassword).length,
)
const adminCount = computed(
() =>
users.value.filter((user) =>
user.roles.some((role) => role.toLowerCase() === 'admin'),
).length,
)
const filteredUsers = computed(() => {
const query = (searchQuery.value ?? '').trim().toLowerCase()
if (query.length === 0) {
return users.value
}
return users.value.filter((user) => {
if (user.userName.toLowerCase().includes(query)) {
return true
}
if (user.id.toLowerCase().includes(query)) {
return true
}
return user.roles.some((role) => role.toLowerCase().includes(query))
})
})
function userInitials(user: AdminUser) {
const name = user.userName?.trim() ?? ''
if (name.length === 0) {
return '·'
}
const parts = name.split(/[\s._-]+/).filter((part) => part.length > 0)
if (parts.length === 0) {
return name.slice(0, 2).toUpperCase()
}
if (parts.length === 1) {
const first = parts[0] as string
return first.slice(0, 2).toUpperCase()
}
const first = parts[0] as string
const second = parts[1] as string
return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase()
}
function formatRoles(roles: string[]): string {
return roles.length > 0 ? roles.join(', ') : 'Keine Rolle'
}
async function loadUsers() {
isLoading.value = true
errorMessage.value = ''
try {
users.value = await fetchAdminUsers()
} catch (error) {
if (error instanceof AuthRequestError) {
errorMessage.value = error.message
if (error.status === 401) {
await router.replace({
name: 'Login',
query: { redirect: '/admin/users' },
})
return
}
if (error.status === 403) {
await router.replace({ name: 'Forbidden' })
return
}
} else {
errorMessage.value = 'Benutzerliste konnte nicht geladen werden.'
}
appBannersStore.pushError(errorMessage.value, 'Benutzerverwaltung')
} finally {
isLoading.value = false
}
}
async function openUserDetail(userId: string) {
await router.push({ name: 'AdminUserDetail', params: { userId } })
}
function openCreateDialog() {
isCreateDialogOpen.value = true
}
function handleUserCreated(user: AdminUser) {
users.value = [...users.value, user]
}
onMounted(() => {
void loadUsers()
})
</script>
<template>
<v-container fluid class="admin-users-page ui-page">
<header class="admin-users-header ui-panel ui-panel-gradient">
<div class="admin-users-header__copy">
<p class="ui-kicker ui-kicker--wide">Adminbereich</p>
<h1>Benutzerverwaltung</h1>
<p>Alle Hoard-Konten mit Rollen, Status und Passwortwechselpflicht read-only.</p>
</div>
<div class="admin-users-header__actions ui-action-row">
<v-text-field
v-model="searchQuery"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-magnify"
placeholder="Benutzer, Rollen oder ID suchen"
hide-details
clearable
class="admin-users-search"
/>
<v-btn
variant="elevated"
color="primary"
prepend-icon="mdi-account-plus-outline"
:disabled="isLoading"
@click="openCreateDialog"
>
Benutzer anlegen
</v-btn>
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@click="loadUsers"
>
Neu laden
</v-btn>
</div>
</header>
<section v-if="!isLoading && !errorMessage" class="admin-users-stats" aria-label="Benutzerübersicht">
<article class="admin-users-stat">
<span class="ui-icon-tile">
<v-icon icon="mdi-account-group-outline" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Konten</p>
<p class="admin-users-stat__value">{{ users.length }}</p>
</div>
</article>
<article class="admin-users-stat">
<span class="ui-icon-tile">
<v-icon icon="mdi-account-check-outline" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Aktiv</p>
<p class="admin-users-stat__value">{{ activeUserCount }}</p>
</div>
</article>
<article class="admin-users-stat">
<span class="ui-icon-tile">
<v-icon icon="mdi-shield-account-outline" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Admins</p>
<p class="admin-users-stat__value">{{ adminCount }}</p>
</div>
</article>
<article class="admin-users-stat">
<span class="ui-icon-tile">
<v-icon icon="mdi-lock-reset" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Passwortwechsel</p>
<p class="admin-users-stat__value">{{ passwordChangeCount }}</p>
</div>
</article>
</section>
<v-alert v-if="errorMessage" type="error">
{{ errorMessage }}
</v-alert>
<p v-else-if="isLoading" class="admin-users-loading">Benutzer werden geladen </p>
<EmptyState
v-else-if="!hasUsers"
icon="mdi-account-question-outline"
title="Keine Benutzer gefunden"
>
Aktuell sind keine Konten vorhanden. Ein neuer Account muss vom Admin manuell angelegt werden.
</EmptyState>
<section v-else class="admin-users-listing ui-panel">
<header class="admin-users-listing__head ui-toolbar">
<div>
<p class="admin-users-listing__title">Benutzer</p>
<p class="admin-users-listing__meta">{{ filteredUsers.length }} von {{ users.length }} angezeigt</p>
</div>
</header>
<p v-if="filteredUsers.length === 0" class="admin-users-empty-search">
Keine Treffer für {{ searchQuery }}".
</p>
<div v-else class="admin-users-table-wrap">
<v-table class="admin-users-table">
<thead>
<tr>
<th>Benutzer</th>
<th>Rollen</th>
<th>Aktiv</th>
<th>Passwort</th>
<th class="admin-users-col-actions">Aktion</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.id">
<td>
<div class="admin-users-cell-user">
<UserAvatar class="admin-users-cell-user__avatar" :initials="userInitials(user)" />
<div>
<p class="admin-users-cell-user__name">{{ user.userName || '(ohne Benutzername)' }}</p>
<p class="admin-users-cell-user__id">{{ user.id }}</p>
</div>
</div>
</td>
<td>{{ formatRoles(user.roles) }}</td>
<td>
<StatusPill :variant="user.isActive ? 'success' : 'danger'">
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</StatusPill>
</td>
<td>
<StatusPill :variant="user.mustChangePassword ? 'warning' : 'info'">
{{ user.mustChangePassword ? 'Erforderlich' : 'Aktuell' }}
</StatusPill>
</td>
<td class="admin-users-col-actions">
<v-btn
size="small"
variant="outlined"
prepend-icon="mdi-arrow-right"
@click="openUserDetail(user.id)"
>
Details
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</div>
<div v-if="filteredUsers.length > 0" class="admin-users-mobile-list" aria-label="Benutzerliste">
<article v-for="user in filteredUsers" :key="user.id" class="admin-users-mobile-card">
<header class="admin-users-mobile-head">
<UserAvatar class="admin-users-mobile-avatar" :initials="userInitials(user)" />
<div>
<p class="admin-users-mobile-label">Benutzer</p>
<h2>{{ user.userName || '(ohne Benutzername)' }}</h2>
<p class="admin-users-mobile-id">{{ user.id }}</p>
</div>
</header>
<dl class="admin-users-mobile-details">
<div>
<dt>Rollen</dt>
<dd>{{ formatRoles(user.roles) }}</dd>
</div>
<div>
<dt>Aktiv</dt>
<dd>
<StatusPill :variant="user.isActive ? 'success' : 'danger'">
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</StatusPill>
</dd>
</div>
<div>
<dt>Passwortwechsel</dt>
<dd>
<StatusPill :variant="user.mustChangePassword ? 'warning' : 'info'">
{{ user.mustChangePassword ? 'Erforderlich' : 'Nein' }}
</StatusPill>
</dd>
</div>
</dl>
<v-btn
variant="outlined"
prepend-icon="mdi-arrow-right"
block
@click="openUserDetail(user.id)"
>
Details öffnen
</v-btn>
</article>
</div>
</section>
<CreateUserDialog v-model="isCreateDialogOpen" @created="handleUserCreated" />
</v-container>
</template>
<style scoped>
.admin-users-page {
--ui-page-width: 1200px;
}
/* ---------- Header ---------- */
.admin-users-header {
--ui-gradient-angle: 120deg;
--ui-gradient-start: color-mix(in srgb, var(--color-primary-100) 55%, var(--color-surface) 45%);
--ui-gradient-end: var(--color-surface);
--ui-gradient-end-stop: 65%;
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--space-6);
align-items: end;
padding: var(--space-7) var(--space-8);
border-radius: var(--radius-xl);
}
.admin-users-header__copy h1 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.admin-users-header__copy p {
margin: 0;
color: var(--color-text-secondary);
}
.admin-users-header__actions {
justify-content: flex-end;
align-items: center;
}
.admin-users-search {
flex: 1 1 280px;
min-width: 220px;
max-width: 380px;
}
/* ---------- Stats ---------- */
.admin-users-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
}
.admin-users-stat {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 95%, var(--color-surface-alt) 5%),
var(--color-surface)
);
box-shadow: var(--shadow-xs);
}
.admin-users-stat__label,
.admin-users-stat__value {
margin: 0;
}
.admin-users-stat__label {
color: var(--color-text-muted);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.admin-users-stat__value {
color: var(--color-text);
font-size: var(--font-size-2xl);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.1;
}
/* ---------- Listing ---------- */
.admin-users-listing {
padding: 0;
overflow: hidden;
}
.admin-users-listing__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.admin-users-listing__title {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-md);
font-weight: 600;
}
.admin-users-listing__meta {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.admin-users-empty-search {
margin: 0;
padding: var(--space-6);
color: var(--color-text-secondary);
text-align: center;
}
.admin-users-loading {
margin: 0;
color: var(--color-text-secondary);
}
.admin-users-table-wrap {
overflow-x: auto;
}
.admin-users-mobile-list {
display: none;
}
.admin-users-table {
min-width: 920px;
border: 0;
border-radius: 0;
}
.admin-users-cell-user {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
min-width: 0;
}
.admin-users-cell-user__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-primary-600), var(--color-primary-800));
color: var(--color-text-on-primary);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: 0.02em;
}
.admin-users-cell-user__name,
.admin-users-cell-user__id {
margin: 0;
}
.admin-users-cell-user__name {
color: var(--color-text);
font-weight: 600;
}
.admin-users-cell-user__id {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-2xs);
overflow-wrap: anywhere;
}
.admin-users-col-actions {
text-align: right;
white-space: nowrap;
}
.ui-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
@media (prefers-reduced-motion: no-preference) {
.admin-users-header,
.admin-users-stat,
.admin-users-listing {
animation: ui-soft-enter 280ms both;
}
.admin-users-stat:nth-child(2) { animation-delay: 60ms; }
.admin-users-stat:nth-child(3) { animation-delay: 120ms; }
.admin-users-stat:nth-child(4) { animation-delay: 180ms; }
.admin-users-listing { animation-delay: 220ms; }
}
@media (width <= 1100px) {
.admin-users-header {
grid-template-columns: 1fr;
align-items: flex-start;
}
.admin-users-header__actions {
justify-content: flex-start;
}
.admin-users-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 960px) {
.admin-users-header {
padding: var(--space-6);
}
}
@media (width <= 600px) {
.admin-users-header {
padding: var(--space-5);
}
.admin-users-search {
flex: 1 1 100%;
max-width: none;
}
.admin-users-stats {
grid-template-columns: 1fr;
}
.admin-users-listing {
overflow: hidden;
}
.admin-users-table-wrap {
display: none;
}
.admin-users-mobile-list {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
}
.admin-users-mobile-card {
display: grid;
gap: var(--space-4);
min-width: 0;
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-surface);
box-shadow: var(--shadow-xs);
}
.admin-users-mobile-head {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
min-width: 0;
}
.admin-users-mobile-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-primary-600), var(--color-primary-800));
color: var(--color-text-on-primary);
font-size: var(--font-size-sm);
font-weight: 700;
}
.admin-users-mobile-label,
.admin-users-mobile-head h2,
.admin-users-mobile-id,
.admin-users-mobile-details {
margin: 0;
}
.admin-users-mobile-label {
color: var(--color-text-muted);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.admin-users-mobile-head h2 {
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
overflow-wrap: anywhere;
}
.admin-users-mobile-id {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-2xs);
overflow-wrap: anywhere;
}
.admin-users-mobile-details {
display: grid;
gap: var(--space-3);
}
.admin-users-mobile-details div {
display: grid;
gap: var(--space-1);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.admin-users-mobile-details div:last-child {
padding-bottom: 0;
border-bottom: none;
}
.admin-users-mobile-details dt {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.admin-users-mobile-details dd {
margin: 0;
color: var(--color-text);
overflow-wrap: anywhere;
}
}
</style>