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:
+24
-4
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.'
|
||||
|
||||
Reference in New Issue
Block a user