Add legal pages, ToS acceptance, and alpha banner

- Journal: Impressum (§5 TMG), Terms of Service, GDPR privacy page
- Registration flow: required ToS checkbox, termsAcceptedAt persisted
- Alpha banner on Journal (always visible); alpha badge on Planner hero
- Footer with legal links + GitHub on both apps
- Reduce PII: sendDefaultPii=false, Sentry init only after login (Journal)
- Drop Sentry from Planner (no login, no consent possible)
- Drop localStorage caching from i18n client detection
- Sync SSR i18n resources each request so locale edits HMR without restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-18 00:08:48 +02:00
parent 6c1cd23358
commit cc0f44258a
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
24 changed files with 773 additions and 206 deletions

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">
<nav 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>
</nav>
</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

@ -16,6 +16,7 @@ Sentry.init({
environment: sentryEnvironment,
tracesSampleRate: 1.0,
enabled: process.env.NODE_ENV === "production" && !process.env.CI,
sendDefaultPii: false,
beforeSend(event) {
// Drop 404s — they're expected (scanners, typos), not bugs
const serialized = event.extra?.__serialized__ as Record<string, unknown> | undefined;

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,30 @@
import * as Sentry from "@sentry/react";
import { useEffect } from "react";
import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
let initialized = false;
export function initSentryClient() {
if (initialized) return;
initialized = true;
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",
sendDefaultPii: false,
});
}

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 } from "~/lib/sentry.client";
import stylesheet from "@trails-cool/ui/styles.css?url";
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
@ -116,7 +119,8 @@ 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);
}
@ -124,8 +128,10 @@ export default function App({ loaderData }: Route.ComponentProps) {
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

@ -7,6 +7,7 @@ export function initSentry() {
dsn: SENTRY_DSN,
tracesSampleRate: __DEV__ ? 0 : 1.0,
enabled: !__DEV__,
sendDefaultPii: false,
});
}

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

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