From daea748a209264ed519d498ddf99de2e9b592772 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 01:01:19 +0200 Subject: [PATCH] feat(frontend): invite-based registration UI - 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 --- frontend/messages/de.json | 50 ++- frontend/messages/en.json | 50 ++- frontend/messages/es.json | 50 ++- frontend/src/hooks.server.ts | 9 +- frontend/src/lib/errors.ts | 12 + frontend/src/routes/admin/+layout.server.ts | 15 +- frontend/src/routes/admin/+layout.svelte | 1 + frontend/src/routes/admin/+page.svelte | 10 + frontend/src/routes/admin/EntityNav.svelte | 43 +++ .../routes/admin/entity-nav.svelte.spec.ts | 1 + .../src/routes/admin/invites/+page.server.ts | 88 +++++ .../src/routes/admin/invites/+page.svelte | 343 ++++++++++++++++++ .../src/routes/admin/layout.server.spec.ts | 8 +- .../src/routes/admin/layout.svelte.spec.ts | 1 + frontend/src/routes/admin/page.svelte.spec.ts | 1 + frontend/src/routes/login/+page.server.ts | 5 + frontend/src/routes/login/+page.svelte | 15 +- frontend/src/routes/login/page.svelte.spec.ts | 26 +- .../screenshots/login-default.png | Bin 0 -> 4200 bytes .../test-results/screenshots/login-error.png | Bin 0 -> 4656 bytes frontend/src/routes/register/+page.server.ts | 53 +++ frontend/src/routes/register/+page.svelte | 193 ++++++++++ 22 files changed, 953 insertions(+), 21 deletions(-) create mode 100644 frontend/src/routes/admin/invites/+page.server.ts create mode 100644 frontend/src/routes/admin/invites/+page.svelte create mode 100644 frontend/src/routes/login/test-results/screenshots/login-default.png create mode 100644 frontend/src/routes/login/test-results/screenshots/login-error.png create mode 100644 frontend/src/routes/register/+page.server.ts create mode 100644 frontend/src/routes/register/+page.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index cce36840..49f631fb 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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?" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8e16ebaf..42bda41f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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?" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 387d5256..ca9750dc 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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?" } diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 8fa79aa5..cd16673b 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -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); } diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 81057260..d9ec385d 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -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': diff --git a/frontend/src/routes/admin/+layout.server.ts b/frontend/src/routes/admin/+layout.server.ts index 875c403a..08fd59bf 100644 --- a/frontend/src/routes/admin/+layout.server.ts +++ b/frontend/src/routes/admin/+layout.server.ts @@ -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') diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index abe0106f..b3bd38e7 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -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} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 728c6a69..702dbd1a 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -46,6 +46,16 @@ onMount(() => { {/if} + {#if data.canManageUsers} + +
+
{m.admin_tab_invites()}
+
{data.inviteCount}
+
+ +
+ {/if} + {#if data.canManageTags}
diff --git a/frontend/src/routes/admin/EntityNav.svelte b/frontend/src/routes/admin/EntityNav.svelte index 41cd32b6..668c36a3 100644 --- a/frontend/src/routes/admin/EntityNav.svelte +++ b/frontend/src/routes/admin/EntityNav.svelte @@ -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) { {/snippet} +{#snippet invitesIcon()} + +{/snippet} + {#snippet tagsIcon()} {/if} + {#if canManageUsers} + + {/if} + {#if canManageTags} {/if} + {#if canManageUsers} + + {/if} + {#if canManageTags} { + 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; diff --git a/frontend/src/routes/admin/invites/+page.svelte b/frontend/src/routes/admin/invites/+page.svelte new file mode 100644 index 00000000..56832109 --- /dev/null +++ b/frontend/src/routes/admin/invites/+page.svelte @@ -0,0 +1,343 @@ + + + + {m.admin_tab_invites()} · Familienarchiv + + +
+
+

+ {m.admin_invites_list_title()} +

+ +
+ + + + +
+
+ +
+ {#if data.loadError} +
+ {getErrorMessage(data.loadError)} +
+ {/if} + + {#if form?.created} +
+

+ {m.admin_invite_created_title()} +

+

{m.admin_invite_created_desc()}

+
+ + {form.created.shareableUrl} + + +
+
+ {/if} + + {#if showNewForm} +
+

+ {m.admin_btn_new_invite()} +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {#if form?.createError} +
+ {getErrorMessage(form.createError)} +
+ {/if} +
+ + +
+
+
+ {/if} + + +
+ {#if data.invites.length === 0} +

{m.admin_invites_empty()}

+ {:else} +
+ + + + + + + + + + + + + + {#each data.invites as invite (invite.id)} + + + + + + + + + + {/each} + +
{m.admin_invite_col_code()}{m.admin_invite_col_label()}{m.admin_invite_col_uses()}{m.admin_invite_col_expiry()}{m.admin_invite_col_status()}{m.admin_invite_col_link()}
{invite.displayCode}{invite.label ?? '–'} + {invite.useCount} / {invite.maxUses != null ? invite.maxUses : m.admin_invite_unlimited()} + + {invite.expiresAt + ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(invite.expiresAt)) + : m.admin_invite_no_expiry()} + + + {statusLabel(invite.status)} + + + + + {#if invite.status === 'active'} +
+ + +
+ {/if} +
+
+ {/if} +
+
+
diff --git a/frontend/src/routes/admin/layout.server.spec.ts b/frontend/src/routes/admin/layout.server.spec.ts index 5f911af9..c6df2f28 100644 --- a/frontend/src/routes/admin/layout.server.spec.ts +++ b/frontend/src/routes/admin/layout.server.spec.ts @@ -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); diff --git a/frontend/src/routes/admin/layout.svelte.spec.ts b/frontend/src/routes/admin/layout.svelte.spec.ts index 1a3c9770..5797ccac 100644 --- a/frontend/src/routes/admin/layout.svelte.spec.ts +++ b/frontend/src/routes/admin/layout.svelte.spec.ts @@ -17,6 +17,7 @@ const fullPerms = { userCount: 4, groupCount: 3, tagCount: 7, + inviteCount: 2, canManageUsers: true, canManageTags: true, canManagePermissions: true, diff --git a/frontend/src/routes/admin/page.svelte.spec.ts b/frontend/src/routes/admin/page.svelte.spec.ts index 679e797d..ef44cc4b 100644 --- a/frontend/src/routes/admin/page.svelte.spec.ts +++ b/frontend/src/routes/admin/page.svelte.spec.ts @@ -14,6 +14,7 @@ const fullData = { userCount: 4, groupCount: 3, tagCount: 7, + inviteCount: 2, canManageUsers: true, canManageTags: true, canManagePermissions: true, diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 6acbf003..4c29b511 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -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 }) => { diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index db70c763..95607e1c 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -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(); @@ -25,6 +28,16 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
+ {#if data.registered} +
+ {m.login_registered_success()} +
+ {/if} +

{m.login_heading()}

diff --git a/frontend/src/routes/login/page.svelte.spec.ts b/frontend/src/routes/login/page.svelte.spec.ts index 64681752..9a162be7 100644 --- a/frontend/src/routes/login/page.svelte.spec.ts +++ b/frontend/src/routes/login/page.svelte.spec.ts @@ -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('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('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('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('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('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('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('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(); }); diff --git a/frontend/src/routes/login/test-results/screenshots/login-default.png b/frontend/src/routes/login/test-results/screenshots/login-default.png new file mode 100644 index 0000000000000000000000000000000000000000..6b15416cf4cf26fa0699c8f39174d78d6c20b6cb GIT binary patch literal 4200 zcmeI0S5#AN7KX9>ib2HCMF@fd3J4-aI*L+$S`Y~(G^GeZiZnwDC`}}Qs31yJq=qIX zv_NnOMT&%~lmJ2qNFdZi2qnz<=VoTDnYo>-IX7po{hf8zS^InUyPxyDFgG*c1POuI z*w{G#G`wR0+;iC2j##oE1tK_^+lYT7=(7b9K{`qazZ5#P0|PaUs$ zw7pjEGfjF-6cUif7$lFqm1}`nJ%pP11b-!4S&mtkV}|SMy+X#U^7#~+nxw5twJs8_ zduIf|GbA)7`N6ZPsg~|Fp<6Skh6`9 zMm}87g6rw2`A*yGJSp2)FfG7OLwH@k_SSTCOu=ivN=S9RO|@L3mCIj+E%pbgyblB+ z!st7dP*Fm)TgAcNdG(+n&hrn;Dy`cHoP&e?9Q>*{+;TXIVG3h_=&o+I@=jKzr8Gy! z-FJdT7-`&hOrCB~!0@c5Y6MB$JvqRmSWI>7TJYyvmGv4L%g!zuA{q~Alh)8mT3l6y z*)z;*Zd`7zoZ>KbsiX!T#4g|N5rK!>c;Fu2x+-Vor>8H9c4}$P8q_1^&LD;hcS-h2 zmRKt}IrWwDlDnE(jHM;3th4c6dUuIge!|BiOCDKIrHGw~trcj(NGo3?U}^e7fR1o_K37bI6IRxr|(w#UWBB>)~mi5@r579WySpLBXZNcHfID))X>>RRPEJ=+4 zi~JZ{S$<*ZOPB75dB3!*NZ#WJ5)u-Ub7;y> zQCWDhXyfPz5fBgngTaD=Hg`UE*w3H$J>)K54qhrqe+(ZP z8EIZ`RJiQt>G(2w_0qo;>3H;>C-!w6w$Npj|p*WunUEWL=S2c9ZW6 zK5V+&t0Hcllate-(O)7o2&mIx`~;m&udc4vh{hHde;pp)%DIY0qZM|jll8tqL0JDt z^Q+3r*!=wCx22+=ZB6_pDCpwDnbnz@cD;=LY>n+cMWW_o!L-aw^aIO!pDB28G}!;a zgW2_20^iVE)jWRDz}}6Rr-YOqFBKQih_14|-@*R=*+M=(zN?mm?id?u!}Fh9ETGaR z)EBXTYE@k;k>ZzRwCCYMVWy_o%e0R9ZZkaoH0k%#i>_7Ikkb4ZK913ZSkDe!tn){B zm3g^Mv6ZZ)43I`UB8(Ff65`H?V1}1GQ*P|&Fqup-F|qA%_Sd!I^`kjz0fp9;j(9wt z-?-F`W0$_9=rQmC2a1l0I(qCF-%uyX3WwVJEsx*HD=U*#R)&ckqB2IrEtaWNXJ==7 zdwcvLE3AC|%d=zI8pt#3?D0uS%I{5l@k~h&u(^Zj$wJChO1hK&tW<7zbvV;MW-sxa zVwqLf4OX4k_;vsJufTQ~qovmF#2hU^S{vjpQSI;s z=Fj0G=-S{X0}G4Sx{!29?3F85cqC08Bayl4LDbbLoUyU--FPU8NF)*nE>lfG0SkS$ ztq})+BC+yE5(O2Um%o?q&|6u%k1ogTIYlrjM4iL3;^JU((hWxxV-z_Z;{Gi|Ir8uT zx#iT;*SEh;;JVAhnW^FWb-KnKs;?h6?&a7L%E&&sHeMqY3Xav3GxbESj6D-P*xw?8 zu^3Fm_7oC1I-H|nfSj_Qo1Z_vp_2J+bd)jfo(mIdRZh*$MlFB8s;bX!mZb`w{KmMO zr{sy-`rMT)LLkP(92cJH>MCj^c_{7f?QPjPL8DwYqXj#HrL?sj5&J?SN*7IZSUcn{ zNV_g|(zmtT`vwxT-OD7q!kYq1>Kx3?KcrZ-fw5=L@$nTLtgftRy7i>CwYAMe9y){s z0zp&YQZ;gII&v+P#a!Ijn(uytkf2k4{~vNN)Jx z+_ksX+Uid+qV>K3nngW${Q@X*k9LFhx5&W!%kAf$Zu}69-2X`H7+;@wRC%yNe%YWu zXMc*5vtvwJO6nx;8nyE=V!YaICSs3P*~-H3`HX#@jpow+qVtpS!>KC`QWQ&mfa{DV z&jhTJ)V#>D?=?fJbs-|!=Oy^3J$V0%q=7UAfzh+D>OGP0@pk#U_OP$zC#r=+jLIS7 z{`0QC*JfHnwjP$Vcjqd&blxX;Vv`|SO~u8AGOfWIbFVHMq}a76iX9F^b(C)$7e&!u z>8hX5x3;#vd}kNc{aONk_vTHuer?xZDQ~+@RMXGj@ag;ZEn5jOlqO>xy1hC@8hcp7 z+}m3tHS$OqJb9vG7S+_Geu)7nFoqy>IdFw+ zLuP<(Tjd;EU%&nA*hy|4p1aq*Cu-;CU0pqsl8lr0c6J(YxEbbC4vd?t>j&-4l6e1} z$(FDi{uO|+8Yl~&1-%+IXcB1AH8nMT3DHXVq&G|HO}BF#epSn_A5PzC;1bnNT-mns zZBh0isTS)dYpJRVwrtdSPo71a7fZ|o{^jS5k2namw-=5nx2;(KDxen;!Jv+AEfNhX zb8>UnD3rfU83KuzYd@7GZfk4%>&qZWge~t}i@K+DK5h0IX7rZ_p}0tmIS6s*y=`D8 zQ#U#S%FO5IKIPdL3o4|sWiucKIbaPT}u4reU6NKV_sV#5TrTSOdqAP}pT0X+-QNtI zZTq9odq9Vnd4p@I`OBA^s;fULMI{Xl`+?cVi&RYQOyf%VOdts$;Q6#`_u7)q1g?OV zt^p?QJEQIT0Q}?|w2#yQxHUoOZz?`e7GQA=MbDmg#-F-dnsTO-fS22NRgA&R*=^;h zCqKH{RvYA`<5@Fa{JVruB-=hjZ)%^Pk8{95+!3AkioCeW0=5eM$(@9pn|AeZZf@$T zjck(HFXBA={gd3k{|6%N9FSwRw{}cV`Jn4{NBD0w}cpw5X3N3jVFX&Sz}E zZBoTJ0{?AhW?Id@p(ccjsK)jejNt*dtnk3 z>RJjr@nQ9!{1GJ}iYolU$BfKJcGc}2UC$kJc`q?iC*TsQV*{U_lY?F?5+@VTqo;Th zB_y6WIf)#tRVZcI;WTu@NU1f=%`KV7SPW`1iI-E5 z?m6WMF1)BOxVZ_rB}nIj^!<3K4Hgzd$Iev#2Z-3V>7A2XwQmzwQ?m_Gm6n#aDu)+% z`;J%ZiP6l4;r!>$of|GR1txEAe}8g(yrGeiZojguY@e43^!;@`yEa{zUn%n}rXW8* zH9h?{b7zBWY-AJ_6*U073MADvR^{S5-E8aR^s)^g$%cjoJv2XMe*Wjv>FMc$f`Wj> zet;$b_RjcTZByrEd&oM#uI=A62@Vbp45UZ>!9kjuLZMKZ7%F4nOb`4gZ2Swk_jj_?0(5WRj*gB7S~JnAzrVk#stTC8wTN0Q)*~?)n`u+8=8`IRP^J7Y_UHv+O@n+y9^bcar^oP5u`(>8aq70FDH} vf1L&V^Dm_g}VNEQ4%tmSoNR5=+%B^q6-s`ljcY*JY{js?AJ zf;&)AS;m;#JIsKvw)x_Taf=<)R*dY%Y3Ytl9zBx2wHlg{U6Y34xa-9BHFkWg((tRf z4=`Pe!^4r>TxKOD&fa9pgcQ4oP+nKp!R6(oJV=eZ#|UrEW&c9+;ih|c{J7^@mhFJ@ zU`=^b$*Z+#LkWkJ%@X};O-&XW8hah>((7`cJ_WN%ylQK+Kp^a$Dtc07EZoZjug9WL zBr7fBRl(MSb{FSv^77KArm4EN)!5;nAdr-6dtW9h-h`FP&${rsj+B%I1froB*wUtA z{@AJy*9RUP)JRNhFGgsLk8>Nvh_)|)5eP%7elg3ZpCSa+HQFM%-9=<%sH@}Sc!h;0 zY9)y^$qwvPM2#D*q{#fW_m`8>>e4-CbOYa?$-(fCXX_k+HFD!nLjG2Ga6U18fm)JXbwoYI-^~HPzNy+r-5G;?(`)kXRH+$NBkrUmr9e z;7T)9db&B_nW!j6C7iLPrDbMjM*NVRoP2k8_rr2TL_~i!c<~!AA0KiPJDN81duHac zH&sS*8O-UI6CJ$ZI4b9rBx>GXGwZ*;v?OlXn-a+`Bk#c(kz165W|c^j_d6wwnvPn5 zu%O9`H^c|cgb_mu>*wDf6GZmKh;m^;!Sj{8xHx*d37n5ad`A>#z|LGtN3X6ya^~6T z$w{eE-JR@uMV7E;!bELtZDV62K2=;mpu4SY3FBj7X~}hr@5@wjcE;?z?GxnY=H}rc z&5YITXsn*EQ0|m)m~wJ*D!@%Ibtfc?+a3-*<`)-dcmwZ_7qYcRkx7Y(Rhc&XQxrl3 zag3a9n-}Elx9;m`Y0<(m43g%<*4NfPIT%2pP<64CSF7t3g_a;e{L7Cja+rz&9!5sO z&mUQ7dY~GIL^Ct~xL~v2Osq6C9#g^ZX)E>gWEd!i!#>)yDPqks4KUDBy#if9xBz3( zKVg;PAs&5weZSL#ek;(U0@m~NgM$O0V9yS@Rbe5rFrVFpwrcxHZ!a%6d`KUg;dgc@ z=Y3Fs8JL`G(9YKc_Q6p&UFW(YG3y(fkZ_ldE<5^4UdN_iTU}ipg+gs^Z1`WESh_F8 zaDmy_*=0O7nC{$J*w{Fq@!5W}gdZ*zjlabLvicE3u2=WA>obs=>;^~@sOxow>~4+w z`p5@jQj!}te2?ct#Qn#Mbc&@tw`7rfe+3X_1-1^;_2TaMp*O9;)SU9?KR?lppdcxd z7+@7GEiDDoq-f{`7#zhU`lGAM`uuqJ>~OQYr>9NLcOfk;O~~NeSjC&xZ`OlP9j3h2 z1|AXazrRLIVmDb$oqk#GzMgS=%X4GQd1DL{7#KKSW!ZdjI7!JUsMp}JX&P|x?m`2A zWV-(;pMe>8{XNzA+0k}XRFnumzd@CS1_4!LJxG!rs+g9*k8SYWe!+Qhx?bLItHC`8 zd3FKMPW7IZ3btKmVP$0vm0&?19v+rA?uEflhM+LmKx+uiJd?50-_97l>go=bT$mxD zHBHKugj!joiMS3vXg~P8jtHxda9{1 zo@MoR?C^B>en;0gw>6l%6c?A$efOZ;QN_2fUuT?Fm6mRfVa&zE#Aw*1b7W<3-UOS8 zZ)-rhFY5{et}c&{&QJD-5er2TP5$^HzK=uV-VvDf(Q=5o`u@>T$K%jqF5!Z)o!t)3 za-%w!pV!`xkCfmjj<#RVE{~OPyPU*)x(ig#&j~B(0Y!$+PaF4pWtuNf14aSp|DCDaVQlUkA9h=R3-);wu0MW3*xi6U5|I>g$%0; zilOJsPu`L-QjxUIwVP*YPgsBu8E2Z)zXr`T z`N^C)0l3Yw#0no`c)*G)Sa~w6son>dGRqyp&U&ov>esP z71L&>d;k9Z`CnfIR82%rqGMwh=jT7%3fmwXgw4;-FgQFX zx*eRlghdM#`zYKv-yV8rA|MF4C~qC-#aKCKs_dcq$YXIaF)`v#7f-{IX!x&L{z=p6 zM%q+Yxv%TmqF5R-c8-P-rgED{{I%^|PTC2hrG|(_tqFXa!ZPAd$V`7fZ5IT{y|g<% zh|RMl@Fu_uV5*N+%%YDg@9CK~dhH&~21LCcd;7%iXxcNJQAm;565v4_VtP-l`4$DJ z=6BleU%ze}x3VqgSIA39cy3MArTgxY^S=i=UGcZL_kk5w&PL-Hty<{ppCN~DDmcRJ zwzJweKYsLRX{b;;bilc{CryquMyfjNFoPqO=JzuO^*v%{wRiLE>0I&+b91kKeM8Z- zB#^I(XVP>M(D@vq~F2`zvm z(IxlY`Pex1OQh&|7tJ#>FX$u};*daS6J!cD(u1%2F_8}~+^Yvz-@gCOuqhjKI&3yH zG7`lm>vcPz!drq2DF~s;-WgXQ2{egl|1T~G~Kh@-X4&j~Z zZCQ*8=u_ie0Hksr8)E?LnViYjX87;4km~6*`{M{2V#D*QeN4xbcJzUFArQzzj!viO z6p-T#8*^zw!sz!8#2E5nj-QhF9!F78(b_)6`Q@HuS~%CtIADf6&iA@S7zQN04kFvb z!YG1D5alLq8_%S7Bbdda)9)pHTm(Fjxy2&jJ@O&xFT6_Nz3>}1Z{9amEDLO;qZJ=u zealT@4U&b0hf07z^wYiG5F|AqawEle1i6S$mJ&>(uQHU(S=e#9_t8}9K@sx#CbsJo zL0UgJmQO^?>PF0Fz^Qgc0{9*DufKJef;JEbw_jX6%8}QR%ITM)pw27RkEkrG1Jg`zPFkqCxC}_j*j77=&;TwJj1V++*j~s=6kop#W4M(pkw93_ z7vX4WQ}O90+ylAk)umhk5eXWW9DwayL_|s(8V-BeW0RAg zKB?RoMiBhY@Z6f;$SK9>?ZQXpMj+yE&2uGLh9E}#w zQqjn%s93oBsf9VOLUAD>)fIj8w}~L_f!A}o8CL$VQd0IrMkZC;W5>eEQqmpqBN`zw z&+TR3hUPl!#;=VCxtgRc{e*Q=YBvKlWU?YIU(F*XSsfTiQ(!tcJH^xQU4N{|NNFnk z@c-Q6zlD}#|3NbgJRran4v*X0=H=zp!`ghQxAS`*3VNs`vKJl|MMFUmhDK|ssqGyd zMTLe|$Y+UpZ0Hd#{J81zlA^Np-Q3*#{pI6|A*4tjAM^6>-=C+jDTUnf+nH0)*Jswv z%gy~59Ne2MAtEKkOzP(93fQ+)dB6Iqswzc%dePkzK*&qf6NK@UH23mOxuiKbICM0y z2CFY$y@GQ#W8~R$wmv%G=jPt;&uxrX4d!X=0}n7q0R3-LU0n?jJ}@7w^<@&{o-&;R zGF_m!vVU;!N>$a#*;#?7>@u`>xEL{fbfm!HAEZUtP44VeC9K{1XOxm5Fp%j*hKIM3 ze@H8Z`$MEg%rlYu%Z=NO_vhIv^#G3eccT8~w*S||EuDtTz{SA$KX(EDHAwugAMO8- tqbqntOhj~bRWqII2Hg9Q{8MCNqAZ$B_Q)4(B|wDerHZC { + 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; diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte new file mode 100644 index 00000000..c13c3cc9 --- /dev/null +++ b/frontend/src/routes/register/+page.svelte @@ -0,0 +1,193 @@ + + + + {m.register_heading()} – Familienarchiv + + +
+ + +
+
+ + + {#if data.codeError} +
+ + + +

+ {m.register_invalid_code()} +

+

{m.register_invalid_code_desc()}

+
+ {:else} +
+

+ {m.register_heading()} +

+ {#if data.code} +

{m.register_subtext()}

+ {/if} + +
+ + +
+ + +
+ +
+ + +
+ +
+ + + {#if data.prefill?.email} +

{m.register_prefill_hint()}

+ {/if} +
+ +
+ +
+ + +
+
+ + {#if form?.error} +
+ {getErrorMessage(form.error)} +
+ {/if} + + +
+
+ {/if} +
+
+ +
+

Familienarchiv

+
+