From 178bc8731ea7a3ae68e581873453f561fe2b05ab Mon Sep 17 00:00:00 2001 From: Jonas <77726472+kobolol@users.noreply.github.com> Date: Sun, 3 May 2026 15:56:28 +0200 Subject: [PATCH] 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. --- API/Contracts/Auth/ChangeUserRequest.cs | 1 + API/Contracts/Auth/CreateUserRequest.cs | 10 + API/Controllers/Auth/AppUserController.cs | 65 +++ GUI/src/Layout.vue | 64 ++- GUI/src/components/admin/CreateUserDialog.vue | 407 ++++++++++++++++++ GUI/src/components/admin/EditUserDialog.vue | 17 +- GUI/src/routes/admin/AdminUsers.vue | 21 + GUI/src/services/adminUsers.ts | 114 +++++ GUI/src/stores/appBanners.ts | 29 +- 9 files changed, 720 insertions(+), 8 deletions(-) create mode 100644 API/Contracts/Auth/CreateUserRequest.cs create mode 100644 GUI/src/components/admin/CreateUserDialog.vue diff --git a/API/Contracts/Auth/ChangeUserRequest.cs b/API/Contracts/Auth/ChangeUserRequest.cs index 9deba6c..7c6deb4 100644 --- a/API/Contracts/Auth/ChangeUserRequest.cs +++ b/API/Contracts/Auth/ChangeUserRequest.cs @@ -4,5 +4,6 @@ { public string? UserName { get; set; } public bool? IsActive { get; set; } + public bool? MustChangePassword { get; set; } } } diff --git a/API/Contracts/Auth/CreateUserRequest.cs b/API/Contracts/Auth/CreateUserRequest.cs new file mode 100644 index 0000000..4220b00 --- /dev/null +++ b/API/Contracts/Auth/CreateUserRequest.cs @@ -0,0 +1,10 @@ +namespace API.Contracts.Auth +{ + public class CreateUserRequest + { + public required string UserName { get; set; } + public required string StartPassword { get; set; } + public bool IsAdmin { get; set; } = false; + public bool IsActive { get; set; } = true; + } +} diff --git a/API/Controllers/Auth/AppUserController.cs b/API/Controllers/Auth/AppUserController.cs index d2e868a..56ed9c9 100644 --- a/API/Controllers/Auth/AppUserController.cs +++ b/API/Controllers/Auth/AppUserController.cs @@ -92,7 +92,72 @@ namespace API.Controllers.Auth } } + if (changeDto.MustChangePassword != null) + { + user.MustChangePassword = changeDto.MustChangePassword.Value; + + await userManager.UpdateAsync(user); + } + return Ok(await user.ToCurrentUserResponseAsync(userManager)); } + + [HttpPost] + public async Task CreateNewAppUser([FromBody] CreateUserRequest createDto) + { + var newUser = new AppUser + { + UserName = createDto.UserName.Trim(), + MustChangePassword = true, + IsActive = createDto.IsActive, + }; + + var result = await userManager.CreateAsync(newUser, createDto.StartPassword); + + if (!result.Succeeded) + { + if (result.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName))) + { + return Conflict(new { message = "Benutzername ist bereits vergeben." }); + } + + var passwordErrors = result.Errors + .Where(e => e.Code.StartsWith("Password")) + .Select(e => e.Description) + .ToList(); + + if (passwordErrors.Any()) + { + return UnprocessableEntity(new + { + message = "Passwort erfüllt nicht die Sicherheitsanforderungen.", + errors = passwordErrors + }); + } + + return BadRequest(new + { + message = "Benutzer konnte nicht erstellt werden.", + errors = result.Errors.Select(e => e.Description) + }); + } + + if (createDto.IsAdmin) + { + var roleResult = await userManager.AddToRoleAsync(newUser, RoleNames.Admin); + + if (!roleResult.Succeeded) + { + return BadRequest(new + { + message = "Benutzer wurde erstellt, aber Rolle konnte nicht zugewiesen werden.", + errors = roleResult.Errors.Select(e => e.Description) + }); + } + } + + return CreatedAtAction(nameof(GetAppUserById), new { id = newUser.Id }, + await newUser.ToCurrentUserResponseAsync(userManager)); + } } } diff --git a/GUI/src/Layout.vue b/GUI/src/Layout.vue index c7c0d20..da650b3 100644 --- a/GUI/src/Layout.vue +++ b/GUI/src/Layout.vue @@ -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 ---------- */ diff --git a/GUI/src/components/admin/CreateUserDialog.vue b/GUI/src/components/admin/CreateUserDialog.vue new file mode 100644 index 0000000..4dd364f --- /dev/null +++ b/GUI/src/components/admin/CreateUserDialog.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/GUI/src/components/admin/EditUserDialog.vue b/GUI/src/components/admin/EditUserDialog.vue index 59858cb..c74fc19 100644 --- a/GUI/src/components/admin/EditUserDialog.vue +++ b/GUI/src/components/admin/EditUserDialog.vue @@ -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" /> + +

Adminkonten können nicht deaktiviert werden. diff --git a/GUI/src/routes/admin/AdminUsers.vue b/GUI/src/routes/admin/AdminUsers.vue index 58cb695..fcb6c79 100644 --- a/GUI/src/routes/admin/AdminUsers.vue +++ b/GUI/src/routes/admin/AdminUsers.vue @@ -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([]) 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(() => { /> + Benutzer anlegen + + { + + diff --git a/GUI/src/services/adminUsers.ts b/GUI/src/services/adminUsers.ts index 1800d8c..038affd 100644 --- a/GUI/src/services/adminUsers.ts +++ b/GUI/src/services/adminUsers.ts @@ -164,6 +164,7 @@ export async function fetchAdminUserById(userId: string): Promise { 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 { + 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.' diff --git a/GUI/src/stores/appBanners.ts b/GUI/src/stores/appBanners.ts index 6e7749f..c78b61c 100644 --- a/GUI/src/stores/appBanners.ts +++ b/GUI/src/stores/appBanners.ts @@ -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([]) + const dismissTimers = new Map>() + + 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 = [] }