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 00000000..6b15416c Binary files /dev/null and b/frontend/src/routes/login/test-results/screenshots/login-default.png differ diff --git a/frontend/src/routes/login/test-results/screenshots/login-error.png b/frontend/src/routes/login/test-results/screenshots/login-error.png new file mode 100644 index 00000000..3765f5ff Binary files /dev/null and b/frontend/src/routes/login/test-results/screenshots/login-error.png differ diff --git a/frontend/src/routes/register/+page.server.ts b/frontend/src/routes/register/+page.server.ts new file mode 100644 index 00000000..0b67ea44 --- /dev/null +++ b/frontend/src/routes/register/+page.server.ts @@ -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; 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

+
+