From b15c23f544be6e1a2fee39d6227c39d72149e0b2 Mon Sep 17 00:00:00 2001 From: Akargi Date: Sat, 17 Oct 2026 23:26:21 +0100 Subject: [PATCH] Session hydration, viewport export, Sentry sampling and auth field errors (Akargi) - #1983: hydrate the session store once inside a client effect instead of reading the cookie at store creation / during render. Adds a `hydrated` flag, a `useSessionHydration` hook and a `useSessionToken` replacement for the old non-reactive `useAdminToken` cookie read. All render-time cookie reads on /usage, /credits, /payments, /payments/new, /payments/return and the admin pages are replaced by store-driven, hydration-gated rendering, so the first client render matches the SSR output. Cross-tab sign-out syncs via a localStorage signal + storage event listener. - #1986: export `viewport` separately from `metadata` in app/layout.tsx (Next.js 14 requirement) with the same theme colour as app/manifest.ts. - #1987: unify Sentry sampling in lib/sentry-sampling.ts shared by the client, server and edge configs. Default trace sampling is 1.0 in development and 0.1 in production. Session replays are opt-in only (default 0). The beforeSend hook no longer crashes on non-Error rejections: strings, undefined and plain objects are reported as NonError events with a stringified message. SENTRY_SETUP.md documents the defaults. - #1988: auth API errors now carry structured field information (AuthApiError with status + fieldErrors). The auth form renders 400 validation messages next to their own input (aria-invalid, aria-describedby), maps 409 taken-email onto the email field, shows a single form-level message for 429 rate limiting, and focuses the first invalid field. Field errors clear as the user edits. Notes for reviewers: - The session-store changes are additive: `setAccessToken` and the cookie behaviour are unchanged, so sign-in and sign-out keep working exactly as before. The only new behaviour is that the token starts as null and is populated by the hydration effect. - The Sentry server/edge configs now import from lib/sentry-sampling.ts instead of hardcoding tracesSampleRate: 1.0. This changes the default production behaviour from 100% to 10% sampling. - The auth-form changes are purely additive: existing behaviour (success redirect, password confirmation mismatch) is unchanged. --- frontend/SENTRY_SETUP.md | 17 +- frontend/app/admin/accounts/page.tsx | 5 +- frontend/app/admin/integrity/page.tsx | 5 +- frontend/app/admin/payments/page.tsx | 7 +- frontend/app/admin/refund/page.tsx | 13 +- frontend/app/admin/settlements/page.tsx | 5 +- frontend/app/admin/splits/page.tsx | 5 +- frontend/app/credits/page.tsx | 20 ++- frontend/app/layout.tsx | 15 +- frontend/app/payments/new/page.tsx | 6 +- frontend/app/payments/page.tsx | 15 ++ frontend/app/payments/return/page.tsx | 19 ++ frontend/app/usage/page.tsx | 16 +- .../admin/__tests__/require-admin.test.tsx | 68 +++++++- frontend/components/admin/require-admin.tsx | 36 +++- frontend/components/auth/auth-form.test.tsx | 163 +++++++++++++++++- frontend/components/auth/auth-form.tsx | 73 +++++++- .../__tests__/payment-return-page.test.tsx | 4 + .../__tests__/sentry-client-config.test.ts | 66 ++++++- frontend/lib/__tests__/session-store.test.ts | 90 ++++++++++ frontend/lib/auth-api.ts | 68 +++++++- frontend/lib/sentry-sampling.ts | 27 +++ frontend/lib/stores/session-store.ts | 40 ++++- frontend/lib/stores/use-session-hydration.ts | 49 ++++++ frontend/sentry.client.config.ts | 63 +++++-- frontend/sentry.edge.config.ts | 11 +- frontend/sentry.server.config.ts | 11 +- 27 files changed, 836 insertions(+), 81 deletions(-) create mode 100644 frontend/lib/__tests__/session-store.test.ts create mode 100644 frontend/lib/sentry-sampling.ts create mode 100644 frontend/lib/stores/use-session-hydration.ts diff --git a/frontend/SENTRY_SETUP.md b/frontend/SENTRY_SETUP.md index 63214adc..60c23d79 100644 --- a/frontend/SENTRY_SETUP.md +++ b/frontend/SENTRY_SETUP.md @@ -47,9 +47,24 @@ Reference `.env.example` for all required variables. After setting the DSN, Sentry will automatically: - Initialize on client and server - Capture unhandled exceptions -- Record session replays for errors - Report API failures with context +## Sampling Defaults (issue #1987) + +- **Traces**: `NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE` defaults to `1.0` in + development but only `0.1` in production, so one forgotten deployment + cannot exhaust the quota. Set the variable explicitly to change this + (values outside [0, 1] fall back to the default). The server and edge + configs share the same helper (`lib/sentry-sampling.ts`). +- **Session replays are opt-in only**: both + `NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE` and + `NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE` default to `0`. Enable + them explicitly (e.g. `0.1` / `1.0`) if you want recording; the replay + integration still masks all text and blocks all media whenever it runs. +- **Non-Error rejections** never crash the `beforeSend` hook anymore: thrown + strings, `undefined` or plain objects are reported as `NonError` events + with a stringified message instead of being dropped. + ## Error Context Errors are tagged with: - `errorBoundary`: Which error boundary caught the error (global/wallet) diff --git a/frontend/app/admin/accounts/page.tsx b/frontend/app/admin/accounts/page.tsx index 4d70b33c..35547f59 100644 --- a/frontend/app/admin/accounts/page.tsx +++ b/frontend/app/admin/accounts/page.tsx @@ -16,7 +16,8 @@ import { SecondaryButton, inputClass, } from "@/components/admin/ui"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; +import { useSessionToken } from "@/lib/stores/use-session-hydration"; const KINDS: LedgerAccountKind[] = [ "USER", @@ -28,7 +29,7 @@ const KINDS: LedgerAccountKind[] = [ ]; export default function AccountsPage() { - const token = useAdminToken(); + const token = useSessionToken(); return ( diff --git a/frontend/app/admin/integrity/page.tsx b/frontend/app/admin/integrity/page.tsx index 568f359c..c2da37f0 100644 --- a/frontend/app/admin/integrity/page.tsx +++ b/frontend/app/admin/integrity/page.tsx @@ -5,10 +5,11 @@ import { CheckCircle2, AlertTriangle } from "lucide-react"; import { checkIntegrity, type LedgerIntegrityReport } from "@/lib/admin-api"; import { Card, CardHeader, Button } from "@/components/admin/ui"; import { IntegrityResults } from "@/components/admin/integrity-results"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; +import { useSessionToken } from "@/lib/stores/use-session-hydration"; export default function IntegrityPage() { - const token = useAdminToken(); + const token = useSessionToken(); return ( diff --git a/frontend/app/admin/payments/page.tsx b/frontend/app/admin/payments/page.tsx index 2255a467..14bda9cf 100644 --- a/frontend/app/admin/payments/page.tsx +++ b/frontend/app/admin/payments/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -12,12 +12,13 @@ import { Badge, StatusBadge, } from "@/components/admin/ui"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; +import { useSessionToken } from "@/lib/stores/use-session-hydration"; const PAGE_SIZE = 25; export default function AdminManualReviewPage() { - const token = useAdminToken(); + const token = useSessionToken(); return ( diff --git a/frontend/app/admin/refund/page.tsx b/frontend/app/admin/refund/page.tsx index 39c985b2..df5e8e01 100644 --- a/frontend/app/admin/refund/page.tsx +++ b/frontend/app/admin/refund/page.tsx @@ -9,12 +9,12 @@ import { StatusBadge, } from "@/components/app-ui"; import { RequireAdmin } from "@/components/admin/require-admin"; +import { useSessionHydration, useSessionToken } from "@/lib/stores/use-session-hydration"; import { listManualReviewPayments, requestRefund, type Payment, } from "@/lib/payments-api"; -import Cookies from "js-cookie"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; @@ -24,7 +24,7 @@ function PaymentRow({ payment }: { payment: Payment }) { const [amount, setAmount] = useState(String(payment.amount)); const [reason, setReason] = useState(""); const [busy, setBusy] = useState(false); - const token = typeof window !== "undefined" ? Cookies.get("accessToken") : null; + const token = useSessionToken(); async function onRefund(e: React.FormEvent) { e.preventDefault(); @@ -91,11 +91,12 @@ function PaymentRow({ payment }: { payment: Payment }) { } export default function AdminRefundPage() { - const token = typeof window !== "undefined" ? Cookies.get("accessToken") : null; + const hydrated = useSessionHydration(); + const token = useSessionToken(); const { data, isLoading, error } = useQuery({ queryKey: ["manual-review"], queryFn: () => listManualReviewPayments(token!), - enabled: !!token, + enabled: Boolean(token), }); return ( @@ -107,7 +108,9 @@ export default function AdminRefundPage() { description="Payments awaiting reconciliation or manual review can be refunded." />
- {!token ? ( + {!hydrated ? ( +

Checking your session...

+ ) : !token ? (

Sign in required.

) : isLoading ? (

Loading…

diff --git a/frontend/app/admin/settlements/page.tsx b/frontend/app/admin/settlements/page.tsx index 2f7fa6c8..a2261c66 100644 --- a/frontend/app/admin/settlements/page.tsx +++ b/frontend/app/admin/settlements/page.tsx @@ -13,7 +13,8 @@ import { StatusBadge, Button, } from "@/components/admin/ui"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; +import { useSessionToken } from "@/lib/stores/use-session-hydration"; import { SettlementVolumeChart } from "@/components/admin/settlement-volume-chart"; const STATUSES: Array<{ value: SettlementBatchStatus | ""; label: string }> = [ @@ -27,7 +28,7 @@ const STATUSES: Array<{ value: SettlementBatchStatus | ""; label: string }> = [ ]; export default function SettlementsPage() { - const token = useAdminToken(); + const token = useSessionToken(); return ( diff --git a/frontend/app/admin/splits/page.tsx b/frontend/app/admin/splits/page.tsx index 7962b732..f7f2ee3a 100644 --- a/frontend/app/admin/splits/page.tsx +++ b/frontend/app/admin/splits/page.tsx @@ -16,10 +16,11 @@ import { SecondaryButton, inputClass, } from "@/components/admin/ui"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; +import { useSessionToken } from "@/lib/stores/use-session-hydration"; export default function SplitsPage() { - const token = useAdminToken(); + const token = useSessionToken(); return ( diff --git a/frontend/app/credits/page.tsx b/frontend/app/credits/page.tsx index 50066e8a..2268f749 100644 --- a/frontend/app/credits/page.tsx +++ b/frontend/app/credits/page.tsx @@ -3,18 +3,26 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Wallet, ArrowDownLeft, ArrowUpRight, ChevronLeft, ChevronRight } from "lucide-react"; -import Cookies from "js-cookie"; +import { + useSessionHydration, + useSessionToken, +} from "@/lib/stores/use-session-hydration"; import { getCreditBalance, getCreditStatement } from "@/lib/credits-api"; import type { CreditStatementEntry } from "@/lib/credits-api"; const PAGE_SIZE = 20; -function useAccessToken(): string | null { - return Cookies.get("accessToken") ?? null; -} - export default function CreditsPage() { - const token = useAccessToken(); + const hydrated = useSessionHydration(); + const token = useSessionToken(); + + if (!hydrated) { + return ( +
+

Checking your session...

+
+ ); + } if (!token) { return ( diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 4609d2d7..9cad8425 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Providers } from "@/lib/providers/providers"; import { OfflineBanner } from "@/components/admin/offline-banner"; @@ -14,15 +14,24 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +/** + * Since Next.js 14 the viewport must be exported separately (issue #1986); + * leaving it inside `metadata` produces build warnings and is ignored in + * newer releases. `app/manifest.ts` uses the same theme colour. + */ +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + themeColor: "#0f172a", +}; + export const metadata: Metadata = { - viewport: "width=device-width, initial-scale=1", title: { default: "ManageHub - Smart Hub & Workspace Management", template: "%s | ManageHub", }, description: "Smart Hub & Workspace Management System for modern teams. Streamline operations, manage resources, and boost productivity with our comprehensive management platform.", - themeColor: "#0f172a", keywords: [ "workspace management", "hub management", diff --git a/frontend/app/payments/new/page.tsx b/frontend/app/payments/new/page.tsx index c98d2536..2500d5ae 100644 --- a/frontend/app/payments/new/page.tsx +++ b/frontend/app/payments/new/page.tsx @@ -2,12 +2,14 @@ import { Button, Card, CardHeader, Field, inputClass } from "@/components/app-ui"; import { initiatePayment, type PaymentRail } from "@/lib/payments-api"; -import Cookies from "js-cookie"; +import { useSessionToken } from "@/lib/stores/use-session-hydration"; import { useState } from "react"; import { toast } from "sonner"; export default function NewPaymentPage() { - const [token] = useState(() => Cookies.get("accessToken")); + // Token comes from the hydrated session store (issue #1983): no cookie is + // read during the initial render, which would diverge from the SSR output. + const token = useSessionToken(); const [bookingId, setBookingId] = useState(""); const [amount, setAmount] = useState(""); const [currency, setCurrency] = useState("USD"); diff --git a/frontend/app/payments/page.tsx b/frontend/app/payments/page.tsx index ad9253f9..72f8b64e 100644 --- a/frontend/app/payments/page.tsx +++ b/frontend/app/payments/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { getPayments } from "@/lib/payments-api"; import { useSessionStore } from "@/lib/stores/session-store"; +import { useSessionHydration } from "@/lib/stores/use-session-hydration"; import { Card, CardContent, @@ -31,6 +32,7 @@ function statusTone(status: string): string { } export default function PaymentsPage() { + const hydrated = useSessionHydration(); const accessToken = useSessionStore((state) => state.accessToken); const { @@ -43,6 +45,19 @@ export default function PaymentsPage() { enabled: Boolean(accessToken), }); + if (!hydrated) { + // Before hydration the token is deliberately null (issue #1983), so + // render the same neutral state the server did and avoid a mismatch. + return ( +
+

Payments

+

+ Checking your session... +

+
+ ); + } + if (!accessToken) { return (
diff --git a/frontend/app/payments/return/page.tsx b/frontend/app/payments/return/page.tsx index e15d5f29..98e8631d 100644 --- a/frontend/app/payments/return/page.tsx +++ b/frontend/app/payments/return/page.tsx @@ -26,6 +26,7 @@ import Link from "next/link"; import { verifyPaymentReturn, getPayment } from "@/lib/payments-api"; import type { Payment } from "@/lib/payments-api"; import { useSessionStore } from "@/lib/stores/session-store"; +import { useSessionHydration } from "@/lib/stores/use-session-hydration"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { getProviderReturnErrorMessage } from "@/lib/payment-return-errors"; @@ -82,6 +83,7 @@ function statusTone(status: string): string { export default function PaymentReturnPage() { const router = useRouter(); const searchParams = useSearchParams(); + const hydrated = useSessionHydration(); const accessToken = useSessionStore((state) => state.accessToken); const paymentId = @@ -229,6 +231,23 @@ export default function PaymentReturnPage() { ); } + if (!hydrated) { + // Pre-hydration the token is null (issue #1983); render the neutral + // state the server rendered so the markup cannot mismatch. + return ( +
+

Verifying Payment

+ + +

+ Checking your session... +

+
+
+
+ ); + } + if (!accessToken) { return (
diff --git a/frontend/app/usage/page.tsx b/frontend/app/usage/page.tsx index 2de74440..dd7b33dd 100644 --- a/frontend/app/usage/page.tsx +++ b/frontend/app/usage/page.tsx @@ -1,9 +1,12 @@ "use client"; -import { Card, CardHeader, StatusBadge } from "@/components/app-ui"; +import { CardHeader, StatusBadge } from "@/components/app-ui"; import { listMyUsage, type MeteredUsageEvent } from "@/lib/payments-api"; import { formatCurrency } from "@/lib/utils"; -import Cookies from "js-cookie"; +import { + useSessionHydration, + useSessionToken, +} from "@/lib/stores/use-session-hydration"; import { useQuery } from "@tanstack/react-query"; function formatMoney(amount: number, currency: string) { @@ -11,11 +14,12 @@ function formatMoney(amount: number, currency: string) { } export default function UsagePage() { - const token = typeof window !== "undefined" ? Cookies.get("accessToken") : null; + const hydrated = useSessionHydration(); + const token = useSessionToken(); const { data, isLoading, error } = useQuery({ queryKey: ["my-usage"], queryFn: () => listMyUsage(token!), - enabled: !!token, + enabled: Boolean(token), }); return ( @@ -23,7 +27,9 @@ export default function UsagePage() {
- {!token ? ( + {!hydrated ? ( +

Checking your session...

+ ) : !token ? (

Sign in to view usage.

) : isLoading ? (

Loading…

diff --git a/frontend/components/admin/__tests__/require-admin.test.tsx b/frontend/components/admin/__tests__/require-admin.test.tsx index faecbf4d..b35eb3c4 100644 --- a/frontend/components/admin/__tests__/require-admin.test.tsx +++ b/frontend/components/admin/__tests__/require-admin.test.tsx @@ -1,7 +1,11 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import Cookies from "js-cookie"; import { usePathname, useRouter } from "next/navigation"; +import { + SESSION_SIGNAL_KEY, + useSessionStore, +} from "@/lib/stores/session-store"; import { RequireAdmin } from "../require-admin"; vi.mock("js-cookie", () => ({ @@ -17,6 +21,9 @@ const replace = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + // The zustand store is module-scoped and would otherwise leak state + // between tests (issue #1983): every test starts un-hydrated. + useSessionStore.setState({ accessToken: null, hydrated: false }); window.history.replaceState({}, "", "/admin/accounts?tab=frozen"); vi.mocked(usePathname).mockReturnValue("/admin/accounts"); vi.mocked(useRouter).mockReturnValue({ @@ -41,7 +48,7 @@ describe("RequireAdmin", () => { await waitFor(() => expect(replace).toHaveBeenCalledWith(loginUrl)); }); - it("renders protected content when an access token is present", () => { + it("renders protected content when an access token is present", async () => { vi.mocked(Cookies.get).mockReturnValue("access-token"); render( @@ -50,7 +57,62 @@ describe("RequireAdmin", () => { , ); - expect(screen.getByText("Protected content")).toBeInTheDocument(); + // The token is only known after the client-side hydration effect runs, + // which is exactly what keeps the first render identical to the SSR + // output (issue #1983). + await waitFor(() => + expect(screen.getByText("Protected content")).toBeInTheDocument(), + ); expect(replace).not.toHaveBeenCalled(); }); + + it("clears the session and re-gates when another tab signs out", async () => { + vi.mocked(Cookies.get).mockReturnValue("access-token"); + + render( + +
Protected content
+
, + ); + await waitFor(() => + expect(screen.getByText("Protected content")).toBeInTheDocument(), + ); + + // The other tab removed the cookie and emitted the session signal. + vi.mocked(Cookies.get).mockReturnValue(undefined); + act(() => { + window.dispatchEvent( + new StorageEvent("storage", { key: SESSION_SIGNAL_KEY }), + ); + }); + + await waitFor(() => + expect( + screen.getByRole("link", { name: "Sign in to continue" }), + ).toBeInTheDocument(), + ); + expect(screen.queryByText("Protected content")).not.toBeInTheDocument(); + }); + + it("renders a neutral checking state while hydration is pending", () => { + vi.mocked(Cookies.get).mockReturnValue(undefined); + + render( + +
Protected content
+
, + ); + + // Force the pre-hydration state the component shows on first paint, + // before the store's hydrate effect has run: it must be a neutral + // "checking" state so it matches the SSR output (issue #1983) instead + // of flashing the signed-out panel. + act(() => + useSessionStore.setState({ accessToken: null, hydrated: false }), + ); + expect(screen.getByText("Checking your session...")).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Sign in to continue" }), + ).not.toBeInTheDocument(); + }); }); diff --git a/frontend/components/admin/require-admin.tsx b/frontend/components/admin/require-admin.tsx index 9136e0af..9760de21 100644 --- a/frontend/components/admin/require-admin.tsx +++ b/frontend/components/admin/require-admin.tsx @@ -1,18 +1,24 @@ "use client"; -import Cookies from "js-cookie"; import Link from "next/link"; import { useEffect, useState } from "react"; -import { ShieldAlert } from "lucide-react"; +import { Loader2, ShieldAlert } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { buildReturnTo, getSafeReturnTo } from "@/lib/auth-redirect"; +import { useSessionHydration } from "@/lib/stores/use-session-hydration"; +import { useSessionStore } from "@/lib/stores/session-store"; -export function useAdminToken(): string | null { - return Cookies.get("accessToken") ?? null; -} - +/** + * The token comes from the session store, which is hydrated once inside a + * client effect (issue #1983): no cookie is read during render, so the + * server-rendered markup and the first client render agree, and the gate + * re-renders reactively when the token changes (for example after sign-out + * in another tab). The former `useAdminToken` (a plain per-render cookie + * read) is replaced by `useSessionToken` from use-session-hydration. + */ export function RequireAdmin({ children }: { children: React.ReactNode }) { - const token = useAdminToken(); + const hydrated = useSessionHydration(); + const token = useSessionStore((state) => state.accessToken); const pathname = usePathname(); const router = useRouter(); const [returnTo, setReturnTo] = useState(pathname); @@ -23,10 +29,22 @@ export function RequireAdmin({ children }: { children: React.ReactNode }) { buildReturnTo(pathname, window.location.search.replace(/^\?/, "")), ); setReturnTo(currentReturnTo); - if (!token) { + if (hydrated && !token) { router.replace(`/login?returnTo=${encodeURIComponent(currentReturnTo)}`); } - }, [pathname, router, token]); + }, [pathname, router, token, hydrated]); + + if (!hydrated) { + return ( +
+ + Checking your session... +
+ ); + } if (!token) { return ( diff --git a/frontend/components/auth/auth-form.test.tsx b/frontend/components/auth/auth-form.test.tsx index 9b5009a1..34dbce9b 100644 --- a/frontend/components/auth/auth-form.test.tsx +++ b/frontend/components/auth/auth-form.test.tsx @@ -21,11 +21,34 @@ vi.mock("@/lib/stores/session-store", () => ({ ) => selector({ setAccessToken: mocks.setAccessToken }), })); -vi.mock("@/lib/auth-api", () => ({ - login: mocks.login, - register: mocks.register, -})); +// The mocked module must expose the same AuthApiError class the component +// uses for its instanceof checks (issue #1988), so thrown errors are +// recognised as structured field failures. +vi.mock("@/lib/auth-api", () => { + class MockAuthApiError extends Error { + status: number; + fieldErrors: Record; + + constructor( + status: number, + fieldErrors: Record, + message: string, + ) { + super(message); + this.name = "AuthApiError"; + this.status = status; + this.fieldErrors = fieldErrors; + } + } + + return { + AuthApiError: MockAuthApiError, + login: mocks.login, + register: mocks.register, + }; +}); +import { AuthApiError } from "@/lib/auth-api"; import { AuthForm } from "./auth-form"; function renderForm(mode: "login" | "register") { @@ -79,3 +102,135 @@ describe("AuthForm registration confirmation", () => { expect(mocks.push).toHaveBeenCalledWith("/wallet"); }); }); + +describe("AuthForm per-field API errors (issue #1988)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders 400 validation messages next to their own field", async () => { + const user = userEvent.setup(); + mocks.register.mockRejectedValue( + new AuthApiError( + 400, + { + email: "email must be an email", + password: "password must be longer than or equal to 8 characters", + }, + "Bad Request", + ), + ); + renderForm("register"); + + await user.type(screen.getByLabelText("Email"), "not-an-email"); + await user.type(screen.getByLabelText("Password"), "short"); + await user.type(screen.getByLabelText("Confirm password"), "short"); + await user.click(screen.getByRole("button", { name: "Create account" })); + + const emailError = await screen.findByText("email must be an email"); + expect(emailError).toHaveAttribute("id", "email-error"); + expect(screen.getByLabelText("Email")).toHaveAttribute( + "aria-invalid", + "true", + ); + expect(screen.getByLabelText("Email")).toHaveAttribute( + "aria-describedby", + "email-error", + ); + + const passwordError = screen.getByText( + "password must be longer than or equal to 8 characters", + ); + expect(passwordError).toHaveAttribute("id", "password-error"); + expect(screen.getByLabelText("Password")).toHaveAttribute( + "aria-invalid", + "true", + ); + + // The generic form-level banner is silent when the fields carry the + // detail - the message is already next to the offending input. + expect(screen.queryByText("Bad Request")).not.toBeInTheDocument(); + // The first invalid field receives focus for keyboard users. + expect(document.activeElement).toBe(screen.getByLabelText("Email")); + }); + + it("maps a 409 taken-email failure onto the email field", async () => { + const user = userEvent.setup(); + mocks.register.mockRejectedValue( + new AuthApiError( + 409, + { email: "Email is already registered" }, + "Conflict", + ), + ); + renderForm("register"); + + await user.type(screen.getByLabelText("Email"), "taken@example.com"); + await user.type(screen.getByLabelText("Password"), "password-one"); + await user.type(screen.getByLabelText("Confirm password"), "password-one"); + await user.click(screen.getByRole("button", { name: "Create account" })); + + expect( + await screen.findByText("Email is already registered"), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Email")).toHaveAttribute( + "aria-invalid", + "true", + ); + }); + + it("shows a single form-level message when rate limited (429)", async () => { + const user = userEvent.setup(); + mocks.login.mockRejectedValue( + new AuthApiError(429, {}, "Too many attempts, try again in 60 seconds"), + ); + renderForm("login"); + + await user.type(screen.getByLabelText("Email"), "person@example.com"); + await user.type(screen.getByLabelText("Password"), "whatever"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent( + "Too many attempts. Please wait a few minutes and try again.", + ); + expect(screen.queryByText(/seconds/)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Email")).not.toHaveAttribute( + "aria-invalid", + ); + }); + + it("keeps the top banner for failures without field detail (401)", async () => { + const user = userEvent.setup(); + mocks.login.mockRejectedValue(new AuthApiError(401, {}, "Invalid credentials")); + renderForm("login"); + + await user.type(screen.getByLabelText("Email"), "person@example.com"); + await user.type(screen.getByLabelText("Password"), "wrong-password"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + expect( + await screen.findByText("Invalid credentials"), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Email")).not.toHaveAttribute( + "aria-invalid", + ); + }); + + it("clears a field's error when the user edits that field", async () => { + const user = userEvent.setup(); + mocks.register.mockRejectedValue( + new AuthApiError(400, { email: "email must be an email" }, "Bad Request"), + ); + renderForm("register"); + + await user.type(screen.getByLabelText("Email"), "nope"); + await user.type(screen.getByLabelText("Password"), "password-one"); + await user.type(screen.getByLabelText("Confirm password"), "password-one"); + await user.click(screen.getByRole("button", { name: "Create account" })); + await screen.findByText("email must be an email"); + + await user.type(screen.getByLabelText("Email"), "x"); + expect(screen.queryByText("email must be an email")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/components/auth/auth-form.tsx b/frontend/components/auth/auth-form.tsx index 0a85f64f..1435da1b 100644 --- a/frontend/components/auth/auth-form.tsx +++ b/frontend/components/auth/auth-form.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; -import { login, register } from "@/lib/auth-api"; +import { login, register, AuthApiError } from "@/lib/auth-api"; import { getSafeReturnTo } from "@/lib/auth-redirect"; import { useSessionStore } from "@/lib/stores/session-store"; @@ -17,14 +17,29 @@ export function AuthForm({ mode }: { mode: Mode }) { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(null); + // Per-field validation errors from the API (issue #1988): keys are + // "email" / "password", values are backend messages for that field. + const [fieldErrors, setFieldErrors] = useState< + Record | null + >(null); const [confirmationError, setConfirmationError] = useState(false); const [submitting, setSubmitting] = useState(false); const isLogin = mode === "login"; + function clearFieldError(field: string) { + setFieldErrors((prev) => { + if (!prev || !(field in prev)) return prev; + const next = { ...prev }; + delete next[field]; + return next; + }); + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setError(null); + setFieldErrors(null); if (!isLogin && password !== confirmPassword) { setConfirmationError(true); return; @@ -41,7 +56,29 @@ export function AuthForm({ mode }: { mode: Mode }) { ); router.push(returnTo); } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong"); + // Rate limiting (#1988): show a single, calm form-level message and + // make it clear the problem is not a specific field. + if (err instanceof AuthApiError && err.status === 429) { + setFieldErrors(null); + setError("Too many attempts. Please wait a few minutes and try again."); + return; + } + + const fieldErrors = + err instanceof AuthApiError ? { ...err.fieldErrors } : {}; + const firstInvalidField = Object.entries(fieldErrors)[0]; + + if (firstInvalidField) { + // Render each message next to its field, announce them to screen + // readers (aria-live via role="alert") and focus the first invalid + // input so keyboard users land straight on what to fix. + setFieldErrors(fieldErrors); + setError(null); + document.getElementById(firstInvalidField[0])?.focus(); + } else { + setFieldErrors(null); + setError(err instanceof Error ? err.message : "Something went wrong"); + } } finally { setSubmitting(false); } @@ -72,9 +109,25 @@ export function AuthForm({ mode }: { mode: Mode }) { type="email" required value={email} - onChange={(e) => setEmail(e.target.value)} + onChange={(e) => { + setEmail(e.target.value); + clearFieldError("email"); + }} + aria-invalid={ + fieldErrors?.email ? true : undefined + } + aria-describedby={fieldErrors?.email ? "email-error" : undefined} className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-transparent px-3 py-2 text-sm" /> + {fieldErrors?.email && ( + + )}
@@ -94,9 +147,23 @@ export function AuthForm({ mode }: { mode: Mode }) { onChange={(e) => { setPassword(e.target.value); setConfirmationError(false); + clearFieldError("password"); }} + aria-invalid={fieldErrors?.password ? true : undefined} + aria-describedby={ + fieldErrors?.password ? "password-error" : undefined + } className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-transparent px-3 py-2 text-sm" /> + {fieldErrors?.password && ( + + )}
{!isLogin && ( diff --git a/frontend/lib/__tests__/payment-return-page.test.tsx b/frontend/lib/__tests__/payment-return-page.test.tsx index 2cd9aecf..21fbc1df 100644 --- a/frontend/lib/__tests__/payment-return-page.test.tsx +++ b/frontend/lib/__tests__/payment-return-page.test.tsx @@ -20,6 +20,10 @@ vi.mock("@/lib/stores/session-store", () => ({ selector({ accessToken: "token" }), })); +vi.mock("@/lib/stores/use-session-hydration", () => ({ + useSessionHydration: () => true, +})); + vi.mock("@/lib/payments-api", () => ({ verifyPaymentReturn: mocks.verifyPaymentReturn, getPayment: mocks.getPayment, diff --git a/frontend/lib/__tests__/sentry-client-config.test.ts b/frontend/lib/__tests__/sentry-client-config.test.ts index 1e6d7720..b06013e3 100644 --- a/frontend/lib/__tests__/sentry-client-config.test.ts +++ b/frontend/lib/__tests__/sentry-client-config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; const sentryMocks = vi.hoisted(() => ({ init: vi.fn(), @@ -7,7 +7,11 @@ const sentryMocks = vi.hoisted(() => ({ vi.mock("@sentry/nextjs", () => sentryMocks); -import { getSampleRate } from "../../sentry.client.config"; +import { + defaultTracesSampleRate, + describeException, + getSampleRate, +} from "../../sentry.client.config"; describe("getSampleRate", () => { it("uses a configured rate within the valid range", () => { @@ -21,3 +25,61 @@ describe("getSampleRate", () => { expect(getSampleRate("1.1", 0.1)).toBe(0.1); }); }); + +describe("defaultTracesSampleRate (issue #1987)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("samples 100 percent in development", () => { + vi.stubEnv("NODE_ENV", "development"); + expect(defaultTracesSampleRate()).toBe(1.0); + }); + + it("samples at a production-safe 10 percent by default", () => { + vi.stubEnv("NODE_ENV", "production"); + expect(defaultTracesSampleRate()).toBe(0.1); + }); +}); + +describe("describeException (issue #1987)", () => { + it("passes real Errors through with their identity", () => { + const error = new RangeError("boom"); + expect(describeException(error)).toMatchObject({ + name: "RangeError", + message: "boom", + }); + expect(describeException(error).stack).toContain("describeException"); + }); + + it("labels thrown strings as NonError instead of crashing", () => { + expect(describeException("plain string failure")).toEqual({ + name: "NonError", + message: "plain string failure", + }); + }); + + it("handles undefined and null rejections without throwing", () => { + expect(describeException(undefined)).toEqual({ + name: "NonError", + message: JSON.stringify(undefined) ?? "", + }); + expect(describeException(null).name).toBe("NonError"); + }); + + it("stringifies plain objects and never throws on circular ones", () => { + expect( + describeException({ reason: "websocket closed" }), + ).toMatchObject({ + name: "NonError", + message: JSON.stringify({ reason: "websocket closed" }), + }); + + const circular: Record = {}; + circular.self = circular; + expect(describeException(circular)).toEqual({ + name: "NonError", + message: String(circular), + }); + }); +}); diff --git a/frontend/lib/__tests__/session-store.test.ts b/frontend/lib/__tests__/session-store.test.ts new file mode 100644 index 00000000..4d132639 --- /dev/null +++ b/frontend/lib/__tests__/session-store.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const cookieMocks = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), +})); + +vi.mock("js-cookie", () => ({ default: cookieMocks })); + +import { + ACCESS_TOKEN_COOKIE, + SESSION_SIGNAL_KEY, + useSessionStore, +} from "../session-store"; + +describe("session-store hydration (issue #1983)", () => { + beforeEach(() => { + vi.clearAllMocks(); + useSessionStore.setState({ accessToken: null, hydrated: false }); + }); + + it("starts un-hydrated with a null token even when a cookie exists", () => { + cookieMocks.get.mockReturnValue("stale-cookie-token"); + + const state = useSessionStore.getState(); + expect(state.accessToken).toBeNull(); + expect(state.hydrated).toBe(false); + // The cookie is deliberately not read at store creation: the server + // render and the first client render must agree. + expect(cookieMocks.get).not.toHaveBeenCalled(); + }); + + it("hydrate() reads the cookie and flips the hydrated flag", () => { + cookieMocks.get.mockReturnValue("cookie-token"); + expect(useSessionStore.getState().accessToken).toBeNull(); + + useSessionStore.getState().hydrate(); + + const state = useSessionStore.getState(); + expect(state.accessToken).toBe("cookie-token"); + expect(state.hydrated).toBe(true); + }); + + it("setAccessToken(null) removes the cookie and emits the cross-tab signal", () => { + const setItemSpy = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => {}); + + useSessionStore.getState().setAccessToken(null); + + expect(cookieMocks.remove).toHaveBeenCalledWith(ACCESS_TOKEN_COOKIE); + expect(setItemSpy).toHaveBeenCalledWith( + SESSION_SIGNAL_KEY, + expect.any(String), + ); + expect(useSessionStore.getState().accessToken).toBeNull(); + expect(useSessionStore.getState().hydrated).toBe(true); + }); + + it("setAccessToken(token) stores the cookie and emits the cross-tab signal", () => { + const setItemSpy = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => {}); + + useSessionStore.getState().setAccessToken("fresh-token"); + + expect(cookieMocks.set).toHaveBeenCalledWith( + ACCESS_TOKEN_COOKIE, + "fresh-token", + expect.objectContaining({ sameSite: "lax" }), + ); + expect(setItemSpy).toHaveBeenCalledWith( + SESSION_SIGNAL_KEY, + expect.any(String), + ); + expect(useSessionStore.getState().accessToken).toBe("fresh-token"); + }); + + it("keeps working when localStorage is unavailable (private browsing)", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); + + expect(() => + useSessionStore.getState().setAccessToken("token"), + ).not.toThrow(); + expect(useSessionStore.getState().accessToken).toBe("token"); + }); +}); diff --git a/frontend/lib/auth-api.ts b/frontend/lib/auth-api.ts index 381d68d0..b1a3adce 100644 --- a/frontend/lib/auth-api.ts +++ b/frontend/lib/auth-api.ts @@ -6,6 +6,57 @@ import { export type UserRole = "MEMBER" | "ADMIN" | "SUPER_ADMIN"; +/** + * Auth failures that carry structured field information (issue #1988). + * `fieldErrors` maps a form field ("email", "password") to the first + * validation message the backend produced for it; keys are absent when the + * backend did not attribute the failure to a specific field (401, network + * downtime, unexpected payloads). + */ +export class AuthApiError extends Error { + readonly status: number; + readonly fieldErrors: Readonly>; + + constructor( + status: number, + fieldErrors: Record, + message: string, + ) { + super(message); + this.name = "AuthApiError"; + this.status = status; + this.fieldErrors = fieldErrors; + } +} + +const AUTH_FIELDS = ["email", "password"] as const; + +/** + * Maps backend validation output onto form fields. Nest/class-validator + * validation failures arrive as a 400 whose `message` is an array of strings + * such as "email must be an email" or "password must be longer than or + * equal to 8 characters"; each message is matched to the field it names so + * the form can render it next to the offending input. + */ +function collectFieldErrors(errorBody: unknown): Record { + const fieldErrors: Record = {}; + const message = (errorBody as { message?: unknown } | null)?.message; + + if (Array.isArray(message)) { + for (const text of message) { + if (typeof text !== "string") continue; + const field = AUTH_FIELDS.find((name) => + text.toLowerCase().startsWith(name), + ); + if (field && !fieldErrors[field]) { + fieldErrors[field] = text; + } + } + } + + return fieldErrors; +} + export interface AuthUser { id: string; email: string; @@ -35,7 +86,6 @@ async function authRequest( response.status === 401 ? "Invalid credentials" : `Authentication failed (${response.status})`; - const error = new Error(getApiErrorMessage(errorBody, fallbackMessage)); reportApiError({ api: "auth", @@ -45,7 +95,21 @@ async function authRequest( requestId: getRequestId(response), }); - throw error; + // Attach per-field validation detail when the backend provided it + // (issue #1988): 400 field arrays, and 409 for a taken email address. + const fieldErrors = collectFieldErrors(errorBody); + if (response.status === 409 && !fieldErrors.email) { + fieldErrors.email = getApiErrorMessage( + errorBody, + "Email is already registered", + ); + } + + throw new AuthApiError( + response.status, + fieldErrors, + getApiErrorMessage(errorBody, fallbackMessage), + ); } return response.json() as Promise; diff --git a/frontend/lib/sentry-sampling.ts b/frontend/lib/sentry-sampling.ts new file mode 100644 index 00000000..29e60c6c --- /dev/null +++ b/frontend/lib/sentry-sampling.ts @@ -0,0 +1,27 @@ +/** + * Sampling helpers shared by the three Sentry configs (client, server, edge) + * so their defaults cannot drift apart (issue #1987). + */ + +/** + * Parses a sampling-rate env var. Returns the fallback when the value is + * missing, not a number, or outside [0, 1]. + */ +export function getSampleRate(value: string | undefined, fallback: number): number { + if (!value) return fallback; + + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 + ? parsed + : fallback; +} + +/** + * Default trace sampling when NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE is not + * set: 100% in development (cheap, everything is local) and 10% by default in + * production so one forgotten deployment cannot exhaust the Sentry quota. + * Applications that want full tracing in production opt in explicitly. + */ +export function defaultTracesSampleRate(): number { + return process.env.NODE_ENV === "production" ? 0.1 : 1.0; +} diff --git a/frontend/lib/stores/session-store.ts b/frontend/lib/stores/session-store.ts index 06c65a34..fe81bf04 100644 --- a/frontend/lib/stores/session-store.ts +++ b/frontend/lib/stores/session-store.ts @@ -3,20 +3,40 @@ import { create } from "zustand"; import Cookies from "js-cookie"; -const ACCESS_TOKEN_COOKIE = "accessToken"; +export const ACCESS_TOKEN_COOKIE = "accessToken"; -function readStoredToken(): string | null { - if (typeof window === "undefined") return null; - return Cookies.get(ACCESS_TOKEN_COOKIE) ?? null; +/** + * localStorage signal written whenever the session changes (issue #1983). + * Cookies raise no cross-tab events, so other tabs listen for this key's + * `storage` event and re-read the session store, keeping them in step when + * a user signs out (or in) in a different tab. + */ +export const SESSION_SIGNAL_KEY = "managehub-session-signal"; + +function emitSessionSignal(): void { + try { + localStorage.setItem(SESSION_SIGNAL_KEY, String(Date.now())); + } catch { + // Private browsing etc. - cross-tab sync is best effort. + } } interface SessionState { + /** + * Null until `hydrate()` has run in the client: the cookie must never be + * read synchronously at store creation or during render, because the + * server render and the first client render would disagree and produce a + * hydration mismatch. Pages gate on `hydrated` before trusting the token. + */ accessToken: string | null; + hydrated: boolean; setAccessToken: (token: string | null) => void; + hydrate: () => void; } export const useSessionStore = create((set) => ({ - accessToken: readStoredToken(), + accessToken: null, + hydrated: false, setAccessToken: (token) => { if (token) { Cookies.set(ACCESS_TOKEN_COOKIE, token, { @@ -26,6 +46,14 @@ export const useSessionStore = create((set) => ({ } else { Cookies.remove(ACCESS_TOKEN_COOKIE); } - set({ accessToken: token }); + emitSessionSignal(); + set({ accessToken: token, hydrated: true }); + }, + hydrate: () => { + if (typeof window === "undefined") return; + set({ + accessToken: Cookies.get(ACCESS_TOKEN_COOKIE) ?? null, + hydrated: true, + }); }, })); diff --git a/frontend/lib/stores/use-session-hydration.ts b/frontend/lib/stores/use-session-hydration.ts new file mode 100644 index 00000000..ebf5c2f9 --- /dev/null +++ b/frontend/lib/stores/use-session-hydration.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect } from "react"; +import { + SESSION_SIGNAL_KEY, + useSessionStore, +} from "@/lib/stores/session-store"; + +/** + * Hydrates the session store once inside a client effect (issue #1983). + * + * Reading the auth cookie synchronously - at store creation or during + * render - makes the server-rendered markup disagree with the first client + * render ("Sign in to view usage" vs. content) and produces hydration + * mismatches on /usage, /credits and admin pages. It also made auth state + * non-reactive: nothing re-read the cookie when another tab signed out. + * + * This hook runs `hydrate()` once in an effect (no render-time reads), and + * re-runs it whenever another tab issues a session change signal + * (`storage` event). Pages gate their rendering on the returned flag. + */ +export function useSessionHydration(): boolean { + const hydrated = useSessionStore((state) => state.hydrated); + + useEffect(() => { + useSessionStore.getState().hydrate(); + + const onStorage = (event: StorageEvent) => { + if (event.key === SESSION_SIGNAL_KEY) { + useSessionStore.getState().hydrate(); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + return hydrated; +} + +/** + * Reactive replacement for the old non-hook `useAdminToken` cookie read + * (issue #1983): the token comes from the session store, so sign-out in + * another tab unsets it here too. Returns null before hydration, letting + * callers render a stable "checking" state that matches the server output. + */ +export function useSessionToken(): string | null { + useSessionHydration(); + return useSessionStore((state) => state.accessToken); +} diff --git a/frontend/sentry.client.config.ts b/frontend/sentry.client.config.ts index 1a69b3e5..94cf65d2 100644 --- a/frontend/sentry.client.config.ts +++ b/frontend/sentry.client.config.ts @@ -1,13 +1,12 @@ import * as Sentry from "@sentry/nextjs"; +import { + defaultTracesSampleRate, + getSampleRate, +} from "@/lib/sentry-sampling"; -export function getSampleRate(value: string | undefined, fallback: number): number { - if (!value) return fallback; - - const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 - ? parsed - : fallback; -} +// Re-exported so tooling and tests can treat this config as the single entry +// point for the client sampling behavior. +export { defaultTracesSampleRate, getSampleRate }; // Keys whose values must never be sent to Sentry. const SENSITIVE_KEY_PATTERN = @@ -29,20 +28,53 @@ function scrubValue(value: unknown): unknown { return value; } +/** + * Describes the original exception for the event's `contexts.error` without + * assuming it is an `Error` (issue #1987): rejected strings, `undefined`, + * thrown objects that are not Errors or promise rejection reasons arrived at + * the old cast and crashed this hook, silently dropping the whole event. + * Anything that is not a real Error is labelled `NonError` and stringified as + * safely as possible - it must never throw here. + */ +export function describeException(original: unknown): { + name: string; + message: string; + stack?: string; +} { + if (original instanceof Error) { + return { + name: original.name, + message: original.message, + stack: original.stack, + }; + } + if (typeof original === "string") { + return { name: "NonError", message: original }; + } + try { + return { name: "NonError", message: JSON.stringify(original) ?? "" }; + } catch { + return { name: "NonError", message: String(original) }; + } +} + Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || process.env.NODE_ENV, tracesSampleRate: getSampleRate( process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE, - 1.0, + defaultTracesSampleRate(), ), + // Replays are opt-in only (issue #1987): no env var, no replay. Recording + // every session by default is a privacy and quota decision that belongs to + // the people deploying the app, not the default configuration. replaysSessionSampleRate: getSampleRate( process.env.NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE, - 0.1, + 0, ), replaysOnErrorSampleRate: getSampleRate( process.env.NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE, - 1.0, + 0, ), integrations: [ Sentry.replayIntegration({ @@ -52,14 +84,11 @@ Sentry.init({ ], beforeSend(event, hint) { if (event.exception) { - const error = hint.originalException as Error; + const original = + hint && typeof hint === "object" ? hint.originalException : undefined; event.contexts = { ...event.contexts, - error: { - message: error.message, - name: error.name, - stack: error.stack, - }, + error: describeException(original), }; } // Second line of defence: never ship request bodies, and redact any diff --git a/frontend/sentry.edge.config.ts b/frontend/sentry.edge.config.ts index fdc900bc..ef0f8a61 100644 --- a/frontend/sentry.edge.config.ts +++ b/frontend/sentry.edge.config.ts @@ -1,7 +1,16 @@ import * as Sentry from "@sentry/nextjs"; +import { + defaultTracesSampleRate, + getSampleRate, +} from "@/lib/sentry-sampling"; +// Kept consistent with sentry.client.config.ts (issue #1987): 100% in +// development, 10% in production unless explicitly configured. Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || process.env.NODE_ENV, - tracesSampleRate: 1.0, + tracesSampleRate: getSampleRate( + process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE, + defaultTracesSampleRate(), + ), }); diff --git a/frontend/sentry.server.config.ts b/frontend/sentry.server.config.ts index fdc900bc..ef0f8a61 100644 --- a/frontend/sentry.server.config.ts +++ b/frontend/sentry.server.config.ts @@ -1,7 +1,16 @@ import * as Sentry from "@sentry/nextjs"; +import { + defaultTracesSampleRate, + getSampleRate, +} from "@/lib/sentry-sampling"; +// Kept consistent with sentry.client.config.ts (issue #1987): 100% in +// development, 10% in production unless explicitly configured. Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || process.env.NODE_ENV, - tracesSampleRate: 1.0, + tracesSampleRate: getSampleRate( + process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE, + defaultTracesSampleRate(), + ), });