Frontend: A4 — Join household (accept invite) #21

Open
opened 2026-04-02 11:26:52 +02:00 by marcel · 10 comments
Owner

Summary

Invited member opens a link, sees the household identity, creates an account, and is automatically joined with the Member role.

Journey: J6 — Household setup
Role: New user (invited, unauthenticated)

Layout

Mobile (< 768px)

  • Green-tint banner (logo 48px, household name 22px, inviter text 12px) + form below

Desktop (> 1024px)

  • Left: 400px identity panel, --green-tint bg (logo, household name, inviter name, permissions list)
  • Right: flex:1 form on --color-page bg

Identity Panel Content

  • App logo
  • Household name (e.g., "Smith family")
  • "Invited by [planner name]"
  • Permissions info box:
    • View the weekly meal plan
    • Check off shopping list items
    • Add items to the shopping list

Form Fields

  • Name, email, password
  • Submit → creates user_account + household_member (role=member) + updates household_invite → redirect to C1

Acceptance Criteria

  • Accepts invite code/link from URL parameter
  • Displays household name and inviter info
  • Shows permissions the member will receive
  • Creates account with Member role on submit
  • Redirects to C1 (weekly planner) after join
  • No navigation chrome visible (pre-auth layout)
## Summary Invited member opens a link, sees the household identity, creates an account, and is automatically joined with the Member role. **Journey:** J6 — Household setup **Role:** New user (invited, unauthenticated) ## Layout ### Mobile (< 768px) - Green-tint banner (logo 48px, household name 22px, inviter text 12px) + form below ### Desktop (> 1024px) - Left: 400px identity panel, `--green-tint` bg (logo, household name, inviter name, permissions list) - Right: flex:1 form on `--color-page` bg ## Identity Panel Content - App logo - Household name (e.g., "Smith family") - "Invited by [planner name]" - Permissions info box: - View the weekly meal plan - Check off shopping list items - Add items to the shopping list ## Form Fields - Name, email, password - Submit → creates `user_account` + `household_member` (role=member) + updates `household_invite` → redirect to C1 ## Acceptance Criteria - [ ] Accepts invite code/link from URL parameter - [ ] Displays household name and inviter info - [ ] Shows permissions the member will receive - [ ] Creates account with Member role on submit - [ ] Redirects to C1 (weekly planner) after join - [ ] No navigation chrome visible (pre-auth layout)
marcel added the kind/featurepriority/medium labels 2026-04-02 11:30:02 +02:00
Author
Owner

Spec file: specs/frontend/j6-household-setup.html — screen A4 with mobile + desktop previews, agent table, and LLM implementation guide.

**Spec file:** [`specs/frontend/j6-household-setup.html`](../specs/frontend/j6-household-setup.html) — screen A4 with mobile + desktop previews, agent table, and LLM implementation guide.
Author
Owner

👨‍💻 Kai — Frontend Engineer

A4 is a pre-auth page, which means a different layout shell than anything else in the app — no nav, no session context, fully public route. Let me think through the implementation concerns.

Routing:

  • This page lives at something like /join/[token] — a dynamic route. The +page.server.ts load function must read the invite token from params.token, call the backend to validate it (is the invite valid? not expired?), and return the household name + inviter name. If the token is invalid or expired, the load function should redirect to an error page or return a 404/410 — not render a broken invite page.
  • This is a pre-auth route, so hooks.server.ts must explicitly allow it through without a session redirect.

Form handling:

  • The submit creates a user account + joins the household in one action. That's a +page.server.ts form action, not a client-side fetch. The invite token must be included as a hidden field or re-read from params in the action.
  • On success, the action should redirect to C1. On failure (e.g., name already taken, email already registered, invite expired between load and submit), we need defined error states.

Component split I'd propose:

  • JoinHouseholdPage.svelte — layout wrapper (no-chrome, mobile/desktop split)
  • HouseholdIdentityPanel.svelte — logo, household name, inviter text, permissions list (left panel desktop / banner mobile)
  • JoinForm.svelte — name, email, password fields + submit

Questions:

  • What happens if the user already has an account with that email? Do we show a "log in instead" prompt? This is a UX gap in the current spec.
  • The permissions list — is it static text (hardcoded in the component) or fetched from the backend? It looks static from the spec, but I want to confirm before I hardcode it.
  • Desktop split: left panel is 400px, right is flex:1. Is there a minimum total width before the layout collapses to mobile? Or is it strictly < 768px mobile, > 1024px desktop with a tablet state in between?
## 👨‍💻 Kai — Frontend Engineer A4 is a pre-auth page, which means a different layout shell than anything else in the app — no nav, no session context, fully public route. Let me think through the implementation concerns. **Routing:** - This page lives at something like `/join/[token]` — a dynamic route. The `+page.server.ts` load function must read the invite token from `params.token`, call the backend to validate it (is the invite valid? not expired?), and return the household name + inviter name. If the token is invalid or expired, the load function should redirect to an error page or return a 404/410 — not render a broken invite page. - This is a pre-auth route, so `hooks.server.ts` must explicitly allow it through without a session redirect. **Form handling:** - The submit creates a user account + joins the household in one action. That's a `+page.server.ts` form action, not a client-side fetch. The invite token must be included as a hidden field or re-read from params in the action. - On success, the action should redirect to C1. On failure (e.g., name already taken, email already registered, invite expired between load and submit), we need defined error states. **Component split I'd propose:** - `JoinHouseholdPage.svelte` — layout wrapper (no-chrome, mobile/desktop split) - `HouseholdIdentityPanel.svelte` — logo, household name, inviter text, permissions list (left panel desktop / banner mobile) - `JoinForm.svelte` — name, email, password fields + submit **Questions:** - What happens if the user already has an account with that email? Do we show a "log in instead" prompt? This is a UX gap in the current spec. - The permissions list — is it static text (hardcoded in the component) or fetched from the backend? It looks static from the spec, but I want to confirm before I hardcode it. - Desktop split: left panel is 400px, right is flex:1. Is there a minimum total width before the layout collapses to mobile? Or is it strictly `< 768px` mobile, `> 1024px` desktop with a tablet state in between?
Author
Owner

🔧 Backend Engineer — Join Household (A4)

The accept-invite flow touches three tables in one transaction: user_account, household_member, and household_invite. Let me walk through the backend concerns.

Transaction boundary:

  • The submit action must create the user_account, create the household_member (role=member), and mark household_invite as used — all within a single @Transactional block. If any step fails, the whole thing rolls back. A partial success (user created but not joined, or invite not consumed) would leave the system in an inconsistent state.

Invite token lifecycle:

  • GET /invites/{token} (for the load function) — validates the token and returns household name + inviter name. Must check: token exists, not expired, not already used. Returning a 404 vs 410 (Gone) for expired vs used tokens would give the frontend a useful distinction.
  • POST /invites/{token}/accept (for the form submit) — performs the three-step transaction above. The token must be re-validated inside this endpoint too, not trusted because the load step passed (TOCTOU window between page load and form submission).

Email uniqueness:

  • What happens if the email in the form is already registered? The user_account insert will hit a UNIQUE constraint. The service must catch this and return a meaningful 409 response — not let a raw SQL constraint violation bubble up as a 500.
  • Related: the spec says "creates user_account" — should we also handle the case where the person already has an account and just wants to join? Or is that out of scope for v1?

Questions:

  • Is household_invite a token column or a UUID primary key? How is it generated? The issue says "invite code/link from URL parameter" but doesn't specify format. UUIDv4 is the minimum bar for unguessability (see also issue #14 on invite authorization).
  • Does accepting an invite also trigger a session creation (the user is logged in immediately after), or do they need to log in separately after account creation? The redirect to C1 implies immediate login — confirm the action creates a session.
## 🔧 Backend Engineer — Join Household (A4) The accept-invite flow touches three tables in one transaction: `user_account`, `household_member`, and `household_invite`. Let me walk through the backend concerns. **Transaction boundary:** - The submit action must create the `user_account`, create the `household_member` (role=member), and mark `household_invite` as used — all within a single `@Transactional` block. If any step fails, the whole thing rolls back. A partial success (user created but not joined, or invite not consumed) would leave the system in an inconsistent state. **Invite token lifecycle:** - `GET /invites/{token}` (for the load function) — validates the token and returns household name + inviter name. Must check: token exists, not expired, not already used. Returning a 404 vs 410 (Gone) for expired vs used tokens would give the frontend a useful distinction. - `POST /invites/{token}/accept` (for the form submit) — performs the three-step transaction above. The token must be re-validated inside this endpoint too, not trusted because the load step passed (TOCTOU window between page load and form submission). **Email uniqueness:** - What happens if the email in the form is already registered? The `user_account` insert will hit a UNIQUE constraint. The service must catch this and return a meaningful 409 response — not let a raw SQL constraint violation bubble up as a 500. - Related: the spec says "creates `user_account`" — should we also handle the case where the person already has an account and just wants to join? Or is that out of scope for v1? **Questions:** - Is `household_invite` a token column or a UUID primary key? How is it generated? The issue says "invite code/link from URL parameter" but doesn't specify format. UUIDv4 is the minimum bar for unguessability (see also issue #14 on invite authorization). - Does accepting an invite also trigger a session creation (the user is logged in immediately after), or do they need to log in separately after account creation? The redirect to C1 implies immediate login — confirm the action creates a session.
Author
Owner

🧪 QA Engineer — Join Household (A4)

The invite flow is a high-stakes path — it's the only way a new member enters the system. I want comprehensive coverage before this ships.

Happy path:

  • Valid invite token in URL → page loads with correct household name and inviter name
  • Fill name, email, password → submit → account created, member joined, redirected to C1 with active session

Bad paths I need tested:

Scenario Expected behavior
Invalid token (never existed) Error page or 404
Expired invite token Error with "invite expired" message
Already-used invite token Error with "already accepted" or similar
Email already registered 409 with "email already in use" message
Name field empty Validation error, form not submitted
Password too short Validation error (matches signup rules)
Valid token, submit fails mid-transaction No partial state, meaningful error

Edge cases:

  • Token in URL is valid when page loads, but expires before form is submitted (TOCTOU) — the submit must re-validate and return a clear error
  • Submitting the form twice (double-click or duplicate POST) — second submit should return a meaningful error, not create a duplicate account or throw a raw DB exception
  • What if the invited user's browser has an existing session? Does the join flow clear the old session, log them out first, or error?

Pre-auth layout:

  • Navigating to /join/[token] while already logged in — what happens? Redirect to C1? Show the form anyway? This needs a defined behavior and a test.

Component tests I'll write:

  • HouseholdIdentityPanel: renders household name, inviter name, and all three permissions from props
  • JoinForm: shows validation errors on empty submit; disables submit button while submitting; renders success state / redirect after valid submission
## 🧪 QA Engineer — Join Household (A4) The invite flow is a high-stakes path — it's the only way a new member enters the system. I want comprehensive coverage before this ships. **Happy path:** - Valid invite token in URL → page loads with correct household name and inviter name - Fill name, email, password → submit → account created, member joined, redirected to C1 with active session **Bad paths I need tested:** | Scenario | Expected behavior | |---|---| | Invalid token (never existed) | Error page or 404 | | Expired invite token | Error with "invite expired" message | | Already-used invite token | Error with "already accepted" or similar | | Email already registered | 409 with "email already in use" message | | Name field empty | Validation error, form not submitted | | Password too short | Validation error (matches signup rules) | | Valid token, submit fails mid-transaction | No partial state, meaningful error | **Edge cases:** - Token in URL is valid when page loads, but expires before form is submitted (TOCTOU) — the submit must re-validate and return a clear error - Submitting the form twice (double-click or duplicate POST) — second submit should return a meaningful error, not create a duplicate account or throw a raw DB exception - What if the invited user's browser has an existing session? Does the join flow clear the old session, log them out first, or error? **Pre-auth layout:** - Navigating to `/join/[token]` while already logged in — what happens? Redirect to C1? Show the form anyway? This needs a defined behavior and a test. **Component tests I'll write:** - `HouseholdIdentityPanel`: renders household name, inviter name, and all three permissions from props - `JoinForm`: shows validation errors on empty submit; disables submit button while submitting; renders success state / redirect after valid submission
Author
Owner

🔐 Sable — Security Engineer

A4 is the invite acceptance flow, and it's a concentrated security risk surface. This is exactly the kind of screen that needs a threat model before a single line is written.

Invite token security (critical):

  • The invite token in the URL is a capability token — anyone with the link can create an account and join the household. Per the project threat model: tokens must be UUIDv4 minimum (128 bits of entropy), single-use, and time-limited. This issue doesn't specify token expiry — what's the TTL? 24 hours? 7 days?
  • The token must be invalidated immediately upon use (set used_at timestamp or mark status = accepted). The TOCTOU window between GET /invites/{token} (load) and POST /invites/{token}/accept (submit) is a real race condition — the accept endpoint must re-validate atomically.
  • Tokens must not be logged in application logs or access logs. If the token is in the URL path (/join/{token}), it will appear in server access logs by default. Consider whether this is acceptable or whether a POST-first flow (token submitted in request body) would be safer.

Session fixation on account creation:

  • After the account is created and the session is established, ensure session fixation protection is active — Spring Security should rotate the session ID at login. Don't reuse any pre-auth session.

Open redirect prevention:

  • The redirect to C1 after join must be a hardcoded path, not taken from a query parameter (e.g., ?next=/c1). A ?next=https://evil.com parameter is an open redirect vulnerability. We've addressed this in the login flow — apply the same rule here.

Role enforcement:

  • The accepted invite must always produce a member role, regardless of what's in the request body. The role must be set by the server from the invite record, not accepted from the client. A client sending role=planner in the form submit must be ignored.

Questions:

  • Is there a maximum number of uses per invite link planned (e.g., link works for one person only, or can be reused until it expires)? Single-use is strongly preferred for least-privilege.
  • Will invite links be sent via email? If so, what's the email deliverability and link-leak threat model (forwarded emails, email provider scanning)?
## 🔐 Sable — Security Engineer A4 is the invite acceptance flow, and it's a concentrated security risk surface. This is exactly the kind of screen that needs a threat model before a single line is written. **Invite token security (critical):** - The invite token in the URL is a capability token — anyone with the link can create an account and join the household. Per the project threat model: tokens must be UUIDv4 minimum (128 bits of entropy), single-use, and time-limited. This issue doesn't specify token expiry — what's the TTL? 24 hours? 7 days? - The token must be invalidated immediately upon use (set `used_at` timestamp or mark `status = accepted`). The TOCTOU window between `GET /invites/{token}` (load) and `POST /invites/{token}/accept` (submit) is a real race condition — the accept endpoint must re-validate atomically. - Tokens must not be logged in application logs or access logs. If the token is in the URL path (`/join/{token}`), it will appear in server access logs by default. Consider whether this is acceptable or whether a POST-first flow (token submitted in request body) would be safer. **Session fixation on account creation:** - After the account is created and the session is established, ensure session fixation protection is active — Spring Security should rotate the session ID at login. Don't reuse any pre-auth session. **Open redirect prevention:** - The redirect to C1 after join must be a hardcoded path, not taken from a query parameter (e.g., `?next=/c1`). A `?next=https://evil.com` parameter is an open redirect vulnerability. We've addressed this in the login flow — apply the same rule here. **Role enforcement:** - The accepted invite must always produce a `member` role, regardless of what's in the request body. The role must be set by the server from the invite record, not accepted from the client. A client sending `role=planner` in the form submit must be ignored. **Questions:** - Is there a maximum number of uses per invite link planned (e.g., link works for one person only, or can be reused until it expires)? Single-use is strongly preferred for least-privilege. - Will invite links be sent via email? If so, what's the email deliverability and link-leak threat model (forwarded emails, email provider scanning)?
Author
Owner

🎨 Atlas — UI/UX Designer

A4 is the first thing an invited member sees of this app — it's both a trust signal and an onboarding moment. The design needs to do real work here.

Identity panel — mobile vs desktop:

  • The mobile layout uses a "green-tint banner" at the top with the form below. The desktop layout uses a 400px left panel with --green-tint bg. The green-tint color is correct for this — it's the app's primary identity color and it communicates "you're joining something real."
  • Mobile banner: logo 48px, household name at 22px. What font? Fraunces for the household name (it's a display moment) or DM Sans? I'd use Fraunces at weight 600 for the household name — it's the one piece of identity content the invited user cares about.
  • The inviter text at 12px — confirm this is DM Sans, weight 400, --color-text-muted or similar. It should be clearly subordinate to the household name.

Permissions info box:

  • The permissions list (view plan, check off items, add to shopping list) should be visually grouped — a light box on --green-tint bg with a slightly darker border or subtle inset. Three bullet points with checkmark icons (not list bullets) would reinforce the positive framing.
  • This content is static — it describes the member role's fixed capabilities. Don't design it as dynamic/fetched content.

Form design:

  • No navigation chrome visible — good, this is a pre-auth layout. The form should feel clean and focused: name, email, password, one primary CTA button.
  • Button: "Join household" (not "Submit" or "Create account") — the action language should match the context.
  • Password field should have a show/hide toggle — this is signup, not login, and users often mistype new passwords.

Missing states to design:

  • Invalid/expired token: a friendly error screen that explains what happened and offers to contact the inviter (not a raw 404)
  • Email already in use: inline form error with a "log in instead" link
  • Loading state between submit and redirect

Questions:

  • What does the 768px–1024px tablet range show? The spec jumps from mobile to desktop. I need to define a tablet behavior — probably the mobile layout (banner + form stacked) at tablet widths is fine, but let's confirm.
## 🎨 Atlas — UI/UX Designer A4 is the first thing an invited member sees of this app — it's both a trust signal and an onboarding moment. The design needs to do real work here. **Identity panel — mobile vs desktop:** - The mobile layout uses a "green-tint banner" at the top with the form below. The desktop layout uses a 400px left panel with `--green-tint` bg. The green-tint color is correct for this — it's the app's primary identity color and it communicates "you're joining something real." - Mobile banner: logo 48px, household name at 22px. What font? Fraunces for the household name (it's a display moment) or DM Sans? I'd use Fraunces at weight 600 for the household name — it's the one piece of identity content the invited user cares about. - The inviter text at 12px — confirm this is DM Sans, weight 400, `--color-text-muted` or similar. It should be clearly subordinate to the household name. **Permissions info box:** - The permissions list (view plan, check off items, add to shopping list) should be visually grouped — a light box on `--green-tint` bg with a slightly darker border or subtle inset. Three bullet points with checkmark icons (not list bullets) would reinforce the positive framing. - This content is static — it describes the member role's fixed capabilities. Don't design it as dynamic/fetched content. **Form design:** - No navigation chrome visible — good, this is a pre-auth layout. The form should feel clean and focused: name, email, password, one primary CTA button. - Button: "Join household" (not "Submit" or "Create account") — the action language should match the context. - Password field should have a show/hide toggle — this is signup, not login, and users often mistype new passwords. **Missing states to design:** - Invalid/expired token: a friendly error screen that explains what happened and offers to contact the inviter (not a raw 404) - Email already in use: inline form error with a "log in instead" link - Loading state between submit and redirect **Questions:** - What does the 768px–1024px tablet range show? The spec jumps from mobile to desktop. I need to define a tablet behavior — probably the mobile layout (banner + form stacked) at tablet widths is fine, but let's confirm.
Author
Owner

🔐 Sable — Security Engineer (follow-up discussion)

Picking up from my April 3 threat model comment. Worked through the open items with the team.

Resolved

1. Invite token format (8-char alphanumeric)
Intentional. Time-limited exposure reduces risk. Mitigated by requiring enumeration-resistant responses: all negative token cases (never existed, expired, already used) must return 404 with no distinguishing message. Frontend shows a single generic "invite link invalid or expired" screen.

2. Token TTL and single-use — deferred
Raised the gap: a single-use, single-active-link model makes it hard to invite multiple members simultaneously. This is a product decision, not a security decision. Opened issue #60 for resolution.

3. Token in access logs
Token appears in URL path → server access logs. Accepted as a known risk given the self-hosted deployment context and low-value target profile. No mitigation required.

4. Session fixation protection — IN SCOPE
Current SecurityFilterChain has maximumSessions(1) but no explicit session fixation config. Spring's default (IF_REQUIRED) does not guarantee session ID rotation on login. Must add .sessionFixation().changeSessionId() to the sessionManagement block as part of this issue. This also closes the gap for the existing login flow.

5. Role pinning
HouseholdService.acceptInvite() already hardcodes "member" — role is never read from the request body. Correct as-is, no change needed.

6. Authenticated user on /join/[token]
Defined behavior: hooks.server.ts detects an existing session on /join/[token] routes and redirects to C1 immediately. Invite is not consumed. User must be unauthenticated to accept an invite.

Unresolved

  • Rate limiting on the accept endpoint — accepted as a low-priority risk for a household app. Revisit if the endpoint gets broader exposure.

Overall read

The flow is sound with the mitigations above. The two concrete additions to the acceptance criteria are: (1) enumeration-resistant 404s on all invalid-token paths, and (2) session fixation protection in SecurityFilterChain. Both are straightforward to implement and verify.

## 🔐 Sable — Security Engineer (follow-up discussion) Picking up from my April 3 threat model comment. Worked through the open items with the team. ### Resolved **1. Invite token format (8-char alphanumeric)** Intentional. Time-limited exposure reduces risk. Mitigated by requiring **enumeration-resistant responses**: all negative token cases (never existed, expired, already used) must return **404** with no distinguishing message. Frontend shows a single generic "invite link invalid or expired" screen. **2. Token TTL and single-use — deferred** Raised the gap: a single-use, single-active-link model makes it hard to invite multiple members simultaneously. This is a product decision, not a security decision. Opened issue #60 for resolution. **3. Token in access logs** Token appears in URL path → server access logs. Accepted as a known risk given the self-hosted deployment context and low-value target profile. No mitigation required. **4. Session fixation protection — IN SCOPE** Current `SecurityFilterChain` has `maximumSessions(1)` but no explicit session fixation config. Spring's default (`IF_REQUIRED`) does not guarantee session ID rotation on login. **Must add `.sessionFixation().changeSessionId()`** to the `sessionManagement` block as part of this issue. This also closes the gap for the existing login flow. **5. Role pinning** `HouseholdService.acceptInvite()` already hardcodes `"member"` — role is never read from the request body. Correct as-is, no change needed. **6. Authenticated user on `/join/[token]`** Defined behavior: `hooks.server.ts` detects an existing session on `/join/[token]` routes and redirects to C1 immediately. Invite is not consumed. User must be unauthenticated to accept an invite. ### Unresolved - **Rate limiting** on the accept endpoint — accepted as a low-priority risk for a household app. Revisit if the endpoint gets broader exposure. ### Overall read The flow is sound with the mitigations above. The two concrete additions to the acceptance criteria are: (1) enumeration-resistant 404s on all invalid-token paths, and (2) session fixation protection in `SecurityFilterChain`. Both are straightforward to implement and verify.
Author
Owner

👨‍💻 Kai — Frontend Engineer (follow-up discussion)

Picking up from my April 3 implementation comment. Resolved all open questions.

Resolved

1. Email-already-registered UX
409 from backend → inline form error: "Diese E-Mail-Adresse ist bereits registriert." + "Anmelden →" link to /login?next=/join/{token}. Existing login redirect logic handles the return flow. Note: login's next param must validate same-origin (Sable's open redirect rule applies here too).

2. Tablet breakpoint
Mobile layout (banner + form stacked) for everything < 1024px. Desktop split (400px identity panel + flex:1 form) kicks in at >= 1024px only.

3. Permissions list
Static, hardcoded in HouseholdIdentityPanel.svelte. No prop, no fetch.

4. Authenticated-user redirect in hooks.server.ts

  • Add /join to PUBLIC_ROUTES so unauthenticated users can reach the page
  • Immediately after the isPublicRoute check passes, redirect authenticated users (cookie present) to /
  • No need to call /v1/auth/me — cookie presence is sufficient signal

5. Invalid token error screen
Load function returns invalid: true on 404 (covers never-existed, expired, already-used — all the same from Sable's enumeration-resistant 404 rule). Page renders a "Einladung ungültig oder abgelaufen" state within the no-chrome layout. No SvelteKit +error.svelte boundary — that would re-introduce app chrome.

6. Form submit pattern
+page.server.ts form action, not client-side fetch. Token re-read from params.token inside the action (not from a hidden field). On success: redirect(302, '/'). Progressive enhancement works out of the box.

Overall read

All implementation decisions are settled. This is ready to build.

## 👨‍💻 Kai — Frontend Engineer (follow-up discussion) Picking up from my April 3 implementation comment. Resolved all open questions. ### Resolved **1. Email-already-registered UX** 409 from backend → inline form error: "Diese E-Mail-Adresse ist bereits registriert." + "Anmelden →" link to `/login?next=/join/{token}`. Existing login redirect logic handles the return flow. Note: login's `next` param must validate same-origin (Sable's open redirect rule applies here too). **2. Tablet breakpoint** Mobile layout (banner + form stacked) for everything `< 1024px`. Desktop split (400px identity panel + flex:1 form) kicks in at `>= 1024px` only. **3. Permissions list** Static, hardcoded in `HouseholdIdentityPanel.svelte`. No prop, no fetch. **4. Authenticated-user redirect in `hooks.server.ts`** - Add `/join` to `PUBLIC_ROUTES` so unauthenticated users can reach the page - Immediately after the `isPublicRoute` check passes, redirect authenticated users (cookie present) to `/` - No need to call `/v1/auth/me` — cookie presence is sufficient signal **5. Invalid token error screen** Load function returns `invalid: true` on 404 (covers never-existed, expired, already-used — all the same from Sable's enumeration-resistant 404 rule). Page renders a "Einladung ungültig oder abgelaufen" state within the no-chrome layout. No SvelteKit `+error.svelte` boundary — that would re-introduce app chrome. **6. Form submit pattern** `+page.server.ts` form action, not client-side fetch. Token re-read from `params.token` inside the action (not from a hidden field). On success: `redirect(302, '/')`. Progressive enhancement works out of the box. ### Overall read All implementation decisions are settled. This is ready to build.
Author
Owner

🔧 Backend Engineer — follow-up discussion

Picking up from my April 3 comment. All open backend questions resolved.

Resolved

1. Enumeration-resistant 404s
Service throws ResourceNotFoundException for all negative token cases (never existed, expired, already used). No 404 vs 410 distinction. Controller stays thin — unified error lives in the service.

2. GET /v1/invites/{token} — new endpoint required
This endpoint does not exist yet and must be created. Unauthenticated. Response: { householdName, inviterName } — nothing more. 404 for all negative cases per item 1.

3. Session creation after accept
After householdService.acceptInvite() completes: invalidate old session if present, then call the authenticateInSession() pattern from AuthController (manually build UsernamePasswordAuthenticationToken, store in SecurityContext + HTTP session). Returns 200; SvelteKit action handles the redirect to /.

4. Transaction boundary + PasswordEncoder
acceptInvite() is already @Transactional — boundary is correct. Two things need adding:

  • PasswordEncoder must be injected into HouseholdService (not currently present)
  • acceptInvite() signature must expand to (token, name, email, rawPassword) — currently assumes the user account already exists via findUser(email)

5. sessionFixation().changeSessionId() in SecurityFilterChain
Add to the existing sessionManagement block:

.sessionManagement(session -> session
    .sessionFixation().changeSessionId()
    .maximumSessions(1))

Login controller already manually invalidates old sessions — this is defence in depth and covers any future auth path.

6. permitAll() for invite endpoints
Add to SecurityFilterChain:

.requestMatchers("/v1/invites/*").permitAll()

Covers both GET and POST on the invite path. Unauthenticated access required since the user has no session at this point.

Overall read

The accept flow is more substantial than it looks — it's effectively a signup + login + household join in one transaction. The pieces are all there in the codebase; it's a matter of wiring them together correctly.

## 🔧 Backend Engineer — follow-up discussion Picking up from my April 3 comment. All open backend questions resolved. ### Resolved **1. Enumeration-resistant 404s** Service throws `ResourceNotFoundException` for all negative token cases (never existed, expired, already used). No 404 vs 410 distinction. Controller stays thin — unified error lives in the service. **2. `GET /v1/invites/{token}` — new endpoint required** This endpoint does not exist yet and must be created. Unauthenticated. Response: `{ householdName, inviterName }` — nothing more. 404 for all negative cases per item 1. **3. Session creation after accept** After `householdService.acceptInvite()` completes: invalidate old session if present, then call the `authenticateInSession()` pattern from `AuthController` (manually build `UsernamePasswordAuthenticationToken`, store in `SecurityContext` + HTTP session). Returns 200; SvelteKit action handles the redirect to `/`. **4. Transaction boundary + `PasswordEncoder`** `acceptInvite()` is already `@Transactional` — boundary is correct. Two things need adding: - `PasswordEncoder` must be injected into `HouseholdService` (not currently present) - `acceptInvite()` signature must expand to `(token, name, email, rawPassword)` — currently assumes the user account already exists via `findUser(email)` **5. `sessionFixation().changeSessionId()` in `SecurityFilterChain`** Add to the existing `sessionManagement` block: ```java .sessionManagement(session -> session .sessionFixation().changeSessionId() .maximumSessions(1)) ``` Login controller already manually invalidates old sessions — this is defence in depth and covers any future auth path. **6. `permitAll()` for invite endpoints** Add to `SecurityFilterChain`: ```java .requestMatchers("/v1/invites/*").permitAll() ``` Covers both `GET` and `POST` on the invite path. Unauthenticated access required since the user has no session at this point. ### Overall read The accept flow is more substantial than it looks — it's effectively a signup + login + household join in one transaction. The pieces are all there in the codebase; it's a matter of wiring them together correctly.
Author
Owner

Implementation complete — branch feat/issue-21-join-household

All acceptance criteria met. Two commits:

Commits

92f25e5 — Backend

  • V027 migration: invited_by FK column on household_invite
  • HouseholdInvite entity: invitedBy field, set on createInvite
  • New DTOs: InviteInfoResponse, AcceptInviteRequest
  • HouseholdService.getInviteInfo(code): validates token, returns {householdName, inviterName} — 404 for all invalid cases (enumeration-resistant)
  • HouseholdService.acceptInvite(code, name, email, rawPassword): creates UserAccount (409 if email taken), validates invite, marks used, creates HouseholdMember — all in one @Transactional
  • HouseholdController: GET /v1/invites/{code} (unauthenticated), updated POST /v1/invites/{code}/accept (no Principal, takes request body, creates session after service call)
  • SecurityConfig: .requestMatchers("/v1/invites/*").permitAll() + .sessionFixation().changeSessionId()

6950b3d — Frontend

  • schema.d.ts: GET /v1/invites/{code}, InviteInfoResponse, AcceptInviteRequest, updated acceptInvite operation
  • hooks.server.ts: /join added to PUBLIC_ROUTES; authenticated users on /join/* redirected to /
  • +page.server.ts (/join/[token]): load validates token (returns invalid: true on 404, no SvelteKit error boundary); action POSTs accept, handles 409 (email taken), sets JSESSIONID cookie, redirects to /
  • HouseholdIdentityPanel.svelte: logo, household name (Fraunces), inviter text, static permissions list
  • JoinForm.svelte: name/email/password + show/hide toggle, "Haushalt beitreten" CTA, field errors, server error replay, pre-fill
  • +page.svelte: no-chrome layout; invalid-token state inline; mobile < 1024px (banner + stacked form) / desktop ≥ 1024px (400px identity panel + flex:1 form)

Test results

  • Backend: 339 tests, 0 failures
  • Frontend: 794 tests, 0 failures

Next step

/review-pr on the branch, then merge.

## ✅ Implementation complete — branch `feat/issue-21-join-household` All acceptance criteria met. Two commits: ### Commits **`92f25e5` — Backend** - **V027 migration**: `invited_by` FK column on `household_invite` - **`HouseholdInvite` entity**: `invitedBy` field, set on `createInvite` - **New DTOs**: `InviteInfoResponse`, `AcceptInviteRequest` - **`HouseholdService.getInviteInfo(code)`**: validates token, returns `{householdName, inviterName}` — 404 for all invalid cases (enumeration-resistant) - **`HouseholdService.acceptInvite(code, name, email, rawPassword)`**: creates `UserAccount` (409 if email taken), validates invite, marks used, creates `HouseholdMember` — all in one `@Transactional` - **`HouseholdController`**: `GET /v1/invites/{code}` (unauthenticated), updated `POST /v1/invites/{code}/accept` (no Principal, takes request body, creates session after service call) - **`SecurityConfig`**: `.requestMatchers("/v1/invites/*").permitAll()` + `.sessionFixation().changeSessionId()` **`6950b3d` — Frontend** - **`schema.d.ts`**: `GET /v1/invites/{code}`, `InviteInfoResponse`, `AcceptInviteRequest`, updated `acceptInvite` operation - **`hooks.server.ts`**: `/join` added to `PUBLIC_ROUTES`; authenticated users on `/join/*` redirected to `/` - **`+page.server.ts`** (`/join/[token]`): load validates token (returns `invalid: true` on 404, no SvelteKit error boundary); action POSTs accept, handles 409 (email taken), sets `JSESSIONID` cookie, redirects to `/` - **`HouseholdIdentityPanel.svelte`**: logo, household name (Fraunces), inviter text, static permissions list - **`JoinForm.svelte`**: name/email/password + show/hide toggle, "Haushalt beitreten" CTA, field errors, server error replay, pre-fill - **`+page.svelte`**: no-chrome layout; invalid-token state inline; mobile `< 1024px` (banner + stacked form) / desktop `≥ 1024px` (400px identity panel + flex:1 form) ### Test results - Backend: **339 tests**, 0 failures - Frontend: **794 tests**, 0 failures ### Next step `/review-pr` on the branch, then merge.
Sign in to join this conversation.