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
+162
View File
@@ -0,0 +1,162 @@
import { AuthRequestError } from '@/services/authSession'
export interface AdminUser {
id: string
userName: string
roles: string[]
isActive: boolean
mustChangePassword: boolean
}
interface ApiMessageResponse {
message?: unknown
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function normalizeAdminUser(value: unknown): AdminUser | null {
if (!isObject(value)) {
return null
}
const { id, userName, roles, isActive, mustChangePassword } = value
if (typeof id !== 'string') {
return null
}
const normalizedRoles = Array.isArray(roles)
? roles
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: []
return {
id,
userName: typeof userName === 'string' ? userName : '',
roles: normalizedRoles,
isActive: Boolean(isActive),
mustChangePassword: Boolean(mustChangePassword),
}
}
async function readApiMessage(response: Response): Promise<string | null> {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
return null
}
try {
const payload = (await response.json()) as ApiMessageResponse
if (typeof payload.message === 'string' && payload.message.trim().length > 0) {
return payload.message.trim()
}
} catch {
return null
}
return null
}
function toAdminUserError(error: unknown, fallbackMessage: string): AuthRequestError {
if (error instanceof AuthRequestError) {
return error
}
return new AuthRequestError(fallbackMessage, 0)
}
export async function fetchAdminUsers(): Promise<AdminUser[]> {
let response: Response
try {
response = await fetch('/auth/user', {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} catch (error) {
throw toAdminUserError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401) {
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', response.status)
}
if (response.status === 403) {
throw new AuthRequestError('Du bist angemeldet, aber nicht als Admin autorisiert.', response.status)
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Benutzerliste konnte nicht geladen werden.',
response.status,
)
}
const payload: unknown = await response.json()
if (!Array.isArray(payload)) {
throw new AuthRequestError('Antwortformat von /auth/user ist ungültig.', response.status)
}
const users = payload.map(normalizeAdminUser)
if (users.some((user) => user === null)) {
throw new AuthRequestError('Antwortformat von /auth/user ist ungültig.', response.status)
}
return users as AdminUser[]
}
export async function fetchAdminUserById(userId: string): Promise<AdminUser> {
const normalizedId = userId.trim()
if (normalizedId.length === 0) {
throw new AuthRequestError('Ungültige Benutzer-ID.', 400)
}
let response: Response
try {
response = await fetch(`/auth/user/${encodeURIComponent(normalizedId)}`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} catch (error) {
throw toAdminUserError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401) {
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', response.status)
}
if (response.status === 403) {
throw new AuthRequestError('Du bist angemeldet, aber nicht als Admin autorisiert.', response.status)
}
if (response.status === 404) {
throw new AuthRequestError('Benutzer wurde nicht gefunden.', response.status)
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Benutzerdetails konnten nicht geladen werden.',
response.status,
)
}
const payload: unknown = await response.json()
const user = normalizeAdminUser(payload)
if (!user) {
throw new AuthRequestError('Antwortformat von /auth/user/{id} ist ungültig.', response.status)
}
return user
}