From 14176a3ee28e30b7cc705943f4367011058b3e5d Mon Sep 17 00:00:00 2001 From: Jonas <77726472+kobolol@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:02:16 +0200 Subject: [PATCH] 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. --- API/Contracts/Auth/CurrentUserResponse.cs | 2 +- .../Auth/CurrentUserResponseExtensions.cs | 27 ++ API/Controllers/Auth/AppUserController.cs | 29 ++- API/Controllers/Auth/AuthController.cs | 23 +- GUI/src/Layout.vue | 56 ++++- GUI/src/plugins/routesLayout.ts | 33 +++ GUI/src/router/index.ts | 14 +- GUI/src/routes/admin/AdminUserDetail.vue | 233 ++++++++++++++++++ GUI/src/routes/admin/AdminUsers.vue | 172 ++++++++----- .../routes/authentication/ChangePassword.vue | 208 ++++++++++++++++ GUI/src/routes/authentication/Login.vue | 23 +- GUI/src/services/adminUsers.ts | 162 ++++++++++++ GUI/src/services/authSession.ts | 92 ++++++- codexInfo.md | 13 + 14 files changed, 995 insertions(+), 92 deletions(-) create mode 100644 API/Contracts/Auth/CurrentUserResponseExtensions.cs create mode 100644 GUI/src/routes/admin/AdminUserDetail.vue create mode 100644 GUI/src/routes/authentication/ChangePassword.vue create mode 100644 GUI/src/services/adminUsers.ts diff --git a/API/Contracts/Auth/CurrentUserResponse.cs b/API/Contracts/Auth/CurrentUserResponse.cs index 0e14fb3..7b49022 100644 --- a/API/Contracts/Auth/CurrentUserResponse.cs +++ b/API/Contracts/Auth/CurrentUserResponse.cs @@ -3,7 +3,7 @@ public class CurrentUserResponse { public Guid Id { get; set; } - public string UserName { get; set; } = string.Empty; + public string? UserName { get; set; } = string.Empty; public List Roles { get; set; } = new(); public bool IsActive { get; set; } public bool MustChangePassword { get; set; } diff --git a/API/Contracts/Auth/CurrentUserResponseExtensions.cs b/API/Contracts/Auth/CurrentUserResponseExtensions.cs new file mode 100644 index 0000000..e38d0d1 --- /dev/null +++ b/API/Contracts/Auth/CurrentUserResponseExtensions.cs @@ -0,0 +1,27 @@ +using API.Models; +using Microsoft.AspNetCore.Identity; + +namespace API.Contracts.Auth +{ + public static class CurrentUserResponseExtensions + { + public static async Task ToCurrentUserResponseAsync( + this AppUser user, + UserManager userManager) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(userManager); + + var roles = await userManager.GetRolesAsync(user); + + return new CurrentUserResponse + { + Id = user.Id, + UserName = user.UserName, + Roles = roles.OrderBy(role => role).ToList(), + IsActive = user.IsActive, + MustChangePassword = user.MustChangePassword, + }; + } + } +} diff --git a/API/Controllers/Auth/AppUserController.cs b/API/Controllers/Auth/AppUserController.cs index 3d18dc7..5793c17 100644 --- a/API/Controllers/Auth/AppUserController.cs +++ b/API/Controllers/Auth/AppUserController.cs @@ -1,18 +1,39 @@ +using API.Contracts.Auth; +using API.Models; using API.Security; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace API.Controllers.Auth { [ApiController] + [Authorize(Policy = PolicyNames.AdminOnly)] [Route("auth/user")] - public class AppUserController : ControllerBase + public class AppUserController(UserManager userManager) : ControllerBase { [HttpGet] - [Authorize(Policy = PolicyNames.AdminOnly)] - public IActionResult GetAppUsers() + public async Task>> GetAppUsers() { - return Ok(new { message = "Adminzugriff bestätigt." }); + var users = await userManager.Users + .OrderBy(x => x.UserName) + .ToListAsync(); + + var tasks = users.Select(user => user.ToCurrentUserResponseAsync(userManager)); + return Ok(await Task.WhenAll(tasks)); + } + + [HttpGet("{id:guid}")] + public async Task> GetAppUserById([FromRoute] Guid id) + { + var user = await userManager.Users.FirstOrDefaultAsync(x => x.Id == id); + if (user is null) + { + return NotFound(new { message = "Benutzer wurde nicht gefunden." }); + } + + return Ok(await user.ToCurrentUserResponseAsync(userManager)); } } } diff --git a/API/Controllers/Auth/AuthController.cs b/API/Controllers/Auth/AuthController.cs index 696c92a..74e3f15 100644 --- a/API/Controllers/Auth/AuthController.cs +++ b/API/Controllers/Auth/AuthController.cs @@ -58,16 +58,7 @@ namespace API.Controllers.Auth if (user is null) return Unauthorized(); - var roles = await userManager.GetRolesAsync(user); - - return Ok(new CurrentUserResponse - { - Id = user.Id, - UserName = user.UserName ?? string.Empty, - Roles = roles.OrderBy(x => x).ToList(), - IsActive = user.IsActive, - MustChangePassword = user.MustChangePassword - }); + return Ok(await user.ToCurrentUserResponseAsync(userManager)); } [HttpPost("password")] @@ -105,6 +96,18 @@ namespace API.Controllers.Auth }); } + user.MustChangePassword = false; + user.UpdatedAt = DateTimeOffset.UtcNow; + var updateResult = await userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + { + return StatusCode(500, new + { + message = "Passwort wurde geändert, Benutzerdaten konnten aber nicht final gespeichert werden.", + errors = updateResult.Errors.Select(e => e.Description) + }); + } + var stampResult = await userManager.UpdateSecurityStampAsync(user); if (!stampResult.Succeeded) { diff --git a/GUI/src/Layout.vue b/GUI/src/Layout.vue index 7e01cad..60fac58 100644 --- a/GUI/src/Layout.vue +++ b/GUI/src/Layout.vue @@ -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(