diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index 3a2e12d..66b8dbd 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -134,7 +134,14 @@ export async function load(event) { chat = await resolveChatIdentity(session, pbToken); // fams is superadmin-only (non-realtime). Fetched server-side for both // roles; also recomputes + persists the derived `active` flag. - const res = await ensureFamAccess(famId).catch(() => null); + // Retried: a post-deploy PB blip here blanks `fam`, which both hides + // data AND disables the canonical slug redirect below (letting /{id} + // URLs persist). + let res: any = null; + for (let i = 0; i < 3 && !res; i++) { + if (i > 0) await new Promise((r) => setTimeout(r, 400 * i)); + res = await ensureFamAccess(famId).catch(() => null); + } if (res) { fam = res.fam; famAccess = res.access; diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index 34fc626..59a3579 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -78,6 +78,14 @@ // Settings stays usable while paused so admins can apply a code / manage billing. let hasAuth = $derived(!!data.session); let locked = $derived(disabled && !page.url.pathname.endsWith('/settings')); + // Expired-token zombie state: public PB rules still let logged-out users + // READ everything, so the dashboard looks alive while every write fails. + // Say so explicitly (join/switch/picker flows render their own UI). + let showLoggedOut = $derived( + !data.session && + !pickerMode && + !['join', 'switch'].includes(page.url.pathname.split('/')[2] || '') + ); // ── Post-checkout activation (event-driven) ── // Landing with ?checkout=return: if the webhook has already landed we show @@ -282,6 +290,28 @@
{@render children()}
+ {#if showLoggedOut} +
+
+ You're logged out + + Your session expired, so everything below is read-only — toggles and + claims won't work until you log back in. + + + Parent log in + {#if page.params.username} + Rejoin as {page.params.username} + {/if} + + Kids: re-open your invite link, or ask a parent to send it again. +
+
+ {/if} {#if locked}
@@ -482,6 +512,34 @@ color: #6b7280; font-size: 0.9rem; } + /* Logged-out card must be clickable (the paused overlay is display-only). */ + .loggedout-overlay { + pointer-events: auto; + } + .loggedout-actions { + display: flex; + gap: 0.6rem; + justify-content: center; + margin-top: 0.4rem; + } + .loggedout-actions a { + border-radius: 8px; + padding: 0.5rem 1.1rem; + font-size: 0.85rem; + font-weight: 700; + text-decoration: none; + } + .loggedout-actions .btn-primary { + background: #6366f1; + color: #fff; + } + .loggedout-actions .btn-secondary { + background: #eef2ff; + color: #4338ca; + } + .disabled-card .hint { + font-size: 0.78rem; + } .chat-toggle { position: relative; width: 40px; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 72ff63a..a05408b 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -847,6 +847,10 @@ async function toggle(chore: AssignedChore) { if (accessDisabled) return; + if (!pbToken) { + showToast('Logged out — please log in again to update chores.'); + return; + } if (chore.isTodo && isTodoExpired(chore)) return; if (togglingIds.has(chore.id)) return; togglingIds = new Set(togglingIds).add(chore.id); diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 9434447..b4add95 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -30,8 +30,21 @@ export const actions = { // An explicit email/password login supersedes kid mode on a shared device. clearActiveChild(event.cookies); - const fam = await pbAdmin.getOne('fams', user.famId).catch(() => null); - const famSlug = fam?.slug || user.famId; + // Fam lookup with retries: right after a deploy/rebuild PB can blip and + // a single failed fetch falls back to the raw fam ID in the URL + // (/{famId}/...), which then persists via bookmarks/shortcuts. + let fam: any = null; + for (let i = 0; i < 4 && !fam; i++) { + if (i > 0) await new Promise((r) => setTimeout(r, 500 * i)); + fam = await pbAdmin.getOne('fams', user.famId).catch(() => null); + } + if (!fam) { + return fail(503, { + error: 'Family data is briefly unavailable (server restarting). Please try again.', + email + }); + } + const famSlug = fam.slug; // Parents (admins) land on the fam dashboard — no username in the URL. if (user.role === 'parent') {