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.
This commit is contained in:
Jonas
2026-05-03 15:56:28 +02:00
parent 1d00fb3a4b
commit 178bc8731e
9 changed files with 720 additions and 8 deletions
+58 -6
View File
@@ -910,19 +910,71 @@ watch(
line-height: 1.45;
}
.app-banner-transition-enter-active,
.app-banner-transition-leave-active {
transition: opacity 180ms ease, transform 180ms ease;
.app-banner-list {
perspective: 1200px;
}
.app-banner-transition-enter-active {
transition:
opacity 380ms cubic-bezier(0.22, 1, 0.36, 1),
transform 480ms cubic-bezier(0.22, 1, 0.36, 1),
filter 380ms ease-out;
will-change: opacity, transform, filter;
}
.app-banner-transition-leave-active {
transition:
opacity 320ms cubic-bezier(0.4, 0, 0.2, 1),
transform 380ms cubic-bezier(0.4, 0, 0.2, 1),
filter 320ms ease-in;
will-change: opacity, transform, filter;
}
.app-banner-transition-enter-from {
opacity: 0;
transform: translate3d(48px, 24px, 0) scale(0.92) rotateX(-12deg);
filter: blur(8px);
}
.app-banner-transition-enter-to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1) rotateX(0);
filter: blur(0);
}
.app-banner-transition-leave-from {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
filter: blur(0);
}
.app-banner-transition-enter-from,
.app-banner-transition-leave-to {
opacity: 0;
transform: translateY(10px);
transform: translate3d(72px, -8px, 0) scale(0.9);
filter: blur(6px);
}
.app-banner-transition-leave-active {
position: relative;
}
.app-banner-transition-move {
transition: transform 180ms ease;
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
.app-banner-transition-enter-active,
.app-banner-transition-leave-active,
.app-banner-transition-move {
transition: opacity 160ms ease;
}
.app-banner-transition-enter-from,
.app-banner-transition-leave-to {
opacity: 0;
transform: none;
filter: none;
}
}
/* ---------- Responsive ---------- */
@@ -0,0 +1,407 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
CreateAdminUserError,
FORBIDDEN_NOT_ADMIN_MESSAGE,
createAdminUser,
type AdminUser,
type CreateAdminUserPayload,
} from '@/services/adminUsers'
import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
interface Props {
modelValue: boolean
}
defineProps<Props>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'created', user: AdminUser): void
}>()
const route = useRoute()
const router = useRouter()
const appBannersStore = useAppBannersStore()
const formUserName = ref('')
const formStartPassword = ref('')
const formStartPasswordConfirm = ref('')
const formIsAdmin = ref(false)
const formIsActive = ref(true)
const showPassword = ref(false)
const isSubmitting = ref(false)
const errorMessage = ref('')
const passwordIssues = ref<string[]>([])
const userNameError = ref('')
const passwordError = ref('')
const passwordConfirmError = ref('')
function resetForm() {
formUserName.value = ''
formStartPassword.value = ''
formStartPasswordConfirm.value = ''
formIsAdmin.value = false
formIsActive.value = true
showPassword.value = false
isSubmitting.value = false
errorMessage.value = ''
passwordIssues.value = []
userNameError.value = ''
passwordError.value = ''
passwordConfirmError.value = ''
}
watch(
() => route.fullPath,
() => {
// safety: dialog should not survive a route change
},
)
function open(value: boolean) {
if (value) {
resetForm()
}
emit('update:modelValue', value)
}
const trimmedUserName = computed(() => formUserName.value.trim())
const passwordsMatch = computed(
() => formStartPassword.value.length > 0 && formStartPassword.value === formStartPasswordConfirm.value,
)
const passwordHints = computed(() => [
{ label: 'Mindestens 8 Zeichen', valid: formStartPassword.value.length >= 8 },
{ label: 'Buchstabe enthalten', valid: /[A-Za-zÄÖÜäöüß]/.test(formStartPassword.value) },
{ label: 'Ziffer enthalten', valid: /\d/.test(formStartPassword.value) },
{ label: 'Übereinstimmung mit Bestätigung', valid: passwordsMatch.value },
])
const canSubmit = computed(() => {
return (
!isSubmitting.value &&
trimmedUserName.value.length > 0 &&
formStartPassword.value.length > 0 &&
passwordsMatch.value
)
})
function clearFieldErrors(field: 'user' | 'password' | 'confirm') {
if (field === 'user' && userNameError.value) userNameError.value = ''
if (field === 'password') {
if (passwordError.value) passwordError.value = ''
if (passwordIssues.value.length > 0) passwordIssues.value = []
}
if (field === 'confirm' && passwordConfirmError.value) passwordConfirmError.value = ''
}
function close() {
if (isSubmitting.value) {
return
}
emit('update:modelValue', false)
}
async function submit() {
if (!canSubmit.value) {
if (trimmedUserName.value.length === 0) {
userNameError.value = 'Benutzername darf nicht leer sein.'
}
if (formStartPassword.value.length === 0) {
passwordError.value = 'Startpasswort darf nicht leer sein.'
} else if (!passwordsMatch.value) {
passwordConfirmError.value = 'Passwörter stimmen nicht überein.'
}
return
}
errorMessage.value = ''
passwordIssues.value = []
userNameError.value = ''
passwordError.value = ''
passwordConfirmError.value = ''
const payload: CreateAdminUserPayload = {
userName: trimmedUserName.value,
startPassword: formStartPassword.value,
isAdmin: formIsAdmin.value,
isActive: formIsActive.value,
}
isSubmitting.value = true
try {
const created = await createAdminUser(payload)
emit('created', created)
appBannersStore.push({
type: 'success',
message: `Benutzer „${created.userName}" wurde angelegt.`,
})
emit('update:modelValue', false)
await router.push({ name: 'AdminUserDetail', params: { userId: created.id } })
} catch (error) {
if (error instanceof CreateAdminUserError) {
errorMessage.value = error.message
if (error.status === 409) {
userNameError.value = error.message
} else if (error.status === 422) {
passwordIssues.value = error.fieldErrors
passwordError.value = error.message
} else if (error.status === 400) {
passwordIssues.value = error.fieldErrors
}
} else if (error instanceof AuthRequestError) {
if (error.status === 401) {
emit('update:modelValue', false)
await router.replace({
name: 'Login',
query: { redirect: route.fullPath },
})
return
}
if (error.status === 403 && error.message === FORBIDDEN_NOT_ADMIN_MESSAGE) {
emit('update:modelValue', false)
await router.replace({ name: 'Forbidden' })
return
}
errorMessage.value = error.message
} else {
errorMessage.value = 'Benutzer konnte nicht angelegt werden.'
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="560"
persistent
@update:model-value="(value: boolean) => open(value)"
>
<v-card class="create-user-dialog ui-panel">
<v-card-title class="create-user-dialog__title">
<p class="ui-kicker ui-kicker--xs">Adminbereich</p>
<h2>Neuen Benutzer anlegen</h2>
</v-card-title>
<v-card-text class="create-user-dialog__body">
<v-form @submit.prevent="submit">
<v-text-field
v-model="formUserName"
label="Benutzername"
prepend-inner-icon="mdi-account-outline"
autocomplete="off"
:error="!!userNameError"
:error-messages="userNameError"
:disabled="isSubmitting"
@update:model-value="() => clearFieldErrors('user')"
/>
<v-text-field
v-model="formStartPassword"
label="Startpasswort"
prepend-inner-icon="mdi-lock-outline"
:type="showPassword ? 'text' : 'password'"
:append-inner-icon="showPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
:error="!!passwordError"
:error-messages="passwordError"
:disabled="isSubmitting"
@click:append-inner="showPassword = !showPassword"
@update:model-value="() => clearFieldErrors('password')"
/>
<v-text-field
v-model="formStartPasswordConfirm"
label="Startpasswort bestätigen"
prepend-inner-icon="mdi-lock-check-outline"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
:error="!!passwordConfirmError"
:error-messages="passwordConfirmError"
:disabled="isSubmitting"
@update:model-value="() => clearFieldErrors('confirm')"
/>
<ul class="password-hints">
<li
v-for="hint in passwordHints"
:key="hint.label"
:class="['password-hint', { 'password-hint--valid': hint.valid }]"
>
<v-icon :icon="hint.valid ? 'mdi-check-circle' : 'mdi-circle-outline'" size="14" />
{{ hint.label }}
</li>
</ul>
<p class="create-user-dialog__hint">
<v-icon size="16" icon="mdi-information-outline" />
Der Benutzer muss das Passwort beim ersten Login ändern.
</p>
<v-switch
v-model="formIsActive"
color="primary"
hide-details
:label="formIsActive ? 'Konto ist aktiv' : 'Konto ist inaktiv'"
:disabled="isSubmitting"
/>
<v-switch
v-model="formIsAdmin"
color="primary"
hide-details
:label="formIsAdmin ? 'Adminrechte werden vergeben' : 'Standardbenutzer ohne Adminrechte'"
:disabled="isSubmitting"
/>
<v-alert
v-if="passwordIssues.length > 0"
type="warning"
density="comfortable"
class="create-user-dialog__alert"
>
<p class="create-user-dialog__alert-title">Passwort erfüllt die Anforderungen nicht:</p>
<ul class="create-user-dialog__alert-list">
<li v-for="(issue, index) in passwordIssues" :key="index">{{ issue }}</li>
</ul>
</v-alert>
<v-alert
v-else-if="errorMessage"
type="error"
density="comfortable"
class="create-user-dialog__alert"
>
{{ errorMessage }}
</v-alert>
</v-form>
</v-card-text>
<v-card-actions class="create-user-dialog__actions">
<v-btn variant="text" :disabled="isSubmitting" @click="close">
Abbrechen
</v-btn>
<v-btn
variant="elevated"
color="primary"
prepend-icon="mdi-account-plus-outline"
:loading="isSubmitting"
:disabled="!canSubmit"
@click="submit"
>
Anlegen
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.create-user-dialog {
padding: var(--space-2);
}
.create-user-dialog__title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-5) var(--space-6) 0;
}
.create-user-dialog__title h2 {
margin: 0;
font-size: var(--font-size-xl);
letter-spacing: -0.01em;
}
.create-user-dialog__body {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
}
.create-user-dialog__body :deep(.v-form) {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.create-user-dialog__hint {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.create-user-dialog__alert {
margin-top: var(--space-1);
}
.create-user-dialog__alert-title {
margin: 0 0 var(--space-2);
font-weight: 600;
}
.create-user-dialog__alert-list {
margin: 0;
padding-left: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.create-user-dialog__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: 0 var(--space-6) var(--space-5);
}
.password-hints {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
padding: 0;
margin: 0;
list-style: none;
}
.password-hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 1.3;
transition: color var(--transition-fast);
}
.password-hint--valid {
color: var(--color-primary-700);
}
.password-hint .v-icon {
flex: 0 0 auto;
}
@media (width <= 600px) {
.password-hints {
grid-template-columns: 1fr;
}
}
</style>
+16 -1
View File
@@ -28,6 +28,7 @@ const appBannersStore = useAppBannersStore()
const formUserName = ref(props.user.userName)
const formIsActive = ref(props.user.isActive)
const formMustChangePassword = ref(props.user.mustChangePassword)
const isSubmitting = ref(false)
const errorMessage = ref('')
const userNameError = ref('')
@@ -38,6 +39,7 @@ watch(
if (open && !prevOpen) {
formUserName.value = props.user.userName
formIsActive.value = props.user.isActive
formMustChangePassword.value = props.user.mustChangePassword
errorMessage.value = ''
userNameError.value = ''
isSubmitting.value = false
@@ -52,7 +54,8 @@ const trimmedUserName = computed(() => formUserName.value.trim())
const hasChanges = computed(() => {
return (
trimmedUserName.value !== props.user.userName ||
formIsActive.value !== props.user.isActive
formIsActive.value !== props.user.isActive ||
formMustChangePassword.value !== props.user.mustChangePassword
)
})
@@ -95,6 +98,10 @@ async function submit() {
payload.isActive = formIsActive.value
}
if (formMustChangePassword.value !== props.user.mustChangePassword) {
payload.mustChangePassword = formMustChangePassword.value
}
isSubmitting.value = true
try {
@@ -170,6 +177,14 @@ async function submit() {
:disabled="isSubmitting || isAdmin"
/>
<v-switch
v-model="formMustChangePassword"
color="primary"
hide-details
:label="formMustChangePassword ? 'Passwortwechsel beim nächsten Login erzwingen' : 'Passwort kann normal verwendet werden'"
:disabled="isSubmitting"
/>
<p v-if="isAdmin" class="edit-user-dialog__hint">
<v-icon size="16" icon="mdi-shield-lock-outline" />
Adminkonten können nicht deaktiviert werden.
+21
View File
@@ -7,6 +7,7 @@ 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()
@@ -15,6 +16,7 @@ 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)
@@ -108,6 +110,14 @@ 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()
})
@@ -135,6 +145,15 @@ onMounted(() => {
/>
<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"
@@ -303,6 +322,8 @@ onMounted(() => {
</article>
</div>
</section>
<CreateUserDialog v-model="isCreateDialogOpen" @created="handleUserCreated" />
</v-container>
</template>
+114
View File
@@ -164,6 +164,7 @@ export async function fetchAdminUserById(userId: string): Promise<AdminUser> {
export interface UpdateAdminUserPayload {
userName?: string
isActive?: boolean
mustChangePassword?: boolean
}
export async function updateAdminUser(
@@ -235,4 +236,117 @@ export async function updateAdminUser(
return user
}
export interface CreateAdminUserPayload {
userName: string
startPassword: string
isAdmin: boolean
isActive: boolean
}
export class CreateAdminUserError extends AuthRequestError {
readonly fieldErrors: string[]
constructor(message: string, status: number, fieldErrors: string[] = []) {
super(message, status)
this.name = 'CreateAdminUserError'
this.fieldErrors = fieldErrors
}
}
async function readApiPayload(response: Response): Promise<{ message: string | null; errors: string[] }> {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
return { message: null, errors: [] }
}
try {
const payload = (await response.json()) as { message?: unknown; errors?: unknown }
const message =
typeof payload.message === 'string' && payload.message.trim().length > 0
? payload.message.trim()
: null
const errors = Array.isArray(payload.errors)
? payload.errors
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: []
return { message, errors }
} catch {
return { message: null, errors: [] }
}
}
export async function createAdminUser(payload: CreateAdminUserPayload): Promise<AdminUser> {
let response: Response
try {
response = await fetch('/auth/user', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
})
} 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) {
const { message } = await readApiPayload(response)
throw new AuthRequestError(
message ?? FORBIDDEN_NOT_ADMIN_MESSAGE,
response.status,
)
}
if (response.status === 409) {
const { message } = await readApiPayload(response)
throw new CreateAdminUserError(
message ?? 'Benutzername ist bereits vergeben.',
response.status,
)
}
if (response.status === 422) {
const { message, errors } = await readApiPayload(response)
throw new CreateAdminUserError(
message ?? 'Passwort erfüllt nicht die Sicherheitsanforderungen.',
response.status,
errors,
)
}
if (response.status === 400) {
const { message, errors } = await readApiPayload(response)
throw new CreateAdminUserError(
message ?? 'Benutzer konnte nicht erstellt werden.',
response.status,
errors,
)
}
if (!response.ok) {
const { message } = await readApiPayload(response)
throw new AuthRequestError(
message ?? 'Benutzer konnte nicht erstellt werden.',
response.status,
)
}
const body: unknown = await response.json()
const user = normalizeAdminUser(body)
if (!user) {
throw new AuthRequestError('Antwortformat von POST /auth/user ist ungültig.', response.status)
}
return user
}
export const FORBIDDEN_NOT_ADMIN_MESSAGE = 'Du bist angemeldet, aber nicht als Admin autorisiert.'
+28 -1
View File
@@ -18,6 +18,7 @@ interface PushBannerInput {
}
const MAX_BANNERS = 8
const AUTO_DISMISS_MS = 6000
function createBannerId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
@@ -29,6 +30,25 @@ function createBannerId() {
export const useAppBannersStore = defineStore('app-banners', () => {
const banners = ref<AppBannerMessage[]>([])
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
function scheduleAutoDismiss(id: string) {
if (typeof window === 'undefined') {
return
}
const timer = setTimeout(() => {
dismiss(id)
}, AUTO_DISMISS_MS)
dismissTimers.set(id, timer)
}
function clearTimer(id: string) {
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
}
function push(input: PushBannerInput) {
const now = Date.now()
@@ -56,7 +76,11 @@ export const useAppBannersStore = defineStore('app-banners', () => {
createdAt: now,
}
banners.value = [...banners.value, banner].slice(-MAX_BANNERS)
const next = [...banners.value, banner]
const removed = next.length > MAX_BANNERS ? next.slice(0, next.length - MAX_BANNERS) : []
removed.forEach((entry) => clearTimer(entry.id))
banners.value = next.slice(-MAX_BANNERS)
scheduleAutoDismiss(banner.id)
return banner.id
}
@@ -65,10 +89,13 @@ export const useAppBannersStore = defineStore('app-banners', () => {
}
function dismiss(id: string) {
clearTimer(id)
banners.value = banners.value.filter((banner) => banner.id !== id)
}
function clear() {
dismissTimers.forEach((timer) => clearTimeout(timer))
dismissTimers.clear()
banners.value = []
}