feat(register): redesign register page to match spec

Replaces the minimal login-style form with the full spec design:
hero section (eyebrow, headline, subtext), three labelled form sections,
2-column name grid, confirm-password field with client-side match hints,
password strength indicator, notification checkbox card, loading state on
submit, and "already have an account?" footer link.

Backend: adds notifyOnMention to RegisterRequest and wires both
notifyOnMention and notifyOnReply via updateNotificationPreferences on
invite redemption.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 12:27:03 +02:00
parent 1926e8e6e5
commit d07f7debf8
9 changed files with 321 additions and 117 deletions

5
frontend/.gitignore vendored
View File

@@ -34,3 +34,8 @@ src/lib/paraglide_bak*
# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
e2e/.auth/
**/test-results/**
# Proofshot browser verification artifacts
proofshot-artifacts/

View File

@@ -658,6 +658,22 @@
"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.",
"register_eyebrow": "Ein Familienprojekt",
"register_hero_headline": "Schön, dass du da bist.",
"register_hero_subtext": "Gemeinsam bewahren wir Briefe und Dokumente für die Familie, jetzt und in Zukunft.",
"register_section_about": "Über dich",
"register_section_account": "Konto",
"register_section_notifications": "Benachrichtigungen",
"register_label_password_confirm": "Passwort bestätigen",
"register_pw_hint": "Mindestens 8 Zeichen.",
"register_pw_ok": "Mindestens 8 Zeichen. ✓",
"register_pw_match_ok": "Passwörter stimmen überein.",
"register_pw_match_no": "Die beiden Passwörter stimmen noch nicht überein.",
"register_notify_title": "Benachrichtige mich,",
"register_notify_desc": "wenn jemand mich in einem Kommentar erwähnt oder mir auf einen Kommentar antwortet.",
"register_btn_loading": "Wird erstellt …",
"register_already_have_account": "Du hast bereits ein Konto?",
"register_sign_in": "Anmelden",
"login_registered_success": "Dein Konto wurde erfolgreich erstellt. Melde dich jetzt an.",
"admin_tab_invites": "Einladungen",
"admin_invites_list_title": "Einladungen",

View File

@@ -658,6 +658,22 @@
"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.",
"register_eyebrow": "A family project",
"register_hero_headline": "Glad you're here.",
"register_hero_subtext": "Together we preserve letters and documents for the family, now and in the future.",
"register_section_about": "About you",
"register_section_account": "Account",
"register_section_notifications": "Notifications",
"register_label_password_confirm": "Confirm password",
"register_pw_hint": "At least 8 characters.",
"register_pw_ok": "At least 8 characters. ✓",
"register_pw_match_ok": "Passwords match.",
"register_pw_match_no": "The two passwords don't match yet.",
"register_notify_title": "Notify me",
"register_notify_desc": "when someone mentions me in a comment or replies to one of my comments.",
"register_btn_loading": "Creating …",
"register_already_have_account": "Already have an account?",
"register_sign_in": "Sign in",
"login_registered_success": "Your account was created successfully. Sign in now.",
"admin_tab_invites": "Invites",
"admin_invites_list_title": "Invites",

View File

@@ -658,6 +658,22 @@
"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.",
"register_eyebrow": "Un proyecto familiar",
"register_hero_headline": "Qué bueno que estés aquí.",
"register_hero_subtext": "Juntos conservamos cartas y documentos para la familia, ahora y en el futuro.",
"register_section_about": "Sobre ti",
"register_section_account": "Cuenta",
"register_section_notifications": "Notificaciones",
"register_label_password_confirm": "Confirmar contraseña",
"register_pw_hint": "Al menos 8 caracteres.",
"register_pw_ok": "Al menos 8 caracteres. ✓",
"register_pw_match_ok": "Las contraseñas coinciden.",
"register_pw_match_no": "Las dos contraseñas aún no coinciden.",
"register_notify_title": "Notifícame",
"register_notify_desc": "cuando alguien me mencione en un comentario o responda a uno de mis comentarios.",
"register_btn_loading": "Creando …",
"register_already_have_account": "¿Ya tienes una cuenta?",
"register_sign_in": "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",

View File

@@ -5,7 +5,7 @@ import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
<header class="bg-header">
<div class="h-1 bg-accent"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-12 items-center justify-between">
<div class="flex h-16 items-center justify-between">
<a href="/" class="flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-sm font-bold tracking-widest text-white uppercase"
>Familienarchiv</span

View File

@@ -35,12 +35,13 @@ export const actions = {
const password = formData.get('password') as string;
const firstName = formData.get('firstName') as string;
const lastName = formData.get('lastName') as string;
const notifyOnMention = formData.get('notifyOnMention') === 'on';
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 })
body: JSON.stringify({ code, email, password, firstName, lastName, notifyOnMention })
});
if (!res.ok) {

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/errors';
import { enhance } from '$app/forms';
import AuthHeader from '../AuthHeader.svelte';
let {
@@ -16,6 +17,22 @@ let {
} = $props();
let showPassword = $state(false);
let showPasswordConfirm = $state(false);
let password = $state('');
let passwordConfirm = $state('');
let notifyOnMention = $state(true);
let submitAttempted = $state(false);
let submitting = $state(false);
const pwValid = $derived(password.length >= 8);
const pwMatch = $derived(passwordConfirm.length > 0 && password === passwordConfirm);
const pwMismatch = $derived(passwordConfirm.length > 0 && password !== passwordConfirm);
const showPwHint = $derived(submitAttempted || password.length > 0);
const showMatchHint = $derived(submitAttempted || passwordConfirm.length > 0);
$effect(() => {
if (form?.error) submitting = false;
});
</script>
<svelte:head>
@@ -25,16 +42,8 @@ let showPassword = $state(false);
<div class="flex min-h-screen flex-col bg-canvas">
<AuthHeader />
<main 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>
<main class="flex flex-1 items-center justify-center px-4 py-12">
<div class="w-full max-w-2xl">
{#if data.codeError}
<div class="rounded-sm border border-line bg-surface p-8 text-center shadow-sm">
<svg
@@ -62,118 +71,217 @@ let showPassword = $state(false);
</a>
</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()}
<!-- Hero -->
<div class="mb-8 text-center">
<p
class="mb-3 inline-block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>
{m.register_eyebrow()}
</p>
<h1 class="mb-3 font-serif text-4xl font-normal text-ink sm:text-[46px] sm:leading-tight">
{m.register_hero_headline()}
</h1>
{#if data.code}
<p class="mb-6 font-serif text-xs text-ink-2">{m.register_subtext()}</p>
{/if}
<p class="mx-auto max-w-lg font-serif text-lg text-ink-2">
{m.register_hero_subtext()}
</p>
</div>
<form method="POST" class="space-y-5">
<!-- Form card -->
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm sm:p-10">
<form
method="POST"
class="space-y-8"
use:enhance={() => {
if (!pwValid || pwMismatch) {
submitAttempted = true;
return async () => {};
}
submitAttempted = true;
submitting = true;
return async ({ update }) => {
await update();
};
}}
>
<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>
<!-- Section: Über dich -->
<section class="space-y-5">
<p class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.register_section_about()}
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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-3 font-serif text-[17px] 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-3 font-serif text-[17px] text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
</div>
</section>
<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>
<!-- Section: Konto -->
<section class="space-y-5">
<p class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.register_section_account()}
</p>
<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()}
<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
>
{#if showPassword}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<input
type="email"
name="email"
id="email"
required
autocomplete="email"
value={data.prefill?.email ?? ''}
class="block w-full border border-line px-3 py-3 font-serif text-[17px] 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.5 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"
bind:value={password}
class="block w-full border border-line px-3 py-3 pr-20 font-serif text-[17px] 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 font-sans text-xs font-bold text-ink-3 hover:text-ink"
aria-label={showPassword ? m.register_password_hide() : m.register_password_show()}
>
{showPassword ? m.register_password_hide() : m.register_password_show()}
</button>
</div>
{#if showPwHint}
<p class="mt-1.5 font-sans text-xs {pwValid ? 'text-green-700' : 'text-ink-3'}">
{pwValid ? m.register_pw_ok() : m.register_pw_hint()}
</p>
{/if}
</div>
<div>
<label
for="passwordConfirm"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.register_label_password_confirm()}</label
>
<div class="relative">
<input
type={showPasswordConfirm ? 'text' : 'password'}
id="passwordConfirm"
autocomplete="new-password"
bind:value={passwordConfirm}
aria-invalid={submitAttempted && pwMismatch}
class="block w-full border px-3 py-3 pr-20 font-serif text-[17px] text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring
{submitAttempted && pwMismatch ? 'border-red-400' : 'border-line'}"
/>
<button
type="button"
onclick={() => (showPasswordConfirm = !showPasswordConfirm)}
class="absolute inset-y-0 right-0 flex items-center px-3 font-sans text-xs font-bold text-ink-3 hover:text-ink"
aria-label={showPasswordConfirm ? m.register_password_hide() : m.register_password_show()}
>
{showPasswordConfirm ? m.register_password_hide() : m.register_password_show()}
</button>
</div>
{#if showMatchHint}
{#if pwMatch}
<p class="mt-1.5 font-sans text-xs text-green-700">
{m.register_pw_match_ok()}
</p>
{:else if pwMismatch}
<p class="mt-1.5 font-sans text-xs text-red-600">{m.register_pw_match_no()}</p>
{/if}
{/if}
</div>
</section>
<!-- Section: Benachrichtigungen -->
<section>
<p class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.register_section_notifications()}
</p>
<label
class="flex cursor-pointer items-start gap-3 rounded-sm border p-4 transition-colors
{notifyOnMention ? 'border-brand-navy bg-brand-mint/10' : 'border-line bg-white'}"
>
<input
type="checkbox"
name="notifyOnMention"
bind:checked={notifyOnMention}
class="sr-only"
/>
<!-- Visual checkbox -->
<span
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border transition-colors
{notifyOnMention ? 'border-brand-navy bg-brand-navy' : 'border-line bg-white'}"
aria-hidden="true"
>
{#if notifyOnMention}
<svg class="h-3 w-3 text-white" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
stroke-width="1.8"
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>
</span>
<span>
<span class="block font-sans text-sm font-bold text-ink"
>{m.register_notify_title()}</span
>
<span class="mt-0.5 block font-serif text-sm text-ink-2"
>{m.register_notify_desc()}</span
>
</span>
</label>
</section>
{#if form?.error}
<div class="font-sans text-xs font-medium text-red-600">
@@ -181,12 +289,51 @@ let showPassword = $state(false);
</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>
<div class="space-y-4">
<button
type="submit"
disabled={submitting}
class="flex w-full items-center justify-center gap-2 bg-primary py-3 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90 disabled:cursor-wait disabled:opacity-70"
>
{#if submitting}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{m.register_btn_loading()}
{:else}
{m.register_btn_submit()}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
{/if}
</button>
<p class="text-center font-sans text-xs text-ink-3">
{m.register_already_have_account()}
<a
href="/login"
class="font-bold tracking-widest text-brand-navy uppercase transition-colors hover:text-brand-navy/70"
>{m.register_sign_in()}</a
>
</p>
</div>
</form>
</div>
{/if}