Replace IsAdmin with role-based admin
Switch user admin handling from an AppUser boolean to ASP.NET Identity roles. Removed AppUser.IsAdmin and related configuration/model entries; added migration ReplaceIsAdminWithRoles to copy Users.IsAdmin=true into a persistent admin role and drop the IsAdmin column. CurrentUserResponse now exposes roles (string[]), AuthController returns ordered roles from UserManager, and IdentitySeedService now ensures the admin role exists and assigns/creates an initial admin user in that role. Program.cs registers an Admin-only policy (PolicyNames/RoleNames), adjusts cookie auth events to return 401/403 for API requests, and wires up authorization. Frontend updated to use roles: authSession normalizes roles, adds hasRole and ROLE_ADMIN, router and layout support meta.requiredRoles, and new Forbidden and AdminUsers pages/route are added. codexInfo.md updated to reflect the migration to role-based auth.
This commit is contained in:
@@ -9,6 +9,7 @@ import { Visibility, routes } from '@/plugins/routesLayout'
|
||||
import {
|
||||
AuthRequestError,
|
||||
fetchCurrentUser,
|
||||
hasRole,
|
||||
logout,
|
||||
type CurrentUser,
|
||||
} from '@/services/authSession'
|
||||
@@ -75,6 +76,18 @@ const sidebarRoutes = computed(() =>
|
||||
return currentUser.value === null
|
||||
}
|
||||
|
||||
if (item.visible === Visibility.Authorized) {
|
||||
if (!currentUser.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!item.requiredRoles || item.requiredRoles.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return item.requiredRoles.every((role) => hasRole(currentUser.value, role))
|
||||
}
|
||||
|
||||
if (item.visible !== Visibility.Route) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
import Home from '@/routes/Home.vue'
|
||||
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 AdminUsers from '@/routes/admin/AdminUsers.vue'
|
||||
import Impressum from '@/routes/Impressum.vue'
|
||||
import { ROLE_ADMIN } from '@/services/authSession'
|
||||
|
||||
export enum Visibility {
|
||||
Hidden,
|
||||
@@ -24,6 +27,7 @@ export interface LayoutRoute {
|
||||
disableFooter?: boolean
|
||||
visible: Visibility
|
||||
visibilityRoute?: string | string[]
|
||||
requiredRoles?: string[]
|
||||
meta?: RouteRecordRaw
|
||||
}
|
||||
|
||||
@@ -65,6 +69,23 @@ export const routes: LayoutRoute[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'Benutzer',
|
||||
description: 'Adminbereich für Benutzerverwaltung',
|
||||
icon: 'mdi-shield-account-outline',
|
||||
visible: Visibility.Authorized,
|
||||
requiredRoles: [ROLE_ADMIN],
|
||||
meta: {
|
||||
name: 'AdminUsers',
|
||||
path: '/admin/users',
|
||||
component: AdminUsers,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiredRoles: [ROLE_ADMIN],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
@@ -92,6 +113,21 @@ export const routes: LayoutRoute[] = [
|
||||
component: Impressum,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/forbidden',
|
||||
name: 'Kein Zugriff',
|
||||
description: 'Du hast keine Berechtigung für diese Seite',
|
||||
icon: 'mdi-alert-circle-outline',
|
||||
visible: Visibility.Hidden,
|
||||
meta: {
|
||||
path: '/forbidden',
|
||||
name: 'Forbidden',
|
||||
component: Forbidden,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/notFound',
|
||||
name: 'Nicht gefunden',
|
||||
|
||||
+12
-2
@@ -1,6 +1,6 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { routes } from '@/plugins/routesLayout'
|
||||
import { fetchCurrentUser } from '@/services/authSession'
|
||||
import { fetchCurrentUser, hasRole } from '@/services/authSession'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -10,8 +10,11 @@ const router = createRouter({
|
||||
router.beforeEach(async (to) => {
|
||||
const requiresAuth = to.meta.requiresAuth === true
|
||||
const guestOnly = to.meta.guestOnly === true
|
||||
const requiredRoles = Array.isArray(to.meta.requiredRoles)
|
||||
? to.meta.requiredRoles.filter((role): role is string => typeof role === 'string')
|
||||
: []
|
||||
|
||||
if (!requiresAuth && !guestOnly) {
|
||||
if (!requiresAuth && !guestOnly && requiredRoles.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -45,6 +48,13 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredRoles.length > 0 && !requiredRoles.every((role) => hasRole(currentUser, role))) {
|
||||
return {
|
||||
name: 'Forbidden',
|
||||
replace: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (guestOnly && isAuthenticated) {
|
||||
return {
|
||||
name: 'Dashboard',
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchCurrentUser } from '@/services/authSession'
|
||||
|
||||
const router = useRouter()
|
||||
const isLoading = ref(true)
|
||||
const isAuthenticated = ref(false)
|
||||
|
||||
const primaryActionLabel = computed(() => (isAuthenticated.value ? 'Zum Dashboard' : 'Zum Login'))
|
||||
|
||||
async function resolveAuthState() {
|
||||
try {
|
||||
const user = await fetchCurrentUser({ force: true })
|
||||
isAuthenticated.value = user !== null
|
||||
} catch {
|
||||
isAuthenticated.value = false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function navigatePrimaryAction() {
|
||||
if (isAuthenticated.value) {
|
||||
await router.replace({ name: 'Dashboard' })
|
||||
return
|
||||
}
|
||||
|
||||
await router.replace({ name: 'Login' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void resolveAuthState()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="forbidden-page hoard-page hoard-page--centered">
|
||||
<section class="forbidden-shell hoard-shell-grid hoard-panel">
|
||||
<header class="forbidden-head">
|
||||
<p class="hoard-kicker">Fehlende Berechtigung</p>
|
||||
<h1>Kein Zugriff</h1>
|
||||
<p>Dein Konto hat keine ausreichende Rolle für diese Seite.</p>
|
||||
</header>
|
||||
|
||||
<div class="forbidden-actions hoard-action-row">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-arrow-right"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="navigatePrimaryAction"
|
||||
>
|
||||
{{ primaryActionLabel }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.forbidden-page {
|
||||
--hoard-shell-width: min(640px, 100%);
|
||||
}
|
||||
|
||||
.forbidden-shell {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.forbidden-head h1,
|
||||
.forbidden-head p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forbidden-head h1 {
|
||||
margin-top: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.forbidden-head p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { AuthRequestError, ROLE_ADMIN, fetchCurrentUser, hasRole } from '@/services/authSession'
|
||||
|
||||
const isLoading = ref(true)
|
||||
const responseMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
async function loadAdminStatus() {
|
||||
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.'
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequestError) {
|
||||
errorMessage.value = error.message
|
||||
} else {
|
||||
errorMessage.value = 'Adminbereich konnte nicht geladen werden.'
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAdminStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="admin-users-page hoard-page">
|
||||
<section class="admin-users-shell hoard-panel">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
>
|
||||
{{ 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">Adminstatus wird geladen...</p>
|
||||
|
||||
<div class="admin-users-actions">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="loadAdminStatus"
|
||||
>
|
||||
Neu laden
|
||||
</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-users-page {
|
||||
--hoard-page-width: 980px;
|
||||
}
|
||||
|
||||
.admin-users-shell {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.admin-users-head h1,
|
||||
.admin-users-head p,
|
||||
.admin-users-loading {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-users-head h1 {
|
||||
margin-top: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-users-head p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.admin-users-loading {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.admin-users-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.admin-users-shell {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-users-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
:deep(.admin-users-actions .v-btn) {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,13 @@
|
||||
export interface CurrentUser {
|
||||
id: string
|
||||
userName: string
|
||||
isAdmin: boolean
|
||||
roles: string[]
|
||||
isActive: boolean
|
||||
mustChangePassword: boolean
|
||||
}
|
||||
|
||||
export const ROLE_ADMIN = 'admin'
|
||||
|
||||
interface ApiMessageResponse {
|
||||
message?: unknown
|
||||
}
|
||||
@@ -38,21 +40,38 @@ function normalizeCurrentUser(value: unknown): CurrentUser | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const { id, userName, isAdmin, isActive, mustChangePassword } = value
|
||||
const { id, userName, roles, isActive, mustChangePassword } = value
|
||||
|
||||
if (typeof id !== 'string' || typeof userName !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
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,
|
||||
isAdmin: Boolean(isAdmin),
|
||||
roles: normalizedRoles,
|
||||
isActive: Boolean(isActive),
|
||||
mustChangePassword: Boolean(mustChangePassword),
|
||||
}
|
||||
}
|
||||
|
||||
export function hasRole(user: CurrentUser | null | undefined, role: string): boolean {
|
||||
if (!user) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedRole = role.trim().toLowerCase()
|
||||
if (normalizedRole.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return user.roles.some((userRole) => userRole.toLowerCase() === normalizedRole)
|
||||
}
|
||||
|
||||
function isUnauthenticatedResponse(response: Response) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user