Merge pull request #235 from trails-cool/feat/legal-disclaimers
Add legal pages, ToS acceptance, and alpha banner
This commit is contained in:
commit
32a844d8de
37 changed files with 943 additions and 235 deletions
|
|
@ -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/
|
||||
|
|
|
|||
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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
38
apps/journal/app/lib/sentry.client.ts
Normal file
38
apps/journal/app/lib/sentry.client.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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__),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
17
packages/sentry-config/package.json
Normal file
17
packages/sentry-config/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
100
packages/sentry-config/src/index.ts
Normal file
100
packages/sentry-config/src/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
9
packages/sentry-config/tsconfig.json
Normal file
9
packages/sentry-config/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue