Admin user edit: UI, API and server guard

Add full admin user editing flow: introduce EditUserDialog component and integrate it into AdminUserDetail (with minor copy and button variant tweaks), plus layout tweaks to animate the account chevron. Implement updateAdminUser(...) in GUI services to PATCH /auth/user/{id} with comprehensive error handling and export FORBIDDEN_NOT_ADMIN_MESSAGE. Server-side AppUserController now prevents deactivating users in the Admin role and returns a 403, ensuring admin accounts cannot be disabled. These changes enable editing usernames and activation status from the admin UI while protecting admin accounts.
This commit is contained in:
Jonas
2026-05-01 15:40:54 +02:00
parent 847ac119d8
commit 1d00fb3a4b
5 changed files with 395 additions and 6 deletions
@@ -2,6 +2,7 @@ using API.Contracts.Auth;
using API.Models;
using API.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -48,6 +49,12 @@ namespace API.Controllers.Auth
if (changeDto.IsActive != null)
{
if (!changeDto.IsActive.Value && await userManager.IsInRoleAsync(user, RoleNames.Admin))
{
return StatusCode(StatusCodes.Status403Forbidden,
new { message = "Adminkonten können nicht deaktiviert werden." });
}
user.IsActive = changeDto.IsActive.Value;
if (!changeDto.IsActive.Value)
+24 -4
View File
@@ -20,6 +20,8 @@ const theme = useTheme()
const route = useRoute()
const router = useRouter()
const showDrawer = ref(true)
const isAccountMenuOpen = ref(false)
const accountChevRotation = ref(0)
const currentYear = new Date().getFullYear()
const themeStorageKey = 'theme'
const currentUser = ref<CurrentUser | null>(null)
@@ -162,6 +164,14 @@ onMounted(() => {
void refreshAuthState({ force: true })
})
watch(isAccountMenuOpen, (open) => {
if (open) {
accountChevRotation.value = Math.random() < 0.5 ? 180 : -180
} else {
accountChevRotation.value = 0
}
})
watch(
() => route.fullPath,
() => {
@@ -224,8 +234,7 @@ watch(
<template v-if="isAuthenticated">
<v-menu
v-if="!display.smAndDown.value"
open-on-hover
:open-on-click="false"
v-model="isAccountMenuOpen"
location="bottom end"
offset="10"
>
@@ -234,14 +243,18 @@ watch(
type="button"
class="account-pill"
v-bind="props"
@click="router.push({ name: 'Dashboard' })"
>
<span class="account-pill__avatar">{{ userInitials }}</span>
<span class="account-pill__label">
<span class="account-pill__hint">Angemeldet</span>
<span class="account-pill__name">{{ userLabel }}</span>
</span>
<v-icon class="account-pill__chev">mdi-chevron-down</v-icon>
<v-icon
class="account-pill__chev"
:style="{ transform: `rotate(${accountChevRotation}deg)` }"
>
mdi-chevron-down
</v-icon>
</button>
</template>
@@ -636,6 +649,13 @@ watch(
color: var(--color-text-muted);
width: 18px;
height: 18px;
transition: transform var(--transition-medium);
}
@media (prefers-reduced-motion: reduce) {
.account-pill__chev {
transition: none;
}
}
@media (width <= 1280px) {
+264
View File
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
FORBIDDEN_NOT_ADMIN_MESSAGE,
updateAdminUser,
type AdminUser,
type UpdateAdminUserPayload,
} from '@/services/adminUsers'
import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
interface Props {
modelValue: boolean
user: AdminUser
}
const props = defineProps<Props>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'updated', user: AdminUser): void
}>()
const route = useRoute()
const router = useRouter()
const appBannersStore = useAppBannersStore()
const formUserName = ref(props.user.userName)
const formIsActive = ref(props.user.isActive)
const isSubmitting = ref(false)
const errorMessage = ref('')
const userNameError = ref('')
watch(
() => props.modelValue,
(open, prevOpen) => {
if (open && !prevOpen) {
formUserName.value = props.user.userName
formIsActive.value = props.user.isActive
errorMessage.value = ''
userNameError.value = ''
isSubmitting.value = false
}
},
)
const isAdmin = computed(() => props.user.roles.some((role) => role.toLowerCase() === 'admin'))
const trimmedUserName = computed(() => formUserName.value.trim())
const hasChanges = computed(() => {
return (
trimmedUserName.value !== props.user.userName ||
formIsActive.value !== props.user.isActive
)
})
const showDeactivationHint = computed(() => {
return props.user.isActive && !formIsActive.value
})
function close() {
if (isSubmitting.value) {
return
}
emit('update:modelValue', false)
}
function clearUserNameErrorOnEdit() {
if (userNameError.value) {
userNameError.value = ''
}
}
async function submit() {
if (isSubmitting.value || !hasChanges.value) {
return
}
errorMessage.value = ''
userNameError.value = ''
const payload: UpdateAdminUserPayload = {}
if (trimmedUserName.value !== props.user.userName) {
if (trimmedUserName.value.length === 0) {
userNameError.value = 'Benutzername darf nicht leer sein.'
return
}
payload.userName = trimmedUserName.value
}
if (formIsActive.value !== props.user.isActive) {
payload.isActive = formIsActive.value
}
isSubmitting.value = true
try {
const updated = await updateAdminUser(props.user.id, payload)
emit('updated', updated)
appBannersStore.push({
type: 'success',
message: 'Benutzer wurde aktualisiert.',
})
emit('update:modelValue', false)
} catch (error) {
if (error instanceof AuthRequestError) {
if (error.status === 401) {
emit('update:modelValue', false)
await router.replace({
name: 'Login',
query: { redirect: route.fullPath },
})
return
}
if (error.status === 403 && error.message === FORBIDDEN_NOT_ADMIN_MESSAGE) {
emit('update:modelValue', false)
await router.replace({ name: 'Forbidden' })
return
}
errorMessage.value = error.message
if (error.status === 400 && /leer/i.test(error.message)) {
userNameError.value = error.message
}
} else {
errorMessage.value = 'Benutzer konnte nicht aktualisiert werden.'
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="520"
persistent
@update:model-value="(value: boolean) => emit('update:modelValue', value)"
>
<v-card class="edit-user-dialog ui-panel">
<v-card-title class="edit-user-dialog__title">
<p class="ui-kicker ui-kicker--xs">Adminbereich</p>
<h2>Benutzer bearbeiten</h2>
</v-card-title>
<v-card-text class="edit-user-dialog__body">
<v-form @submit.prevent="submit">
<v-text-field
v-model="formUserName"
label="Benutzername"
prepend-inner-icon="mdi-account-outline"
autocomplete="off"
:error="!!userNameError"
:error-messages="userNameError"
:disabled="isSubmitting"
@update:model-value="clearUserNameErrorOnEdit"
/>
<v-switch
v-model="formIsActive"
color="primary"
hide-details
:label="formIsActive ? 'Konto ist aktiv' : 'Konto ist inaktiv'"
:disabled="isSubmitting || isAdmin"
/>
<p v-if="isAdmin" class="edit-user-dialog__hint">
<v-icon size="16" icon="mdi-shield-lock-outline" />
Adminkonten können nicht deaktiviert werden.
</p>
<p v-else-if="showDeactivationHint" class="edit-user-dialog__hint">
<v-icon size="16" icon="mdi-information-outline" />
Beim Deaktivieren werden bestehende Sessions des Nutzers beendet.
</p>
<v-alert
v-if="errorMessage"
type="error"
density="comfortable"
class="edit-user-dialog__alert"
>
{{ errorMessage }}
</v-alert>
</v-form>
</v-card-text>
<v-card-actions class="edit-user-dialog__actions">
<v-btn variant="text" :disabled="isSubmitting" @click="close">
Abbrechen
</v-btn>
<v-btn
variant="elevated"
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="isSubmitting"
:disabled="!hasChanges || isSubmitting"
@click="submit"
>
Speichern
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.edit-user-dialog {
padding: var(--space-2);
}
.edit-user-dialog__title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-5) var(--space-6) 0;
}
.edit-user-dialog__title h2 {
margin: 0;
font-size: var(--font-size-xl);
letter-spacing: -0.01em;
}
.edit-user-dialog__body {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
}
.edit-user-dialog__body :deep(.v-form) {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.edit-user-dialog__hint {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.edit-user-dialog__alert {
margin-top: var(--space-1);
}
.edit-user-dialog__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: 0 var(--space-6) var(--space-5);
}
</style>
+24 -2
View File
@@ -6,6 +6,7 @@ import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
import StatusPill from '@/components/ui/StatusPill.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
import EditUserDialog from '@/components/admin/EditUserDialog.vue'
const route = useRoute()
const router = useRouter()
@@ -14,6 +15,7 @@ const appBannersStore = useAppBannersStore()
const isLoading = ref(true)
const errorMessage = ref('')
const user = ref<AdminUser | null>(null)
const isEditDialogOpen = ref(false)
const routeUserId = computed(() => {
const value = route.params.userId
@@ -91,6 +93,10 @@ async function navigateToList() {
await router.push({ name: 'AdminUsers' })
}
function handleUserUpdated(updated: AdminUser) {
user.value = updated
}
onMounted(() => {
void loadUser()
})
@@ -102,7 +108,7 @@ onMounted(() => {
<div class="admin-user-detail-head__copy">
<p class="ui-kicker ui-kicker--wide">Adminbereich</p>
<h1>Benutzerdetails</h1>
<p>Read-only Ansicht des ausgewählten Kontos. Änderungen erfolgen aktuell außerhalb der App.</p>
<p>Detailansicht des ausgewählten Kontos. Über <strong>Bearbeiten</strong> lassen sich Benutzername und Status anpassen.</p>
</div>
<div class="admin-user-detail-head__actions ui-action-row">
@@ -114,7 +120,7 @@ onMounted(() => {
Zurück zur Liste
</v-btn>
<v-btn
variant="elevated"
variant="outlined"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@@ -122,6 +128,15 @@ onMounted(() => {
>
Neu laden
</v-btn>
<v-btn
variant="elevated"
color="primary"
prepend-icon="mdi-pencil"
:disabled="isLoading || !user"
@click="isEditDialogOpen = true"
>
Bearbeiten
</v-btn>
</div>
</header>
@@ -174,6 +189,13 @@ onMounted(() => {
</div>
</dl>
</article>
<EditUserDialog
v-if="user"
v-model="isEditDialogOpen"
:user="user"
@updated="handleUserUpdated"
/>
</v-container>
</template>
+76
View File
@@ -160,3 +160,79 @@ export async function fetchAdminUserById(userId: string): Promise<AdminUser> {
return user
}
export interface UpdateAdminUserPayload {
userName?: string
isActive?: boolean
}
export async function updateAdminUser(
userId: string,
payload: UpdateAdminUserPayload,
): 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: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
})
} 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) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Du bist angemeldet, aber nicht als Admin autorisiert.',
response.status,
)
}
if (response.status === 404) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(apiMessage ?? 'Benutzer wurde nicht gefunden.', response.status)
}
if (response.status === 409) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(apiMessage ?? 'Benutzername ist bereits vergeben.', response.status)
}
if (response.status === 400) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(apiMessage ?? 'Eingaben sind ungültig.', response.status)
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Benutzer konnte nicht aktualisiert werden.',
response.status,
)
}
const body: unknown = await response.json()
const user = normalizeAdminUser(body)
if (!user) {
throw new AuthRequestError('Antwortformat von /auth/user/{id} ist ungültig.', response.status)
}
return user
}
export const FORBIDDEN_NOT_ADMIN_MESSAGE = 'Du bist angemeldet, aber nicht als Admin autorisiert.'