Prompt users with stale terms_version to re-accept

The three pre-legal-disclaimer users (ullrich, pistazie, nelli) have
NULL terms_version, and any future Terms update would leave every
existing user in the same state. Close the loop now that we have
version storage by redirecting any logged-in user whose
users.terms_version doesn't match the currently-published
TERMS_VERSION to a dedicated acceptance page.

Changes:
- auth.server: new recordTermsAcceptance(userId, version) helper that
  writes both terms_accepted_at and terms_version.
- root loader: if the session user has a stale or NULL terms_version,
  throw redirect("/auth/accept-terms?returnTo=<pathname>") unless the
  request is already on an allow-listed path
  (/auth/accept-terms, /auth/logout, /legal/*) so Terms are reachable
  and logout works.
- New route /auth/accept-terms (GET renders the prompt, POST records
  acceptance and bounces to a sanitised returnTo). Same-origin check
  on returnTo to avoid open-redirect abuse. Logout button is provided
  as an escape hatch.
- i18n: new auth.reaccept.* keys for EN and DE.
- Spec: new Requirement + five scenarios (redirect, allow-list,
  successful re-accept, missing consent, returnTo sanitisation).

No action on the three legacy users is required beyond what they'll
experience on their next visit — the gate takes care of it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 08:06:16 +02:00
parent 18fc023cb6
commit f16e80a2eb
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 192 additions and 1 deletions

View file

@ -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);

View file

@ -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 };
}

View file

@ -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"),

View file

@ -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<typeof loader>();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo") ?? "/";
const [accepted, setAccepted] = useState(false);
return (
<div className="mx-auto max-w-md px-4 py-16">
<h1 className="text-2xl font-bold text-gray-900">
{t("auth.reaccept.heading")}
</h1>
<p className="mt-4 text-sm text-gray-700">
{previousVersion
? t("auth.reaccept.bodyUpdated", { from: previousVersion, to: TERMS_VERSION })
: t("auth.reaccept.bodyNew", { version: TERMS_VERSION })}
</p>
<Form method="post" className="mt-8 space-y-4">
<input type="hidden" name="returnTo" value={returnTo} />
<label className="flex items-start gap-2 text-sm text-gray-700">
<input
type="checkbox"
name="termsAccepted"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
className="mt-0.5"
/>
<span>
{t("auth.termsBefore")}
<a
href="/legal/terms"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{t("auth.termsLink")}
</a>
{t("auth.termsAfter")}
</span>
</label>
<button
type="submit"
disabled={!accepted}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{t("auth.reaccept.submit")}
</button>
</Form>
<Form method="post" action="/auth/logout" className="mt-6 text-center">
<button
type="submit"
className="text-sm text-gray-500 hover:text-gray-700"
>
{t("auth.reaccept.logoutInstead")}
</button>
</Form>
</div>
);
}

View file

@ -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=<original path>`
#### 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

View file

@ -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...",

View file

@ -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...",