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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user