feat(frontend): invite-based registration UI
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / OCR Service Tests (push) Successful in 32s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m47s
CI / Unit & Component Tests (pull_request) Failing after 2m29s
CI / Backend Unit Tests (pull_request) Failing after 2m46s

- Add /register route with invite code prefill, password show/hide
- Add /login?registered=1 success banner
- Add /admin/invites page: list, create, revoke, copy link
- Add Einladungen nav section to admin sidebar (ADMIN_USER perm)
- Add invite error codes to errors.ts
- Add 48 i18n keys across de/en/es
- Update hooks.server.ts to allow public access to invite/register API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 01:01:19 +02:00
parent 61fa35df67
commit daea748a20
22 changed files with 953 additions and 21 deletions

View File

@@ -640,5 +640,53 @@
"filter_operator_and": "UND",
"filter_operator_or": "ODER",
"filter_operator_and_label": "Alle gewählten Schlagworte müssen zutreffen (UND)",
"filter_operator_or_label": "Mindestens ein Schlagwort muss zutreffen (ODER)"
"filter_operator_or_label": "Mindestens ein Schlagwort muss zutreffen (ODER)",
"error_invite_not_found": "Einladungslink nicht gefunden oder ungültig.",
"error_invite_exhausted": "Dieser Einladungslink wurde bereits vollständig verwendet.",
"error_invite_revoked": "Dieser Einladungslink wurde deaktiviert.",
"error_invite_expired": "Dieser Einladungslink ist abgelaufen.",
"register_heading": "Konto erstellen",
"register_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.",
"register_label_first_name": "Vorname",
"register_label_last_name": "Nachname",
"register_label_email": "E-Mail-Adresse",
"register_label_password": "Passwort",
"register_prefill_hint": "Von deiner Einladung übernommen du kannst es ändern",
"register_password_show": "Passwort anzeigen",
"register_password_hide": "Passwort ausblenden",
"register_btn_submit": "Konto erstellen",
"register_invalid_code": "Ungültiger Einladungslink",
"register_invalid_code_desc": "Dieser Einladungslink ist nicht gültig, wurde bereits verwendet oder ist abgelaufen. Bitte wende dich an den Administrator.",
"register_success": "Dein Konto wurde erfolgreich erstellt. Du kannst dich jetzt anmelden.",
"login_registered_success": "Dein Konto wurde erfolgreich erstellt. Melde dich jetzt an.",
"admin_tab_invites": "Einladungen",
"admin_invites_list_title": "Einladungen",
"admin_invites_empty": "Keine aktiven Einladungen vorhanden.",
"admin_btn_new_invite": "Neue Einladung",
"admin_btn_show_all": "Alle anzeigen",
"admin_btn_show_active": "Nur aktive",
"admin_btn_revoke": "Widerrufen",
"admin_btn_copy_link": "Link kopieren",
"admin_btn_copied": "Kopiert!",
"admin_invite_status_active": "Aktiv",
"admin_invite_status_exhausted": "Erschöpft",
"admin_invite_status_revoked": "Widerrufen",
"admin_invite_status_expired": "Abgelaufen",
"admin_invite_col_code": "Code",
"admin_invite_col_label": "Bezeichnung",
"admin_invite_col_uses": "Verwendungen",
"admin_invite_col_expiry": "Ablauf",
"admin_invite_col_status": "Status",
"admin_invite_col_link": "Link",
"admin_invite_unlimited": "∞",
"admin_invite_no_expiry": "Kein Ablauf",
"admin_new_invite_label": "Bezeichnung (z. B. für wen)",
"admin_new_invite_max_uses": "Max. Verwendungen (leer = unbegrenzt)",
"admin_new_invite_prefill_first": "Vorname vorausfüllen (optional)",
"admin_new_invite_prefill_last": "Nachname vorausfüllen (optional)",
"admin_new_invite_prefill_email": "E-Mail vorausfüllen (optional)",
"admin_new_invite_expires": "Ablaufdatum (optional)",
"admin_invite_created_title": "Einladung erstellt",
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?"
}

View File

@@ -640,5 +640,53 @@
"filter_operator_and": "AND",
"filter_operator_or": "OR",
"filter_operator_and_label": "All selected tags must match (AND)",
"filter_operator_or_label": "At least one tag must match (OR)"
"filter_operator_or_label": "At least one tag must match (OR)",
"error_invite_not_found": "Invite link not found or invalid.",
"error_invite_exhausted": "This invite link has already been fully used.",
"error_invite_revoked": "This invite link has been deactivated.",
"error_invite_expired": "This invite link has expired.",
"register_heading": "Create account",
"register_subtext": "You've been invited to join Familienarchiv.",
"register_label_first_name": "First name",
"register_label_last_name": "Last name",
"register_label_email": "Email address",
"register_label_password": "Password",
"register_prefill_hint": "Pre-filled from your invite you can change it",
"register_password_show": "Show password",
"register_password_hide": "Hide password",
"register_btn_submit": "Create account",
"register_invalid_code": "Invalid invite link",
"register_invalid_code_desc": "This invite link is not valid, has already been used, or has expired. Please contact the administrator.",
"register_success": "Your account has been created. You can now sign in.",
"login_registered_success": "Your account was created successfully. Sign in now.",
"admin_tab_invites": "Invites",
"admin_invites_list_title": "Invites",
"admin_invites_empty": "No active invites.",
"admin_btn_new_invite": "New invite",
"admin_btn_show_all": "Show all",
"admin_btn_show_active": "Active only",
"admin_btn_revoke": "Revoke",
"admin_btn_copy_link": "Copy link",
"admin_btn_copied": "Copied!",
"admin_invite_status_active": "Active",
"admin_invite_status_exhausted": "Exhausted",
"admin_invite_status_revoked": "Revoked",
"admin_invite_status_expired": "Expired",
"admin_invite_col_code": "Code",
"admin_invite_col_label": "Label",
"admin_invite_col_uses": "Uses",
"admin_invite_col_expiry": "Expiry",
"admin_invite_col_status": "Status",
"admin_invite_col_link": "Link",
"admin_invite_unlimited": "∞",
"admin_invite_no_expiry": "No expiry",
"admin_new_invite_label": "Label (e.g. who it is for)",
"admin_new_invite_max_uses": "Max uses (empty = unlimited)",
"admin_new_invite_prefill_first": "Pre-fill first name (optional)",
"admin_new_invite_prefill_last": "Pre-fill last name (optional)",
"admin_new_invite_prefill_email": "Pre-fill email (optional)",
"admin_new_invite_expires": "Expiry date (optional)",
"admin_invite_created_title": "Invite created",
"admin_invite_created_desc": "Share this link with the person you are inviting:",
"admin_invite_revoke_confirm": "Really revoke this invite?"
}

View File

@@ -640,5 +640,53 @@
"filter_operator_and": "Y",
"filter_operator_or": "O",
"filter_operator_and_label": "Todas las etiquetas seleccionadas deben coincidir (Y)",
"filter_operator_or_label": "Al menos una etiqueta debe coincidir (O)"
"filter_operator_or_label": "Al menos una etiqueta debe coincidir (O)",
"error_invite_not_found": "Enlace de invitación no encontrado o inválido.",
"error_invite_exhausted": "Este enlace de invitación ya ha sido completamente utilizado.",
"error_invite_revoked": "Este enlace de invitación ha sido desactivado.",
"error_invite_expired": "Este enlace de invitación ha expirado.",
"register_heading": "Crear cuenta",
"register_subtext": "Has sido invitado a unirte al Familienarchiv.",
"register_label_first_name": "Nombre",
"register_label_last_name": "Apellido",
"register_label_email": "Correo electrónico",
"register_label_password": "Contraseña",
"register_prefill_hint": "Completado automáticamente desde tu invitación puedes cambiarlo",
"register_password_show": "Mostrar contraseña",
"register_password_hide": "Ocultar contraseña",
"register_btn_submit": "Crear cuenta",
"register_invalid_code": "Enlace de invitación inválido",
"register_invalid_code_desc": "Este enlace de invitación no es válido, ya ha sido utilizado o ha expirado. Contacta al administrador.",
"register_success": "Tu cuenta ha sido creada. Ahora puedes iniciar sesión.",
"login_registered_success": "Tu cuenta fue creada con éxito. Inicia sesión ahora.",
"admin_tab_invites": "Invitaciones",
"admin_invites_list_title": "Invitaciones",
"admin_invites_empty": "No hay invitaciones activas.",
"admin_btn_new_invite": "Nueva invitación",
"admin_btn_show_all": "Mostrar todo",
"admin_btn_show_active": "Solo activas",
"admin_btn_revoke": "Revocar",
"admin_btn_copy_link": "Copiar enlace",
"admin_btn_copied": "¡Copiado!",
"admin_invite_status_active": "Activa",
"admin_invite_status_exhausted": "Agotada",
"admin_invite_status_revoked": "Revocada",
"admin_invite_status_expired": "Expirada",
"admin_invite_col_code": "Código",
"admin_invite_col_label": "Etiqueta",
"admin_invite_col_uses": "Usos",
"admin_invite_col_expiry": "Vencimiento",
"admin_invite_col_status": "Estado",
"admin_invite_col_link": "Enlace",
"admin_invite_unlimited": "∞",
"admin_invite_no_expiry": "Sin vencimiento",
"admin_new_invite_label": "Etiqueta (p. ej. para quién)",
"admin_new_invite_max_uses": "Usos máx. (vacío = ilimitado)",
"admin_new_invite_prefill_first": "Prellenar nombre (opcional)",
"admin_new_invite_prefill_last": "Prellenar apellido (opcional)",
"admin_new_invite_prefill_email": "Prellenar correo (opcional)",
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
"admin_invite_created_title": "Invitación creada",
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?"
}

View File

@@ -5,7 +5,7 @@ import { env } from 'process';
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
import { detectLocale } from '$lib/server/locale';
const PUBLIC_PATHS = ['/login', '/logout', '/forgot-password', '/reset-password'];
const PUBLIC_PATHS = ['/login', '/logout', '/forgot-password', '/reset-password', '/register'];
const handleLocaleDetection: Handle = ({ event, resolve }) => {
if (!event.cookies.get(cookieName)) {
@@ -72,7 +72,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
}
// Password reset endpoints are public — no auth header needed.
const PUBLIC_API_PATHS = ['/api/auth/forgot-password', '/api/auth/reset-password'];
const PUBLIC_API_PATHS = [
'/api/auth/forgot-password',
'/api/auth/reset-password',
'/api/auth/invite/',
'/api/auth/register'
];
if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) {
return fetch(request);
}

View File

@@ -17,6 +17,10 @@ export type ErrorCode =
| 'WRONG_CURRENT_PASSWORD'
| 'IMPORT_ALREADY_RUNNING'
| 'INVALID_RESET_TOKEN'
| 'INVITE_NOT_FOUND'
| 'INVITE_EXHAUSTED'
| 'INVITE_REVOKED'
| 'INVITE_EXPIRED'
| 'ANNOTATION_NOT_FOUND'
| 'ANNOTATION_UPDATE_FAILED'
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
@@ -87,6 +91,14 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_import_already_running();
case 'INVALID_RESET_TOKEN':
return m.error_invalid_reset_token();
case 'INVITE_NOT_FOUND':
return m.error_invite_not_found();
case 'INVITE_EXHAUSTED':
return m.error_invite_exhausted();
case 'INVITE_REVOKED':
return m.error_invite_revoked();
case 'INVITE_EXPIRED':
return m.error_invite_expired();
case 'ANNOTATION_NOT_FOUND':
return m.error_annotation_not_found();
case 'ANNOTATION_UPDATE_FAILED':

View File

@@ -1,4 +1,5 @@
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import type { components } from '$lib/generated/api';
@@ -23,6 +24,7 @@ export async function load({ fetch, locals }) {
if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN'));
const api = createApiClient(fetch);
const canManageUsers = hasPerm(user, 'ADMIN_USER');
// TODO: replace with a dedicated /api/admin/stats endpoint that returns counts only,
// so the System page does not load full entity lists it does not render.
@@ -45,11 +47,22 @@ export async function load({ fetch, locals }) {
throw error(tagsResult.response.status, getErrorMessage(code));
}
let inviteCount = 0;
if (canManageUsers) {
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const inviteRes = await fetch(`${apiUrl}/api/invites`);
if (inviteRes.ok) {
const invites = await inviteRes.json();
inviteCount = Array.isArray(invites) ? invites.length : 0;
}
}
return {
userCount: (usersResult.data ?? []).length,
groupCount: (groupsResult.data ?? []).length,
tagCount: (tagsResult.data ?? []).length,
canManageUsers: hasPerm(user, 'ADMIN_USER'),
inviteCount,
canManageUsers,
canManageTags: hasPerm(user, 'ADMIN_TAG'),
canManagePermissions: hasPerm(user, 'ADMIN_PERMISSION'),
canRunMaintenance: hasPerm(user, 'ADMIN')

View File

@@ -19,6 +19,7 @@ let { data, children } = $props();
userCount={data.userCount}
groupCount={data.groupCount}
tagCount={data.tagCount}
inviteCount={data.inviteCount}
canManageUsers={data.canManageUsers}
canManageTags={data.canManageTags}
canManagePermissions={data.canManagePermissions}

View File

@@ -46,6 +46,16 @@ onMount(() => {
</a>
{/if}
{#if data.canManageUsers}
<a href="/admin/invites" class="flex items-center justify-between px-4 py-4 hover:bg-muted">
<div>
<div class="font-sans text-sm font-bold text-ink">{m.admin_tab_invites()}</div>
<div class="mt-0.5 font-sans text-xs text-ink-3">{data.inviteCount}</div>
</div>
<span class="text-ink-3"></span>
</a>
{/if}
{#if data.canManageTags}
<a href="/admin/tags" class="flex items-center justify-between px-4 py-4 hover:bg-muted">
<div>

View File

@@ -9,6 +9,7 @@ let {
userCount,
groupCount,
tagCount,
inviteCount,
canManageUsers,
canManageTags,
canManagePermissions,
@@ -17,6 +18,7 @@ let {
userCount: number;
groupCount: number;
tagCount: number;
inviteCount: number;
canManageUsers: boolean;
canManageTags: boolean;
canManagePermissions: boolean;
@@ -86,6 +88,23 @@ function handleKeydown(event: KeyboardEvent) {
</svg>
{/snippet}
{#snippet invitesIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('invites') ? 'text-brand-mint' : 'text-white/40'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
{/snippet}
{#snippet tagsIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
@@ -180,6 +199,18 @@ function handleKeydown(event: KeyboardEvent) {
/>
{/if}
{#if canManageUsers}
<EntityNavSection
variant="sidebar"
href="/admin/invites"
label={m.admin_tab_invites()}
isActive={isActive('invites')}
count={inviteCount}
onTabletTrigger={openFlyout}
icon={invitesIcon}
/>
{/if}
{#if canManageTags}
<EntityNavSection
variant="sidebar"
@@ -264,6 +295,18 @@ function handleKeydown(event: KeyboardEvent) {
/>
{/if}
{#if canManageUsers}
<EntityNavSection
variant="flyout"
href="/admin/invites"
label={m.admin_tab_invites()}
isActive={isActive('invites')}
count={inviteCount}
onFlyoutClick={closeFlyout}
icon={invitesIcon}
/>
{/if}
{#if canManageTags}
<EntityNavSection
variant="flyout"

View File

@@ -13,6 +13,7 @@ const props = {
userCount: 5,
groupCount: 3,
tagCount: 8,
inviteCount: 2,
canManageUsers: true,
canManageTags: true,
canManagePermissions: true,

View File

@@ -0,0 +1,88 @@
import { fail } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { parseBackendError } from '$lib/errors';
import type { Actions, PageServerLoad } from './$types';
export interface InviteListItem {
id: string;
code: string;
displayCode: string;
label?: string;
useCount: number;
maxUses?: number;
expiresAt?: string;
revoked: boolean;
status: string;
createdAt: string;
shareableUrl: string;
}
export const load: PageServerLoad = async ({ url, fetch }) => {
const status = url.searchParams.get('status') ?? 'active';
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`);
if (!res.ok) {
const backendError = await parseBackendError(res);
return {
invites: [] as InviteListItem[],
status,
loadError: backendError?.code ?? 'INTERNAL_ERROR'
};
}
const invites: InviteListItem[] = await res.json();
return { invites, status, loadError: null };
};
export const actions = {
create: async ({ request, fetch }) => {
const formData = await request.formData();
const label = (formData.get('label') as string) || undefined;
const maxUsesRaw = formData.get('maxUses') as string;
const maxUses = maxUsesRaw ? parseInt(maxUsesRaw, 10) : undefined;
const prefillFirstName = (formData.get('prefillFirstName') as string) || undefined;
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
const expiresAt = (formData.get('expiresAt') as string) || undefined;
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${apiUrl}/api/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label,
maxUses,
prefillFirstName,
prefillLastName,
prefillEmail,
expiresAt
})
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { createError: backendError?.code ?? 'INTERNAL_ERROR' });
}
const created: InviteListItem = await res.json();
return { created };
},
revoke: async ({ request, fetch }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${apiUrl}/api/invites/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { revokeError: backendError?.code ?? 'INTERNAL_ERROR' });
}
return { revoked: id };
}
} satisfies Actions;

View File

@@ -0,0 +1,343 @@
<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';
}
}
</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-1.5 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-1.5 transition-colors {data.status === 'all' ? 'bg-primary text-primary-fg' : 'bg-surface text-ink-2 hover:bg-muted'}"
>
Alle
</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 ? '✓' : 'Kopieren'}
</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
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_label()}
</label>
<input
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
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_max_uses()}
</label>
<input
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
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_expires()}
</label>
<input
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
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_prefill_first()}
</label>
<input
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
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_prefill_last()}
</label>
<input
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
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_prefill_email()}
</label>
<input
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"
>
Abbrechen
</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="rounded px-2 py-0.5 font-sans text-xs font-bold {statusColor(invite.status)}"
>
{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"
title={invite.shareableUrl}
>
{copiedId === invite.id ? '✓ Kopiert' : 'Link kopieren'}
</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"
>
Widerrufen
</button>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -56,14 +56,20 @@ describe('admin layout load — permission check', () => {
[{ id: 't1' }, { id: 't2' }, { id: 't3' }]
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 'i1' }, { id: 'i2' }]
});
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
fetch: mockFetch as unknown as typeof fetch,
locals: { user: adminUser }
});
expect(result.userCount).toBe(2);
expect(result.groupCount).toBe(1);
expect(result.tagCount).toBe(3);
expect(result.inviteCount).toBe(2);
expect(result.canManageUsers).toBe(true);
expect(result.canManageTags).toBe(true);
expect(result.canManagePermissions).toBe(true);

View File

@@ -17,6 +17,7 @@ const fullPerms = {
userCount: 4,
groupCount: 3,
tagCount: 7,
inviteCount: 2,
canManageUsers: true,
canManageTags: true,
canManagePermissions: true,

View File

@@ -14,6 +14,7 @@ const fullData = {
userCount: 4,
groupCount: 3,
tagCount: 7,
inviteCount: 2,
canManageUsers: true,
canManageTags: true,
canManagePermissions: true,

View File

@@ -1,6 +1,11 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { getErrorMessage } from '$lib/errors';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => {
return { registered: url.searchParams.get('registered') === '1' };
};
export const actions = {
login: async ({ request, cookies, fetch }) => {

View File

@@ -2,7 +2,10 @@
import { m } from '$lib/paraglide/messages.js';
import AuthHeader from '../AuthHeader.svelte';
let { form }: { form?: { error?: string; success?: boolean } } = $props();
let {
data,
form
}: { data: { registered: boolean }; form?: { error?: string; success?: boolean } } = $props();
</script>
<svelte:head>
@@ -25,6 +28,16 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
<!-- Card -->
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
{#if data.registered}
<div
role="status"
aria-live="polite"
class="mb-5 rounded-sm border border-green-200 bg-green-50 px-4 py-3 font-sans text-xs font-medium text-green-800"
>
{m.login_registered_success()}
</div>
{/if}
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-ink uppercase">
{m.login_heading()}
</h1>

View File

@@ -9,7 +9,7 @@ afterEach(cleanup);
describe('Login page rendering', () => {
it('renders the page title', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await expect
.element(page.getByRole('link', { name: 'Familienarchiv' }).first())
.toBeInTheDocument();
@@ -17,54 +17,54 @@ describe('Login page rendering', () => {
});
it('renders the submit button', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await expect.element(page.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument();
});
it('renders the email input', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input).not.toBeNull();
});
it('renders the password input', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
expect(input).not.toBeNull();
});
it('email field is required', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input?.required).toBe(true);
});
it('password field is required', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
expect(input?.required).toBe(true);
});
it('email field has type="email"', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input?.type).toBe('email');
});
it('password field has type="password"', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
expect(input?.type).toBe('password');
});
it('form submits to the login action', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await tick();
const form = document.querySelector<HTMLFormElement>('form');
expect(form?.action).toMatch(/\?\/login$/);
@@ -73,25 +73,25 @@ describe('Login page rendering', () => {
describe('Login page error state', () => {
it('shows no error when form is undefined', async () => {
render(LoginPage, {});
render(LoginPage, { data: { registered: false } });
await tick();
expect(document.querySelector('.text-red-600')).toBeNull();
});
it('shows no error when form has no error property', async () => {
render(LoginPage, { form: {} });
render(LoginPage, { data: { registered: false }, form: {} });
await tick();
expect(document.querySelector('.text-red-600')).toBeNull();
});
it('displays the error message from the form action', async () => {
render(LoginPage, { form: { error: 'Ungültige Anmeldedaten.' } });
render(LoginPage, { data: { registered: false }, form: { error: 'Ungültige Anmeldedaten.' } });
await expect.element(page.getByText('Ungültige Anmeldedaten.')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/login-error.png' });
});
it('applies red styling to the error text', async () => {
render(LoginPage, { form: { error: 'Fehler!' } });
render(LoginPage, { data: { registered: false }, form: { error: 'Fehler!' } });
await tick();
expect(document.querySelector('.text-red-600')).not.toBeNull();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,53 @@
import { fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { parseBackendError } from '$lib/errors';
import type { Actions, PageServerLoad } from './$types';
interface InvitePrefill {
firstName?: string;
lastName?: string;
email?: string;
}
export const load: PageServerLoad = async ({ url, fetch }) => {
const code = url.searchParams.get('code');
if (!code) {
return { code: null, prefill: null, codeError: null };
}
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${apiUrl}/api/auth/invite/${encodeURIComponent(code)}`);
if (!res.ok) {
const backendError = await parseBackendError(res);
return { code, prefill: null, codeError: backendError?.code ?? 'INTERNAL_ERROR' };
}
const prefill: InvitePrefill = await res.json();
return { code, prefill, codeError: null };
};
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const code = formData.get('code') as string;
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const firstName = formData.get('firstName') as string;
const lastName = formData.get('lastName') as string;
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${apiUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, email, password, firstName, lastName })
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: backendError?.code ?? 'INTERNAL_ERROR' });
}
throw redirect(303, '/login?registered=1');
}
} satisfies Actions;

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/errors';
import AuthHeader from '../AuthHeader.svelte';
let {
data,
form
}: {
data: {
code: string | null;
prefill: { firstName?: string; lastName?: string; email?: string } | null;
codeError: string | null;
};
form?: { error?: string };
} = $props();
let showPassword = $state(false);
</script>
<svelte:head>
<title>{m.register_heading()} Familienarchiv</title>
</svelte:head>
<div class="flex min-h-screen flex-col bg-canvas">
<AuthHeader />
<div class="flex flex-1 items-center justify-center px-4 py-8">
<div class="w-full max-w-sm">
<div class="mb-10 text-center">
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-2xl font-bold tracking-widest text-ink uppercase"
>Familienarchiv</span
>
</a>
</div>
{#if data.codeError}
<div class="rounded-sm border border-line bg-surface p-8 text-center shadow-sm">
<svg
class="mx-auto mb-4 h-10 w-10 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<h1 class="mb-2 font-sans text-sm font-bold tracking-widest text-ink uppercase">
{m.register_invalid_code()}
</h1>
<p class="font-serif text-sm text-ink-2">{m.register_invalid_code_desc()}</p>
</div>
{:else}
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
<h1 class="mb-1 font-sans text-sm font-bold tracking-widest text-ink uppercase">
{m.register_heading()}
</h1>
{#if data.code}
<p class="mb-6 font-serif text-xs text-ink-2">{m.register_subtext()}</p>
{/if}
<form method="POST" class="space-y-5">
<input type="hidden" name="code" value={data.code ?? ''} />
<div>
<label
for="firstName"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.register_label_first_name()}</label
>
<input
type="text"
name="firstName"
id="firstName"
autocomplete="given-name"
value={data.prefill?.firstName ?? ''}
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="lastName"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.register_label_last_name()}</label
>
<input
type="text"
name="lastName"
id="lastName"
autocomplete="family-name"
value={data.prefill?.lastName ?? ''}
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="email"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.register_label_email()}</label
>
<input
type="email"
name="email"
id="email"
required
autocomplete="email"
value={data.prefill?.email ?? ''}
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if data.prefill?.email}
<p class="mt-1 font-sans text-xs text-ink-3">{m.register_prefill_hint()}</p>
{/if}
</div>
<div>
<label
for="password"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.register_label_password()}</label
>
<div class="relative">
<input
type={showPassword ? 'text' : 'password'}
name="password"
id="password"
required
autocomplete="new-password"
class="block w-full border border-line px-3 py-2.5 pr-10 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="absolute inset-y-0 right-0 flex items-center px-3 text-ink-3 hover:text-ink"
aria-label={showPassword ? m.register_password_hide() : m.register_password_show()}
>
{#if showPassword}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
{:else}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{/if}
</button>
</div>
</div>
{#if form?.error}
<div class="font-sans text-xs font-medium text-red-600">
{getErrorMessage(form.error)}
</div>
{/if}
<button
type="submit"
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.register_btn_submit()}
</button>
</form>
</div>
{/if}
</div>
</div>
<div class="py-4 text-center">
<p class="font-sans text-xs tracking-widest text-ink-3 uppercase">Familienarchiv</p>
</div>
</div>