feat(admin): add dedicated routes for admin user management (#37)
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m4s
CI / Backend Unit Tests (push) Successful in 1m59s
CI / E2E Tests (push) Failing after 18m4s
CI / Unit & Component Tests (pull_request) Successful in 2m2s
CI / Backend Unit Tests (pull_request) Successful in 2m0s
CI / E2E Tests (pull_request) Failing after 16m10s

- New GET /admin/users/new page: create user with all profile fields
  (login, password, firstName, lastName, birthDate, email, contact, groups)
- New GET /admin/users/[id] page: edit user profile, groups, and
  optional password change without requiring current password
- New PUT /api/users/{id} backend endpoint (ADMIN_USER permission)
  with AdminUpdateUserRequest DTO for admin-override user updates
- Refactored admin users tab: replaced inline editing with edit links
  to dedicated routes; create button now links to /admin/users/new
- Extended CreateUserRequest with profile fields so new users can be
  created with full profile data in a single request
- Added 28 component tests across 3 new spec files (TDD)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-22 16:33:50 +01:00
parent 9731afb776
commit fb4f8e820c
16 changed files with 999 additions and 199 deletions

View File

@@ -8,7 +8,6 @@ let { data, form } = $props();
let activeTab = $state('users');
let editingTagId: string | null = $state(null);
let editingTagName = $state('');
let editingUserId: string | null = $state(null);
let editingGroupId: string | null = $state(null);
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
@@ -23,14 +22,6 @@ function cancelEditTag() {
editingTagName = '';
}
function startEditUser(id: string) {
editingUserId = id;
}
function cancelEditUser() {
editingUserId = null;
}
function startEditGroup(id: string) {
editingGroupId = id;
}
@@ -80,6 +71,20 @@ function cancelEditGroup() {
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
<div class="flex items-center justify-between border-b border-gray-100 p-6">
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
<a
href="/admin/users/new"
class="inline-flex items-center gap-1 rounded-sm bg-brand-navy px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{m.admin_btn_new_user()}
</a>
</div>
<table class="min-w-full divide-y divide-gray-200">
@@ -88,200 +93,89 @@ function cancelEditGroup() {
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_login()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_full_name()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_groups()}</th
>
{#if editingUserId}
<th
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_password()}</th
>
{/if}
<th
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_actions()}</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.users as user (user.id)}
<tr class="group/row hover:bg-gray-50">
{#if editingUserId === user.id}
<!-- === EDIT MODE === -->
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{user.username}
<input
type="hidden"
name="username"
value={user.username}
form="edit-form-{user.id}"
/>
</td>
<td class="px-6 py-4 text-sm">
<select
name="groupIds"
multiple
form="edit-form-{user.id}"
class="block min-h-[80px] w-full rounded border-brand-mint p-1 text-xs"
>
{#each data.groups as group (group.id)}
<option
value={group.id}
selected={user.groups.some((g: { id: string }) => g.id === group.id)}
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
{user.username}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{#if user.firstName || user.lastName}
{user.firstName ?? ''} {user.lastName ?? ''}
{:else}
<span class="text-gray-300 italic"></span>
{/if}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#if user.groups && user.groups.length > 0}
{#each user.groups as group (group.id)}
<span
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
>
{group.name}
</option>
</span>
{/each}
</select>
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint()}</p>
</td>
<td class="px-6 py-4 text-right align-top whitespace-nowrap">
<form
id="edit-form-{user.id}"
method="POST"
action="?/createUser"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditUser();
}}
class="flex flex-col items-end gap-2"
{:else}
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
{/if}
</div>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-4">
<a
href="/admin/users/{user.id}"
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
>
<input
type="password"
name="password"
placeholder={m.admin_password_placeholder()}
class="w-32 rounded border border-brand-mint px-2 py-1 text-xs"
/>
{m.btn_edit()}
</a>
<div class="mt-1 flex gap-2">
<button
type="submit"
class="rounded bg-green-600 px-2 py-1 text-xs font-bold text-white uppercase hover:bg-green-700"
>
{m.btn_save()}
</button>
<button
type="button"
onclick={cancelEditUser}
class="rounded bg-gray-200 px-2 py-1 text-xs font-bold text-gray-600 uppercase hover:bg-gray-300"
>
{m.btn_cancel()}
</button>
</div>
</form>
</td>
{:else}
<!-- === VIEW MODE === -->
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{user.username}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#if user.groups && user.groups.length > 0}
{#each user.groups as group (group.id)}
<span
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
>
{group.name}
</span>
{/each}
{:else}
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
{/if}
</div>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-4">
<form
method="POST"
action="?/deleteUser"
use:enhance={({ cancel }) => {
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="flex items-center"
>
<input type="hidden" name="id" value={user.id} />
<button
onclick={() => startEditUser(user.id)}
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
class="p-1 text-gray-300 transition-colors hover:text-red-600"
title={m.admin_btn_delete_user_title()}
>
{m.btn_edit()}
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<form
method="POST"
action="?/deleteUser"
use:enhance={({ cancel }) => {
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="flex items-center"
>
<input type="hidden" name="id" value={user.id} />
<button
class="p-1 text-gray-300 transition-colors hover:text-red-600"
title={m.admin_btn_delete_user_title()}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
{/if}
</form>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Create User Form -->
<div class="border-t border-gray-200 bg-gray-50 p-6">
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
{m.admin_section_new_user()}
</h3>
<form
method="POST"
action="?/createUser"
use:enhance
class="grid grid-cols-1 items-start gap-4 md:grid-cols-6"
>
<input
type="text"
name="username"
placeholder="Login"
required
class="w-full rounded border-gray-300 text-sm"
/>
<input
type="password"
name="password"
placeholder={m.admin_col_password()}
required
class="w-full rounded border-gray-300 text-sm"
/>
<div class="md:col-span-3">
<select
name="groupIds"
multiple
class="h-[42px] w-full rounded border-gray-300 py-1 text-sm"
required
title={m.admin_multiselect_hint_multi()}
>
{#each data.groups as group (group.id)}
<option value={group.id}>{group.name}</option>
{/each}
</select>
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint_full()}</p>
</div>
<button
type="submit"
class="h-[42px] w-full rounded bg-brand-navy text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy"
>{m.btn_create()}</button
>
</form>
</div>
</div>
{:else if activeTab === 'tags'}
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>