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
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AuthRequestError, changePassword } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
const router = useRouter()
const appBannersStore = useAppBannersStore()
const oldPassword = ref('')
const newPassword = ref('')
const newPasswordConfirm = ref('')
const showOldPassword = ref(false)
const showNewPassword = ref(false)
const showNewPasswordConfirm = ref(false)
const isSubmitting = ref(false)
const errorMessage = ref('')
const submitDisabled = computed(() => {
return (
isSubmitting.value ||
oldPassword.value.length === 0 ||
newPassword.value.length === 0 ||
newPasswordConfirm.value.length === 0
)
})
async function handleSubmit() {
if (submitDisabled.value) {
errorMessage.value = 'Bitte alle Passwortfelder ausfüllen.'
return
}
if (newPassword.value !== newPasswordConfirm.value) {
errorMessage.value = 'Die neuen Passwörter stimmen nicht überein.'
return
}
isSubmitting.value = true
errorMessage.value = ''
try {
await changePassword({
oldPassword: oldPassword.value,
newPassword: newPassword.value,
newPasswordConfirm: newPasswordConfirm.value,
})
await router.replace({
name: 'Login',
query: { passwordChanged: '1' },
})
} catch (error) {
if (error instanceof AuthRequestError) {
errorMessage.value = error.message
if (error.status === 401 || error.status === 403) {
appBannersStore.pushError('Session abgelaufen. Bitte erneut anmelden.', 'Passwort ändern')
await router.replace({ name: 'Login' })
return
}
} else {
errorMessage.value = 'Passwortänderung fehlgeschlagen. Bitte versuche es erneut.'
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<v-container fluid class="change-password-page hoard-page hoard-page--centered">
<section class="change-password-shell hoard-panel hoard-shell-grid">
<header class="change-password-head">
<p class="hoard-kicker">Sicherheitsvorgabe</p>
<h1>Passwort ändern</h1>
<p>Hier kannst du ganz bequem dein Password aktualisieren.</p>
</header>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
border="start"
>
{{ errorMessage }}
</v-alert>
<v-form class="change-password-form" @submit.prevent="handleSubmit">
<v-text-field
v-model="oldPassword"
label="Altes Passwort"
:type="showOldPassword ? 'text' : 'password'"
variant="outlined"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showOldPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="current-password"
required
:disabled="isSubmitting"
@click:append-inner="showOldPassword = !showOldPassword"
/>
<v-divider class="change-password-divider" />
<p class="change-password-section-label">Neues Passwort</p>
<v-text-field
v-model="newPassword"
label="Neues Passwort"
:type="showNewPassword ? 'text' : 'password'"
variant="outlined"
prepend-inner-icon="mdi-lock-reset"
:append-inner-icon="showNewPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
required
:disabled="isSubmitting"
@click:append-inner="showNewPassword = !showNewPassword"
/>
<v-text-field
v-model="newPasswordConfirm"
label="Neues Passwort bestätigen"
:type="showNewPasswordConfirm ? 'text' : 'password'"
variant="outlined"
prepend-inner-icon="mdi-lock-check-outline"
:append-inner-icon="showNewPasswordConfirm ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
required
:disabled="isSubmitting"
@click:append-inner="showNewPasswordConfirm = !showNewPasswordConfirm"
/>
<p class="change-password-hint">
Nach erfolgreicher Änderung wirst du automatisch abgemeldet und meldest dich mit dem neuen Passwort wieder an.
</p>
<v-btn
type="submit"
color="primary"
size="large"
prepend-icon="mdi-content-save-outline"
:loading="isSubmitting"
:disabled="submitDisabled"
block
>
Passwort speichern
</v-btn>
</v-form>
</section>
</v-container>
</template>
<style scoped>
.change-password-page {
--hoard-shell-width: min(720px, 100%);
}
.change-password-shell {
gap: var(--space-4);
}
.change-password-head h1,
.change-password-head p {
margin: 0;
}
.change-password-head h1 {
margin-top: var(--space-2);
margin-bottom: var(--space-2);
}
.change-password-head p {
color: var(--color-text-secondary);
}
.change-password-form {
display: grid;
gap: var(--space-3);
}
.change-password-divider {
margin-top: var(--space-1);
margin-bottom: 0;
border-color: color-mix(in srgb, var(--color-border) 82%, white 18%);
}
.change-password-section-label {
margin: 0;
color: var(--color-primary-700);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.change-password-hint {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
@media (width <= 600px) {
.change-password-form {
gap: var(--space-2);
}
}
</style>