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
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user