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:
@@ -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<string> Roles { get; set; } = new();
|
||||
public bool IsActive { 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AppUser> userManager) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = PolicyNames.AdminOnly)]
|
||||
public IActionResult GetAppUsers()
|
||||
public async Task<ActionResult<IReadOnlyList<CurrentUserResponse>>> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
+50
-6
@@ -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(
|
||||
</template>
|
||||
|
||||
<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
|
||||
prepend-icon="mdi-logout"
|
||||
:title="isLoggingOut ? 'Abmelden...' : 'Abmelden'"
|
||||
@@ -316,6 +333,11 @@ watch(
|
||||
title="Zum Dashboard"
|
||||
:to="{ name: 'Dashboard' }"
|
||||
/>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-lock-reset"
|
||||
title="Passwort ändern"
|
||||
:to="{ name: 'ChangePassword' }"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
prepend-icon="mdi-logout"
|
||||
@@ -363,13 +385,27 @@ watch(
|
||||
</div>
|
||||
|
||||
<v-list nav :density="display.mobile.value ? 'default' : 'comfortable'" class="px-1">
|
||||
<template v-for="(item, index) in sidebarRoutes" :key="item.path">
|
||||
<v-divider
|
||||
v-if="firstAuthenticatedSidebarIndex > 0 && index === firstAuthenticatedSidebarIndex"
|
||||
class="my-2 mx-2"
|
||||
<template v-for="item in primarySidebarRoutes" :key="item.path">
|
||||
<v-list-item
|
||||
:to="item.path"
|
||||
: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-for="item in adminSidebarRoutes"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:active="route.path === item.path"
|
||||
:prepend-icon="item.icon"
|
||||
@@ -586,6 +622,10 @@ watch(
|
||||
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) {
|
||||
min-height: 44px;
|
||||
}
|
||||
@@ -606,6 +646,10 @@ watch(
|
||||
}
|
||||
|
||||
.hoard-footer {
|
||||
flex: 0 0 auto;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: 88px;
|
||||
padding-inline: var(--space-6);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import Dashboard from '@/routes/dashboard/Dashboard.vue'
|
||||
import NotFound from '@/routes/404NotFound.vue'
|
||||
import Forbidden from '@/routes/Forbidden.vue'
|
||||
import Login from '@/routes/authentication/Login.vue'
|
||||
import ChangePassword from '@/routes/authentication/ChangePassword.vue'
|
||||
import AdminUsers from '@/routes/admin/AdminUsers.vue'
|
||||
import AdminUserDetail from '@/routes/admin/AdminUserDetail.vue'
|
||||
import Impressum from '@/routes/Impressum.vue'
|
||||
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',
|
||||
name: 'Login',
|
||||
|
||||
@@ -10,20 +10,17 @@ const router = createRouter({
|
||||
router.beforeEach(async (to) => {
|
||||
const requiresAuth = to.meta.requiresAuth === true
|
||||
const guestOnly = to.meta.guestOnly === true
|
||||
const isPasswordChangeRoute = to.name === 'ChangePassword'
|
||||
const requiredRoles = Array.isArray(to.meta.requiredRoles)
|
||||
? to.meta.requiredRoles.filter((role): role is string => typeof role === 'string')
|
||||
: []
|
||||
|
||||
if (!requiresAuth && !guestOnly && requiredRoles.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
let currentUser = null
|
||||
|
||||
try {
|
||||
currentUser = await fetchCurrentUser()
|
||||
} catch {
|
||||
if (requiresAuth) {
|
||||
if (requiresAuth || requiredRoles.length > 0 || isPasswordChangeRoute) {
|
||||
const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {}
|
||||
|
||||
return {
|
||||
@@ -38,6 +35,13 @@ router.beforeEach(async (to) => {
|
||||
|
||||
const isAuthenticated = currentUser !== null
|
||||
|
||||
if (currentUser && currentUser.mustChangePassword && !isPasswordChangeRoute) {
|
||||
return {
|
||||
name: 'ChangePassword',
|
||||
replace: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -1,61 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { AuthRequestError, ROLE_ADMIN, fetchCurrentUser, hasRole } from '@/services/authSession'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
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 responseMessage = 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
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const currentUser = await fetchCurrentUser({ force: true })
|
||||
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.'
|
||||
users.value = await fetchAdminUsers()
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequestError) {
|
||||
errorMessage.value = error.message
|
||||
|
||||
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 = 'Adminbereich konnte nicht geladen werden.'
|
||||
errorMessage.value = 'Benutzerliste konnte nicht geladen werden.'
|
||||
}
|
||||
|
||||
appBannersStore.pushError(errorMessage.value, 'Benutzerverwaltung')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openUserDetail(userId: string) {
|
||||
await router.push({ name: 'AdminUserDetail', params: { userId } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAdminStatus()
|
||||
void loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -65,7 +65,7 @@ onMounted(() => {
|
||||
<header class="admin-users-head">
|
||||
<p class="hoard-kicker">Adminbereich</p>
|
||||
<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>
|
||||
|
||||
<v-alert
|
||||
@@ -77,24 +77,70 @@ onMounted(() => {
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-else-if="responseMessage"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
>
|
||||
{{ responseMessage }}
|
||||
</v-alert>
|
||||
<p v-else-if="isLoading" class="admin-users-loading">Benutzer werden geladen...</p>
|
||||
|
||||
<p v-else-if="isLoading" class="admin-users-loading">Adminstatus wird geladen...</p>
|
||||
<section v-else-if="!hasUsers" class="hoard-empty-state">
|
||||
<h2>Keine Benutzer gefunden</h2>
|
||||
<p>Aktuell sind keine Konten vorhanden.</p>
|
||||
</section>
|
||||
|
||||
<div class="admin-users-actions">
|
||||
<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',
|
||||
]"
|
||||
>
|
||||
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
|
||||
</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>
|
||||
|
||||
<div class="admin-users-actions hoard-action-row">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="loadAdminStatus"
|
||||
@click="loadUsers"
|
||||
>
|
||||
Neu laden
|
||||
</v-btn>
|
||||
@@ -105,7 +151,7 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.admin-users-page {
|
||||
--hoard-page-width: 980px;
|
||||
--hoard-page-width: 1120px;
|
||||
}
|
||||
|
||||
.admin-users-shell {
|
||||
@@ -133,8 +179,25 @@ onMounted(() => {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -146,10 +209,5 @@ onMounted(() => {
|
||||
.admin-users-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
:deep(.admin-users-actions .v-btn) {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AuthRequestError, login } from '@/services/authSession'
|
||||
import { useAppBannersStore } from '@/stores/appBanners'
|
||||
@@ -54,6 +54,27 @@ async function handleSubmit() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const ROLE_ADMIN = 'admin'
|
||||
|
||||
interface ApiMessageResponse {
|
||||
message?: unknown
|
||||
errors?: unknown
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
@@ -17,6 +18,17 @@ interface LoginPayload {
|
||||
password: string
|
||||
}
|
||||
|
||||
interface ChangePasswordPayload {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
newPasswordConfirm: string
|
||||
}
|
||||
|
||||
interface ApiErrorPayload {
|
||||
message: string | null
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export class AuthRequestError extends Error {
|
||||
status: number
|
||||
|
||||
@@ -42,17 +54,19 @@ function normalizeCurrentUser(value: unknown): CurrentUser | null {
|
||||
|
||||
const { id, userName, roles, isActive, mustChangePassword } = value
|
||||
|
||||
if (typeof id !== 'string' || typeof userName !== 'string') {
|
||||
if (typeof id !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedUserName = typeof userName === 'string' ? userName : ''
|
||||
|
||||
const normalizedRoles = Array.isArray(roles)
|
||||
? roles.filter((role): role is string => typeof role === 'string').map((role) => role.trim()).filter((role) => role.length > 0)
|
||||
: []
|
||||
|
||||
return {
|
||||
id,
|
||||
userName,
|
||||
userName: normalizedUserName,
|
||||
roles: normalizedRoles,
|
||||
isActive: Boolean(isActive),
|
||||
mustChangePassword: Boolean(mustChangePassword),
|
||||
@@ -90,21 +104,41 @@ function isUnauthenticatedResponse(response: Response) {
|
||||
}
|
||||
|
||||
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') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
return null
|
||||
return { message: null, errors: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await response.json()) as ApiMessageResponse
|
||||
if (typeof body.message === 'string' && body.message.trim().length > 0) {
|
||||
return body.message
|
||||
const message =
|
||||
typeof body.message === 'string' && body.message.trim().length > 0
|
||||
? body.message.trim()
|
||||
: null
|
||||
|
||||
return {
|
||||
message,
|
||||
errors: normalizeApiErrors(body.errors),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
return { message: null, errors: [] }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function toAuthError(error: unknown, fallbackMessage: string): AuthRequestError {
|
||||
@@ -257,3 +291,45 @@ export async function logout(): Promise<void> {
|
||||
|
||||
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.'
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
- 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`.
|
||||
- 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
|
||||
- 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`.
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
Reference in New Issue
Block a user