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 string? UserName { get; set; }
|
||||||
public bool? IsActive { 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));
|
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;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-banner-transition-enter-active,
|
.app-banner-list {
|
||||||
.app-banner-transition-leave-active {
|
perspective: 1200px;
|
||||||
transition: opacity 180ms ease, transform 180ms ease;
|
}
|
||||||
|
|
||||||
|
.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 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-enter-from,
|
|
||||||
.app-banner-transition-leave-to {
|
.app-banner-transition-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translate3d(72px, -8px, 0) scale(0.9);
|
||||||
|
filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-banner-transition-leave-active {
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-banner-transition-move {
|
.app-banner-transition-move {
|
||||||
transition: transform 180ms ease;
|
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: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Responsive ---------- */
|
/* ---------- 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 formUserName = ref(props.user.userName)
|
||||||
const formIsActive = ref(props.user.isActive)
|
const formIsActive = ref(props.user.isActive)
|
||||||
|
const formMustChangePassword = ref(props.user.mustChangePassword)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const userNameError = ref('')
|
const userNameError = ref('')
|
||||||
@@ -38,6 +39,7 @@ watch(
|
|||||||
if (open && !prevOpen) {
|
if (open && !prevOpen) {
|
||||||
formUserName.value = props.user.userName
|
formUserName.value = props.user.userName
|
||||||
formIsActive.value = props.user.isActive
|
formIsActive.value = props.user.isActive
|
||||||
|
formMustChangePassword.value = props.user.mustChangePassword
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
userNameError.value = ''
|
userNameError.value = ''
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
@@ -52,7 +54,8 @@ const trimmedUserName = computed(() => formUserName.value.trim())
|
|||||||
const hasChanges = computed(() => {
|
const hasChanges = computed(() => {
|
||||||
return (
|
return (
|
||||||
trimmedUserName.value !== props.user.userName ||
|
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
|
payload.isActive = formIsActive.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formMustChangePassword.value !== props.user.mustChangePassword) {
|
||||||
|
payload.mustChangePassword = formMustChangePassword.value
|
||||||
|
}
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -170,6 +177,14 @@ async function submit() {
|
|||||||
:disabled="isSubmitting || isAdmin"
|
: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">
|
<p v-if="isAdmin" class="edit-user-dialog__hint">
|
||||||
<v-icon size="16" icon="mdi-shield-lock-outline" />
|
<v-icon size="16" icon="mdi-shield-lock-outline" />
|
||||||
Adminkonten können nicht deaktiviert werden.
|
Adminkonten können nicht deaktiviert werden.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppBannersStore } from '@/stores/appBanners'
|
|||||||
import StatusPill from '@/components/ui/StatusPill.vue'
|
import StatusPill from '@/components/ui/StatusPill.vue'
|
||||||
import UserAvatar from '@/components/ui/UserAvatar.vue'
|
import UserAvatar from '@/components/ui/UserAvatar.vue'
|
||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
import CreateUserDialog from '@/components/admin/CreateUserDialog.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const appBannersStore = useAppBannersStore()
|
const appBannersStore = useAppBannersStore()
|
||||||
@@ -15,6 +16,7 @@ const isLoading = ref(true)
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const users = ref<AdminUser[]>([])
|
const users = ref<AdminUser[]>([])
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const isCreateDialogOpen = ref(false)
|
||||||
|
|
||||||
const hasUsers = computed(() => users.value.length > 0)
|
const hasUsers = computed(() => users.value.length > 0)
|
||||||
const activeUserCount = computed(() => users.value.filter((user) => user.isActive).length)
|
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 } })
|
await router.push({ name: 'AdminUserDetail', params: { userId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
isCreateDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserCreated(user: AdminUser) {
|
||||||
|
users.value = [...users.value, user]
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadUsers()
|
void loadUsers()
|
||||||
})
|
})
|
||||||
@@ -135,6 +145,15 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="elevated"
|
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"
|
prepend-icon="mdi-refresh"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
@@ -303,6 +322,8 @@ onMounted(() => {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<CreateUserDialog v-model="isCreateDialogOpen" @created="handleUserCreated" />
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export async function fetchAdminUserById(userId: string): Promise<AdminUser> {
|
|||||||
export interface UpdateAdminUserPayload {
|
export interface UpdateAdminUserPayload {
|
||||||
userName?: string
|
userName?: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
|
mustChangePassword?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAdminUser(
|
export async function updateAdminUser(
|
||||||
@@ -235,4 +236,117 @@ export async function updateAdminUser(
|
|||||||
return user
|
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.'
|
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 MAX_BANNERS = 8
|
||||||
|
const AUTO_DISMISS_MS = 6000
|
||||||
|
|
||||||
function createBannerId() {
|
function createBannerId() {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
@@ -29,6 +30,25 @@ function createBannerId() {
|
|||||||
|
|
||||||
export const useAppBannersStore = defineStore('app-banners', () => {
|
export const useAppBannersStore = defineStore('app-banners', () => {
|
||||||
const banners = ref<AppBannerMessage[]>([])
|
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) {
|
function push(input: PushBannerInput) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -56,7 +76,11 @@ export const useAppBannersStore = defineStore('app-banners', () => {
|
|||||||
createdAt: now,
|
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
|
return banner.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +89,13 @@ export const useAppBannersStore = defineStore('app-banners', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dismiss(id: string) {
|
function dismiss(id: string) {
|
||||||
|
clearTimer(id)
|
||||||
banners.value = banners.value.filter((banner) => banner.id !== id)
|
banners.value = banners.value.filter((banner) => banner.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
|
dismissTimers.forEach((timer) => clearTimeout(timer))
|
||||||
|
dismissTimers.clear()
|
||||||
banners.value = []
|
banners.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user