Add admin user creation & must-change flag
Add server and UI support for creating admin users and forcing password change. API: introduce CreateUserRequest contract and add CreateNewAppUser endpoint in AppUserController; extend ChangeUserRequest with MustChangePassword and handle role assignment and detailed error responses (409/422/400). Frontend: new CreateUserDialog component, integrate it into AdminUsers list, and add createAdminUser service with CreateAdminUserError and payload handling; include mustChangePassword in update payloads and EditUserDialog. UI polish: enhanced app banner enter/leave animations in Layout.vue and add auto-dismiss timers/cleanup to appBanners store to limit and auto-remove banners.
This commit is contained in:
@@ -4,5 +4,6 @@
|
||||
{
|
||||
public string? UserName { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public bool? MustChangePassword { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace API.Contracts.Auth
|
||||
{
|
||||
public class CreateUserRequest
|
||||
{
|
||||
public required string UserName { get; set; }
|
||||
public required string StartPassword { get; set; }
|
||||
public bool IsAdmin { get; set; } = false;
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,72 @@ namespace API.Controllers.Auth
|
||||
}
|
||||
}
|
||||
|
||||
if (changeDto.MustChangePassword != null)
|
||||
{
|
||||
user.MustChangePassword = changeDto.MustChangePassword.Value;
|
||||
|
||||
await userManager.UpdateAsync(user);
|
||||
}
|
||||
|
||||
return Ok(await user.ToCurrentUserResponseAsync(userManager));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateNewAppUser([FromBody] CreateUserRequest createDto)
|
||||
{
|
||||
var newUser = new AppUser
|
||||
{
|
||||
UserName = createDto.UserName.Trim(),
|
||||
MustChangePassword = true,
|
||||
IsActive = createDto.IsActive,
|
||||
};
|
||||
|
||||
var result = await userManager.CreateAsync(newUser, createDto.StartPassword);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName)))
|
||||
{
|
||||
return Conflict(new { message = "Benutzername ist bereits vergeben." });
|
||||
}
|
||||
|
||||
var passwordErrors = result.Errors
|
||||
.Where(e => e.Code.StartsWith("Password"))
|
||||
.Select(e => e.Description)
|
||||
.ToList();
|
||||
|
||||
if (passwordErrors.Any())
|
||||
{
|
||||
return UnprocessableEntity(new
|
||||
{
|
||||
message = "Passwort erfüllt nicht die Sicherheitsanforderungen.",
|
||||
errors = passwordErrors
|
||||
});
|
||||
}
|
||||
|
||||
return BadRequest(new
|
||||
{
|
||||
message = "Benutzer konnte nicht erstellt werden.",
|
||||
errors = result.Errors.Select(e => e.Description)
|
||||
});
|
||||
}
|
||||
|
||||
if (createDto.IsAdmin)
|
||||
{
|
||||
var roleResult = await userManager.AddToRoleAsync(newUser, RoleNames.Admin);
|
||||
|
||||
if (!roleResult.Succeeded)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
message = "Benutzer wurde erstellt, aber Rolle konnte nicht zugewiesen werden.",
|
||||
errors = roleResult.Errors.Select(e => e.Description)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(GetAppUserById), new { id = newUser.Id },
|
||||
await newUser.ToCurrentUserResponseAsync(userManager));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
-6
@@ -910,19 +910,71 @@ watch(
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.app-banner-transition-enter-active,
|
||||
.app-banner-list {
|
||||
perspective: 1200px;
|
||||
}
|
||||
|
||||
.app-banner-transition-enter-active {
|
||||
transition:
|
||||
opacity 380ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 480ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
filter 380ms ease-out;
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
.app-banner-transition-leave-active {
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
transition:
|
||||
opacity 320ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 380ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
filter 320ms ease-in;
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
.app-banner-transition-enter-from {
|
||||
opacity: 0;
|
||||
transform: translate3d(48px, 24px, 0) scale(0.92) rotateX(-12deg);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.app-banner-transition-enter-to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1) rotateX(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.app-banner-transition-leave-from {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.app-banner-transition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate3d(72px, -8px, 0) scale(0.9);
|
||||
filter: blur(6px);
|
||||
}
|
||||
|
||||
.app-banner-transition-leave-active {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-banner-transition-move {
|
||||
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-banner-transition-enter-active,
|
||||
.app-banner-transition-leave-active,
|
||||
.app-banner-transition-move {
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.app-banner-transition-enter-from,
|
||||
.app-banner-transition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.app-banner-transition-move {
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
CreateAdminUserError,
|
||||
FORBIDDEN_NOT_ADMIN_MESSAGE,
|
||||
createAdminUser,
|
||||
type AdminUser,
|
||||
type CreateAdminUserPayload,
|
||||
} from '@/services/adminUsers'
|
||||
import { AuthRequestError } from '@/services/authSession'
|
||||
import { useAppBannersStore } from '@/stores/appBanners'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'created', user: AdminUser): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appBannersStore = useAppBannersStore()
|
||||
|
||||
const formUserName = ref('')
|
||||
const formStartPassword = ref('')
|
||||
const formStartPasswordConfirm = ref('')
|
||||
const formIsAdmin = ref(false)
|
||||
const formIsActive = ref(true)
|
||||
const showPassword = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const errorMessage = ref('')
|
||||
const passwordIssues = ref<string[]>([])
|
||||
const userNameError = ref('')
|
||||
const passwordError = ref('')
|
||||
const passwordConfirmError = ref('')
|
||||
|
||||
function resetForm() {
|
||||
formUserName.value = ''
|
||||
formStartPassword.value = ''
|
||||
formStartPasswordConfirm.value = ''
|
||||
formIsAdmin.value = false
|
||||
formIsActive.value = true
|
||||
showPassword.value = false
|
||||
isSubmitting.value = false
|
||||
errorMessage.value = ''
|
||||
passwordIssues.value = []
|
||||
userNameError.value = ''
|
||||
passwordError.value = ''
|
||||
passwordConfirmError.value = ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
// safety: dialog should not survive a route change
|
||||
},
|
||||
)
|
||||
|
||||
function open(value: boolean) {
|
||||
if (value) {
|
||||
resetForm()
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const trimmedUserName = computed(() => formUserName.value.trim())
|
||||
|
||||
const passwordsMatch = computed(
|
||||
() => formStartPassword.value.length > 0 && formStartPassword.value === formStartPasswordConfirm.value,
|
||||
)
|
||||
|
||||
const passwordHints = computed(() => [
|
||||
{ label: 'Mindestens 8 Zeichen', valid: formStartPassword.value.length >= 8 },
|
||||
{ label: 'Buchstabe enthalten', valid: /[A-Za-zÄÖÜäöüß]/.test(formStartPassword.value) },
|
||||
{ label: 'Ziffer enthalten', valid: /\d/.test(formStartPassword.value) },
|
||||
{ label: 'Übereinstimmung mit Bestätigung', valid: passwordsMatch.value },
|
||||
])
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return (
|
||||
!isSubmitting.value &&
|
||||
trimmedUserName.value.length > 0 &&
|
||||
formStartPassword.value.length > 0 &&
|
||||
passwordsMatch.value
|
||||
)
|
||||
})
|
||||
|
||||
function clearFieldErrors(field: 'user' | 'password' | 'confirm') {
|
||||
if (field === 'user' && userNameError.value) userNameError.value = ''
|
||||
if (field === 'password') {
|
||||
if (passwordError.value) passwordError.value = ''
|
||||
if (passwordIssues.value.length > 0) passwordIssues.value = []
|
||||
}
|
||||
if (field === 'confirm' && passwordConfirmError.value) passwordConfirmError.value = ''
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (isSubmitting.value) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value) {
|
||||
if (trimmedUserName.value.length === 0) {
|
||||
userNameError.value = 'Benutzername darf nicht leer sein.'
|
||||
}
|
||||
if (formStartPassword.value.length === 0) {
|
||||
passwordError.value = 'Startpasswort darf nicht leer sein.'
|
||||
} else if (!passwordsMatch.value) {
|
||||
passwordConfirmError.value = 'Passwörter stimmen nicht überein.'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
passwordIssues.value = []
|
||||
userNameError.value = ''
|
||||
passwordError.value = ''
|
||||
passwordConfirmError.value = ''
|
||||
|
||||
const payload: CreateAdminUserPayload = {
|
||||
userName: trimmedUserName.value,
|
||||
startPassword: formStartPassword.value,
|
||||
isAdmin: formIsAdmin.value,
|
||||
isActive: formIsActive.value,
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const created = await createAdminUser(payload)
|
||||
emit('created', created)
|
||||
appBannersStore.push({
|
||||
type: 'success',
|
||||
message: `Benutzer „${created.userName}" wurde angelegt.`,
|
||||
})
|
||||
emit('update:modelValue', false)
|
||||
await router.push({ name: 'AdminUserDetail', params: { userId: created.id } })
|
||||
} catch (error) {
|
||||
if (error instanceof CreateAdminUserError) {
|
||||
errorMessage.value = error.message
|
||||
if (error.status === 409) {
|
||||
userNameError.value = error.message
|
||||
} else if (error.status === 422) {
|
||||
passwordIssues.value = error.fieldErrors
|
||||
passwordError.value = error.message
|
||||
} else if (error.status === 400) {
|
||||
passwordIssues.value = error.fieldErrors
|
||||
}
|
||||
} else 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
|
||||
} else {
|
||||
errorMessage.value = 'Benutzer konnte nicht angelegt werden.'
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="560"
|
||||
persistent
|
||||
@update:model-value="(value: boolean) => open(value)"
|
||||
>
|
||||
<v-card class="create-user-dialog ui-panel">
|
||||
<v-card-title class="create-user-dialog__title">
|
||||
<p class="ui-kicker ui-kicker--xs">Adminbereich</p>
|
||||
<h2>Neuen Benutzer anlegen</h2>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="create-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="() => clearFieldErrors('user')"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="formStartPassword"
|
||||
label="Startpasswort"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
autocomplete="new-password"
|
||||
:error="!!passwordError"
|
||||
:error-messages="passwordError"
|
||||
:disabled="isSubmitting"
|
||||
@click:append-inner="showPassword = !showPassword"
|
||||
@update:model-value="() => clearFieldErrors('password')"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="formStartPasswordConfirm"
|
||||
label="Startpasswort bestätigen"
|
||||
prepend-inner-icon="mdi-lock-check-outline"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:error="!!passwordConfirmError"
|
||||
:error-messages="passwordConfirmError"
|
||||
:disabled="isSubmitting"
|
||||
@update:model-value="() => clearFieldErrors('confirm')"
|
||||
/>
|
||||
|
||||
<ul class="password-hints">
|
||||
<li
|
||||
v-for="hint in passwordHints"
|
||||
:key="hint.label"
|
||||
:class="['password-hint', { 'password-hint--valid': hint.valid }]"
|
||||
>
|
||||
<v-icon :icon="hint.valid ? 'mdi-check-circle' : 'mdi-circle-outline'" size="14" />
|
||||
{{ hint.label }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="create-user-dialog__hint">
|
||||
<v-icon size="16" icon="mdi-information-outline" />
|
||||
Der Benutzer muss das Passwort beim ersten Login ändern.
|
||||
</p>
|
||||
|
||||
<v-switch
|
||||
v-model="formIsActive"
|
||||
color="primary"
|
||||
hide-details
|
||||
:label="formIsActive ? 'Konto ist aktiv' : 'Konto ist inaktiv'"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
|
||||
<v-switch
|
||||
v-model="formIsAdmin"
|
||||
color="primary"
|
||||
hide-details
|
||||
:label="formIsAdmin ? 'Adminrechte werden vergeben' : 'Standardbenutzer ohne Adminrechte'"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="passwordIssues.length > 0"
|
||||
type="warning"
|
||||
density="comfortable"
|
||||
class="create-user-dialog__alert"
|
||||
>
|
||||
<p class="create-user-dialog__alert-title">Passwort erfüllt die Anforderungen nicht:</p>
|
||||
<ul class="create-user-dialog__alert-list">
|
||||
<li v-for="(issue, index) in passwordIssues" :key="index">{{ issue }}</li>
|
||||
</ul>
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-else-if="errorMessage"
|
||||
type="error"
|
||||
density="comfortable"
|
||||
class="create-user-dialog__alert"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="create-user-dialog__actions">
|
||||
<v-btn variant="text" :disabled="isSubmitting" @click="close">
|
||||
Abbrechen
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="elevated"
|
||||
color="primary"
|
||||
prepend-icon="mdi-account-plus-outline"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
>
|
||||
Anlegen
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-user-dialog {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.create-user-dialog__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-5) var(--space-6) 0;
|
||||
}
|
||||
|
||||
.create-user-dialog__title h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.create-user-dialog__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5) var(--space-6);
|
||||
}
|
||||
|
||||
.create-user-dialog__body :deep(.v-form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.create-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);
|
||||
}
|
||||
|
||||
.create-user-dialog__alert {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.create-user-dialog__alert-title {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.create-user-dialog__alert-list {
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.create-user-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-6) var(--space-5);
|
||||
}
|
||||
|
||||
.password-hints {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.password-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.3;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.password-hint--valid {
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
|
||||
.password-hint .v-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.password-hints {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -28,6 +28,7 @@ const appBannersStore = useAppBannersStore()
|
||||
|
||||
const formUserName = ref(props.user.userName)
|
||||
const formIsActive = ref(props.user.isActive)
|
||||
const formMustChangePassword = ref(props.user.mustChangePassword)
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const userNameError = ref('')
|
||||
@@ -38,6 +39,7 @@ watch(
|
||||
if (open && !prevOpen) {
|
||||
formUserName.value = props.user.userName
|
||||
formIsActive.value = props.user.isActive
|
||||
formMustChangePassword.value = props.user.mustChangePassword
|
||||
errorMessage.value = ''
|
||||
userNameError.value = ''
|
||||
isSubmitting.value = false
|
||||
@@ -52,7 +54,8 @@ const trimmedUserName = computed(() => formUserName.value.trim())
|
||||
const hasChanges = computed(() => {
|
||||
return (
|
||||
trimmedUserName.value !== props.user.userName ||
|
||||
formIsActive.value !== props.user.isActive
|
||||
formIsActive.value !== props.user.isActive ||
|
||||
formMustChangePassword.value !== props.user.mustChangePassword
|
||||
)
|
||||
})
|
||||
|
||||
@@ -95,6 +98,10 @@ async function submit() {
|
||||
payload.isActive = formIsActive.value
|
||||
}
|
||||
|
||||
if (formMustChangePassword.value !== props.user.mustChangePassword) {
|
||||
payload.mustChangePassword = formMustChangePassword.value
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
@@ -170,6 +177,14 @@ async function submit() {
|
||||
:disabled="isSubmitting || isAdmin"
|
||||
/>
|
||||
|
||||
<v-switch
|
||||
v-model="formMustChangePassword"
|
||||
color="primary"
|
||||
hide-details
|
||||
:label="formMustChangePassword ? 'Passwortwechsel beim nächsten Login erzwingen' : 'Passwort kann normal verwendet werden'"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
|
||||
<p v-if="isAdmin" class="edit-user-dialog__hint">
|
||||
<v-icon size="16" icon="mdi-shield-lock-outline" />
|
||||
Adminkonten können nicht deaktiviert werden.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAppBannersStore } from '@/stores/appBanners'
|
||||
import StatusPill from '@/components/ui/StatusPill.vue'
|
||||
import UserAvatar from '@/components/ui/UserAvatar.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import CreateUserDialog from '@/components/admin/CreateUserDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const appBannersStore = useAppBannersStore()
|
||||
@@ -15,6 +16,7 @@ const isLoading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const users = ref<AdminUser[]>([])
|
||||
const searchQuery = ref('')
|
||||
const isCreateDialogOpen = ref(false)
|
||||
|
||||
const hasUsers = computed(() => users.value.length > 0)
|
||||
const activeUserCount = computed(() => users.value.filter((user) => user.isActive).length)
|
||||
@@ -108,6 +110,14 @@ async function openUserDetail(userId: string) {
|
||||
await router.push({ name: 'AdminUserDetail', params: { userId } })
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
isCreateDialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleUserCreated(user: AdminUser) {
|
||||
users.value = [...users.value, user]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadUsers()
|
||||
})
|
||||
@@ -135,6 +145,15 @@ onMounted(() => {
|
||||
/>
|
||||
<v-btn
|
||||
variant="elevated"
|
||||
color="primary"
|
||||
prepend-icon="mdi-account-plus-outline"
|
||||
:disabled="isLoading"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
Benutzer anlegen
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@@ -303,6 +322,8 @@ onMounted(() => {
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CreateUserDialog v-model="isCreateDialogOpen" @created="handleUserCreated" />
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ export async function fetchAdminUserById(userId: string): Promise<AdminUser> {
|
||||
export interface UpdateAdminUserPayload {
|
||||
userName?: string
|
||||
isActive?: boolean
|
||||
mustChangePassword?: boolean
|
||||
}
|
||||
|
||||
export async function updateAdminUser(
|
||||
@@ -235,4 +236,117 @@ export async function updateAdminUser(
|
||||
return user
|
||||
}
|
||||
|
||||
export interface CreateAdminUserPayload {
|
||||
userName: string
|
||||
startPassword: string
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export class CreateAdminUserError extends AuthRequestError {
|
||||
readonly fieldErrors: string[]
|
||||
|
||||
constructor(message: string, status: number, fieldErrors: string[] = []) {
|
||||
super(message, status)
|
||||
this.name = 'CreateAdminUserError'
|
||||
this.fieldErrors = fieldErrors
|
||||
}
|
||||
}
|
||||
|
||||
async function readApiPayload(response: Response): Promise<{ message: string | null; errors: string[] }> {
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
return { message: null, errors: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: unknown; errors?: unknown }
|
||||
const message =
|
||||
typeof payload.message === 'string' && payload.message.trim().length > 0
|
||||
? payload.message.trim()
|
||||
: null
|
||||
const errors = Array.isArray(payload.errors)
|
||||
? payload.errors
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
: []
|
||||
return { message, errors }
|
||||
} catch {
|
||||
return { message: null, errors: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: CreateAdminUserPayload): Promise<AdminUser> {
|
||||
let response: Response
|
||||
|
||||
try {
|
||||
response = await fetch('/auth/user', {
|
||||
method: 'POST',
|
||||
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 { message } = await readApiPayload(response)
|
||||
throw new AuthRequestError(
|
||||
message ?? FORBIDDEN_NOT_ADMIN_MESSAGE,
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
const { message } = await readApiPayload(response)
|
||||
throw new CreateAdminUserError(
|
||||
message ?? 'Benutzername ist bereits vergeben.',
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
const { message, errors } = await readApiPayload(response)
|
||||
throw new CreateAdminUserError(
|
||||
message ?? 'Passwort erfüllt nicht die Sicherheitsanforderungen.',
|
||||
response.status,
|
||||
errors,
|
||||
)
|
||||
}
|
||||
|
||||
if (response.status === 400) {
|
||||
const { message, errors } = await readApiPayload(response)
|
||||
throw new CreateAdminUserError(
|
||||
message ?? 'Benutzer konnte nicht erstellt werden.',
|
||||
response.status,
|
||||
errors,
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const { message } = await readApiPayload(response)
|
||||
throw new AuthRequestError(
|
||||
message ?? 'Benutzer konnte nicht erstellt werden.',
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
|
||||
const body: unknown = await response.json()
|
||||
const user = normalizeAdminUser(body)
|
||||
if (!user) {
|
||||
throw new AuthRequestError('Antwortformat von POST /auth/user ist ungültig.', response.status)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
export const FORBIDDEN_NOT_ADMIN_MESSAGE = 'Du bist angemeldet, aber nicht als Admin autorisiert.'
|
||||
|
||||
@@ -18,6 +18,7 @@ interface PushBannerInput {
|
||||
}
|
||||
|
||||
const MAX_BANNERS = 8
|
||||
const AUTO_DISMISS_MS = 6000
|
||||
|
||||
function createBannerId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
@@ -29,6 +30,25 @@ function createBannerId() {
|
||||
|
||||
export const useAppBannersStore = defineStore('app-banners', () => {
|
||||
const banners = ref<AppBannerMessage[]>([])
|
||||
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function scheduleAutoDismiss(id: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(id)
|
||||
}, AUTO_DISMISS_MS)
|
||||
dismissTimers.set(id, timer)
|
||||
}
|
||||
|
||||
function clearTimer(id: string) {
|
||||
const timer = dismissTimers.get(id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
dismissTimers.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function push(input: PushBannerInput) {
|
||||
const now = Date.now()
|
||||
@@ -56,7 +76,11 @@ export const useAppBannersStore = defineStore('app-banners', () => {
|
||||
createdAt: now,
|
||||
}
|
||||
|
||||
banners.value = [...banners.value, banner].slice(-MAX_BANNERS)
|
||||
const next = [...banners.value, banner]
|
||||
const removed = next.length > MAX_BANNERS ? next.slice(0, next.length - MAX_BANNERS) : []
|
||||
removed.forEach((entry) => clearTimer(entry.id))
|
||||
banners.value = next.slice(-MAX_BANNERS)
|
||||
scheduleAutoDismiss(banner.id)
|
||||
return banner.id
|
||||
}
|
||||
|
||||
@@ -65,10 +89,13 @@ export const useAppBannersStore = defineStore('app-banners', () => {
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
clearTimer(id)
|
||||
banners.value = banners.value.filter((banner) => banner.id !== id)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
dismissTimers.forEach((timer) => clearTimeout(timer))
|
||||
dismissTimers.clear()
|
||||
banners.value = []
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user