Merge pull request #235 from trails-cool/feat/legal-disclaimers

Add legal pages, ToS acceptance, and alpha banner
This commit is contained in:
Ullrich Schäfer 2026-04-18 00:46:36 +02:00 committed by GitHub
commit 32a844d8de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 943 additions and 235 deletions

View file

@ -11,6 +11,7 @@ COPY packages/ui/package.json packages/ui/
COPY packages/map/package.json packages/map/
COPY packages/gpx/package.json packages/gpx/
COPY packages/i18n/package.json packages/i18n/
COPY packages/sentry-config/package.json packages/sentry-config/
COPY packages/api/package.json packages/api/
COPY packages/map-core/package.json packages/map-core/
COPY packages/db/package.json packages/db/

View file

@ -0,0 +1,10 @@
import { useTranslation } from "react-i18next";
export function AlphaBanner() {
const { t } = useTranslation("journal");
return (
<div className="bg-amber-50 border-b border-amber-200 px-4 py-2 text-sm text-amber-900">
<p>{t("alpha.message")}</p>
</div>
);
}

View file

@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
export function Footer() {
const { t } = useTranslation("journal");
return (
<footer className="mt-16 border-t border-gray-200 bg-white">
<div className="mx-auto max-w-7xl px-4 py-6 text-sm text-gray-500">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<a href="/legal/imprint" className="hover:text-gray-700">
{t("footer.imprint")}
</a>
<a href="/legal/privacy" className="hover:text-gray-700">
{t("footer.privacy")}
</a>
<a href="/legal/terms" className="hover:text-gray-700">
{t("footer.terms")}
</a>
<a
href="https://github.com/trails-cool/trails"
className="hover:text-gray-700"
target="_blank"
rel="noopener noreferrer"
>
{t("footer.source")}
</a>
<span className="ml-auto text-xs text-gray-400">
{t("footer.alpha")}
</span>
</div>
</div>
</footer>
);
}

View file

@ -1,30 +1,8 @@
import * as Sentry from "@sentry/react";
import { useEffect } from "react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
import { initI18nClient } from "@trails-cool/i18n";
const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ??
(import.meta.env.PROD ? "production" : "development");
Sentry.init({
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
integrations: [
Sentry.reactRouterV7BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
environment: sentryEnvironment,
tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0,
enabled: import.meta.env.PROD && sentryEnvironment !== "ci",
});
initI18nClient();
startTransition(() => {

View file

@ -8,22 +8,6 @@ import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import { initI18nServer, detectLanguage } from "@trails-cool/i18n";
const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development");
Sentry.init({
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
release: process.env.SENTRY_RELEASE,
environment: sentryEnvironment,
tracesSampleRate: 1.0,
enabled: process.env.NODE_ENV === "production" && !process.env.CI,
beforeSend(event) {
// Drop 404s — they're expected (scanners, typos), not bugs
const serialized = event.extra?.__serialized__ as Record<string, unknown> | undefined;
if (serialized?.status === 404) return null;
return event;
},
});
export const streamTimeout = 5_000;
export default function handleRequest(

View file

@ -76,6 +76,7 @@ export async function finishRegistration(
email,
username,
domain,
termsAcceptedAt: new Date(),
});
await db.insert(credentials).values({
@ -158,7 +159,7 @@ export async function registerWithMagicLink(email: string, username: string): Pr
const userId = randomUUID();
const domain = process.env.DOMAIN ?? "localhost";
await db.insert(users).values({ id: userId, email, username, domain });
await db.insert(users).values({ id: userId, email, username, domain, termsAcceptedAt: new Date() });
// Create magic token for verification
const token = randomBytes(32).toString("base64url");

View file

@ -0,0 +1,20 @@
/**
* Operator details for the Impressum (§5 TMG / §18 MStV).
*
* Required by German law. Address must be a reachable physical address.
* Email must be a direct contact (no forms-only policy).
*/
export const operator = {
name: "Ullrich Schäfer",
address: {
street: "Mehringdamm 87",
postalCode: "10965",
city: "Berlin",
country: "Germany",
},
email: "legal@trails.cool",
// Responsible for content per §18 Abs. 2 MStV
responsiblePerson: "Ullrich Schäfer",
} as const;
export type Operator = typeof operator;

View file

@ -0,0 +1,38 @@
import * as Sentry from "@sentry/react";
import { useEffect } from "react";
import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
import { browserSentryConfig } from "@trails-cool/sentry-config";
let initialized = false;
export function initSentryClient() {
if (initialized) return;
initialized = true;
Sentry.init({
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
integrations: [
Sentry.reactRouterV7BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
...browserSentryConfig("journal client", import.meta.env),
});
}
/**
* Tear down the Sentry client on logout. After this call, `Sentry.captureException`
* and other hub methods become no-ops until `initSentryClient` is called again.
*
* Fire-and-forget: the close flush happens async, but we don't want to block the
* logout UI on it.
*/
export function stopSentryClient() {
if (!initialized) return;
initialized = false;
void Sentry.close();
}

View file

@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
import { detectLocale } from "@trails-cool/i18n";
import { getSessionUser } from "~/lib/auth.server";
import { LocaleProvider } from "~/components/LocaleContext";
import { AlphaBanner } from "~/components/AlphaBanner";
import { Footer } from "~/components/Footer";
import { initSentryClient, stopSentryClient } from "~/lib/sentry.client";
import stylesheet from "@trails-cool/ui/styles.css?url";
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
@ -116,16 +119,20 @@ export default function App({ loaderData }: Route.ComponentProps) {
const locale = loaderData?.locale ?? "en";
useEffect(() => {
if (user) {
Sentry.setUser({ id: user.id, username: user.username });
initSentryClient();
Sentry.setUser({ id: user.id });
} else {
Sentry.setUser(null);
stopSentryClient();
}
}, [user]);
return (
<LocaleProvider locale={locale}>
<AlphaBanner />
<NavBar user={user ?? null} />
<Outlet />
<Footer />
</LocaleProvider>
);
}

View file

@ -33,6 +33,9 @@ export default [
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
route("privacy", "routes/privacy.tsx"),
route("legal/imprint", "routes/legal.imprint.tsx"),
route("legal/terms", "routes/legal.terms.tsx"),
route("legal/privacy", "routes/legal.privacy.tsx"),
// REST API v1
route("api/v1/routes", "routes/api.v1.routes._index.ts"),
route("api/v1/routes/compute", "routes/api.v1.routes.compute.ts"),

View file

@ -6,9 +6,15 @@ import { logger } from "~/lib/logger.server";
export async function action({ request }: Route.ActionArgs) {
const body = await request.json();
const { step, email, username, response, challenge, userId } = body;
const { step, email, username, response, challenge, userId, termsAccepted } = body;
const origin = process.env.ORIGIN ?? `http://localhost:3000`;
// Registration steps require terms acceptance
const requiresTerms = step === "start" || step === "finish" || step === "register-magic-link";
if (requiresTerms && !termsAccepted) {
return data({ error: "Terms of Service must be accepted" }, { status: 400 });
}
try {
if (step === "start") {
const result = await startRegistration(email, username);

View file

@ -62,7 +62,7 @@ export default function LoginPage() {
} catch (err) {
const message = (err as Error).message;
if (message.includes("timed out") || message.includes("not allowed")) {
setError("No passkey found for this site. Register a new account or use a magic link instead.");
setError(t("auth.passkeyNotFound"));
} else {
setError(message);
}

View file

@ -5,12 +5,15 @@ export default function RegisterPage() {
const { t } = useTranslation("journal");
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(null);
const [magicLinkSent, setMagicLinkSent] = useState(false);
const [hostname, setHostname] = useState("…");
useEffect(() => {
setHostname(window.location.hostname);
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
setSupportsPasskey(browserSupportsWebAuthn());
});
@ -18,6 +21,10 @@ export default function RegisterPage() {
const handleRegisterPasskey = async (e: React.FormEvent) => {
e.preventDefault();
if (!termsAccepted) {
setError(t("auth.termsRequired"));
return;
}
setError(null);
setLoading(true);
@ -25,7 +32,7 @@ export default function RegisterPage() {
const startResp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "start", email, username }),
body: JSON.stringify({ step: "start", email, username, termsAccepted }),
});
const startData = await startResp.json();
@ -45,6 +52,7 @@ export default function RegisterPage() {
step: "finish",
email,
username,
termsAccepted,
response: webAuthnResp,
challenge: startData.options.challenge,
userId: startData.userId,
@ -66,6 +74,10 @@ export default function RegisterPage() {
const handleRegisterMagicLink = async (e: React.FormEvent) => {
e.preventDefault();
if (!termsAccepted) {
setError(t("auth.termsRequired"));
return;
}
setError(null);
setLoading(true);
@ -73,7 +85,7 @@ export default function RegisterPage() {
const resp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "register-magic-link", email, username }),
body: JSON.stringify({ step: "register-magic-link", email, username, termsAccepted }),
});
const result = await resp.json();
@ -143,10 +155,33 @@ export default function RegisterPage() {
placeholder="alice"
/>
<p className="mt-1 text-xs text-gray-500">
Your handle will be @{username || "..."}@{typeof window !== "undefined" ? window.location.hostname : "trails.cool"}
{t("auth.handleWillBe", {
handle: `@${username || "…"}@${hostname}`,
})}
</p>
</div>
<label className="flex items-start gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(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>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
@ -154,7 +189,7 @@ export default function RegisterPage() {
{supportsPasskey ? (
<button
type="submit"
disabled={loading}
disabled={loading || !termsAccepted}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
@ -162,7 +197,7 @@ export default function RegisterPage() {
) : (
<button
type="submit"
disabled={loading || supportsPasskey === null}
disabled={loading || supportsPasskey === null || !termsAccepted}
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
>
{loading ? t("auth.sending") : t("auth.registerWithMagicLink")}
@ -177,7 +212,7 @@ export default function RegisterPage() {
</form>
<p className="mt-6 text-center text-sm text-gray-500">
Already have an account?{" "}
{t("auth.alreadyHaveAccount")}{" "}
<a href="/auth/login" className="text-blue-600 hover:underline">
{t("auth.login")}
</a>

View file

@ -0,0 +1,94 @@
import { operator } from "~/lib/operator";
export function meta() {
return [
{ title: "Impressum — trails.cool" },
{ name: "robots", content: "noindex" },
];
}
export default function ImprintPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-12">
<h1 className="text-3xl font-bold text-gray-900">Impressum</h1>
<section className="mt-8 space-y-4 text-gray-700">
<h2 className="text-lg font-semibold text-gray-900">
Angaben gemäß § 5 TMG
</h2>
<address className="not-italic">
{operator.name}
<br />
{operator.address.street}
<br />
{operator.address.postalCode} {operator.address.city}
<br />
{operator.address.country}
</address>
<h2 className="text-lg font-semibold text-gray-900">Kontakt</h2>
<p>
E-Mail:{" "}
<a
className="text-blue-600 hover:underline"
href={`mailto:${operator.email}`}
>
{operator.email}
</a>
</p>
<h2 className="text-lg font-semibold text-gray-900">
Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV
</h2>
<address className="not-italic">
{operator.responsiblePerson}
<br />
{operator.address.street}
<br />
{operator.address.postalCode} {operator.address.city}
</address>
<h2 className="text-lg font-semibold text-gray-900">Haftungsausschluss</h2>
<p className="text-sm">
Die Inhalte dieser Seiten wurden mit größtmöglicher Sorgfalt erstellt.
Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte
können wir jedoch keine Gewähr übernehmen. trails.cool befindet sich
in aktiver Entwicklung (Alpha) Inhalte, Funktionen und Daten können
sich jederzeit ändern oder entfallen.
</p>
<p className="text-sm">
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte
auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach
§§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht
verpflichtet, übermittelte oder gespeicherte fremde Informationen zu
überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige
Tätigkeit hinweisen.
</p>
</section>
<hr className="my-12 border-gray-200" />
<section className="space-y-4 text-gray-700">
<h2 className="text-lg font-semibold text-gray-900">
Legal notice (English summary)
</h2>
<p className="text-sm">
trails.cool is operated from Germany by {operator.name}, reachable at
the address above and at{" "}
<a
className="text-blue-600 hover:underline"
href={`mailto:${operator.email}`}
>
{operator.email}
</a>
. The service is currently in alpha see the{" "}
<a className="text-blue-600 hover:underline" href="/legal/terms">
Terms of Service
</a>{" "}
for details.
</p>
</section>
</div>
);
}

View file

@ -0,0 +1,238 @@
import { operator } from "~/lib/operator";
export function meta() {
return [
{ title: "Datenschutzerklärung — trails.cool" },
{ name: "robots", content: "noindex" },
];
}
export default function PrivacyPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-12">
<h1 className="text-3xl font-bold text-gray-900">
Datenschutzerklärung / Privacy Policy
</h1>
<p className="mt-2 text-sm text-gray-500">Last updated: 2026-04-17</p>
{/* GDPR formal sections */}
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">
1. Verantwortliche Stelle / Data Controller
</h2>
<p className="mt-2 text-gray-600">
Verantwortlich für die Datenverarbeitung auf dieser Website im Sinne
der DSGVO ist:
</p>
<address className="mt-2 not-italic text-gray-700">
{operator.name}
<br />
{operator.address.street}
<br />
{operator.address.postalCode} {operator.address.city}
<br />
{operator.address.country}
<br />
E-Mail:{" "}
<a className="text-blue-600 hover:underline" href={`mailto:${operator.email}`}>
{operator.email}
</a>
</address>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">
2. Rechtsgrundlagen / Legal Basis
</h2>
<p className="mt-2 text-gray-600">
Wir verarbeiten personenbezogene Daten auf Grundlage folgender
Rechtsgrundlagen:
</p>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li>
<strong>Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)</strong>:
Kontoverwaltung, Speicherung von Routen und Aktivitäten, Anmeldung
via Passkey / Magic Link.
</li>
<li>
<strong>Berechtigte Interessen (Art. 6 Abs. 1 lit. f DSGVO)</strong>:
Fehlerüberwachung (Sentry) zur Sicherstellung der
Funktionsfähigkeit des Dienstes.
</li>
<li>
<strong>Einwilligung (Art. 6 Abs. 1 lit. a DSGVO)</strong>: Bei
ausdrücklicher Zustimmung zu den Nutzungsbedingungen während der
Registrierung.
</li>
</ul>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">
3. Ihre Rechte / Your Rights
</h2>
<p className="mt-2 text-gray-600">
Als betroffene Person stehen Ihnen folgende Rechte nach DSGVO zu:
</p>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li>Recht auf Auskunft (Art. 15 DSGVO)</li>
<li>Recht auf Berichtigung (Art. 16 DSGVO)</li>
<li>Recht auf Löschung (Art. 17 DSGVO)</li>
<li>Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO)</li>
<li>Recht auf Datenübertragbarkeit (Art. 20 DSGVO)</li>
<li>Widerspruchsrecht (Art. 21 DSGVO)</li>
<li>Recht auf Widerruf einer Einwilligung (Art. 7 Abs. 3 DSGVO)</li>
</ul>
<p className="mt-3 text-gray-600">
Zur Ausübung dieser Rechte wenden Sie sich bitte an{" "}
<a className="text-blue-600 hover:underline" href={`mailto:${operator.email}`}>
{operator.email}
</a>
. Ein vollständiger Export Ihrer Daten ist jederzeit direkt über die
Einstellungen Ihres Kontos möglich (Routen als GPX, Aktivitäten als
GPX/JSON). Sie können Ihr Konto jederzeit selbst löschen.
</p>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">
4. Beschwerderecht / Right to Complain
</h2>
<p className="mt-2 text-gray-600">
Sie haben das Recht, sich bei einer Datenschutzaufsichtsbehörde zu
beschweren. Zuständig für uns ist:
</p>
<address className="mt-2 not-italic text-gray-700">
Berliner Beauftragte für Datenschutz und Informationsfreiheit
<br />
Friedrichstr. 219
<br />
10969 Berlin
<br />
<a
className="text-blue-600 hover:underline"
href="https://www.datenschutz-berlin.de"
>
www.datenschutz-berlin.de
</a>
</address>
</section>
<hr className="my-10 border-gray-200" />
{/* Existing privacy manifest (plain-language) */}
<h2 className="text-2xl font-bold text-gray-900">Privacy Manifest</h2>
<p className="mt-4 text-gray-600">
trails.cool is committed to privacy by design. This manifest documents
everything we collect, why, and how you can control it.
</p>
<section className="mt-10">
<h3 className="text-xl font-semibold text-gray-900">Planner (planner.trails.cool)</h3>
<p className="mt-2 text-gray-600">
The Planner collects <strong>no personal data</strong>. Sessions are anonymous
there are no user accounts, no tracking, and no analytics on your routes.
</p>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li>No cookies (except ephemeral session state)</li>
<li>No user accounts or login</li>
<li>No route data is stored permanently without your action</li>
<li>Session data is automatically deleted after 7 days of inactivity</li>
</ul>
</section>
<section className="mt-10">
<h3 className="text-xl font-semibold text-gray-900">Journal (trails.cool)</h3>
<p className="mt-2 text-gray-600">
The Journal stores the data you explicitly provide:
</p>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Account data</strong>: email, username, display name, passkey credentials</li>
<li><strong>Routes</strong>: name, description, GPX data, geometry</li>
<li><strong>Activities</strong>: title, description, date, linked routes</li>
</ul>
<p className="mt-3 text-gray-600">
All your data is exportable at any time in open formats (GPX, JSON).
You can self-host your own instance and migrate your data completely.
</p>
</section>
<section className="mt-10">
<h3 className="text-xl font-semibold text-gray-900">Error Tracking (Sentry)</h3>
<p className="mt-2 text-gray-600">
Both apps use <a href="https://sentry.io" className="text-blue-600 hover:underline">Sentry</a> for
error monitoring. This helps us find and fix bugs quickly.
</p>
<h4 className="mt-4 font-medium text-gray-800">What Sentry collects:</h4>
<ul className="mt-2 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Error details</strong>: stack traces, error messages, browser/OS info</li>
<li><strong>Performance traces</strong>: page load times, route navigation timing</li>
<li><strong>Session replays on error</strong>: a recording of the session leading up to an error (DOM snapshots, not video)</li>
<li><strong>User context</strong> (Journal only): only the user ID (not username or email) is attached to errors for debugging</li>
<li><strong>Session ID</strong> (Planner only): the anonymous session ID is attached to errors</li>
</ul>
<h4 className="mt-4 font-medium text-gray-800">What Sentry does NOT collect:</h4>
<ul className="mt-2 list-disc pl-6 text-gray-600 space-y-1">
<li>Route or GPX data</li>
<li>Passwords or passkey credentials</li>
<li>Form input contents (masked in replays)</li>
</ul>
<h4 className="mt-4 font-medium text-gray-800">Data retention:</h4>
<p className="mt-2 text-gray-600">
Sentry data is retained for 90 days and then automatically deleted.
Sentry&apos;s servers are hosted in the EU (Frankfurt).
</p>
</section>
<section className="mt-10">
<h3 className="text-xl font-semibold text-gray-900">Email</h3>
<p className="mt-2 text-gray-600">
The Journal sends transactional emails for magic link login and welcome messages
via SMTP. On the official instance, emails are sent through our own mail server.
</p>
<h4 className="mt-4 font-medium text-gray-800">What is sent via email:</h4>
<ul className="mt-2 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Magic link</strong>: a one-time login link sent to your email address</li>
<li><strong>Welcome email</strong>: a greeting after registration</li>
</ul>
<p className="mt-3 text-gray-600">
Self-hosted instances can configure their own SMTP server. No email content
is stored beyond what your mail server retains.
</p>
</section>
<section className="mt-10">
<h3 className="text-xl font-semibold text-gray-900">Third Parties</h3>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Sentry</strong> (Functional Software Inc.) error tracking, as described above</li>
<li><strong>OpenStreetMap</strong> map tiles are loaded from OSM tile servers. OSM&apos;s <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" className="text-blue-600 hover:underline">privacy policy</a> applies to tile requests.</li>
<li><strong>BRouter</strong> routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.</li>
<li><strong>SMTP provider</strong> transactional emails (magic link, welcome) are delivered via SMTP. Self-hosters configure their own provider.</li>
</ul>
</section>
<section className="mt-10">
<h3 className="text-xl font-semibold text-gray-900">What We Don&apos;t Do</h3>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li>We don&apos;t sell data</li>
<li>We don&apos;t show ads</li>
<li>We don&apos;t build user profiles</li>
<li>We don&apos;t use tracking pixels or analytics</li>
<li>We don&apos;t share data with anyone except as listed above</li>
</ul>
</section>
<section className="mt-10">
<h3 className="text-xl font-semibold text-gray-900">Security Practices</h3>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Authentication</strong>: Passkey (WebAuthn) and magic link login. No passwords stored.</li>
<li><strong>Encryption</strong>: All traffic over HTTPS with HSTS preload. Cookies are httpOnly and secure.</li>
<li><strong>Headers</strong>: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options on all responses.</li>
<li><strong>Infrastructure</strong>: Docker containers run as non-root. Firewall restricts to HTTP/HTTPS/SSH only.</li>
<li><strong>CI/CD</strong>: Gitleaks secret scanning and dependency auditing on every pull request.</li>
<li><strong>Vulnerability reporting</strong>: See our <a href="https://github.com/trails-cool/trails/blob/main/SECURITY.md" className="text-blue-600 hover:underline">SECURITY.md</a> for responsible disclosure.</li>
</ul>
</section>
</div>
);
}

View file

@ -0,0 +1,187 @@
export function meta() {
return [
{ title: "Terms of Service — trails.cool" },
{ name: "robots", content: "noindex" },
];
}
export default function TermsPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-12">
<h1 className="text-3xl font-bold text-gray-900">
Nutzungsbedingungen / Terms of Service
</h1>
<p className="mt-2 text-sm text-gray-500">
Last updated: 2026-04-17 Alpha version subject to change
</p>
{/* Deutsche Fassung (maßgebend) */}
<section className="mt-10 space-y-4 text-gray-700">
<h2 className="text-xl font-semibold text-gray-900">Deutsche Fassung</h2>
<h3 className="mt-6 text-base font-semibold text-gray-900">
1. Gegenstand und Alpha-Status
</h3>
<p className="text-sm">
trails.cool ist ein experimenteller Dienst zur Planung und Dokumentation
von Outdoor-Aktivitäten. Der Dienst befindet sich in aktiver Entwicklung
(Alpha). Funktionen, Schnittstellen und gespeicherte Daten können
jederzeit ohne Vorankündigung geändert, gelöscht oder eingestellt
werden. Eine Verfügbarkeitsgarantie besteht nicht.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
2. Zurücksetzen der Datenbank
</h3>
<p className="text-sm">
Während der Alpha-Phase behält sich der Betreiber ausdrücklich das Recht
vor, die gesamte Datenbank zurückzusetzen oder einzelne Datensätze ohne
vorherige Benachrichtigung zu löschen. Nutzer:innen sollten wichtige
Routen und Aktivitäten selbstständig über die Export-Funktion (GPX)
sichern.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
3. Haftungsausschluss
</h3>
<p className="text-sm">
Der Dienst wird ohne jegliche Gewährleistung bereitgestellt. Eine Haftung
für Schäden, die durch die Nutzung oder Nichtverfügbarkeit des Dienstes
entstehen, ist ausgeschlossen, soweit dies gesetzlich zulässig ist. Bei
Vorsatz und grober Fahrlässigkeit sowie bei Verletzung von Leben, Körper
und Gesundheit gelten die gesetzlichen Vorschriften.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
4. Nutzungsregeln
</h3>
<ul className="ml-6 list-disc space-y-1 text-sm">
<li>Keine Verbreitung rechtswidriger Inhalte</li>
<li>Keine missbräuchliche Nutzung (Spam, automatisierte Abfragen in
hoher Frequenz, Denial-of-Service)
</li>
<li>Kein Umgehen technischer Schutzmaßnahmen</li>
</ul>
<h3 className="mt-6 text-base font-semibold text-gray-900">
5. Eigenverantwortung für Daten
</h3>
<p className="text-sm">
Nutzer:innen sind dafür verantwortlich, regelmäßig Sicherungskopien ihrer
Routen und Aktivitäten anzufertigen. GPX-Exporte sind im Journal für
jede Route und Aktivität verfügbar.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
6. Beendigung
</h3>
<p className="text-sm">
Nutzer:innen können ihr Konto jederzeit über die Einstellungen löschen.
Der Betreiber kann Konten bei Verstößen gegen diese Bedingungen sperren
oder löschen.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
7. Änderungen
</h3>
<p className="text-sm">
Diese Nutzungsbedingungen können sich während der Alpha-Phase ändern. Bei
wesentlichen Änderungen werden registrierte Nutzer:innen per E-Mail
informiert und müssen die neuen Bedingungen erneut bestätigen.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
8. Anwendbares Recht
</h3>
<p className="text-sm">
Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts. Verbraucher:innen
mit gewöhnlichem Aufenthalt in der EU genießen den zwingenden Schutz
ihres nationalen Verbraucherrechts.
</p>
</section>
<hr className="my-12 border-gray-200" />
{/* English translation (informational) */}
<section className="space-y-4 text-gray-700">
<h2 className="text-xl font-semibold text-gray-900">English version</h2>
<p className="text-xs text-gray-500">
The German version is authoritative. This English text is a translation
for convenience.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
1. Alpha status
</h3>
<p className="text-sm">
trails.cool is an experimental service for planning and documenting
outdoor activities. It is under active development (alpha). Features,
APIs, and stored data may change, be deleted, or be discontinued at any
time without notice. No availability is guaranteed.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
2. Database resets
</h3>
<p className="text-sm">
During alpha, the operator expressly reserves the right to reset the
entire database or delete individual records without prior notice. Users
should back up important routes and activities via the GPX export.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
3. No warranty
</h3>
<p className="text-sm">
The service is provided without any warranty. Liability for damages
arising from use or unavailability is excluded to the maximum extent
permitted by law. Statutory provisions apply for intent, gross
negligence, and injury to life, body, or health.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
4. Acceptable use
</h3>
<ul className="ml-6 list-disc space-y-1 text-sm">
<li>No illegal content</li>
<li>No abusive use (spam, high-frequency automated requests, denial of service)</li>
<li>No circumvention of technical protections</li>
</ul>
<h3 className="mt-6 text-base font-semibold text-gray-900">
5. Data responsibility
</h3>
<p className="text-sm">
Users are responsible for regularly backing up their routes and
activities. GPX exports are available in the Journal for every route
and activity.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
6. Termination
</h3>
<p className="text-sm">
Users can delete their account at any time via settings. The operator
may suspend or delete accounts that violate these terms.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
7. Changes
</h3>
<p className="text-sm">
These terms may change during alpha. Registered users will be notified
by email of material changes and must re-accept the updated terms.
</p>
<h3 className="mt-6 text-base font-semibold text-gray-900">
8. Governing law
</h3>
<p className="text-sm">
German law applies, excluding the UN Convention on Contracts for the
International Sale of Goods. Consumers habitually residing in the EU
retain the mandatory protection of their national consumer law.
</p>
</section>
</div>
);
}

View file

@ -1,128 +1,5 @@
import type { Route } from "./+types/privacy";
import { redirect } from "react-router";
export function meta(_args: Route.MetaArgs) {
return [{ title: "Privacy — trails.cool" }];
}
export default function PrivacyPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-12">
<h1 className="text-3xl font-bold text-gray-900">Privacy Manifest</h1>
<p className="mt-4 text-gray-600">
trails.cool is committed to privacy by design. This manifest documents
everything we collect, why, and how you can control it.
</p>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">Planner (planner.trails.cool)</h2>
<p className="mt-2 text-gray-600">
The Planner collects <strong>no personal data</strong>. Sessions are anonymous
there are no user accounts, no tracking, and no analytics on your routes.
</p>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li>No cookies (except ephemeral session state)</li>
<li>No user accounts or login</li>
<li>No route data is stored permanently without your action</li>
<li>Session data is automatically deleted after 7 days of inactivity</li>
</ul>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">Journal (trails.cool)</h2>
<p className="mt-2 text-gray-600">
The Journal stores the data you explicitly provide:
</p>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Account data</strong>: email, username, display name, passkey credentials</li>
<li><strong>Routes</strong>: name, description, GPX data, geometry</li>
<li><strong>Activities</strong>: title, description, date, linked routes</li>
</ul>
<p className="mt-3 text-gray-600">
All your data is exportable at any time in open formats (GPX, JSON).
You can self-host your own instance and migrate your data completely.
</p>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">Error Tracking (Sentry)</h2>
<p className="mt-2 text-gray-600">
Both apps use <a href="https://sentry.io" className="text-blue-600 hover:underline">Sentry</a> for
error monitoring. This helps us find and fix bugs quickly.
</p>
<h3 className="mt-4 font-medium text-gray-800">What Sentry collects:</h3>
<ul className="mt-2 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Error details</strong>: stack traces, error messages, browser/OS info</li>
<li><strong>Performance traces</strong>: page load times, route navigation timing</li>
<li><strong>Session replays on error</strong>: a recording of the session leading up to an error (DOM snapshots, not video)</li>
<li><strong>User context</strong> (Journal only): user ID and username are attached to errors for debugging</li>
<li><strong>Session ID</strong> (Planner only): the anonymous session ID is attached to errors</li>
</ul>
<h3 className="mt-4 font-medium text-gray-800">What Sentry does NOT collect:</h3>
<ul className="mt-2 list-disc pl-6 text-gray-600 space-y-1">
<li>Route or GPX data</li>
<li>Passwords or passkey credentials</li>
<li>Form input contents (masked in replays)</li>
</ul>
<h3 className="mt-4 font-medium text-gray-800">Data retention:</h3>
<p className="mt-2 text-gray-600">
Sentry data is retained for 90 days and then automatically deleted.
Sentry&apos;s servers are hosted in the EU (Frankfurt).
</p>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">Email</h2>
<p className="mt-2 text-gray-600">
The Journal sends transactional emails for magic link login and welcome messages
via SMTP. On the official instance, emails are sent through our own mail server.
</p>
<h3 className="mt-4 font-medium text-gray-800">What is sent via email:</h3>
<ul className="mt-2 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Magic link</strong>: a one-time login link sent to your email address</li>
<li><strong>Welcome email</strong>: a greeting after registration</li>
</ul>
<p className="mt-3 text-gray-600">
Self-hosted instances can configure their own SMTP server. No email content
is stored beyond what your mail server retains.
</p>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">Third Parties</h2>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Sentry</strong> (Functional Software Inc.) error tracking, as described above</li>
<li><strong>OpenStreetMap</strong> map tiles are loaded from OSM tile servers. OSM&apos;s <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" className="text-blue-600 hover:underline">privacy policy</a> applies to tile requests.</li>
<li><strong>BRouter</strong> routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.</li>
<li><strong>SMTP provider</strong> transactional emails (magic link, welcome) are delivered via SMTP. Self-hosters configure their own provider.</li>
</ul>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">What We Don&apos;t Do</h2>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li>We don&apos;t sell data</li>
<li>We don&apos;t show ads</li>
<li>We don&apos;t build user profiles</li>
<li>We don&apos;t use tracking pixels or analytics</li>
<li>We don&apos;t share data with anyone except as listed above</li>
</ul>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">Security Practices</h2>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Authentication</strong>: Passkey (WebAuthn) and magic link login. No passwords stored.</li>
<li><strong>Encryption</strong>: All traffic over HTTPS with HSTS preload. Cookies are httpOnly and secure.</li>
<li><strong>Headers</strong>: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options on all responses.</li>
<li><strong>Infrastructure</strong>: Docker containers run as non-root. Firewall restricts to HTTP/HTTPS/SSH only.</li>
<li><strong>CI/CD</strong>: Gitleaks secret scanning and dependency auditing on every pull request.</li>
<li><strong>Vulnerability reporting</strong>: See our <a href="https://github.com/trails-cool/trails/blob/main/SECURITY.md" className="text-blue-600 hover:underline">SECURITY.md</a> for responsible disclosure.</li>
</ul>
</section>
<p className="mt-10 text-sm text-gray-500">
Last updated: March 2026. If this manifest changes, we&apos;ll note it here.
</p>
</div>
);
export function loader() {
return redirect("/legal/privacy", 301);
}

View file

@ -24,6 +24,7 @@
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",
"@trails-cool/map": "workspace:*",
"@trails-cool/sentry-config": "workspace:*",
"@trails-cool/types": "workspace:*",
"@trails-cool/ui": "workspace:*",
"drizzle-orm": "catalog:",

View file

@ -1,3 +1,5 @@
import * as Sentry from "@sentry/node";
import { nodeSentryConfig, drop404s } from "@trails-cool/sentry-config";
import { createRequestListener } from "@react-router/node";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createReadStream, statSync } from "node:fs";
@ -7,6 +9,12 @@ import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
import { createBoss, startWorker } from "@trails-cool/jobs";
import postgres from "postgres";
Sentry.init({
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
...nodeSentryConfig("journal server"),
beforeSend: drop404s,
});
const port = Number(process.env.PORT ?? 3000);
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");

View file

@ -1,12 +1,12 @@
import * as Sentry from "@sentry/react-native";
import { mobileSentryConfig } from "@trails-cool/sentry-config";
const SENTRY_DSN = "https://af50ac7aea803216adbe21e9dd4b896b@o4509530546634752.ingest.de.sentry.io/4511209742532688";
export function initSentry() {
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: __DEV__ ? 0 : 1.0,
enabled: !__DEV__,
...mobileSentryConfig(__DEV__),
});
}

View file

@ -38,6 +38,7 @@
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",
"@trails-cool/map-core": "workspace:*",
"@trails-cool/sentry-config": "workspace:*",
"@trails-cool/types": "workspace:*",
"expo": "~55.0.15",
"expo-constants": "~55.0.14",

View file

@ -11,6 +11,7 @@ COPY packages/ui/package.json packages/ui/
COPY packages/map/package.json packages/map/
COPY packages/gpx/package.json packages/gpx/
COPY packages/i18n/package.json packages/i18n/
COPY packages/sentry-config/package.json packages/sentry-config/
COPY packages/api/package.json packages/api/
COPY packages/map-core/package.json packages/map-core/
COPY packages/db/package.json packages/db/

View file

@ -1,30 +1,8 @@
import * as Sentry from "@sentry/react";
import { useEffect } from "react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
import { initI18nClient } from "@trails-cool/i18n";
const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ??
(import.meta.env.PROD ? "production" : "development");
Sentry.init({
dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208",
integrations: [
Sentry.reactRouterV7BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
environment: sentryEnvironment,
tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0,
enabled: import.meta.env.PROD && sentryEnvironment !== "ci",
});
initI18nClient();
startTransition(() => {

View file

@ -1,3 +1,4 @@
import * as Sentry from "@sentry/node";
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
@ -71,6 +72,7 @@ export default function handleRequest(
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
Sentry.captureException(error);
}
},
},

View file

@ -61,6 +61,9 @@ export default function Home() {
<div className="flex h-full flex-col overflow-y-auto">
{/* Hero */}
<section className="flex flex-1 flex-col items-center justify-center px-4 py-16">
<span className="mb-4 inline-block rounded-full bg-amber-100 px-3 py-1 text-xs font-medium uppercase tracking-wide text-amber-800">
{t("landing.footer.alpha")}
</span>
<h1 className="text-5xl font-bold tracking-tight text-gray-900 sm:text-6xl">
{t("title")}
</h1>
@ -129,11 +132,20 @@ export default function Home() {
{/* Footer */}
<footer className="border-t border-gray-200 px-4 py-6">
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-between gap-4 text-sm text-gray-400">
<span>{t("landing.footer.builtWith")}</span>
<span>
{t("landing.footer.builtWith")}
<span className="ml-2 text-xs">· {t("landing.footer.alpha")}</span>
</span>
<div className="flex gap-4">
<a href="https://trails.cool/privacy" className="hover:text-gray-600">
<a href="https://trails.cool/legal/imprint" className="hover:text-gray-600">
{t("landing.footer.imprint")}
</a>
<a href="https://trails.cool/legal/privacy" className="hover:text-gray-600">
{t("landing.footer.privacy")}
</a>
<a href="https://trails.cool/legal/terms" className="hover:text-gray-600">
{t("landing.footer.terms")}
</a>
<a href="https://github.com/trails-cool/trails" className="hover:text-gray-600">
{t("landing.footer.source")}
</a>

View file

@ -27,6 +27,7 @@
"@trails-cool/i18n": "workspace:*",
"@trails-cool/map": "workspace:*",
"@trails-cool/map-core": "workspace:*",
"@trails-cool/sentry-config": "workspace:*",
"@trails-cool/types": "workspace:*",
"@trails-cool/ui": "workspace:*",
"codemirror": "^6.0.2",

View file

@ -1,4 +1,5 @@
import * as Sentry from "@sentry/node";
import { nodeSentryConfig, drop404s } from "@trails-cool/sentry-config";
import { logger } from "./app/lib/logger.server.ts";
import { httpRequestDuration } from "./app/lib/metrics.server.ts";
import { createRequestListener } from "@react-router/node";
@ -10,19 +11,10 @@ import { createBoss, startWorker } from "@trails-cool/jobs";
import { expireSessionsJob } from "./app/jobs/expire-sessions.ts";
import postgres from "postgres";
const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development");
Sentry.init({
dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208",
release: process.env.SENTRY_RELEASE,
environment: sentryEnvironment,
tracesSampleRate: 1.0,
enabled: process.env.NODE_ENV === "production" && !process.env.CI,
beforeSend(event) {
const serialized = event.extra?.__serialized__ as Record<string, unknown> | undefined;
if (serialized?.status === 404) return null;
return event;
},
...nodeSentryConfig("planner server"),
beforeSend: drop404s,
});
const port = Number(process.env.PORT ?? 3001);

View file

@ -30,6 +30,8 @@ async function registerUser(page: Page, email: string, username: string) {
// Verify both fields retained values before submitting
await expect(page.getByLabel("Email")).toHaveValue(email);
await expect(page.getByLabel("Username")).toHaveValue(username);
// Accept the Terms of Service (required)
await page.getByRole("checkbox").check();
await page.getByRole("button", { name: /Register with Passkey/ }).click();
}

View file

@ -1,42 +1,42 @@
## 1. Operator Information
- [ ] 1.1 Decide what operator address to use on the Impressum (private address vs. business address service vs. lawyer's address) — legal requirement, must be reachable
- [ ] 1.2 Create `apps/journal/app/lib/operator.ts` exporting the operator details (name, address, email, responsible person)
- [x] 1.1 Decide what operator address to use on the Impressum (private address vs. business address service vs. lawyer's address) — legal requirement, must be reachable
- [x] 1.2 Create `apps/journal/app/lib/operator.ts` exporting the operator details (name, address, email, responsible person)
## 2. Legal Pages
- [ ] 2.1 Create `apps/journal/app/routes/legal.imprint.tsx` rendering the Impressum from the operator config
- [ ] 2.2 Create `apps/journal/app/routes/legal.terms.tsx` with Terms of Service covering alpha status, warranty disclaimer, DB reset disclosure, and data export responsibility
- [ ] 2.3 Move existing `apps/journal/app/routes/privacy.tsx` to `apps/journal/app/routes/legal.privacy.tsx` and expand with GDPR-required sections (controller, legal basis, data subject rights, complaint right)
- [ ] 2.4 Add a redirect from `/privacy` to `/legal/privacy` with 301 status
- [ ] 2.5 Register the new routes in `apps/journal/app/routes.ts`
- [x] 2.1 Create `apps/journal/app/routes/legal.imprint.tsx` rendering the Impressum from the operator config
- [x] 2.2 Create `apps/journal/app/routes/legal.terms.tsx` with Terms of Service covering alpha status, warranty disclaimer, DB reset disclosure, and data export responsibility
- [x] 2.3 Move existing `apps/journal/app/routes/privacy.tsx` to `apps/journal/app/routes/legal.privacy.tsx` and expand with GDPR-required sections (controller, legal basis, data subject rights, complaint right)
- [x] 2.4 Add a redirect from `/privacy` to `/legal/privacy` with 301 status
- [x] 2.5 Register the new routes in `apps/journal/app/routes.ts`
## 3. i18n
- [ ] 3.1 Add German translations for all legal page copy (Impressum, Terms, Datenschutzerklärung) — DE is the authoritative version for German consumers
- [ ] 3.2 Add English translations for the same pages
- [ ] 3.3 Add translation keys for the alpha banner and signup acknowledgement checkbox
- [x] 3.1 Add German translations for all legal page copy (Impressum, Terms, Datenschutzerklärung) — DE is the authoritative version for German consumers
- [x] 3.2 Add English translations for the same pages
- [x] 3.3 Add translation keys for the alpha banner and signup acknowledgement checkbox
## 4. Signup Acknowledgement
- [ ] 4.1 Add `terms_accepted_at` column (timestamp, nullable initially for existing users) to the `users` table in `packages/db/src/schema/journal.ts`
- [ ] 4.2 Run `pnpm db:push` to apply the schema
- [ ] 4.3 Add a required checkbox to `apps/journal/app/routes/auth.register.tsx` linking to the Terms
- [ ] 4.4 Client-side: block form submission if checkbox is unchecked; show validation message
- [ ] 4.5 Server-side: reject registration requests in `api.auth.register.ts` that don't include `termsAccepted: true`
- [ ] 4.6 On successful registration, set `users.terms_accepted_at = NOW()`
- [x] 4.1 Add `terms_accepted_at` column (timestamp, nullable initially for existing users) to the `users` table in `packages/db/src/schema/journal.ts`
- [x] 4.2 Run `pnpm db:push` to apply the schema
- [x] 4.3 Add a required checkbox to `apps/journal/app/routes/auth.register.tsx` linking to the Terms
- [x] 4.4 Client-side: block form submission if checkbox is unchecked; show validation message
- [x] 4.5 Server-side: reject registration requests in `api.auth.register.ts` that don't include `termsAccepted: true`
- [x] 4.6 On successful registration, set `users.terms_accepted_at = NOW()`
## 5. Alpha Banner
- [ ] 5.1 Create `apps/journal/app/components/AlphaBanner.tsx` — thin banner with message and dismiss button, reads `sessionStorage.alphaBannerDismissed`
- [ ] 5.2 Render `<AlphaBanner />` in the Journal root layout
- [ ] 5.3 Add corresponding i18n strings
- [x] 5.1 Create `apps/journal/app/components/AlphaBanner.tsx` — thin banner with message and dismiss button, reads `sessionStorage.alphaBannerDismissed`
- [x] 5.2 Render `<AlphaBanner />` in the Journal root layout
- [x] 5.3 Add corresponding i18n strings
## 6. Footer Links
- [ ] 6.1 Create `apps/journal/app/components/Footer.tsx` with links to Impressum, Privacy, Terms
- [ ] 6.2 Add the footer to the Journal root layout
- [ ] 6.3 Add a footer to the Planner layout linking to the Journal's legal pages (absolute URLs, since Planner is stateless)
- [x] 6.1 Create `apps/journal/app/components/Footer.tsx` with links to Impressum, Privacy, Terms
- [x] 6.2 Add the footer to the Journal root layout
- [x] 6.3 Add a footer to the Planner layout linking to the Journal's legal pages (absolute URLs, since Planner is stateless)
## 7. Review & Publish

View file

@ -29,6 +29,7 @@ export const users = journalSchema.table("users", {
displayName: text("display_name"),
bio: text("bio"),
domain: text("domain").notNull(),
termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

View file

@ -26,18 +26,34 @@ const commonOptions = {
/**
* Initialize i18next for server-side rendering.
* No language detector language is determined from the request.
*
* Re-adds resource bundles on every call so dev-mode HMR edits to the
* locale files are reflected without a Node restart (the i18n singleton
* otherwise caches the resources from the first init call).
*/
export function initI18nServer(lng: SupportedLng = "en") {
if (i18n.isInitialized) {
syncResources();
i18n.changeLanguage(lng);
return;
}
i18n.use(initReactI18next).init({ ...commonOptions, lng });
}
function syncResources() {
for (const [lang, namespaces] of Object.entries(resources)) {
for (const [ns, bundle] of Object.entries(namespaces)) {
i18n.addResourceBundle(lang, ns, bundle, true, true);
}
}
}
/**
* Initialize i18next for the client.
* Uses LanguageDetector with htmlTag priority so hydration matches the server.
* No localStorage caching we don't want to set non-essential storage
* without user consent. Language is resolved per-request from the server
* (Accept-Language htmlTag) or the browser's navigator as a fallback.
*/
export function initI18nClient() {
if (i18n.isInitialized) return;
@ -47,8 +63,8 @@ export function initI18nClient() {
.init({
...commonOptions,
detection: {
order: ["htmlTag", "localStorage", "navigator"],
caches: ["localStorage"],
order: ["htmlTag", "navigator"],
caches: [],
},
});
}

View file

@ -143,9 +143,12 @@ export default {
saveRoutesDesc: "Erstelle ein kostenloses Konto auf trails.cool, um Routen zu speichern, Aktivitäten zu verfolgen und mehr.",
goToJournal: "Zu trails.cool",
footer: {
imprint: "Impressum",
privacy: "Datenschutz",
terms: "Nutzungsbedingungen",
source: "Quellcode",
builtWith: "Erstellt mit BRouter und OpenStreetMap",
alpha: "Alpha-Version",
},
},
},
@ -250,6 +253,16 @@ export default {
settings: "Einstellungen",
logout: "Abmelden",
},
alpha: {
message: "trails.cool befindet sich in früher Entwicklung — deine Daten können zurückgesetzt werden.",
},
footer: {
imprint: "Impressum",
privacy: "Datenschutz",
terms: "Nutzungsbedingungen",
source: "Quellcode",
alpha: "Alpha-Version",
},
auth: {
login: "Anmelden",
register: "Registrieren",
@ -267,7 +280,14 @@ export default {
linkExpires: "Der Link ist 15 Minuten gültig.",
codeHelp: "Oder gib den 6-stelligen Code aus der E-Mail ein:",
verifyCode: "Code bestätigen",
termsBefore: "Ich habe die ",
termsLink: "Nutzungsbedingungen",
termsAfter: " gelesen und akzeptiere sie.",
termsRequired: "Du musst die Nutzungsbedingungen akzeptieren.",
noAccount: "Noch kein Konto?",
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.",
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
registerWithPasskey: "Mit Passkey registrieren",
creatingPasskey: "Erstelle Passkey...",

View file

@ -143,9 +143,12 @@ export default {
saveRoutesDesc: "Create a free account on trails.cool to keep your routes, track activities, and more.",
goToJournal: "Go to trails.cool",
footer: {
imprint: "Imprint",
privacy: "Privacy",
terms: "Terms",
source: "Source Code",
builtWith: "Built with BRouter and OpenStreetMap",
alpha: "Alpha version",
},
},
},
@ -250,6 +253,16 @@ export default {
settings: "Settings",
logout: "Log Out",
},
alpha: {
message: "trails.cool is in early development — your data may be reset.",
},
footer: {
imprint: "Imprint",
privacy: "Privacy",
terms: "Terms",
source: "Source Code",
alpha: "Alpha version",
},
auth: {
login: "Sign In",
register: "Register",
@ -267,7 +280,14 @@ export default {
linkExpires: "The link expires in 15 minutes.",
codeHelp: "Or enter the 6-digit code from the email:",
verifyCode: "Verify Code",
termsBefore: "I have read and accept the ",
termsLink: "Terms of Service",
termsAfter: ".",
termsRequired: "You must accept the Terms of Service to register.",
noAccount: "Don't have an account?",
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.",
registerDescription: "Register with a passkey — no password needed.",
registerWithPasskey: "Register with Passkey",
creatingPasskey: "Creating passkey...",

View file

@ -0,0 +1,17 @@
{
"name": "@trails-cool/sentry-config",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint .",
"typecheck": "tsc"
},
"devDependencies": {
"@types/node": "catalog:"
}
}

View file

@ -0,0 +1,100 @@
/**
* Shared Sentry configuration helpers for all trails.cool apps.
*
* Each app still calls its own SDK-specific `Sentry.init(...)` (they use
* three different SDKs: `@sentry/node`, `@sentry/react`, `@sentry/react-native`),
* but the common options are centralised here so privacy defaults (`sendDefaultPii: false`,
* replay off) and enable logic stay consistent.
*/
/**
* Drop 404 errors they come from scanners/typos and aren't bugs.
* Pass as `beforeSend` to `Sentry.init(...)` on server-side init sites.
*
* Typed loosely on purpose: the `@sentry/node` and `@sentry/react` SDKs
* each have their own `ErrorEvent` / `Event` types, and we don't want this
* package to depend on either SDK.
*/
export function drop404s<E extends { extra?: Record<string, unknown> }>(event: E): E | null {
const serialized = event.extra?.__serialized__ as Record<string, unknown> | undefined;
if (serialized?.status === 404) return null;
return event;
}
/**
* Shared Sentry options for Node-side runtimes (the HTTP servers).
* Spread into `Sentry.init({...nodeSentryConfig("journal server"), dsn, beforeSend: drop404s, integrations})`.
*
* Logs a debug message at init time when the config is inert (local dev)
* so developers can see that Sentry would send in production.
*/
export function nodeSentryConfig(appContext: string) {
const environment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development");
const enabled = process.env.NODE_ENV === "production" && !process.env.CI;
if (!enabled && !process.env.CI) {
console.debug(
`[sentry] ${appContext} init inert (env=${environment}); would send to Sentry in production`,
);
}
return {
environment,
enabled,
release: process.env.SENTRY_RELEASE,
tracesSampleRate: 1.0,
sendDefaultPii: false as const,
};
}
interface BrowserEnv {
PROD: boolean;
VITE_SENTRY_ENVIRONMENT?: string;
}
/**
* Shared Sentry options for Vite/browser runtimes.
* Spread into `Sentry.init({...browserSentryConfig("journal client", import.meta.env), dsn, integrations})`.
*
* Replay sample rates are explicitly 0 replay integration is not installed,
* but the options document the intent and prevent accidental enablement.
*/
export function browserSentryConfig(appContext: string, env: BrowserEnv) {
const environment = env.VITE_SENTRY_ENVIRONMENT ?? (env.PROD ? "production" : "development");
const enabled = env.PROD && environment !== "ci";
if (!enabled && !env.PROD) {
console.debug(
`[sentry] ${appContext} init inert (env=${environment}); would send to Sentry in production`,
);
}
return {
environment,
enabled,
tracesSampleRate: environment === "ci" ? 0 : 1.0,
sendDefaultPii: false as const,
replaysSessionSampleRate: 0 as const,
replaysOnErrorSampleRate: 0 as const,
};
}
/**
* Shared Sentry options for React Native.
* Spread into `Sentry.init({...mobileSentryConfig(__DEV__), dsn})`.
*/
export function mobileSentryConfig(dev: boolean) {
const enabled = !dev;
if (dev) {
console.debug("[sentry] mobile init inert in dev; would send to Sentry in production");
}
return {
enabled,
tracesSampleRate: dev ? 0 : 1.0,
sendDefaultPii: false as const,
replaysSessionSampleRate: 0 as const,
replaysOnErrorSampleRate: 0 as const,
};
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

15
pnpm-lock.yaml generated
View file

@ -230,6 +230,9 @@ importers:
'@trails-cool/map':
specifier: workspace:*
version: link:../../packages/map
'@trails-cool/sentry-config':
specifier: workspace:*
version: link:../../packages/sentry-config
'@trails-cool/types':
specifier: workspace:*
version: link:../../packages/types
@ -327,6 +330,9 @@ importers:
'@trails-cool/map-core':
specifier: workspace:*
version: link:../../packages/map-core
'@trails-cool/sentry-config':
specifier: workspace:*
version: link:../../packages/sentry-config
'@trails-cool/types':
specifier: workspace:*
version: link:../../packages/types
@ -478,6 +484,9 @@ importers:
'@trails-cool/map-core':
specifier: workspace:*
version: link:../../packages/map-core
'@trails-cool/sentry-config':
specifier: workspace:*
version: link:../../packages/sentry-config
'@trails-cool/types':
specifier: workspace:*
version: link:../../packages/types
@ -635,6 +644,12 @@ importers:
packages/map-core: {}
packages/sentry-config:
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 22.19.17
packages/types: {}
packages/ui: {}