diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 3a74835..f5ee27e 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -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/ diff --git a/apps/journal/app/components/AlphaBanner.tsx b/apps/journal/app/components/AlphaBanner.tsx new file mode 100644 index 0000000..16ca223 --- /dev/null +++ b/apps/journal/app/components/AlphaBanner.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from "react-i18next"; + +export function AlphaBanner() { + const { t } = useTranslation("journal"); + return ( +
+

{t("alpha.message")}

+
+ ); +} diff --git a/apps/journal/app/components/Footer.tsx b/apps/journal/app/components/Footer.tsx new file mode 100644 index 0000000..f1f242d --- /dev/null +++ b/apps/journal/app/components/Footer.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; + +export function Footer() { + const { t } = useTranslation("journal"); + return ( + + ); +} diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx index 8e3f019..d5b4d57 100644 --- a/apps/journal/app/entry.client.tsx +++ b/apps/journal/app/entry.client.tsx @@ -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(() => { diff --git a/apps/journal/app/entry.server.tsx b/apps/journal/app/entry.server.tsx index 0bdfe8f..5db5d23 100644 --- a/apps/journal/app/entry.server.tsx +++ b/apps/journal/app/entry.server.tsx @@ -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 | undefined; - if (serialized?.status === 404) return null; - return event; - }, -}); - export const streamTimeout = 5_000; export default function handleRequest( diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index d44e4cd..204aa64 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -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"); diff --git a/apps/journal/app/lib/operator.ts b/apps/journal/app/lib/operator.ts new file mode 100644 index 0000000..4ea49af --- /dev/null +++ b/apps/journal/app/lib/operator.ts @@ -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; diff --git a/apps/journal/app/lib/sentry.client.ts b/apps/journal/app/lib/sentry.client.ts new file mode 100644 index 0000000..f4ea454 --- /dev/null +++ b/apps/journal/app/lib/sentry.client.ts @@ -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(); +} diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index be4a5f4..09b32ec 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -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 ( + +