diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index d91059a..0aa8861 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -410,6 +410,19 @@ export const sessionStorage = createCookieSessionStorage({ }, }); +/** + * Record the user's acceptance of the current Terms version. Updates both + * `terms_accepted_at` (NOW) and `terms_version`. Used when an existing user + * re-accepts after the Terms have been updated. + */ +export async function recordTermsAcceptance(userId: string, termsVersion: string) { + const db = getDb(); + await db + .update(users) + .set({ termsAcceptedAt: new Date(), termsVersion }) + .where(eq(users.id, userId)); +} + export async function createSession(userId: string, request: Request) { const session = await sessionStorage.getSession(request.headers.get("Cookie")); session.set("userId", userId); diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index 09b32ec..6d6d2b4 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Form, Link } from "react-router"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Form, Link, redirect } from "react-router"; import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; @@ -10,8 +10,17 @@ import { LocaleProvider } from "~/components/LocaleContext"; import { AlphaBanner } from "~/components/AlphaBanner"; import { Footer } from "~/components/Footer"; import { initSentryClient, stopSentryClient } from "~/lib/sentry.client"; +import { TERMS_VERSION } from "~/lib/legal"; import stylesheet from "@trails-cool/ui/styles.css?url"; +// Paths that must stay reachable even when the user has a stale +// terms_version, so they can read the Terms, accept them, or log out. +const TERMS_GATE_ALLOWLIST = [ + "/auth/accept-terms", + "/auth/logout", + "/legal/", +]; + export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export function Layout({ children }: { children: React.ReactNode }) { @@ -39,6 +48,20 @@ export function Layout({ children }: { children: React.ReactNode }) { export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); const locale = detectLocale(request); + + // Gate logged-in users with stale / missing terms_version: send them to the + // re-accept page on any request that isn't already on an allow-listed path. + if (user && user.termsVersion !== TERMS_VERSION) { + const pathname = new URL(request.url).pathname; + const onAllowlistedPath = TERMS_GATE_ALLOWLIST.some((p) => + p.endsWith("/") ? pathname.startsWith(p) : pathname === p, + ); + if (!onAllowlistedPath) { + const returnTo = encodeURIComponent(pathname + new URL(request.url).search); + throw redirect(`/auth/accept-terms?returnTo=${returnTo}`); + } + } + return { user: user ? { id: user.id, username: user.username } : null, locale }; } diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 8e4558b..20e7dfe 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -9,6 +9,7 @@ export default [ route("auth/login", "routes/auth.login.tsx"), route("auth/verify", "routes/auth.verify.tsx"), route("auth/logout", "routes/auth.logout.tsx"), + route("auth/accept-terms", "routes/auth.accept-terms.tsx"), route("api/auth/register", "routes/api.auth.register.ts"), route("api/auth/login", "routes/api.auth.login.ts"), route("routes", "routes/routes._index.tsx"), diff --git a/apps/journal/app/routes/auth.accept-terms.tsx b/apps/journal/app/routes/auth.accept-terms.tsx new file mode 100644 index 0000000..111b186 --- /dev/null +++ b/apps/journal/app/routes/auth.accept-terms.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { Form, data, redirect, useLoaderData, useSearchParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/auth.accept-terms"; +import { getSessionUser, recordTermsAcceptance } from "~/lib/auth.server"; +import { TERMS_VERSION } from "~/lib/legal"; + +export function meta() { + return [ + { title: "Updated Terms of Service — trails.cool" }, + { name: "robots", content: "noindex" }, + ]; +} + +/** + * Paths we'll bounce back to after a successful acceptance. We only allow + * same-origin absolute paths to avoid being used as an open redirect. + */ +function safeReturnTo(raw: string | null): string { + if (!raw) return "/"; + if (!raw.startsWith("/") || raw.startsWith("//")) return "/"; + return raw; +} + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) { + throw redirect("/auth/login"); + } + // If the user is already current, bounce them back (e.g. double-submit). + if (user.termsVersion === TERMS_VERSION) { + const returnTo = safeReturnTo(new URL(request.url).searchParams.get("returnTo")); + throw redirect(returnTo); + } + return { previousVersion: user.termsVersion }; +} + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) { + throw redirect("/auth/login"); + } + + const form = await request.formData(); + const accepted = form.get("termsAccepted") === "on" || form.get("termsAccepted") === "true"; + if (!accepted) { + return data({ error: "Terms of Service must be accepted to continue" }, { status: 400 }); + } + + await recordTermsAcceptance(user.id, TERMS_VERSION); + + const returnTo = safeReturnTo(form.get("returnTo")?.toString() ?? null); + throw redirect(returnTo); +} + +export default function AcceptTermsPage() { + const { t } = useTranslation("journal"); + const { previousVersion } = useLoaderData(); + const [searchParams] = useSearchParams(); + const returnTo = searchParams.get("returnTo") ?? "/"; + const [accepted, setAccepted] = useState(false); + + return ( +
+

+ {t("auth.reaccept.heading")} +

+

+ {previousVersion + ? t("auth.reaccept.bodyUpdated", { from: previousVersion, to: TERMS_VERSION }) + : t("auth.reaccept.bodyNew", { version: TERMS_VERSION })} +

+ +
+ + + + +
+ +
+ +
+
+ ); +} diff --git a/openspec/specs/journal-auth/spec.md b/openspec/specs/journal-auth/spec.md index 2949f10..2806cdc 100644 --- a/openspec/specs/journal-auth/spec.md +++ b/openspec/specs/journal-auth/spec.md @@ -32,3 +32,26 @@ The registration form SHALL require explicit acknowledgement of the Terms of Ser #### Scenario: Missing version rejected - **WHEN** a registration request arrives without a non-empty `termsVersion` field - **THEN** the server responds with HTTP 400 and does not create a user + +### Requirement: Re-accept updated Terms on next visit +Logged-in users whose stored `terms_version` does not match the currently-published version SHALL be prompted to accept the current Terms before accessing any non-allow-listed page. + +#### Scenario: Stale version redirects to accept-terms page +- **WHEN** a logged-in user whose `users.terms_version` is NULL or differs from the current `TERMS_VERSION` requests any page outside the allow-list (`/auth/accept-terms`, `/auth/logout`, `/legal/*`) +- **THEN** the server redirects them to `/auth/accept-terms?returnTo=` + +#### Scenario: Allow-list keeps Terms and logout reachable +- **WHEN** the same user requests `/legal/terms`, `/legal/privacy`, `/legal/imprint`, `/auth/accept-terms`, or `/auth/logout` +- **THEN** the request is served normally without being redirected + +#### Scenario: Successful re-acceptance updates both fields +- **WHEN** a user submits the acceptance form with the required checkbox ticked +- **THEN** the server updates `users.terms_version` to the current version and `users.terms_accepted_at` to the current timestamp, then redirects to the `returnTo` path (or `/`) + +#### Scenario: Re-acceptance rejects missing consent +- **WHEN** the form is submitted without the checkbox ticked +- **THEN** the server responds with HTTP 400 and does not update the user row + +#### Scenario: returnTo is restricted to same-origin paths +- **WHEN** a `returnTo` value is not a same-origin absolute path (missing leading `/`, or starting with `//`) +- **THEN** the server redirects to `/` instead, preventing open-redirect abuse diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f17e32b..b4cb12c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -288,6 +288,13 @@ export default { alreadyHaveAccount: "Bereits ein Konto?", handleWillBe: "Dein Handle wird {{handle}} sein", passkeyNotFound: "Für diese Seite wurde kein Passkey gefunden. Registriere ein neues Konto oder nutze stattdessen einen Magic Link.", + reaccept: { + heading: "Aktualisierte Nutzungsbedingungen", + bodyUpdated: "Die Nutzungsbedingungen wurden aktualisiert (von Version {{from}} auf {{to}}). Bitte lies und akzeptiere sie, um trails.cool weiter zu nutzen.", + bodyNew: "Ab sofort erfassen wir, welche Version der Nutzungsbedingungen du akzeptiert hast. Bitte lies die aktuelle Version ({{version}}) und akzeptiere sie, um trails.cool weiter zu nutzen.", + submit: "Akzeptieren und fortfahren", + logoutInstead: "Stattdessen abmelden", + }, registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.", registerWithPasskey: "Mit Passkey registrieren", creatingPasskey: "Erstelle Passkey...", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index a4c9a15..f41764d 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -288,6 +288,13 @@ export default { alreadyHaveAccount: "Already have an account?", handleWillBe: "Your handle will be {{handle}}", passkeyNotFound: "No passkey found for this site. Register a new account or use a magic link instead.", + reaccept: { + heading: "Updated Terms of Service", + bodyUpdated: "The Terms of Service have been updated (from version {{from}} to {{to}}). Please review and accept to continue using trails.cool.", + bodyNew: "We now record which version of the Terms of Service you've accepted. Please review the current version ({{version}}) and accept to continue using trails.cool.", + submit: "Accept and continue", + logoutInstead: "Log out instead", + }, registerDescription: "Register with a passkey — no password needed.", registerWithPasskey: "Register with Passkey", creatingPasskey: "Creating passkey...",