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
+115 -57
View File
@@ -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>