feat(web): add legal footer + consent notice on auth pages (TASK-714) (#229)

* 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 <LegalFooter> 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.
This commit is contained in:
xarmian
2026-04-23 23:33:42 -04:00
committed by GitHub
parent 775dd89fdc
commit cf3e64caf4
5 changed files with 157 additions and 13 deletions
@@ -0,0 +1,51 @@
<script lang="ts">
// Legal links shown below the auth card on Pad Cloud (cloudMode=true).
// Self-hosted installs do not need these links — the Terms/Privacy that
// live at getpad.dev are Perpetual Software LLC's for the hosted service,
// not for the user's own instance.
//
// Caller typically reads cloudMode from authStore (which the root layout
// already populates via authStore.load()), so no additional session fetch
// is needed. Links open in a new tab so the auth flow is not interrupted.
let { cloudMode = false }: { cloudMode?: boolean } = $props();
</script>
{#if cloudMode}
<nav class="legal-footer" aria-label="Legal">
<a href="https://getpad.dev/terms" target="_blank" rel="noopener noreferrer">Terms</a>
<span aria-hidden="true">·</span>
<a href="https://getpad.dev/privacy" target="_blank" rel="noopener noreferrer">Privacy</a>
<span aria-hidden="true">·</span>
<a href="https://getpad.dev/subprocessors" target="_blank" rel="noopener noreferrer">Sub-processors</a>
</nav>
{/if}
<style>
.legal-footer {
margin-top: var(--space-6);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.8rem;
}
.legal-footer a {
color: var(--text-muted);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
border-radius: 2px;
}
.legal-footer a:hover {
color: var(--text-primary);
}
.legal-footer a:focus-visible {
color: var(--text-primary);
outline: 2px solid var(--accent-blue);
outline-offset: 2px;
}
</style>
+48 -10
View File
@@ -2,6 +2,16 @@ import { api, type AuthSession } from '$lib/api/client';
let session = $state<AuthSession | null>(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<AuthSession | null> | 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;
}
};
@@ -1,11 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api/client';
import { authStore } from '$lib/stores/auth.svelte';
import LegalFooter from '$lib/components/auth/LegalFooter.svelte';
let email = $state('');
let error = $state('');
let loading = $state(false);
let sent = $state(false);
onMount(() => {
// Re-populate authStore if a prior logout cleared it (see auth.svelte.ts
// ensureLoaded). Without this, authStore.cloudMode would stay false here
// on Pad Cloud after a logout → /forgot-password SPA navigation, and the
// legal footer would silently disappear. Swallow fetch errors — the page
// remains functional for users who can't reach the session endpoint.
authStore.ensureLoaded().catch(() => {});
});
async function handleSubmit() {
error = '';
if (!email) {
@@ -81,11 +93,14 @@
</p>
{/if}
</div>
<LegalFooter cloudMode={authStore.cloudMode} />
</div>
<style>
.page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
+4
View File
@@ -5,6 +5,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import SetupRequiredNotice from '$lib/components/auth/SetupRequiredNotice.svelte';
import LegalFooter from '$lib/components/auth/LegalFooter.svelte';
let email = $state('');
let password = $state('');
@@ -234,11 +235,14 @@
{/if}
{/if}
</div>
<LegalFooter {cloudMode} />
</div>
<style>
.login-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
+39 -3
View File
@@ -3,6 +3,8 @@
import { api } from '$lib/api/client';
import { goto } from '$app/navigation';
import SetupRequiredNotice from '$lib/components/auth/SetupRequiredNotice.svelte';
import LegalFooter from '$lib/components/auth/LegalFooter.svelte';
import { authStore } from '$lib/stores/auth.svelte';
let name = $state('');
let username = $state('');
@@ -22,13 +24,18 @@
onMount(async () => {
try {
const session = await api.auth.session();
if (session.setup_required) {
// Route session fetch through authStore so authStore.cloudMode is
// populated after a logout → /register navigation (the root layout's
// authStore.load() only runs once, so the store can be cleared and
// never re-filled without this). ensureLoaded is a no-op when the
// store already has a session.
const session = await authStore.ensureLoaded();
if (session?.setup_required) {
setupRequired = true;
setupMethod = session.setup_method;
return;
}
if (session.authenticated) {
if (session?.authenticated) {
goto('/console', { replaceState: true });
return;
}
@@ -204,6 +211,15 @@
Create account
{/if}
</button>
{#if authStore.cloudMode}
<p class="consent">
By creating an account, you agree to our
<a href="https://getpad.dev/terms" target="_blank" rel="noopener noreferrer">Terms</a>
and
<a href="https://getpad.dev/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
</p>
{/if}
</div>
<p class="login-link">
@@ -211,11 +227,14 @@
</p>
{/if}
</div>
<LegalFooter cloudMode={authStore.cloudMode} />
</div>
<style>
.register-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
@@ -322,6 +341,23 @@
text-decoration: underline;
}
.consent {
margin-top: var(--space-2);
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.4;
text-align: center;
}
.consent a {
color: var(--text-secondary);
text-decoration: underline;
}
.consent a:hover {
color: var(--text-primary);
}
.username-field {
position: relative;
}