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:
parent
6c1cd23358
commit
cc0f44258a
24 changed files with 773 additions and 206 deletions
10
apps/journal/app/components/AlphaBanner.tsx
Normal file
10
apps/journal/app/components/AlphaBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/journal/app/components/Footer.tsx
Normal file
33
apps/journal/app/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
20
apps/journal/app/lib/operator.ts
Normal file
20
apps/journal/app/lib/operator.ts
Normal 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;
|
||||
30
apps/journal/app/lib/sentry.client.ts
Normal file
30
apps/journal/app/lib/sentry.client.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
94
apps/journal/app/routes/legal.imprint.tsx
Normal file
94
apps/journal/app/routes/legal.imprint.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
apps/journal/app/routes/legal.privacy.tsx
Normal file
238
apps/journal/app/routes/legal.privacy.tsx
Normal 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'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'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't Do</h3>
|
||||
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
|
||||
<li>We don't sell data</li>
|
||||
<li>We don't show ads</li>
|
||||
<li>We don't build user profiles</li>
|
||||
<li>We don't use tracking pixels or analytics</li>
|
||||
<li>We don'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>
|
||||
);
|
||||
}
|
||||
187
apps/journal/app/routes/legal.terms.tsx
Normal file
187
apps/journal/app/routes/legal.terms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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'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't Do</h2>
|
||||
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
|
||||
<li>We don't sell data</li>
|
||||
<li>We don't show ads</li>
|
||||
<li>We don't build user profiles</li>
|
||||
<li>We don't use tracking pixels or analytics</li>
|
||||
<li>We don'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'll note it here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
export function loader() {
|
||||
return redirect("/legal/privacy", 301);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export function initSentry() {
|
|||
dsn: SENTRY_DSN,
|
||||
tracesSampleRate: __DEV__ ? 0 : 1.0,
|
||||
enabled: !__DEV__,
|
||||
sendDefaultPii: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue