Files
familienarchiv/frontend/src/routes/admin/invites/+page.svelte
Marcel 9fc4993fca
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m47s
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 2m43s
fix(invite-ui): accessibility, i18n, and load function tests
- WCAG 1.3.1: add for/id pairs to all 6 fields in the create-invite form
- WCAG 1.4.1: add status icon (●○✕⏱) to status badge alongside label
- Add aria-label to copy-link buttons in the invite table
- Replace hardcoded German strings with i18n keys (Alle, Widerrufen,
  Link kopieren, Kopiert, Abbrechen)
- Increase filter button touch targets py-1.5 → py-2
- Add 5 unit tests for register page load function (no-code, ok,
  error-with-code, error-without-code, URL-encoding)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 09:10:42 +02:00

374 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/errors';
import type { InviteListItem } from './+page.server.ts';
let {
data,
form
}: {
data: {
invites: InviteListItem[];
status: string;
loadError: string | null;
};
form?: {
createError?: string;
revokeError?: string;
created?: InviteListItem;
revoked?: string;
};
} = $props();
let copiedId = $state<string | null>(null);
let showNewForm = $state(false);
function copyLink(id: string, url: string) {
navigator.clipboard.writeText(url).then(() => {
copiedId = id;
setTimeout(() => {
copiedId = null;
}, 2000);
});
}
function statusLabel(status: string) {
switch (status) {
case 'active':
return m.admin_invite_status_active();
case 'exhausted':
return m.admin_invite_status_exhausted();
case 'revoked':
return m.admin_invite_status_revoked();
case 'expired':
return m.admin_invite_status_expired();
default:
return status;
}
}
function statusColor(status: string) {
switch (status) {
case 'active':
return 'text-green-700 bg-green-50';
case 'exhausted':
return 'text-gray-500 bg-gray-100';
case 'revoked':
return 'text-red-600 bg-red-50';
case 'expired':
return 'text-amber-600 bg-amber-50';
default:
return 'text-gray-500 bg-gray-100';
}
}
function statusIcon(status: string) {
switch (status) {
case 'active':
return '●';
case 'exhausted':
return '○';
case 'revoked':
return '✕';
case 'expired':
return '⏱';
default:
return '';
}
}
</script>
<svelte:head>
<title>{m.admin_tab_invites()} · Familienarchiv</title>
</svelte:head>
<div class="flex flex-1 flex-col overflow-y-auto bg-canvas">
<div class="flex items-center justify-between gap-4 border-b border-line bg-surface px-6 py-4">
<h1 class="font-sans text-sm font-bold tracking-widest text-ink uppercase">
{m.admin_invites_list_title()}
</h1>
<div class="flex items-center gap-3">
<!-- Status filter -->
<div
class="flex overflow-hidden rounded-sm border border-line font-sans text-xs font-bold tracking-widest uppercase"
>
<a
href="/admin/invites"
class="px-3 py-2 transition-colors {data.status !== 'all' ? 'bg-primary text-primary-fg' : 'bg-surface text-ink-2 hover:bg-muted'}"
>
{m.admin_invite_status_active()}
</a>
<a
href="/admin/invites?status=all"
class="border-l border-line px-3 py-2 transition-colors {data.status === 'all' ? 'bg-primary text-primary-fg' : 'bg-surface text-ink-2 hover:bg-muted'}"
>
{m.admin_btn_show_all()}
</a>
</div>
<button
type="button"
onclick={() => (showNewForm = !showNewForm)}
class="inline-flex items-center gap-1.5 bg-primary px-3 py-1.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{m.admin_btn_new_invite()}
</button>
</div>
</div>
<div class="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{#if data.loadError}
<div class="rounded-sm border border-red-200 bg-red-50 p-4 font-sans text-xs text-red-700">
{getErrorMessage(data.loadError)}
</div>
{/if}
{#if form?.created}
<div class="rounded-sm border border-green-200 bg-green-50 p-4">
<p class="mb-1 font-sans text-xs font-bold tracking-widest text-green-800 uppercase">
{m.admin_invite_created_title()}
</p>
<p class="mb-2 font-serif text-sm text-green-700">{m.admin_invite_created_desc()}</p>
<div class="flex items-center gap-2">
<code
class="flex-1 rounded border border-green-200 bg-white px-3 py-1.5 font-mono text-xs break-all text-green-900"
>
{form.created.shareableUrl}
</code>
<button
type="button"
onclick={() => copyLink(form!.created!.id, form!.created!.shareableUrl)}
class="flex-shrink-0 rounded border border-green-300 bg-white px-3 py-1.5 font-sans text-xs font-bold text-green-700 transition-colors hover:bg-green-50"
>
{copiedId === form.created.id ? m.admin_btn_copied() : m.admin_btn_copy_link()}
</button>
</div>
</div>
{/if}
{#if showNewForm}
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.admin_btn_new_invite()}
</h2>
<form
method="POST"
action="?/create"
use:enhance
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<div class="sm:col-span-2">
<label
for="invite-label"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_label()}
</label>
<input
id="invite-label"
type="text"
name="label"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="invite-max-uses"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_max_uses()}
</label>
<input
id="invite-max-uses"
type="number"
name="maxUses"
min="1"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="invite-expires-at"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_expires()}
</label>
<input
id="invite-expires-at"
type="datetime-local"
name="expiresAt"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="invite-prefill-first"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_prefill_first()}
</label>
<input
id="invite-prefill-first"
type="text"
name="prefillFirstName"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="invite-prefill-last"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_prefill_last()}
</label>
<input
id="invite-prefill-last"
type="text"
name="prefillLastName"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="sm:col-span-2">
<label
for="invite-prefill-email"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_prefill_email()}
</label>
<input
id="invite-prefill-email"
type="email"
name="prefillEmail"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{#if form?.createError}
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
{getErrorMessage(form.createError)}
</div>
{/if}
<div class="flex justify-end gap-3 sm:col-span-2">
<button
type="button"
onclick={() => (showNewForm = false)}
class="px-4 py-2 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
>
{m.btn_cancel()}
</button>
<button
type="submit"
class="bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.admin_btn_new_invite()}
</button>
</div>
</form>
</div>
{/if}
<!-- Invite table -->
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
{#if data.invites.length === 0}
<p class="px-6 py-8 text-center font-serif text-sm text-ink-3">{m.admin_invites_empty()}</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line">
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_code()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_label()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_uses()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_expiry()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_status()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_link()}</th
>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-line">
{#each data.invites as invite (invite.id)}
<tr class="hover:bg-muted/40">
<td class="px-4 py-3 font-mono text-xs text-ink">{invite.displayCode}</td>
<td class="px-4 py-3 font-serif text-sm text-ink">{invite.label ?? ''}</td>
<td class="px-4 py-3 font-sans text-xs text-ink-2">
{invite.useCount} / {invite.maxUses != null ? invite.maxUses : m.admin_invite_unlimited()}
</td>
<td class="px-4 py-3 font-sans text-xs text-ink-2">
{invite.expiresAt
? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(invite.expiresAt))
: m.admin_invite_no_expiry()}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1 rounded px-2 py-0.5 font-sans text-xs font-bold {statusColor(invite.status)}"
aria-label={statusLabel(invite.status)}
>
<span aria-hidden="true">{statusIcon(invite.status)}</span>
{statusLabel(invite.status)}
</span>
</td>
<td class="px-4 py-3">
<button
type="button"
onclick={() => copyLink(invite.id, invite.shareableUrl)}
class="font-sans text-xs text-brand-navy/60 transition-colors hover:text-brand-navy"
aria-label="{m.admin_btn_copy_link()}: {invite.displayCode}"
title={invite.shareableUrl}
>
{copiedId === invite.id ? m.admin_btn_copied() : m.admin_btn_copy_link()}
</button>
</td>
<td class="px-4 py-3 text-right">
{#if invite.status === 'active'}
<form method="POST" action="?/revoke" use:enhance>
<input type="hidden" name="id" value={invite.id} />
<button
type="submit"
onclick={(e) => {
if (!confirm(m.admin_invite_revoke_confirm())) e.preventDefault();
}}
class="font-sans text-xs font-bold tracking-widest text-red-500 uppercase transition-colors hover:text-red-700"
>
{m.admin_btn_revoke()}
</button>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</div>