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
+1 -1
View File
@@ -3,7 +3,7 @@
public class CurrentUserResponse public class CurrentUserResponse
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string UserName { get; set; } = string.Empty; public string? UserName { get; set; } = string.Empty;
public List<string> Roles { get; set; } = new(); public List<string> Roles { get; set; } = new();
public bool IsActive { get; set; } public bool IsActive { get; set; }
public bool MustChangePassword { get; set; } public bool MustChangePassword { get; set; }
@@ -0,0 +1,27 @@
using API.Models;
using Microsoft.AspNetCore.Identity;
namespace API.Contracts.Auth
{
public static class CurrentUserResponseExtensions
{
public static async Task<CurrentUserResponse> ToCurrentUserResponseAsync(
this AppUser user,
UserManager<AppUser> 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,
};
}
}
}
+25 -4
View File
@@ -1,18 +1,39 @@
using API.Contracts.Auth;
using API.Models;
using API.Security; using API.Security;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace API.Controllers.Auth namespace API.Controllers.Auth
{ {
[ApiController] [ApiController]
[Authorize(Policy = PolicyNames.AdminOnly)]
[Route("auth/user")] [Route("auth/user")]
public class AppUserController : ControllerBase public class AppUserController(UserManager<AppUser> userManager) : ControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize(Policy = PolicyNames.AdminOnly)] public async Task<ActionResult<IReadOnlyList<CurrentUserResponse>>> GetAppUsers()
public IActionResult 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<ActionResult<CurrentUserResponse>> 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));
} }
} }
} }
+13 -10
View File
@@ -58,16 +58,7 @@ namespace API.Controllers.Auth
if (user is null) if (user is null)
return Unauthorized(); return Unauthorized();
var roles = await userManager.GetRolesAsync(user); return Ok(await user.ToCurrentUserResponseAsync(userManager));
return Ok(new CurrentUserResponse
{
Id = user.Id,
UserName = user.UserName ?? string.Empty,
Roles = roles.OrderBy(x => x).ToList(),
IsActive = user.IsActive,
MustChangePassword = user.MustChangePassword
});
} }
[HttpPost("password")] [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); var stampResult = await userManager.UpdateSecurityStampAsync(user);
if (!stampResult.Succeeded) if (!stampResult.Succeeded)
{ {
+50 -6
View File
@@ -8,6 +8,7 @@ import iconImage from '@/assets/images/icon.svg'
import { Visibility, routes } from '@/plugins/routesLayout' import { Visibility, routes } from '@/plugins/routesLayout'
import { import {
AuthRequestError, AuthRequestError,
ROLE_ADMIN,
fetchCurrentUser, fetchCurrentUser,
hasRole, hasRole,
logout, logout,
@@ -97,8 +98,18 @@ const sidebarRoutes = computed(() =>
) )
}), }),
) )
const firstAuthenticatedSidebarIndex = computed(() => const adminSidebarRoutes = computed(() =>
sidebarRoutes.value.findIndex((item) => item.visible === Visibility.Authenticated), 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)) const footerRoutes = computed(() => routes.filter((x) => x.visible === Visibility.Footer))
@@ -290,6 +301,12 @@ watch(
</template> </template>
<v-list density="compact" class="account-menu-list"> <v-list density="compact" class="account-menu-list">
<v-list-item
prepend-icon="mdi-lock-reset"
title="Passwort ändern"
:to="{ name: 'ChangePassword' }"
/>
<v-divider />
<v-list-item <v-list-item
prepend-icon="mdi-logout" prepend-icon="mdi-logout"
:title="isLoggingOut ? 'Abmelden...' : 'Abmelden'" :title="isLoggingOut ? 'Abmelden...' : 'Abmelden'"
@@ -316,6 +333,11 @@ watch(
title="Zum Dashboard" title="Zum Dashboard"
:to="{ name: 'Dashboard' }" :to="{ name: 'Dashboard' }"
/> />
<v-list-item
prepend-icon="mdi-lock-reset"
title="Passwort ändern"
:to="{ name: 'ChangePassword' }"
/>
<v-divider /> <v-divider />
<v-list-item <v-list-item
prepend-icon="mdi-logout" prepend-icon="mdi-logout"
@@ -363,13 +385,27 @@ watch(
</div> </div>
<v-list nav :density="display.mobile.value ? 'default' : 'comfortable'" class="px-1"> <v-list nav :density="display.mobile.value ? 'default' : 'comfortable'" class="px-1">
<template v-for="(item, index) in sidebarRoutes" :key="item.path"> <template v-for="item in primarySidebarRoutes" :key="item.path">
<v-divider <v-list-item
v-if="firstAuthenticatedSidebarIndex > 0 && index === firstAuthenticatedSidebarIndex" :to="item.path"
class="my-2 mx-2" :active="route.path === item.path"
:prepend-icon="item.icon"
:title="item.name"
class="hoard-nav-item"
rounded="lg"
link
/> />
</template>
<template v-if="adminSidebarRoutes.length > 0">
<v-divider class="my-2 mx-2" />
<div class="drawer-section-head">
<p class="drawer-kicker hoard-kicker hoard-kicker--xs">Admin</p>
</div>
<v-list-item <v-list-item
v-for="item in adminSidebarRoutes"
:key="item.path"
:to="item.path" :to="item.path"
:active="route.path === item.path" :active="route.path === item.path"
:prepend-icon="item.icon" :prepend-icon="item.icon"
@@ -586,6 +622,10 @@ watch(
border-top: 1px solid color-mix(in srgb, var(--color-border) 90%, white 10%); border-top: 1px solid color-mix(in srgb, var(--color-border) 90%, white 10%);
} }
.drawer-section-head {
padding: var(--space-2) var(--space-3) var(--space-1);
}
:deep(.hoard-nav-item) { :deep(.hoard-nav-item) {
min-height: 44px; min-height: 44px;
} }
@@ -606,6 +646,10 @@ watch(
} }
.hoard-footer { .hoard-footer {
flex: 0 0 auto;
height: auto !important;
min-height: 0 !important;
max-height: 88px;
padding-inline: var(--space-6); padding-inline: var(--space-6);
} }
+33
View File
@@ -5,7 +5,9 @@ import Dashboard from '@/routes/dashboard/Dashboard.vue'
import NotFound from '@/routes/404NotFound.vue' import NotFound from '@/routes/404NotFound.vue'
import Forbidden from '@/routes/Forbidden.vue' import Forbidden from '@/routes/Forbidden.vue'
import Login from '@/routes/authentication/Login.vue' import Login from '@/routes/authentication/Login.vue'
import ChangePassword from '@/routes/authentication/ChangePassword.vue'
import AdminUsers from '@/routes/admin/AdminUsers.vue' import AdminUsers from '@/routes/admin/AdminUsers.vue'
import AdminUserDetail from '@/routes/admin/AdminUserDetail.vue'
import Impressum from '@/routes/Impressum.vue' import Impressum from '@/routes/Impressum.vue'
import { ROLE_ADMIN } from '@/services/authSession' import { ROLE_ADMIN } from '@/services/authSession'
@@ -86,6 +88,37 @@ export const routes: LayoutRoute[] = [
}, },
}, },
}, },
{
path: '/admin/users/:userId',
name: 'Benutzerdetails',
description: 'Detailansicht eines Benutzerkontos',
icon: 'mdi-account-details-outline',
visible: Visibility.Hidden,
meta: {
name: 'AdminUserDetail',
path: '/admin/users/:userId',
component: AdminUserDetail,
meta: {
requiresAuth: true,
requiredRoles: [ROLE_ADMIN],
},
},
},
{
path: '/password/change',
name: 'Passwort ändern',
description: 'Passwort für dein Konto ändern',
icon: 'mdi-lock-reset',
visible: Visibility.Hidden,
meta: {
path: '/password/change',
name: 'ChangePassword',
component: ChangePassword,
meta: {
requiresAuth: true,
},
},
},
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
+9 -5
View File
@@ -10,20 +10,17 @@ const router = createRouter({
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const requiresAuth = to.meta.requiresAuth === true const requiresAuth = to.meta.requiresAuth === true
const guestOnly = to.meta.guestOnly === true const guestOnly = to.meta.guestOnly === true
const isPasswordChangeRoute = to.name === 'ChangePassword'
const requiredRoles = Array.isArray(to.meta.requiredRoles) const requiredRoles = Array.isArray(to.meta.requiredRoles)
? to.meta.requiredRoles.filter((role): role is string => typeof role === 'string') ? to.meta.requiredRoles.filter((role): role is string => typeof role === 'string')
: [] : []
if (!requiresAuth && !guestOnly && requiredRoles.length === 0) {
return true
}
let currentUser = null let currentUser = null
try { try {
currentUser = await fetchCurrentUser() currentUser = await fetchCurrentUser()
} catch { } catch {
if (requiresAuth) { if (requiresAuth || requiredRoles.length > 0 || isPasswordChangeRoute) {
const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {} const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {}
return { return {
@@ -38,6 +35,13 @@ router.beforeEach(async (to) => {
const isAuthenticated = currentUser !== null const isAuthenticated = currentUser !== null
if (currentUser && currentUser.mustChangePassword && !isPasswordChangeRoute) {
return {
name: 'ChangePassword',
replace: true,
}
}
if (requiresAuth && !isAuthenticated) { if (requiresAuth && !isAuthenticated) {
const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {} const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {}
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchAdminUserById, type AdminUser } from '@/services/adminUsers'
import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
const route = useRoute()
const router = useRouter()
const appBannersStore = useAppBannersStore()
const isLoading = ref(true)
const errorMessage = ref('')
const user = ref<AdminUser | null>(null)
const routeUserId = computed(() => {
const value = route.params.userId
return typeof value === 'string' ? value : ''
})
const rolesText = computed(() => {
if (!user.value || user.value.roles.length === 0) {
return 'Keine Rolle'
}
return user.value.roles.join(', ')
})
async function loadUser() {
isLoading.value = true
errorMessage.value = ''
if (routeUserId.value.length === 0) {
errorMessage.value = 'Ungültige Benutzer-ID in der Route.'
isLoading.value = false
return
}
try {
user.value = await fetchAdminUserById(routeUserId.value)
} catch (error) {
if (error instanceof AuthRequestError) {
errorMessage.value = error.message
if (error.status === 401) {
await router.replace({
name: 'Login',
query: { redirect: route.fullPath },
})
return
}
if (error.status === 403) {
await router.replace({ name: 'Forbidden' })
return
}
} else {
errorMessage.value = 'Benutzerdetails konnten nicht geladen werden.'
}
appBannersStore.pushError(errorMessage.value, 'Benutzerdetails')
} finally {
isLoading.value = false
}
}
async function navigateToList() {
await router.push({ name: 'AdminUsers' })
}
onMounted(() => {
void loadUser()
})
</script>
<template>
<v-container fluid class="admin-user-detail-page hoard-page">
<section class="admin-user-detail-shell hoard-panel">
<header class="admin-user-detail-head">
<p class="hoard-kicker">Adminbereich</p>
<h1>Benutzerdetails</h1>
<p>Read-only Ansicht des ausgewählten Kontos.</p>
</header>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
border="start"
>
{{ errorMessage }}
</v-alert>
<p v-else-if="isLoading" class="admin-user-detail-loading">Benutzerdetails werden geladen...</p>
<article v-else-if="user" class="admin-user-detail-card hoard-panel">
<dl class="admin-user-detail-grid">
<div class="admin-user-detail-item">
<dt>ID</dt>
<dd>{{ user.id }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Benutzername</dt>
<dd>{{ user.userName || '(ohne Benutzername)' }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Rollen</dt>
<dd>{{ rolesText }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Aktiv</dt>
<dd>
<span
:class="[
'hoard-status',
user.isActive ? 'hoard-status--success' : 'hoard-status--danger',
]"
>
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</span>
</dd>
</div>
<div class="admin-user-detail-item">
<dt>Passwortwechsel</dt>
<dd>
<span
:class="[
'hoard-status',
user.mustChangePassword ? 'hoard-status--warning' : 'hoard-status--info',
]"
>
{{ user.mustChangePassword ? 'Erforderlich' : 'Nicht erforderlich' }}
</span>
</dd>
</div>
</dl>
</article>
<div class="admin-user-detail-actions hoard-action-row">
<v-btn
variant="outlined"
prepend-icon="mdi-arrow-left"
@click="navigateToList"
>
Zurück zur Liste
</v-btn>
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@click="loadUser"
>
Neu laden
</v-btn>
</div>
</section>
</v-container>
</template>
<style scoped>
.admin-user-detail-page {
--hoard-page-width: 980px;
}
.admin-user-detail-shell {
display: grid;
gap: var(--space-4);
padding: var(--space-6);
}
.admin-user-detail-head h1,
.admin-user-detail-head p,
.admin-user-detail-loading {
margin: 0;
}
.admin-user-detail-head h1 {
margin-top: var(--space-2);
margin-bottom: var(--space-2);
}
.admin-user-detail-head p,
.admin-user-detail-loading {
color: var(--color-text-secondary);
}
.admin-user-detail-card {
padding: var(--space-4);
}
.admin-user-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: var(--space-4);
margin: 0;
}
.admin-user-detail-item {
display: grid;
gap: var(--space-1);
}
.admin-user-detail-item dt {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-weight: 600;
}
.admin-user-detail-item dd {
margin: 0;
color: var(--color-text);
word-break: break-word;
}
.admin-user-detail-actions {
justify-content: flex-end;
}
@media (width <= 600px) {
.admin-user-detail-shell {
padding: var(--space-4);
}
.admin-user-detail-grid {
grid-template-columns: 1fr;
}
.admin-user-detail-actions {
justify-content: stretch;
}
}
</style>
+116 -58
View File
@@ -1,61 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { AuthRequestError, ROLE_ADMIN, fetchCurrentUser, hasRole } from '@/services/authSession' import { useRouter } from 'vue-router'
import { fetchAdminUsers, type AdminUser } from '@/services/adminUsers'
import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
const router = useRouter()
const appBannersStore = useAppBannersStore()
const isLoading = ref(true) const isLoading = ref(true)
const responseMessage = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const users = ref<AdminUser[]>([])
async function loadAdminStatus() { const hasUsers = computed(() => users.value.length > 0)
function formatRoles(roles: string[]): string {
return roles.length > 0 ? roles.join(', ') : 'Keine Rolle'
}
async function loadUsers() {
isLoading.value = true isLoading.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
const currentUser = await fetchCurrentUser({ force: true }) users.value = await fetchAdminUsers()
if (!hasRole(currentUser, ROLE_ADMIN)) {
errorMessage.value = 'Dein Konto hat aktuell keine Admin-Rolle.'
responseMessage.value = ''
return
}
const response = await fetch('/auth/user', {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
if (response.status === 401) {
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', 401)
}
if (response.status === 403) {
throw new AuthRequestError('Du bist angemeldet, aber nicht als Admin autorisiert.', 403)
}
if (!response.ok) {
throw new AuthRequestError('Admin-Endpunkt konnte nicht geladen werden.', response.status)
}
const payload = (await response.json()) as { message?: unknown }
responseMessage.value =
typeof payload.message === 'string' && payload.message.trim().length > 0
? payload.message
: 'Admin-Endpunkt erfolgreich erreicht.'
} catch (error) { } catch (error) {
if (error instanceof AuthRequestError) { if (error instanceof AuthRequestError) {
errorMessage.value = error.message errorMessage.value = error.message
} else {
errorMessage.value = 'Adminbereich konnte nicht geladen werden.' if (error.status === 401) {
await router.replace({
name: 'Login',
query: { redirect: '/admin/users' },
})
return
} }
if (error.status === 403) {
await router.replace({ name: 'Forbidden' })
return
}
} else {
errorMessage.value = 'Benutzerliste konnte nicht geladen werden.'
}
appBannersStore.pushError(errorMessage.value, 'Benutzerverwaltung')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
async function openUserDetail(userId: string) {
await router.push({ name: 'AdminUserDetail', params: { userId } })
}
onMounted(() => { onMounted(() => {
void loadAdminStatus() void loadUsers()
}) })
</script> </script>
@@ -65,7 +65,7 @@ onMounted(() => {
<header class="admin-users-head"> <header class="admin-users-head">
<p class="hoard-kicker">Adminbereich</p> <p class="hoard-kicker">Adminbereich</p>
<h1>Benutzerverwaltung</h1> <h1>Benutzerverwaltung</h1>
<p>Diese Seite ist nur für Konten mit Rolle <code>admin</code> sichtbar.</p> <p>Alle App-Konten mit Rollen, Status und Passwortwechselpflicht.</p>
</header> </header>
<v-alert <v-alert
@@ -77,24 +77,70 @@ onMounted(() => {
{{ errorMessage }} {{ errorMessage }}
</v-alert> </v-alert>
<v-alert <p v-else-if="isLoading" class="admin-users-loading">Benutzer werden geladen...</p>
v-else-if="responseMessage"
type="success" <section v-else-if="!hasUsers" class="hoard-empty-state">
variant="tonal" <h2>Keine Benutzer gefunden</h2>
border="start" <p>Aktuell sind keine Konten vorhanden.</p>
</section>
<div v-else class="admin-users-table-wrap">
<v-table class="admin-users-table">
<thead>
<tr>
<th>Benutzername</th>
<th>Rollen</th>
<th>Aktiv</th>
<th>Passwortwechsel</th>
<th class="admin-users-col-actions">Aktion</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="admin-users-cell-user">{{ user.userName || '(ohne Benutzername)' }}</td>
<td>{{ formatRoles(user.roles) }}</td>
<td>
<span
:class="[
'hoard-status',
user.isActive ? 'hoard-status--success' : 'hoard-status--danger',
]"
> >
{{ responseMessage }} {{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</v-alert> </span>
</td>
<td>
<span
:class="[
'hoard-status',
user.mustChangePassword ? 'hoard-status--warning' : 'hoard-status--info',
]"
>
{{ user.mustChangePassword ? 'Erforderlich' : 'Nein' }}
</span>
</td>
<td class="admin-users-col-actions">
<v-btn
size="small"
variant="outlined"
prepend-icon="mdi-account-details-outline"
@click="openUserDetail(user.id)"
>
Details
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</div>
<p v-else-if="isLoading" class="admin-users-loading">Adminstatus wird geladen...</p> <div class="admin-users-actions hoard-action-row">
<div class="admin-users-actions">
<v-btn <v-btn
variant="outlined" variant="outlined"
prepend-icon="mdi-refresh" prepend-icon="mdi-refresh"
:loading="isLoading" :loading="isLoading"
:disabled="isLoading" :disabled="isLoading"
@click="loadAdminStatus" @click="loadUsers"
> >
Neu laden Neu laden
</v-btn> </v-btn>
@@ -105,7 +151,7 @@ onMounted(() => {
<style scoped> <style scoped>
.admin-users-page { .admin-users-page {
--hoard-page-width: 980px; --hoard-page-width: 1120px;
} }
.admin-users-shell { .admin-users-shell {
@@ -133,8 +179,25 @@ onMounted(() => {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.admin-users-table-wrap {
overflow-x: auto;
}
.admin-users-table {
min-width: 900px;
}
.admin-users-cell-user {
font-weight: 600;
color: var(--color-text);
}
.admin-users-col-actions {
text-align: right;
white-space: nowrap;
}
.admin-users-actions { .admin-users-actions {
display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -146,10 +209,5 @@ onMounted(() => {
.admin-users-actions { .admin-users-actions {
justify-content: stretch; justify-content: stretch;
} }
:deep(.admin-users-actions .v-btn) {
width: 100%;
min-height: 44px;
}
} }
</style> </style>
@@ -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>
+22 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { AuthRequestError, login } from '@/services/authSession' import { AuthRequestError, login } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners' import { useAppBannersStore } from '@/stores/appBanners'
@@ -54,6 +54,27 @@ async function handleSubmit() {
isSubmitting.value = false isSubmitting.value = false
} }
} }
onMounted(() => {
const passwordChangedFlag = route.query.passwordChanged
if (passwordChangedFlag !== '1' && passwordChangedFlag !== 'true') {
return
}
appBannersStore.push({
type: 'success',
title: 'Passwort geändert',
message: 'Bitte melde dich mit deinem neuen Passwort an.',
})
const nextQuery = Object.fromEntries(
Object.entries(route.query).filter(([key]) => key !== 'passwordChanged'),
)
void router.replace({
query: nextQuery,
})
})
</script> </script>
<template> <template>
+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
}
+84 -8
View File
@@ -10,6 +10,7 @@ export const ROLE_ADMIN = 'admin'
interface ApiMessageResponse { interface ApiMessageResponse {
message?: unknown message?: unknown
errors?: unknown
} }
interface LoginPayload { interface LoginPayload {
@@ -17,6 +18,17 @@ interface LoginPayload {
password: string password: string
} }
interface ChangePasswordPayload {
oldPassword: string
newPassword: string
newPasswordConfirm: string
}
interface ApiErrorPayload {
message: string | null
errors: string[]
}
export class AuthRequestError extends Error { export class AuthRequestError extends Error {
status: number status: number
@@ -42,17 +54,19 @@ function normalizeCurrentUser(value: unknown): CurrentUser | null {
const { id, userName, roles, isActive, mustChangePassword } = value const { id, userName, roles, isActive, mustChangePassword } = value
if (typeof id !== 'string' || typeof userName !== 'string') { if (typeof id !== 'string') {
return null return null
} }
const normalizedUserName = typeof userName === 'string' ? userName : ''
const normalizedRoles = Array.isArray(roles) const normalizedRoles = Array.isArray(roles)
? roles.filter((role): role is string => typeof role === 'string').map((role) => role.trim()).filter((role) => role.length > 0) ? roles.filter((role): role is string => typeof role === 'string').map((role) => role.trim()).filter((role) => role.length > 0)
: [] : []
return { return {
id, id,
userName, userName: normalizedUserName,
roles: normalizedRoles, roles: normalizedRoles,
isActive: Boolean(isActive), isActive: Boolean(isActive),
mustChangePassword: Boolean(mustChangePassword), mustChangePassword: Boolean(mustChangePassword),
@@ -90,21 +104,41 @@ function isUnauthenticatedResponse(response: Response) {
} }
async function readApiMessage(response: Response): Promise<string | null> { async function readApiMessage(response: Response): Promise<string | null> {
const payload = await readApiErrorPayload(response)
return payload.message
}
function normalizeApiErrors(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
}
async function readApiErrorPayload(response: Response): Promise<ApiErrorPayload> {
const contentType = response.headers.get('content-type') ?? '' const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) { if (!contentType.toLowerCase().includes('application/json')) {
return null return { message: null, errors: [] }
} }
try { try {
const body = (await response.json()) as ApiMessageResponse const body = (await response.json()) as ApiMessageResponse
if (typeof body.message === 'string' && body.message.trim().length > 0) { const message =
return body.message typeof body.message === 'string' && body.message.trim().length > 0
? body.message.trim()
: null
return {
message,
errors: normalizeApiErrors(body.errors),
} }
} catch { } catch {
return null return { message: null, errors: [] }
} }
return null
} }
function toAuthError(error: unknown, fallbackMessage: string): AuthRequestError { function toAuthError(error: unknown, fallbackMessage: string): AuthRequestError {
@@ -257,3 +291,45 @@ export async function logout(): Promise<void> {
clearAuthSession() clearAuthSession()
} }
export async function changePassword(payload: ChangePasswordPayload): Promise<string> {
let response: Response
try {
response = await fetch('/auth/password', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
})
} catch (error) {
throw toAuthError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401 || response.status === 403) {
clearAuthSession()
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', response.status)
}
if (!response.ok) {
const apiError = await readApiErrorPayload(response)
if (response.status === 400) {
const fallback = 'Passwortänderung fehlgeschlagen. Bitte Eingaben prüfen.'
const details = apiError.errors.length > 0 ? ` ${apiError.errors.join(' ')}` : ''
throw new AuthRequestError((apiError.message ?? fallback) + details, response.status)
}
throw new AuthRequestError(
apiError.message ?? 'Passwortänderung fehlgeschlagen. Bitte versuche es erneut.',
response.status,
)
}
const successMessage = await readApiMessage(response)
clearAuthSession()
return successMessage ?? 'Passwort erfolgreich geändert.'
}
+13
View File
@@ -112,6 +112,10 @@ Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für
- Klick auf Logo/Branding in der Topbar führt abhängig vom Auth-Status: angemeldet auf `Dashboard`, unangemeldet auf `Welcome`. - Klick auf Logo/Branding in der Topbar führt abhängig vom Auth-Status: angemeldet auf `Dashboard`, unangemeldet auf `Welcome`.
- 404-Seite löst den Auth-Status auf und leitet automatisch weiter: angemeldet zu `Dashboard`, unangemeldet zu `Welcome`; primärer CTA ist entsprechend dynamisch. - 404-Seite löst den Auth-Status auf und leitet automatisch weiter: angemeldet zu `Dashboard`, unangemeldet zu `Welcome`; primärer CTA ist entsprechend dynamisch.
- Brand-Klick ist gegen Auth-Timing abgesichert: bei vorhandener Session führt Logo/Titel auch bei noch nicht synchronisiertem Layout-Status zuverlässig auf `Dashboard`. - Brand-Klick ist gegen Auth-Timing abgesichert: bei vorhandener Session führt Logo/Titel auch bei noch nicht synchronisiertem Layout-Status zuverlässig auf `Dashboard`.
- Admin-Benutzerverwaltung zeigt jetzt echte Kontodaten: `/admin/users` lädt die Benutzerliste aus `GET /auth/user`, `/admin/users/:userId` zeigt read-only Details aus `GET /auth/user/{id}`.
- Der Router erzwingt Passwortwechsel global: bei `mustChangePassword=true` erfolgt vor normaler Navigation ein Redirect auf `/password/change`; nach erfolgreicher Änderung führt der Flow auf Login zurück.
- Im Account-Menü der Topbar gibt es jetzt zusätzlich zur Abmeldung einen direkten Einstieg auf `Passwort ändern` (Desktop und Mobile).
- Sidebar-Navigation trennt adminpflichtige Seiten jetzt in einem eigenen Abschnitt `Admin` (gleicher grüner Kicker-Stil wie `Navigation`), sodass z. B. `Benutzer` nicht mehr direkt neben dem Dashboard steht.
## Änderungen durch Codex ## Änderungen durch Codex
- Grundlegender UI-Neuaufbau der App-Shell (`GUI/src/Layout.vue`) inklusive Navigation, Footer und Seitenkontext. - Grundlegender UI-Neuaufbau der App-Shell (`GUI/src/Layout.vue`) inklusive Navigation, Footer und Seitenkontext.
@@ -174,3 +178,12 @@ Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für
- Neue Migration `ReplaceIsAdminWithRoles` (`API/Migrations/20260420174609_ReplaceIsAdminWithRoles.cs`) ergänzt: migriert bestehende `Users.IsAdmin = true` idempotent in `UserRoles` (`admin`) und entfernt anschließend die Spalte `IsAdmin`. - Neue Migration `ReplaceIsAdminWithRoles` (`API/Migrations/20260420174609_ReplaceIsAdminWithRoles.cs`) ergänzt: migriert bestehende `Users.IsAdmin = true` idempotent in `UserRoles` (`admin`) und entfernt anschließend die Spalte `IsAdmin`.
- `GET /auth/me` liefert jetzt `roles: string[]` statt `isAdmin`; `GUI/src/services/authSession.ts` wurde auf Rollen normalisiert und um `hasRole()` ergänzt. - `GET /auth/me` liefert jetzt `roles: string[]` statt `isAdmin`; `GUI/src/services/authSession.ts` wurde auf Rollen normalisiert und um `hasRole()` ergänzt.
- Frontend-Autorisierung erweitert: Router unterstützt `meta.requiredRoles`, neue 403-Seite `GUI/src/routes/Forbidden.vue` und admin-spezifische Route `GUI/src/routes/admin/AdminUsers.vue` werden nur für Rolle `admin` zugänglich/angezeigt. - Frontend-Autorisierung erweitert: Router unterstützt `meta.requiredRoles`, neue 403-Seite `GUI/src/routes/Forbidden.vue` und admin-spezifische Route `GUI/src/routes/admin/AdminUsers.vue` werden nur für Rolle `admin` zugänglich/angezeigt.
- `API/Controllers/Auth/AppUserController.cs` erweitert: `GET /auth/user` liefert jetzt die Admin-Benutzerliste als `CurrentUserResponse[]`; zusätzlich ergänzt um `GET /auth/user/{id}` für read-only Detailabfragen.
- `API/Controllers/Auth/AuthController.cs` (`POST /auth/password`) nachgeschärft: setzt nach erfolgreicher Änderung `MustChangePassword=false`, aktualisiert `UpdatedAt`, invalidiert Sessions via Security-Stamp und meldet anschließend ab.
- Neue Frontend-Services: `GUI/src/services/adminUsers.ts` (typed Admin-User-Liste + Detail) und `GUI/src/services/authSession.ts` um typed `changePassword()` mit Fehler-Mapping (`message` + Validation-`errors`) erweitert.
- Admin-UI ausgebaut: `GUI/src/routes/admin/AdminUsers.vue` zeigt die Benutzerliste mit den MVP-Werten (Benutzername, Rollen, Aktiv-Status, Passwortwechselpflicht) und Navigation zur neuen Detailseite `GUI/src/routes/admin/AdminUserDetail.vue`.
- Passwort-Änderungsflow ergänzt: neue Route/Seite `GUI/src/routes/authentication/ChangePassword.vue`, globaler Guard in `GUI/src/router/index.ts` für erzwungenen Redirect und Login-Erfolgshinweis nach Passwortwechsel in `GUI/src/routes/authentication/Login.vue`.
- User-Mapping zentralisiert: neue Extension `API/Contracts/Auth/CurrentUserResponseExtensions.cs` (`ToCurrentUserResponseAsync`), genutzt in `API/Controllers/Auth/AppUserController.cs` und `API/Controllers/Auth/AuthController.cs` (`GET /auth/me`) statt lokaler Mapper-Methode.
- Footer-Höhe im Shell-Layout begrenzt: `GUI/src/Layout.vue` setzt für `.hoard-footer` jetzt `max-height` sowie feste Flex-/Height-Regeln, damit der Footer bei kurzen Seiten nicht ungewollt „aufbläht“.
- Sidebar-Struktur in `GUI/src/Layout.vue` nachgezogen: primäre Navigation und adminpflichtige Navigation werden getrennt gerendert; bei vorhandenen Admin-Routen erscheint ein separater Abschnittstitel `Admin` mit eigenem Trenner.
- Passwort-Ändern-Form (`GUI/src/routes/authentication/ChangePassword.vue`) visuell gegliedert: zwischen altem Passwort und den beiden neuen Passwortfeldern trennt jetzt ein Divider mit Abschnittslabel `Neues Passwort`.