mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
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:
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user