Add admin user management and password-change flow

Introduce full admin user listing/detail endpoints and a forced password-change flow. Backend: make CurrentUserResponse.UserName nullable and add ToCurrentUserResponseAsync extension; AppUserController now exposes GET /auth/user (list) and GET /auth/user/{id} (detail) using UserManager and Admin-only policy; AuthController uses the new mapper and after successful password change clears MustChangePassword, updates UpdatedAt and persists changes (with error handling) before updating security stamp. Frontend: add admin users pages (list + detail), ChangePassword page and route, adminUsers and enhanced authSession services (typed responses, changePassword API, error mapping), router guard to redirect users with mustChangePassword=true to the change-password flow, and show success banner on login after password change. UI tweaks: separate admin section in sidebar, add password-change entries in account menu, footer sizing fixes, and various layout/UX improvements. These changes enable admin account management and enforce secure password updates across the app.
This commit is contained in:
Jonas
2026-04-20 21:02:16 +02:00
parent b2984fcf1a
commit 14176a3ee2
14 changed files with 995 additions and 92 deletions
+50 -6
View File
@@ -8,6 +8,7 @@ import iconImage from '@/assets/images/icon.svg'
import { Visibility, routes } from '@/plugins/routesLayout'
import {
AuthRequestError,
ROLE_ADMIN,
fetchCurrentUser,
hasRole,
logout,
@@ -97,8 +98,18 @@ const sidebarRoutes = computed(() =>
)
}),
)
const firstAuthenticatedSidebarIndex = computed(() =>
sidebarRoutes.value.findIndex((item) => item.visible === Visibility.Authenticated),
const adminSidebarRoutes = computed(() =>
sidebarRoutes.value.filter(
(item) =>
item.visible === Visibility.Authorized &&
Array.isArray(item.requiredRoles) &&
item.requiredRoles.some((role) => role.trim().toLowerCase() === ROLE_ADMIN),
),
)
const primarySidebarRoutes = computed(() =>
sidebarRoutes.value.filter(
(item) => !adminSidebarRoutes.value.some((adminItem) => adminItem.path === item.path),
),
)
const footerRoutes = computed(() => routes.filter((x) => x.visible === Visibility.Footer))
@@ -290,6 +301,12 @@ watch(
</template>
<v-list density="compact" class="account-menu-list">
<v-list-item
prepend-icon="mdi-lock-reset"
title="Passwort ändern"
:to="{ name: 'ChangePassword' }"
/>
<v-divider />
<v-list-item
prepend-icon="mdi-logout"
:title="isLoggingOut ? 'Abmelden...' : 'Abmelden'"
@@ -316,6 +333,11 @@ watch(
title="Zum Dashboard"
:to="{ name: 'Dashboard' }"
/>
<v-list-item
prepend-icon="mdi-lock-reset"
title="Passwort ändern"
:to="{ name: 'ChangePassword' }"
/>
<v-divider />
<v-list-item
prepend-icon="mdi-logout"
@@ -363,13 +385,27 @@ watch(
</div>
<v-list nav :density="display.mobile.value ? 'default' : 'comfortable'" class="px-1">
<template v-for="(item, index) in sidebarRoutes" :key="item.path">
<v-divider
v-if="firstAuthenticatedSidebarIndex > 0 && index === firstAuthenticatedSidebarIndex"
class="my-2 mx-2"
<template v-for="item in primarySidebarRoutes" :key="item.path">
<v-list-item
:to="item.path"
:active="route.path === item.path"
:prepend-icon="item.icon"
:title="item.name"
class="hoard-nav-item"
rounded="lg"
link
/>
</template>
<template v-if="adminSidebarRoutes.length > 0">
<v-divider class="my-2 mx-2" />
<div class="drawer-section-head">
<p class="drawer-kicker hoard-kicker hoard-kicker--xs">Admin</p>
</div>
<v-list-item
v-for="item in adminSidebarRoutes"
:key="item.path"
:to="item.path"
:active="route.path === item.path"
:prepend-icon="item.icon"
@@ -586,6 +622,10 @@ watch(
border-top: 1px solid color-mix(in srgb, var(--color-border) 90%, white 10%);
}
.drawer-section-head {
padding: var(--space-2) var(--space-3) var(--space-1);
}
:deep(.hoard-nav-item) {
min-height: 44px;
}
@@ -606,6 +646,10 @@ watch(
}
.hoard-footer {
flex: 0 0 auto;
height: auto !important;
min-height: 0 !important;
max-height: 88px;
padding-inline: var(--space-6);
}