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:
parent
18fc023cb6
commit
f16e80a2eb
7 changed files with 192 additions and 1 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
117
apps/journal/app/routes/auth.accept-terms.tsx
Normal file
117
apps/journal/app/routes/auth.accept-terms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue