From cf3e64caf44e870cf932b02a9de643daab622cc1 Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 23 Apr 2026 23:33:42 -0400 Subject: [PATCH] feat(web): add legal footer + consent notice on auth pages (TASK-714) (#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add legal footer + consent notice on auth pages (TASK-714) Pad Cloud (cloudMode=true) needs visible Terms / Privacy / Sub-processors links so Stripe Live-mode compliance is defensible and users know what they're agreeing to. Self-hosted installs (cloudMode=false) don't need these — the legal docs at getpad.dev are Perpetual Software LLC's TOS for the hosted service, not the user's own instance. Changes: - New LegalFooter.svelte component: renders Terms · Privacy · Sub-processors links to https://getpad.dev/{terms,privacy,subprocessors}. Gated on a cloudMode prop so self-hosted installs see nothing. - login, register, forgot-password pages: render below the card. Each page already fetches session; register/forgot-password now persist session.cloud_mode into a local $state so the footer can be reused consistently. - register page adds a "By creating an account, you agree to Terms and Privacy Policy" consent notice directly under the Create account button (only when cloudMode=true). This is the signup touchpoint for Stripe Live mode. - Auth-page containers switched to flex-direction: column so the card and footer stack cleanly centered. Cookie banner intentionally deferred: the privacy policy already asserts "strictly-necessary cookies only, no banner required" and app.getpad.dev only sets first-party session/CSRF cookies. Stripe Checkout runs on billing.stripe.com (separate origin) so its cookies don't apply here. Parent: PLAN-645 (Pad Cloud Beta Readiness). This covers the main launch-blocking legal bullet (Stripe Live mode compliance); the pad-web marketing site already hosts the actual legal content. * fix(web): read cloudMode from authStore; add link affordance (Codex round 1) Addresses PR #229 review findings: MEDIUM — register and forgot-password pages were fetching session via api.auth.session() specifically to derive cloudMode, duplicating the root layout's authStore.load() and adding a silent failure path (if the extra request failed, the legal footer/consent would vanish even on Pad Cloud). Switched to authStore.cloudMode in both pages. register still calls api.auth.session() for its pre-existing setup_required + authenticated checks, but no longer reads cloud_mode from that call. forgot-password no longer needs onMount at all — its only addition was the cloudMode fetch. LOW — legal footer links had no affordance until hover: muted color, no underline, nothing for keyboard users. Added persistent subtle underline (1px, 2px offset) and :focus-visible outline so the links are discoverable for touch and keyboard users. Also: login page intentionally left untouched. It has a pre-existing local cloudMode state used for OAuth buttons + sign-up link + legal footer; unifying it with authStore is a separate refactor and outside this PR's scope. * fix(auth): add authStore.ensureLoaded for post-logout auth nav (Codex round 2) Addresses PR #229 round 2 finding: MEDIUM — After logout, authStore.session is cleared and the root layout doesn't re-run onMount on SPA navigation, so subsequent visits to /register or /forgot-password would read authStore.cloudMode=false and silently hide the legal footer/consent on Pad Cloud. Fix: add authStore.ensureLoaded() which returns the cached session when present or fetches it otherwise. register's onMount now routes its pre-existing session fetch through ensureLoaded (so the same call populates authStore for downstream components like LegalFooter). forgot-password calls ensureLoaded() on mount — cheap no-op when the store is already populated, one fetch when it's been cleared. This keeps the single-source-of-truth benefit from round 1 while handling the logout-then-navigate case Codex flagged. * fix(auth): coalesce concurrent session loads (Codex round 3) Addresses PR #229 round 3 finding: MEDIUM — authStore.ensureLoaded() did a bare 'if (session)' check, so a hard page load that fired both the root layout's authStore.load() and the page's ensureLoaded() could issue two /auth/session requests. If one succeeded and the other failed, the later catch path (session = null) would overwrite the good session, leaving cloudMode=false after a successful fetch. Fix: add an inflight Promise in auth.svelte.ts. load() returns the inflight promise when one exists; the then/catch/finally chain runs exactly once per fetch and clears inflight in finally. ensureLoaded() keeps its cached-session short-circuit and otherwise delegates to load(), so concurrent callers all await the same underlying request. * fix(auth): guard stale session fetches with generation counter (Codex round 4) Addresses PR #229 round 4 findings: MEDIUM — clear() did not invalidate a pending inflight load, so a pre-logout /auth/session call could resolve after logout and resurrect the logged-out user's session. Subsequent ensureLoaded() calls would also attach to that stale promise. LOW — inflight was only cleared in the promise's finally, so a permanently-hanging fetch wedged loading=true and every subsequent load()/ensureLoaded() returned the same never-settling promise. Fix: introduce a 'generation' counter that bumps on clear(). load() captures the current generation at fetch start and only writes session / clears loading / clears inflight when the generation is still current. clear() now also drops the inflight reference and resets loading=false, so the next ensureLoaded() fires a fresh request and the UI is not left hanging. Late callbacks from pre-logout fetches still resolve in the background but cannot mutate authStore state. --- .../lib/components/auth/LegalFooter.svelte | 51 ++++++++++++++++ web/src/lib/stores/auth.svelte.ts | 58 +++++++++++++++---- web/src/routes/forgot-password/+page.svelte | 15 +++++ web/src/routes/login/+page.svelte | 4 ++ web/src/routes/register/+page.svelte | 42 +++++++++++++- 5 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 web/src/lib/components/auth/LegalFooter.svelte diff --git a/web/src/lib/components/auth/LegalFooter.svelte b/web/src/lib/components/auth/LegalFooter.svelte new file mode 100644 index 00000000..7b728f8c --- /dev/null +++ b/web/src/lib/components/auth/LegalFooter.svelte @@ -0,0 +1,51 @@ + + +{#if cloudMode} + +{/if} + + diff --git a/web/src/lib/stores/auth.svelte.ts b/web/src/lib/stores/auth.svelte.ts index 0fd7f86d..92d0086f 100644 --- a/web/src/lib/stores/auth.svelte.ts +++ b/web/src/lib/stores/auth.svelte.ts @@ -2,6 +2,16 @@ import { api, type AuthSession } from '$lib/api/client'; let session = $state(null); let loading = $state(false); +// Coalesces concurrent load() calls so the root layout and auth-page onMount +// (register, forgot-password) can both request the session without firing +// duplicate /auth/session requests — or, worse, having a late failure from a +// duplicate fetch overwrite a successful fetch's session=null. +let inflight: Promise | null = null; +// Bumps on clear(). Any fetch started in a previous generation is stale: its +// success must not resurrect a logged-out session, and its finally must not +// clobber a new inflight that started after clear(). Readers only write +// state when the generation they captured at fetch-start still matches. +let generation = 0; export const authStore = { get session() { return session; }, @@ -12,20 +22,48 @@ export const authStore = { get loading() { return loading; }, async load() { + if (inflight) return inflight; loading = true; - try { - session = await api.auth.session(); - } catch (err) { - session = null; - loading = false; - throw err; // Re-throw so callers can distinguish fetch errors from "not authenticated". - } finally { - loading = false; - } - return session; + const myGeneration = generation; + const isCurrent = () => generation === myGeneration; + inflight = api.auth.session() + .then((s) => { + if (isCurrent()) session = s; + return session; + }) + .catch((err) => { + if (isCurrent()) session = null; + throw err; // Re-throw so callers can distinguish fetch errors from "not authenticated". + }) + .finally(() => { + if (isCurrent()) { + loading = false; + inflight = null; + } + }); + return inflight; + }, + + // ensureLoaded returns the cached session when one exists, otherwise fetches it. + // Use this on auth pages (register, forgot-password, etc.) that navigate in via + // SPA routing after the user has logged out — logout clears the store, and the + // root layout's one-shot onMount doesn't re-run on subsequent navigation, so a + // page that relies on session fields (e.g. cloud_mode) would otherwise see + // stale nulls and render the self-hosted branch on Pad Cloud. Concurrent calls + // coalesce through load()'s in-flight promise. + async ensureLoaded() { + if (session) return session; + return this.load(); }, clear() { session = null; + generation++; + // Drop the in-flight promise reference so the next ensureLoaded()/load() + // call fires a fresh fetch rather than attaching to a pre-logout request. + // The old promise may still resolve/reject in the background; the + // generation guard above prevents it from writing to any state. + inflight = null; + loading = false; } }; diff --git a/web/src/routes/forgot-password/+page.svelte b/web/src/routes/forgot-password/+page.svelte index e26208d8..c8b71ef5 100644 --- a/web/src/routes/forgot-password/+page.svelte +++ b/web/src/routes/forgot-password/+page.svelte @@ -1,11 +1,23 @@