refactor(admin): phase 7 — delete old tab components and page.server.ts
Remove UsersTab, GroupsTab, TagsTab, SystemTab and their specs; delete the monolithic +page.server.ts with shared load + 6 form actions (all now handled by dedicated sub-route servers under users/, groups/, tags/). Add delete action and confirmation button to user edit panel. Fix test to query the edit form by id rather than the first form in DOM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,116 +0,0 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
type ApiResult = { response: Response; error?: unknown };
|
||||
|
||||
function toActionResult(result: ApiResult) {
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as { code?: string } | undefined)?.code;
|
||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function load({ fetch, locals }) {
|
||||
const user = locals.user;
|
||||
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('ADMIN')
|
||||
);
|
||||
if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN'));
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const [usersResult, groupsResult, tagsResult] = await Promise.all([
|
||||
api.GET('/api/users'),
|
||||
api.GET('/api/groups'),
|
||||
api.GET('/api/tags')
|
||||
]);
|
||||
|
||||
return {
|
||||
users: usersResult.data ?? [],
|
||||
groups: groupsResult.data ?? [],
|
||||
tags: tagsResult.data ?? []
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
deleteUser: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.DELETE('/api/users/{id}', {
|
||||
params: { path: { id } }
|
||||
});
|
||||
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
updateTag: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.PUT('/api/tags/{id}', {
|
||||
params: { path: { id } },
|
||||
body: { name: data.get('name') as string }
|
||||
});
|
||||
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
deleteTag: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.DELETE('/api/tags/{id}', {
|
||||
params: { path: { id } }
|
||||
});
|
||||
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
createGroup: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.POST('/api/groups', {
|
||||
body: {
|
||||
name: data.get('name') as string,
|
||||
permissions: data.getAll('permissions') as string[]
|
||||
}
|
||||
});
|
||||
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
updateGroup: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.PATCH('/api/groups/{id}', {
|
||||
params: { path: { id } },
|
||||
body: {
|
||||
name: data.get('name') as string,
|
||||
permissions: data.getAll('permissions') as string[]
|
||||
}
|
||||
});
|
||||
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
deleteGroup: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.DELETE('/api/groups/{id}', {
|
||||
params: { path: { id } }
|
||||
});
|
||||
|
||||
return toActionResult(result);
|
||||
}
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { groups }: { groups: { id: string; name: string; permissions: string[] }[] } = $props();
|
||||
|
||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||
|
||||
let editingGroupId: string | null = $state(null);
|
||||
|
||||
function startEditGroup(id: string) {
|
||||
editingGroupId = id;
|
||||
}
|
||||
|
||||
function cancelEditGroup() {
|
||||
editingGroupId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
|
||||
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
||||
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_groups()}</h2>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full divide-y divide-line">
|
||||
<thead class="bg-muted">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_name()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_permissions()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_actions()}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line bg-surface">
|
||||
{#each groups as group (group.id)}
|
||||
<tr class="group/row hover:bg-muted">
|
||||
{#if editingGroupId === group.id}
|
||||
<!-- EDIT MODE -->
|
||||
<td colspan="3" class="px-6 py-4">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateGroup"
|
||||
use:enhance={() =>
|
||||
async ({ update }) => {
|
||||
await update();
|
||||
cancelEditGroup();
|
||||
}}
|
||||
class="flex w-full flex-col items-start gap-4 sm:flex-row"
|
||||
>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
|
||||
<div class="w-full sm:w-1/3">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={group.name}
|
||||
class="w-full rounded border-accent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
|
||||
{#each availablePermissions as perm (perm)}
|
||||
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm}
|
||||
checked={group.permissions.includes(perm)}
|
||||
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
||||
/>
|
||||
{perm.replace('_', ' ')}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 self-start sm:self-center">
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={m.btn_save()}
|
||||
class="p-1 text-green-600 hover:text-green-800"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEditGroup}
|
||||
aria-label={m.btn_cancel()}
|
||||
class="p-1 text-ink-3 hover:text-red-500"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
{:else}
|
||||
<!-- VIEW MODE -->
|
||||
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-ink">
|
||||
{group.name}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-ink-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each group.permissions as perm (perm)}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
|
||||
{perm === 'ADMIN'
|
||||
? 'border-red-100 bg-red-50 text-red-700'
|
||||
: 'border-line bg-muted text-ink-2'}"
|
||||
>
|
||||
{perm === 'ADMIN' ? '⚙ ' : ''}{perm}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onclick={() => startEditGroup(group.id)}
|
||||
class="text-sm font-bold tracking-wide text-primary uppercase hover:text-ink-2"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteGroup"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_group_delete_confirm())) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<button
|
||||
class="p-1 text-ink-3 transition-colors hover:text-red-600"
|
||||
title={m.btn_delete()}
|
||||
>
|
||||
<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}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- CREATE GROUP FORM -->
|
||||
<div class="border-t border-line bg-muted p-6">
|
||||
<h3 class="mb-4 text-xs font-bold tracking-wide text-ink-2 uppercase">
|
||||
{m.admin_section_new_group()}
|
||||
</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createGroup"
|
||||
use:enhance
|
||||
class="flex flex-col items-start gap-4 md:flex-row md:items-center"
|
||||
>
|
||||
<div class="w-full flex-1">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder={m.admin_group_name_placeholder()}
|
||||
required
|
||||
class="w-full rounded border-line text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
{#each availablePermissions as perm (perm)}
|
||||
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm}
|
||||
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
||||
/>
|
||||
{perm.replace('_', ' ')}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-primary px-6 py-2 text-sm font-bold text-primary-fg uppercase hover:bg-accent hover:text-ink md:w-auto"
|
||||
>
|
||||
{m.btn_create()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +0,0 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import GroupsTab from './GroupsTab.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const makeGroups = () => [
|
||||
{ id: 'g1', name: 'Administrators', permissions: ['ADMIN', 'WRITE_ALL'] },
|
||||
{ id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] }
|
||||
];
|
||||
|
||||
describe('GroupsTab — ADMIN permission badge (WCAG 1.4.1)', () => {
|
||||
it('ADMIN badge has a non-color indicator so it is distinguishable without color', () => {
|
||||
// The ADMIN badge must not rely on color alone (WCAG 1.4.1).
|
||||
// It must contain a visible non-color indicator: a text prefix such as ⚙.
|
||||
const { container } = render(GroupsTab, { groups: makeGroups() });
|
||||
|
||||
const adminBadge = Array.from(container.querySelectorAll('span')).find((el) =>
|
||||
el.textContent?.includes('ADMIN')
|
||||
);
|
||||
expect(adminBadge).toBeDefined();
|
||||
// Badge text must include a non-color prefix character
|
||||
expect(adminBadge!.textContent).toMatch(/[⚙★⚠!]/);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let backfillResult: number | null = $state(null);
|
||||
let backfillLoading = $state(false);
|
||||
let backfillHashesResult: number | null = $state(null);
|
||||
let backfillHashesLoading = $state(false);
|
||||
|
||||
async function backfillVersions() {
|
||||
backfillLoading = true;
|
||||
backfillResult = null;
|
||||
try {
|
||||
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
backfillResult = data.count;
|
||||
}
|
||||
} finally {
|
||||
backfillLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillFileHashes() {
|
||||
backfillHashesLoading = true;
|
||||
backfillHashesResult = null;
|
||||
try {
|
||||
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
backfillHashesResult = data.count;
|
||||
}
|
||||
} finally {
|
||||
backfillHashesLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-1 text-lg font-bold text-ink-2">{m.admin_system_backfill_heading()}</h2>
|
||||
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_description()}</p>
|
||||
<button
|
||||
onclick={backfillVersions}
|
||||
disabled={backfillLoading}
|
||||
class="rounded bg-primary px-6 py-2 text-sm font-bold text-primary-fg uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
|
||||
</button>
|
||||
{#if backfillResult !== null}
|
||||
<p class="mt-4 text-sm font-medium text-ink">
|
||||
{m.admin_system_backfill_success({ count: backfillResult })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-1 text-lg font-bold text-ink-2">
|
||||
{m.admin_system_backfill_hashes_heading()}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_hashes_description()}</p>
|
||||
<button
|
||||
onclick={backfillFileHashes}
|
||||
disabled={backfillHashesLoading}
|
||||
class="rounded bg-primary px-6 py-2 text-sm font-bold text-primary-fg uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
|
||||
</button>
|
||||
{#if backfillHashesResult !== null}
|
||||
<p class="mt-4 text-sm font-medium text-ink">
|
||||
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { tags }: { tags: { id: string; name: string }[] } = $props();
|
||||
|
||||
let editingTagId: string | null = $state(null);
|
||||
let editingTagName = $state('');
|
||||
|
||||
function startEditTag(tag: { id: string; name: string }) {
|
||||
editingTagId = tag.id;
|
||||
editingTagName = tag.name;
|
||||
}
|
||||
|
||||
function cancelEditTag() {
|
||||
editingTagId = null;
|
||||
editingTagName = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
|
||||
<div class="border-b border-line-2 bg-yellow-50/50 p-6">
|
||||
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_tags()}</h2>
|
||||
<p class="mt-1 text-xs text-yellow-800">
|
||||
{m.admin_tags_warning()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="max-h-[600px] divide-y divide-line-2 overflow-y-auto">
|
||||
{#each tags as tag (tag.id)}
|
||||
<li class="group flex items-center justify-between px-6 py-3 hover:bg-muted">
|
||||
{#if editingTagId === tag.id}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateTag"
|
||||
use:enhance={() =>
|
||||
async ({ update }) => {
|
||||
await update();
|
||||
cancelEditTag();
|
||||
}}
|
||||
class="flex flex-1 items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={editingTagName}
|
||||
class="flex-1 rounded border-accent px-2 py-1 text-sm ring-1 ring-accent"
|
||||
/>
|
||||
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
||||
><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="M5 13l4 4L19 7"
|
||||
/></svg
|
||||
></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEditTag}
|
||||
aria-label={m.btn_cancel()}
|
||||
class="text-ink-3 hover:text-ink-2"
|
||||
><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="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
></button
|
||||
>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
||||
{tag.name}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => startEditTag(tag)}
|
||||
aria-label={m.admin_btn_edit_tag_label()}
|
||||
class="p-1 text-ink-3 hover:text-ink"
|
||||
>
|
||||
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteTag"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_tag_delete_confirm())) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<button
|
||||
aria-label={m.admin_btn_delete_tag_label()}
|
||||
class="p-1 text-ink-3 hover:text-red-600"
|
||||
>
|
||||
<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="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>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import TagsTab from './TagsTab.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const makeTags = () => [
|
||||
{ id: 't1', name: 'Familie' },
|
||||
{ id: 't2', name: 'Urlaub' }
|
||||
];
|
||||
|
||||
describe('TagsTab — action buttons', () => {
|
||||
it('no element with opacity-0 class exists (regression guard: buttons must not be hover-only)', () => {
|
||||
// Before fix: the action buttons container had opacity-0 group-hover:opacity-100
|
||||
// which hides buttons on touch devices. After fix: that class is gone entirely.
|
||||
const { container } = render(TagsTab, { tags: makeTags() });
|
||||
|
||||
const hiddenElements = container.querySelectorAll('.opacity-0');
|
||||
expect(hiddenElements.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
users
|
||||
}: {
|
||||
users: {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
groups?: { id: string; name: string }[];
|
||||
}[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
|
||||
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
||||
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_users()}</h2>
|
||||
<a
|
||||
href="/admin/users/new"
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg 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>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line">
|
||||
<thead class="bg-muted">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_login()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_full_name()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_groups()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_actions()}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line bg-surface">
|
||||
{#each users as user (user.id)}
|
||||
<tr class="group/row hover:bg-muted">
|
||||
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-ink">
|
||||
{user.username}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-ink-2">
|
||||
{#if user.firstName || user.lastName}
|
||||
{user.firstName ?? ''} {user.lastName ?? ''}
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">–</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-ink-2">
|
||||
<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-ink-3 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-primary uppercase hover:text-ink-2"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
|
||||
<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-ink-3 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>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { load } from './+page.server';
|
||||
|
||||
vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
const adminUser = { groups: [{ permissions: ['ADMIN'] }] };
|
||||
const readOnlyUser = { groups: [{ permissions: ['READ_ALL'] }] };
|
||||
|
||||
function mockApiReturning(users: unknown[], groups: unknown[], tags: unknown[]) {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: users })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: groups })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: tags })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
// ─── permission check ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('admin load — permission check', () => {
|
||||
it('throws 403 when user has no ADMIN permission', async () => {
|
||||
await expect(
|
||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: readOnlyUser } })
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 when user is undefined', async () => {
|
||||
await expect(
|
||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: undefined } })
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 when user has no groups', async () => {
|
||||
await expect(
|
||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: { groups: [] } } })
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── happy path ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('admin load — happy path', () => {
|
||||
it('returns users, groups, and tags for an admin user', async () => {
|
||||
mockApiReturning(
|
||||
[{ id: 'u1', username: 'alice' }],
|
||||
[{ id: 'g1', name: 'Editors' }],
|
||||
[{ id: 't1', name: 'Familie' }]
|
||||
);
|
||||
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
locals: { user: adminUser }
|
||||
});
|
||||
|
||||
expect(result.users).toHaveLength(1);
|
||||
expect(result.groups).toHaveLength(1);
|
||||
expect(result.tags).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty arrays when API returns no data', async () => {
|
||||
mockApiReturning([], [], []);
|
||||
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
locals: { user: adminUser }
|
||||
});
|
||||
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.groups).toEqual([]);
|
||||
expect(result.tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
@@ -63,5 +63,19 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
delete: async ({ params, fetch }) => {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.DELETE('/api/users/{id}', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/users');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,6 +16,25 @@ const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string })
|
||||
<h2 class="flex-1 font-sans text-sm font-bold text-ink">
|
||||
{m.admin_user_edit_heading({ username: data.editUser.username })}
|
||||
</h2>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_user_delete_confirm({ username: data.editUser.username }))) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-sm border border-red-200 bg-red-50 px-3 py-1 font-sans text-xs font-bold tracking-widest text-red-700 uppercase transition-colors hover:bg-red-100 dark:border-red-900 dark:bg-red-950/30 dark:text-red-400"
|
||||
>
|
||||
{m.btn_delete()}…
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable body -->
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('Admin edit user page – rendering', () => {
|
||||
|
||||
it('includes pre-selected group ids in FormData at submit time (guards against groupIds being empty)', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
const form = document.querySelector('form')!;
|
||||
const form = document.querySelector<HTMLFormElement>('form#edit-user-form')!;
|
||||
const formData = new FormData(form);
|
||||
expect(formData.getAll('groupIds')).toContain('g1');
|
||||
expect(formData.getAll('groupIds')).not.toContain('g2');
|
||||
|
||||
Reference in New Issue
Block a user