From 25b3d65fbb7276d01ae069393fdd4d764c1468d9 Mon Sep 17 00:00:00 2001 From: Sandip Godhani <72650657+Sandipxg@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:31:30 +0530 Subject: [PATCH 01/56] feat(ui): add withCheckbox support to DataTableSkeleton (#2785) * feat(ui): add withCheckbox support to DataTableSkeleton * refactor(ui): remove withCheckbox and obsolete TODO from DataTableSkeleton - Remove unused withCheckbox property from DataTableSkeleton - Remove obsolete TODO comment regarding checkbox skeleton --- .../src/components/ui/data-table/data-table-skeleton.tsx | 2 -- .../src/components/ui/data-table/data-table-skeleton.tsx | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/dashboard/src/components/ui/data-table/data-table-skeleton.tsx b/apps/dashboard/src/components/ui/data-table/data-table-skeleton.tsx index 4edf01306..7298f07de 100644 --- a/apps/dashboard/src/components/ui/data-table/data-table-skeleton.tsx +++ b/apps/dashboard/src/components/ui/data-table/data-table-skeleton.tsx @@ -16,8 +16,6 @@ interface DataTableSkeletonProps { rows?: number; } -// TODO: add checkbox skeleton (for MonitorTable e.g.) - export function DataTableSkeleton({ rows = 3 }: DataTableSkeletonProps) { return ( diff --git a/apps/status-page/src/components/ui/data-table/data-table-skeleton.tsx b/apps/status-page/src/components/ui/data-table/data-table-skeleton.tsx index f91f496cb..7298f07de 100644 --- a/apps/status-page/src/components/ui/data-table/data-table-skeleton.tsx +++ b/apps/status-page/src/components/ui/data-table/data-table-skeleton.tsx @@ -16,8 +16,6 @@ interface DataTableSkeletonProps { rows?: number; } -// TODO: add checkbox skeleton (for MonitorTable e.g.) - export function DataTableSkeleton({ rows = 3 }: DataTableSkeletonProps) { return (
@@ -39,7 +37,7 @@ export function DataTableSkeleton({ rows = 3 }: DataTableSkeletonProps) { - {new Array(rows).fill(0).map((_, i) => ( + {Array.from({ length: rows }).map((_, i) => ( -- 2.51.2 From 58808205cdcb500b381f921b1e3fd62770b7ce7e Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:48:05 +0200 Subject: [PATCH 02/56] fix: whatsapp notification test (#2840) --- apps/dashboard/.env.example | 3 +++ .../forms/notifications/form-whatsapp.tsx | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 5e45d7c49..c3ffe0744 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -73,6 +73,9 @@ WORKOS_WEBHOOK_SECRET= PAGERDUTY_APP_ID= +# WhatsApp sender number shown in the notification form hint (e.g. +1 754 255 8482) +NEXT_PUBLIC_WHATSAPP_PHONE_NUMBER= + SLACK_SUPPORT_WEBHOOK_URL= WORKSPACES_HIDE_URL= diff --git a/apps/dashboard/src/components/forms/notifications/form-whatsapp.tsx b/apps/dashboard/src/components/forms/notifications/form-whatsapp.tsx index 19c713cb6..4a2d3f628 100644 --- a/apps/dashboard/src/components/forms/notifications/form-whatsapp.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-whatsapp.tsx @@ -15,6 +15,7 @@ import { Input } from "@openstatus/ui/components/ui/input"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; +import Link from "next/link"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -48,6 +49,8 @@ export function FormWhatsApp({ onSubmit: (values: FormValues) => Promise; monitors: { id: number; name: string }[]; }) { + // Sender number shown in the anti-spam hint; unset on self-hosted installs. + const senderPhoneNumber = process.env.NEXT_PUBLIC_WHATSAPP_PHONE_NUMBER; const form = useForm({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { @@ -165,6 +168,28 @@ export function FormWhatsApp({ Enter the phone number to send notifications to. + {senderPhoneNumber ? ( + <> + {" "} + Not receiving messages? Send a message to our number{" "} + + {senderPhoneNumber} + {" "} + first so WhatsApp knows it's not spam.{" "} + + Read more + + . + + ) : null} )} -- 2.51.2 From 9ccbba032f4edecce17d7de347da863c55bc809e Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:05:21 +0200 Subject: [PATCH 03/56] chore: magic link dashboard login (#2832) * chore: magic link dashboard login * wip: * wip: magic link review fixes and tests * fix: login page * fix: * fix: * fix: content * fix: more stuff * head return 200 --------- Co-authored-by: Thibault Le Ouay Ducasse --- apps/dashboard/README.md | 6 +- apps/dashboard/package.json | 6 +- .../app/(dashboard)/incidents/[id]/client.tsx | 3 +- .../src/app/api/auth/[...nextauth]/route.ts | 33 +++- .../src/app/login/_components/actions.ts | 113 ++++++++------ .../src/app/login/_components/email-form.tsx | 145 ++++++++++++++++++ .../app/login/_components/login-button.tsx | 11 +- .../app/login/_components/magic-link-form.tsx | 46 ------ .../src/app/login/_components/sso-form.tsx | 51 ------ apps/dashboard/src/app/login/page.tsx | 84 +++++----- .../src/components/forms/incident/form.tsx | 3 +- .../incidents/incident-composer.tsx | 3 +- .../incidents/incident-properties.tsx | 2 +- .../incidents/incident-timeline.tsx | 7 +- .../dashboard/src/components/nav/nav-user.tsx | 3 +- .../status-reports/status-report-composer.tsx | 2 +- .../src/data/managed-incidents.client.ts | 13 -- apps/dashboard/src/lib/auth/adapter.ts | 34 ++-- apps/dashboard/src/lib/auth/helpers.ts | 37 ++++- apps/dashboard/src/lib/auth/index.ts | 8 +- apps/dashboard/src/lib/auth/providers.test.ts | 69 +++++++++ apps/dashboard/src/lib/auth/providers.ts | 53 ++++++- .../src/lib/rate-limit/magic-link.test.ts | 69 +++++++++ .../src/lib/rate-limit/magic-link.ts | 36 +++++ .../src/lib/rate-limit/sso-lookup.ts | 21 +-- apps/dashboard/src/test-preload.ts | 5 + apps/dashboard/src/test/upstash.mock.ts | 11 ++ apps/dashboard/test.importmap.json | 6 + .../src/app/api/auth/[...nextauth]/route.ts | 6 + .../src/content/pages/changelog/saml-sso.mdx | 2 +- .../docs/guides/how-to-set-up-saml-sso.mdx | 2 +- .../src/content/pages/unrelated/security.mdx | 2 +- deno.lock | 16 ++ packages/api/src/router/stripe/trial.test.ts | 14 ++ packages/api/src/router/stripe/trial.ts | 4 + .../emails/emails/dashboard-magic-link.tsx | 42 +++++ packages/emails/src/client.tsx | 30 ++++ packages/emails/src/index.ts | 1 + packages/emails/src/templates.test.tsx | 13 ++ packages/services/src/attribution.ts | 4 +- packages/upstash/package.json | 5 +- packages/upstash/src/index.ts | 1 + .../upstash/src/redis/incr-with-ttl.test.ts | 49 ++++++ packages/upstash/src/redis/incr-with-ttl.ts | 24 +++ packages/utils/src/index.ts | 1 + packages/utils/src/person-name.ts | 13 ++ pnpm-lock.yaml | 25 ++- 47 files changed, 866 insertions(+), 268 deletions(-) create mode 100644 apps/dashboard/src/app/login/_components/email-form.tsx delete mode 100644 apps/dashboard/src/app/login/_components/magic-link-form.tsx delete mode 100644 apps/dashboard/src/app/login/_components/sso-form.tsx create mode 100644 apps/dashboard/src/lib/auth/providers.test.ts create mode 100644 apps/dashboard/src/lib/rate-limit/magic-link.test.ts create mode 100644 apps/dashboard/src/lib/rate-limit/magic-link.ts create mode 100644 apps/dashboard/src/test-preload.ts create mode 100644 apps/dashboard/src/test/upstash.mock.ts create mode 100644 apps/dashboard/test.importmap.json create mode 100644 packages/emails/emails/dashboard-magic-link.tsx create mode 100644 packages/upstash/src/redis/incr-with-ttl.test.ts create mode 100644 packages/upstash/src/redis/incr-with-ttl.ts create mode 100644 packages/utils/src/person-name.ts diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 097643e81..9d0c3757c 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -23,7 +23,7 @@ cp apps/dashboard/.env.example apps/dashboard/.env The defaults in `.env.example` are dummy values that work for local dev — no real API keys needed. Fill them in before deployment to enable optional functionality (Resend for real magic-link emails, Stripe, Tinybird analytics, Sentry, GitHub/Google OAuth, etc.). -Email/Magic Link login is only available in dev. +Magic-link login works everywhere; in dev the link is printed to the terminal instead of emailed. ### Startup @@ -67,9 +67,9 @@ Turbo runs the dashboard (`apps/dashboard`) and `@openstatus/db` together. ## Logging in -The dashboard uses NextAuth with GitHub, Google, and — in dev mode — a Resend magic-link provider. +The dashboard uses NextAuth with GitHub, Google, SSO and a Resend magic-link provider. -In `NODE_ENV=development` or `SELF_HOST=true`, `src/lib/auth/providers.ts` configures the Resend provider with `apiKey: undefined` and overrides `sendVerificationRequest` to **print the magic link to the dashboard's terminal stdout** instead of sending an email. No OAuth credentials required. +In `NODE_ENV=development`, `src/lib/auth/providers.ts` **prints the magic link to the dashboard's terminal stdout** instead of emailing it; the dummy `RESEND_API_KEY` from `.env.example` is enough and no OAuth credentials are required. Everywhere else the link is emailed through Resend. A self-hosted deployment (`SELF_HOST=true`) whose Resend send fails still tells the user to check their inbox but prints the link to the server log instead, so look there when running without a real key. To log in: diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index b640d6217..a3c5a88e5 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "next lint", "check": "tsc --noEmit", - "tsc": "tsc --noEmit" + "tsc": "tsc --noEmit", + "test": "deno test --parallel -A --no-check --sloppy-imports --import-map=test.importmap.json src" }, "dependencies": { "@ai-sdk/react": "catalog:", @@ -83,6 +84,7 @@ "cmdk": "catalog:", "date-fns": "catalog:", "lucide-react": "catalog:", + "mailchecker": "catalog:", "next": "catalog:", "next-auth": "catalog:", "next-themes": "catalog:", @@ -105,6 +107,8 @@ "zod": "catalog:" }, "devDependencies": { + "@std/expect": "jsr:^1.0.19", + "@std/testing": "jsr:^1.0.19", "@tailwindcss/postcss": "catalog:", "@tailwindcss/typography": "catalog:", "@types/dom-speech-recognition": "catalog:", diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx index e800b6b7b..2f81159fe 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -1,6 +1,7 @@ "use client"; import type { IncidentStatus } from "@openstatus/db/src/schema/incidents/constants"; +import { personName } from "@openstatus/utils"; import { useQuery } from "@tanstack/react-query"; import { formatDistanceStrict, formatDistanceToNow } from "date-fns"; import { useState } from "react"; @@ -40,7 +41,7 @@ import { IncidentProperties } from "@/components/incidents/incident-properties"; import { IncidentStatusReport } from "@/components/incidents/incident-status-report"; import { IncidentTimelineItem } from "@/components/incidents/incident-timeline"; import { ResolveReportDialog } from "@/components/incidents/resolve-report-dialog"; -import { incidentEndedAt, personName } from "@/data/managed-incidents.client"; +import { incidentEndedAt } from "@/data/managed-incidents.client"; import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; diff --git a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts index c55a45ed7..cb7e53437 100644 --- a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts @@ -1,3 +1,34 @@ +import type { NextRequest } from "next/server"; + import { handlers } from "@/lib/auth"; -export const { GET, POST } = handlers; +export const { POST } = handlers; + +// Auth.js bounces an expired or reused magic link to `pages.error` without the +// link's `callbackUrl`; carry it over as `redirectTo` so the retry still lands +// on the invite. +export async function GET(req: NextRequest) { + const res = await handlers.GET(req); + const location = res.headers.get("Location"); + const callbackUrl = req.nextUrl.searchParams.get("callbackUrl"); + if (!location || !callbackUrl) return res; + + const target = new URL(location, req.nextUrl.origin); + if (target.searchParams.get("error") !== "Verification") return res; + const destination = new URL(callbackUrl, req.nextUrl.origin); + if (destination.origin !== req.nextUrl.origin) return res; + + target.searchParams.set( + "redirectTo", + `${destination.pathname}${destination.search}`, + ); + const headers = new Headers(res.headers); + headers.set("Location", target.toString()); + return new Response(null, { status: res.status, headers }); +} + +// Mail link scanners probe magic links with HEAD. Next would route HEAD to +// GET, which consumes the token; answer 200 before Auth.js sees it. +export function HEAD() { + return new Response(null, { status: 200 }); +} diff --git a/apps/dashboard/src/app/login/_components/actions.ts b/apps/dashboard/src/app/login/_components/actions.ts index fb8e6cb15..95484790e 100644 --- a/apps/dashboard/src/app/login/_components/actions.ts +++ b/apps/dashboard/src/app/login/_components/actions.ts @@ -1,12 +1,18 @@ "use server"; +import { resolveClientIp } from "@openstatus/services/page-access"; import { getWorkspaceByVerifiedSsoDomain } from "@openstatus/services/sso"; +import { AuthError } from "next-auth"; import { cookies, headers } from "next/headers"; import { signIn } from "@/lib/auth"; import { ssoLookupRateLimit } from "@/lib/rate-limit/sso-lookup"; import { SSO_ORG_COOKIE } from "@/lib/sso-cookie"; +const hasWorkOS = Boolean( + process.env.AUTH_WORKOS_ID && process.env.AUTH_WORKOS_SECRET, +); + // Same-origin paths only; the Auth.js `redirect` callback is the second line // of defense, not the first. function sanitizeRedirectTo(raw: FormDataEntryValue | null) { @@ -14,58 +20,71 @@ function sanitizeRedirectTo(raw: FormDataEntryValue | null) { return value.startsWith("/") && !value.startsWith("//") ? value : undefined; } -export async function signInWithResendAction(formData: FormData) { - try { - // next-auth lifts `redirectTo` into the magic link's `callbackUrl` itself. - await signIn("resend", { - email: String(formData.get("email") ?? ""), - redirectTo: sanitizeRedirectTo(formData.get("redirectTo")), - }); - } catch (e) { - console.error(e); - } -} - -export type SsoFormState = { error?: string }; +export type EmailFormState = { sent?: boolean; error?: string }; -// Deliberately identical for "no such domain", "SSO disabled" and "rate -// limited": a specific message would tell an unauthenticated caller which -// companies use openstatus and which of them have SSO. -const GENERIC_ERROR = - "We couldn't start SSO for that email. Try GitHub or Google."; +// One message for every refusal (bad address, disposable domain, throttled, +// send failure): the form must not tell a caller which addresses exist or +// what we filter. The screening itself runs in the Resend provider. +const EMAIL_ERROR = + "We couldn't send a sign-in link to that address. Try GitHub or Google."; -export async function startSsoSignIn( - _prevState: SsoFormState, +/** + * Routes by domain: a verified SSO domain goes to the identity provider, + * everything else gets a magic link. SSO is an additional way in, not a + * replacement (GitHub and Google stay available), so an SSO-domain address + * that reaches the Resend provider directly is still allowed. + */ +export async function continueWithEmail( + _prevState: EmailFormState, formData: FormData, -): Promise { - const email = String(formData.get("email") ?? ""); - const redirectTo = - sanitizeRedirectTo(formData.get("redirectTo")) ?? "/overview"; +): Promise { + const email = String(formData.get("email") ?? "").trim(); + if (!email.includes("@")) return { error: EMAIL_ERROR }; - if (!email.includes("@")) return { error: GENERIC_ERROR }; + const redirectTo = sanitizeRedirectTo(formData.get("redirectTo")); - const headerList = await headers(); - const ip = - headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; - if (!(await ssoLookupRateLimit(ip))) return { error: GENERIC_ERROR }; - - const workspace = await getWorkspaceByVerifiedSsoDomain(email); - if (!workspace?.workosOrganizationId) return { error: GENERIC_ERROR }; - - const cookieStore = await cookies(); - cookieStore.set(SSO_ORG_COOKIE, workspace.workosOrganizationId, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 60 * 10, - path: "/", - }); + if (hasWorkOS) { + const ip = resolveClientIp(await headers()) ?? "unknown"; + // The lookup limiter guards the SSO-domain oracle, not the login: once it + // trips (shared office IP), the address takes the magic-link path instead. + const workspace = (await ssoLookupRateLimit(ip)) + ? await getWorkspaceByVerifiedSsoDomain(email).catch((e: unknown) => { + // A lookup outage must not take the magic link down with it. + console.warn("sso domain lookup failed, sending magic link", e); + return null; + }) + : null; + if (workspace?.workosOrganizationId) { + const cookieStore = await cookies(); + cookieStore.set(SSO_ORG_COOKIE, workspace.workosOrganizationId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 10, + path: "/", + }); + await signIn( + "workos", + { redirectTo: redirectTo ?? "/overview" }, + { organization: workspace.workosOrganizationId }, + ); + return {}; + } + } - await signIn( - "workos", - { redirectTo }, - { organization: workspace.workosOrganizationId }, - ); + // next-auth lifts `redirectTo` into the magic link's `callbackUrl` itself. + // In a server action Auth.js rethrows `AuthError`s; anything else comes back + // as the `?error=` URL it would have redirected to. + try { + const url = await signIn("resend", { email, redirectTo, redirect: false }); + if (typeof url === "string" && new URL(url).searchParams.has("error")) { + return { error: EMAIL_ERROR }; + } + } catch (e) { + if (!(e instanceof AuthError)) throw e; + console.error("magic link sign-in failed", e); + return { error: EMAIL_ERROR }; + } - return {}; + return { sent: true }; } diff --git a/apps/dashboard/src/app/login/_components/email-form.tsx b/apps/dashboard/src/app/login/_components/email-form.tsx new file mode 100644 index 000000000..8a00211a7 --- /dev/null +++ b/apps/dashboard/src/app/login/_components/email-form.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { Email } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { Input } from "@openstatus/ui/components/ui/input"; +import { Separator } from "@openstatus/ui/components/ui/separator"; +import { useActionState, useEffect, useState } from "react"; + +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; + +import { type EmailFormState, continueWithEmail } from "./actions"; +import { LoginButton, STORAGE_KEY } from "./login-button"; + +const initialState: EmailFormState = {}; + +type Mode = "closed" | "sso" | "email"; + +/** + * One form, two doors: "Continue with SSO" sits with the OAuth buttons, the + * magic link hides behind a text link so OAuth stays the obvious path. Both + * submit the same action, which routes verified SSO domains server-side. + * Reopens by itself for returning email/SSO users. The OAuth forms come in as + * children so the "check your inbox" state can replace the whole list. + */ +export function EmailForm({ + redirectTo, + sso, + children, +}: { + redirectTo?: string; + sso: boolean; + children?: React.ReactNode; +}) { + const [state, formAction, isPending] = useActionState( + continueWithEmail, + initialState, + ); + const [mode, setMode] = useState("closed"); + // The action state outlives a mode switch; an error from the email form + // must not show up, or mark the input invalid, on the SSO form. + const [staleState, setStaleState] = useState(null); + const error = state === staleState ? undefined : state.error; + const switchMode = (next: Mode) => { + setStaleState(state); + setMode(next); + }; + + useEffect(() => { + const last = localStorage.getItem(STORAGE_KEY); + if (last === "email") setMode("email"); + if (last === "sso" && sso) setMode("sso"); + }, [sso]); + + if (state.sent) { + return ( + + + Check your inbox + + We sent you a sign-in link. It is valid for 24 hours and works once. + + + ); + } + + const form = ( +
+ {redirectTo ? ( + + ) : null} + + {error ? ( + + ) : null} + + {mode === "sso" + ? isPending + ? "Redirecting…" + : "Continue with SSO" + : isPending + ? "Continuing…" + : "Continue with email"} + + + ); + + return ( + <> + {children} + {sso ? ( + mode === "sso" ? ( + form + ) : ( + + ) + ) : null} +
+ + or + +
+ {mode === "email" ? ( + form + ) : ( + + )} + + ); +} diff --git a/apps/dashboard/src/app/login/_components/login-button.tsx b/apps/dashboard/src/app/login/_components/login-button.tsx index 01f55753d..2225549ea 100644 --- a/apps/dashboard/src/app/login/_components/login-button.tsx +++ b/apps/dashboard/src/app/login/_components/login-button.tsx @@ -5,15 +5,16 @@ import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; import { useEffect, useState } from "react"; -const STORAGE_KEY = "openstatus:last-login-provider"; +export const STORAGE_KEY = "openstatus:last-login-provider"; -type Provider = "github" | "google" | "oidc" | "email"; +type Provider = "github" | "google" | "oidc" | "sso" | "email"; export function LoginButton({ provider, children, onClick, className, + variant = "secondary", ...props }: { provider: Provider; @@ -27,7 +28,7 @@ export function LoginButton({ return ( - - ); -} diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx index 9b1eaa2c7..b3a547a89 100644 --- a/apps/dashboard/src/app/login/page.tsx +++ b/apps/dashboard/src/app/login/page.tsx @@ -1,21 +1,29 @@ import { GitHubIcon } from "@openstatus/icons/brand"; import { GoogleIcon } from "@openstatus/icons/brand"; -import { Separator } from "@openstatus/ui/components/ui/separator"; import type { Metadata } from "next"; import Link from "next/link"; import type { SearchParams } from "nuqs/server"; import { signIn } from "@/lib/auth"; +import { EmailForm } from "./_components/email-form"; import { LoginButton } from "./_components/login-button"; -import { MagicLinkForm } from "./_components/magic-link-form"; -import { SsoForm } from "./_components/sso-form"; import { searchParamsCache } from "./search-params"; const hasWorkOS = Boolean( process.env.AUTH_WORKOS_ID && process.env.AUTH_WORKOS_SECRET, ); +// Auth.js error codes that land on `/login?error=`; anything else stays silent. +const ERROR_MESSAGES: Record = { + AccessDenied: + "Your SSO login isn't linked to a workspace yet. Contact your workspace admin.", + Verification: + "That sign-in link has expired or was already used. Request a new one.", + Configuration: + "We couldn't complete your sign-in. Try again or contact support.", +}; + export const metadata: Metadata = { title: "Sign In", description: @@ -43,56 +51,46 @@ export default async function Page(props: { Get started now. No credit card required.

- {error === "AccessDenied" ? ( + {error && Object.hasOwn(ERROR_MESSAGES, error) ? (

- Your SSO login isn't linked to a workspace yet. Contact your - workspace admin. + {ERROR_MESSAGES[error]}

) : null} -
- {process.env.NODE_ENV === "development" || - process.env.SELF_HOST === "true" ? ( -
- - -
- ) : null} -
{ - "use server"; - await signIn("github", { redirectTo: redirectTo ?? undefined }); - }} - className="w-full" - > - - Sign in with GitHub - - -
{ - "use server"; - await signIn("google", { redirectTo: redirectTo ?? undefined }); - }} - className="w-full" - > - - Sign in with Google - - - {process.env.AUTH_OIDC_ISSUER ? ( +
+ +
{ + "use server"; + await signIn("github", { redirectTo: redirectTo ?? undefined }); + }} + > + + Continue with GitHub + +
{ "use server"; - await signIn("oidc", { redirectTo: redirectTo ?? undefined }); + await signIn("google", { redirectTo: redirectTo ?? undefined }); }} - className="w-full" > - - Sign in with {process.env.AUTH_OIDC_NAME ?? "SSO"} + + Continue with Google - ) : null} - {hasWorkOS ? : null} + {process.env.AUTH_OIDC_ISSUER ? ( +
{ + "use server"; + await signIn("oidc", { redirectTo: redirectTo ?? undefined }); + }} + > + + Continue with {process.env.AUTH_OIDC_NAME ?? "SSO"} + + + ) : null} +

By clicking continue, you agree to our{" "} diff --git a/apps/dashboard/src/components/forms/incident/form.tsx b/apps/dashboard/src/components/forms/incident/form.tsx index 3ddaa962d..42ffffe6d 100644 --- a/apps/dashboard/src/components/forms/incident/form.tsx +++ b/apps/dashboard/src/components/forms/incident/form.tsx @@ -22,6 +22,7 @@ import { } from "@openstatus/ui/components/ui/select"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { cn } from "@openstatus/ui/lib/utils"; +import { personName } from "@openstatus/utils"; import { useQuery } from "@tanstack/react-query"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; @@ -35,7 +36,7 @@ import { FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; -import { personName, severityConfig } from "@/data/managed-incidents.client"; +import { severityConfig } from "@/data/managed-incidents.client"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; diff --git a/apps/dashboard/src/components/incidents/incident-composer.tsx b/apps/dashboard/src/components/incidents/incident-composer.tsx index 792e65037..b8ae9de88 100644 --- a/apps/dashboard/src/components/incidents/incident-composer.tsx +++ b/apps/dashboard/src/components/incidents/incident-composer.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; +import { personName } from "@openstatus/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; @@ -22,7 +23,7 @@ import { ComposerTextarea, } from "@/components/content/composer"; import { TimelineAvatar, TimelineItem } from "@/components/content/timeline"; -import { personName, statusConfig } from "@/data/managed-incidents.client"; +import { statusConfig } from "@/data/managed-incidents.client"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; diff --git a/apps/dashboard/src/components/incidents/incident-properties.tsx b/apps/dashboard/src/components/incidents/incident-properties.tsx index 589cc178d..1fde98794 100644 --- a/apps/dashboard/src/components/incidents/incident-properties.tsx +++ b/apps/dashboard/src/components/incidents/incident-properties.tsx @@ -11,6 +11,7 @@ import { SelectItem, SelectValue, } from "@openstatus/ui/components/ui/select"; +import { personName } from "@openstatus/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { format, @@ -32,7 +33,6 @@ import { } from "@/components/content/property-list"; import { incidentEndedAt, - personName, severityConfig, statusConfig, } from "@/data/managed-incidents.client"; diff --git a/apps/dashboard/src/components/incidents/incident-timeline.tsx b/apps/dashboard/src/components/incidents/incident-timeline.tsx index be903cf22..0992b8650 100644 --- a/apps/dashboard/src/components/incidents/incident-timeline.tsx +++ b/apps/dashboard/src/components/incidents/incident-timeline.tsx @@ -25,6 +25,7 @@ import { Warning, } from "@openstatus/icons"; import { SlackIcon } from "@openstatus/icons/brand"; +import { personName } from "@openstatus/utils"; import type { StatusVariant } from "@/components/common/status-dot"; import { ProcessMessage } from "@/components/content/process-message"; @@ -42,11 +43,7 @@ import { TimelineTime, TimelineTitle, } from "@/components/content/timeline"; -import { - personName, - severityConfig, - statusConfig, -} from "@/data/managed-incidents.client"; +import { severityConfig, statusConfig } from "@/data/managed-incidents.client"; import { IncidentSeverityBadge, IncidentStatusBadge } from "./incident-badge"; diff --git a/apps/dashboard/src/components/nav/nav-user.tsx b/apps/dashboard/src/components/nav/nav-user.tsx index 16e4ccfaa..2e2fd8807 100644 --- a/apps/dashboard/src/components/nav/nav-user.tsx +++ b/apps/dashboard/src/components/nav/nav-user.tsx @@ -34,6 +34,7 @@ import { SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; +import { personName } from "@openstatus/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { signOut } from "next-auth/react"; import { useTheme } from "next-themes"; @@ -59,7 +60,7 @@ export function NavUser() { if (!user || !workspace) return null; - const userName = user?.name ?? `${user?.firstName} ${user?.lastName}`.trim(); + const userName = personName(user) ?? ""; const isTrialing = workspace.trialDaysLeft !== null; return ( diff --git a/apps/dashboard/src/components/status-reports/status-report-composer.tsx b/apps/dashboard/src/components/status-reports/status-report-composer.tsx index 27839efdb..25229bde5 100644 --- a/apps/dashboard/src/components/status-reports/status-report-composer.tsx +++ b/apps/dashboard/src/components/status-reports/status-report-composer.tsx @@ -18,6 +18,7 @@ import { SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; +import { personName } from "@openstatus/utils"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; @@ -42,7 +43,6 @@ import { ComposerTextarea, } from "@/components/content/composer"; import { TimelineAvatar, TimelineItem } from "@/components/content/timeline"; -import { personName } from "@/data/managed-incidents.client"; import { toGroupNameLookup } from "@/data/page-components.client"; import { getNextStatus, diff --git a/apps/dashboard/src/data/managed-incidents.client.ts b/apps/dashboard/src/data/managed-incidents.client.ts index 3302b0d5e..9a41b7fd8 100644 --- a/apps/dashboard/src/data/managed-incidents.client.ts +++ b/apps/dashboard/src/data/managed-incidents.client.ts @@ -35,16 +35,3 @@ export function incidentEndedAt(incident: { } return incident.closedAt; } - -export function personName( - person: { - name: string | null; - firstName: string | null; - lastName: string | null; - email: string | null; - } | null, -): string | null { - if (!person) return null; - const full = [person.firstName, person.lastName].filter(Boolean).join(" "); - return person.name || full || person.email || null; -} diff --git a/apps/dashboard/src/lib/auth/adapter.ts b/apps/dashboard/src/lib/auth/adapter.ts index 5ca66ef16..06fdbb371 100644 --- a/apps/dashboard/src/lib/auth/adapter.ts +++ b/apps/dashboard/src/lib/auth/adapter.ts @@ -8,18 +8,32 @@ import { } from "@openstatus/db/src/schema"; import type { Adapter } from "next-auth/adapters"; -import { createUser, getUser } from "./helpers"; +import { createUser, getUser, getUserByEmail } from "./helpers"; + +const drizzleAdapter = DrizzleAdapter(db, { + // @ts-expect-error: problem with type + usersTable: user, + // @ts-expect-error: problem with type + accountsTable: account, + // @ts-expect-error: problem with type + sessionsTable: session, + verificationTokensTable: verificationToken, +}) as Adapter; export const adapter: Adapter = { - ...(DrizzleAdapter(db, { - // @ts-expect-error: problem with type - usersTable: user, - // @ts-expect-error: problem with type - accountsTable: account, - // @ts-expect-error: problem with type - sessionsTable: session, - verificationTokensTable: verificationToken, - }) as Adapter), + ...drizzleAdapter, + // Auth.js lowercases magic-link addresses while OAuth profiles arrive as-is; + // without this a mixed-case OAuth user gets a second account on first + // magic-link sign-in. + getUserByEmail: async (email) => { + const user = await getUserByEmail(email); + if (!user) return null; + return { + ...user, + id: user.id.toString(), + email: user.email || "", + }; + }, createUser: async (data) => { const user = await createUser(data); return { diff --git a/apps/dashboard/src/lib/auth/helpers.ts b/apps/dashboard/src/lib/auth/helpers.ts index 367e21550..9cebbde46 100644 --- a/apps/dashboard/src/lib/auth/helpers.ts +++ b/apps/dashboard/src/lib/auth/helpers.ts @@ -1,13 +1,46 @@ -import { db, eq } from "@openstatus/db"; +import { db, eq, sql } from "@openstatus/db"; import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; import type { AdapterUser } from "next-auth/adapters"; import * as randomWordSlugs from "random-word-slugs"; +/** Stored and looked up lowercase; the `user.email` index is an exact match. */ +export function normalizeEmail(email: string) { + return email.trim().toLowerCase(); +} + +// Rows created before emails were normalized keep the OAuth profile's casing, +// so an indexed exact match comes first and a `lower()` scan only on a miss. +// Two case variants of one address cannot be told apart, so the sign-in fails +// (`?error=Configuration`) rather than landing in either workspace. +export async function getUserByEmail(email: string) { + const normalized = normalizeEmail(email); + const exact = await db + .select() + .from(user) + .where(eq(user.email, normalized)) + .get(); + if (exact) return exact; + + const legacy = await db + .select() + .from(user) + .where(sql`lower(${user.email}) = ${normalized}`) + .limit(2) + .all(); + if (legacy.length > 1) { + console.error("ambiguous legacy email, refusing sign-in", { + userIds: legacy.map((row) => row.id), + }); + throw new Error("ambiguous legacy email"); + } + return legacy[0] ?? null; +} + export async function createUser(data: AdapterUser) { const newUser = await db .insert(user) .values({ - email: data.email, + email: normalizeEmail(data.email), photoUrl: data.image, name: data.name, firstName: data.firstName, diff --git a/apps/dashboard/src/lib/auth/index.ts b/apps/dashboard/src/lib/auth/index.ts index 9a731b36a..da98a158f 100644 --- a/apps/dashboard/src/lib/auth/index.ts +++ b/apps/dashboard/src/lib/auth/index.ts @@ -119,10 +119,7 @@ const { GoogleProvider, ...(process.env.AUTH_OIDC_ISSUER ? [OIDCProvider] : []), ...(hasWorkOS ? [WorkOSProvider] : []), - ...(process.env.NODE_ENV === "development" || - process.env.SELF_HOST === "true" - ? [ResendProvider] - : []), + ResendProvider, ], callbacks: { async redirect({ url, baseUrl }) { @@ -191,7 +188,6 @@ const { return authorizeSsoSignIn(readWorkOSProfile(params.profile)); } - // REMINDER: only used in dev mode if (params.account?.provider === "resend") { if (Number.isNaN(Number(params.user.id))) return true; await db @@ -244,6 +240,8 @@ const { }, pages: { signIn: "/login", + // Expired or reused magic links surface as `?error=Verification` here. + error: "/login", newUser: "/onboarding", }, // basePath: "/api/auth", // default is `/api/auth` diff --git a/apps/dashboard/src/lib/auth/providers.test.ts b/apps/dashboard/src/lib/auth/providers.test.ts new file mode 100644 index 000000000..734f82427 --- /dev/null +++ b/apps/dashboard/src/lib/auth/providers.test.ts @@ -0,0 +1,69 @@ +import "@/test-preload"; +import { AuthError } from "@auth/core/errors"; +import { redis } from "@openstatus/upstash"; +import { expect } from "@std/expect"; +import { afterEach, describe, test } from "@std/testing/bdd"; +import { type Stub, stub } from "@std/testing/mock"; + +import { ResendProvider } from "./providers"; + +let evalStub: Stub | undefined; +let logStub: Stub | undefined; + +afterEach(() => { + evalStub?.restore(); + logStub?.restore(); +}); + +function sendTo(identifier: string, ip = "1.2.3.4") { + // `Resend()` keeps overrides under `options`; Auth.js merges them at init. + const send = ResendProvider.options?.sendVerificationRequest; + if (!send) throw new Error("sendVerificationRequest override missing"); + return send({ + identifier, + url: "https://app.openstatus.dev/api/auth/callback/resend?token=t", + token: "t", + expires: new Date(Date.now() + 60_000), + provider: ResendProvider, + request: new Request("https://app.openstatus.dev/api/auth/signin/resend", { + headers: { "x-forwarded-for": ip }, + }), + theme: {}, + }); +} + +describe("ResendProvider.sendVerificationRequest", () => { + test("refuses disposable domains before touching the rate limit", async () => { + evalStub = stub(redis, "eval"); + + await expect(sendTo("a@mailinator.com")).rejects.toThrow(AuthError); + await expect(sendTo("a@mailinator.com")).rejects.toThrow( + "disposable domain", + ); + expect(evalStub.calls).toHaveLength(0); + }); + + test("refuses throttled addresses", async () => { + evalStub = stub(redis, "eval", () => Promise.resolve([1, 4])); + + await expect(sendTo("gilfoyle@piedpiper.dev")).rejects.toThrow( + "rate limited", + ); + }); + + test("sends when screening passes, keyed by client ip and address", async () => { + evalStub = stub(redis, "eval", () => Promise.resolve([1, 1])); + logStub = stub(console, "log"); + + await sendTo("gilfoyle@piedpiper.dev", "9.9.9.9"); + + expect(evalStub.calls[0]?.args[1]).toEqual([ + "ratelimit:magic-link:ip:9.9.9.9", + "ratelimit:magic-link:email:gilfoyle@piedpiper.dev", + ]); + const printed = logStub.calls.map((c) => String(c.args[0])).join("\n"); + expect(printed).toContain( + "https://app.openstatus.dev/api/auth/callback/resend?token=t", + ); + }); +}); diff --git a/apps/dashboard/src/lib/auth/providers.ts b/apps/dashboard/src/lib/auth/providers.ts index 823125405..2199ba635 100644 --- a/apps/dashboard/src/lib/auth/providers.ts +++ b/apps/dashboard/src/lib/auth/providers.ts @@ -1,3 +1,6 @@ +import { AuthError } from "@auth/core/errors"; +import { EmailClient } from "@openstatus/emails"; +import { resolveClientIp } from "@openstatus/services/page-access"; import type { Profile } from "next-auth"; import type { OIDCConfig } from "next-auth/providers"; import GitHub from "next-auth/providers/github"; @@ -41,11 +44,53 @@ export const WorkOSProvider = WorkOS({ allowDangerousEmailAccountLinking: true, }); +// An `AuthError` is rethrown to a server-action `signIn`; a plain throw comes +// back as a `?error=Configuration` URL and the form would report the link sent. +class MagicLinkRefused extends AuthError { + static type = "MagicLinkRefused"; + static kind = "signIn" as const; +} + +// `apiKey` stays undefined: the email goes through our own template and client, +// which prints the link in development instead of sending. Screening lives here +// rather than in the form action because `POST /api/auth/signin/resend` reaches +// this provider directly. export const ResendProvider = Resend({ - apiKey: undefined, // REMINDER: keep undefined to avoid sending emails + apiKey: undefined, async sendVerificationRequest(params) { - console.log(""); - console.log(`>>> Magic Link: ${params.url}`); - console.log(""); + // Lazy: the proxy loads this module too, and it has no use for the + // disposable-domain list or Redis. + const [{ default: MailChecker }, { magicLinkRateLimit }] = + await Promise.all([ + import("mailchecker"), + import("@/lib/rate-limit/magic-link"), + ]); + + const email = params.identifier; + if (!MailChecker.isValid(email)) { + throw new MagicLinkRefused("disposable domain"); + } + const ip = resolveClientIp(params.request.headers) ?? "unknown"; + if (!(await magicLinkRateLimit({ ip, email }))) { + throw new MagicLinkRefused("rate limited"); + } + + try { + // `@openstatus/emails` refuses to load without RESEND_API_KEY, so the + // key is set here; in development the client prints instead of sending. + const emailClient = new EmailClient({ + apiKey: process.env.RESEND_API_KEY ?? "", + }); + await emailClient.sendDashboardMagicLink({ link: params.url, to: email }); + } catch (cause) { + // Self-hosted installs may run with a dummy Resend key: fall back to the + // dashboard log, only now, so a working install never logs live tokens. + if (process.env.SELF_HOST === "true") { + console.warn("magic link email not sent, use the printed link", cause); + console.log(`>>> Magic Link: ${params.url}`); + return; + } + throw new MagicLinkRefused("send failed", { cause }); + } }, }); diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.test.ts b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts new file mode 100644 index 000000000..966e59599 --- /dev/null +++ b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts @@ -0,0 +1,69 @@ +import "@/test-preload"; +import { redis } from "@openstatus/upstash"; +import { expect } from "@std/expect"; +import { afterEach, describe, test } from "@std/testing/bdd"; +import { type Stub, stub } from "@std/testing/mock"; + +import { magicLinkRateLimit } from "./magic-link"; + +let evalStub: Stub | undefined; +let warnStub: Stub | undefined; + +function stubCounts(counts: number[] | Error) { + evalStub = stub(redis, "eval", () => + counts instanceof Error ? Promise.reject(counts) : Promise.resolve(counts), + ); +} + +afterEach(() => { + evalStub?.restore(); + warnStub?.restore(); +}); + +describe("magicLinkRateLimit", () => { + test("keys the counters by ip and email", async () => { + stubCounts([1, 1]); + + await magicLinkRateLimit({ ip: "1.2.3.4", email: "a@b.c" }); + + expect(evalStub?.calls[0]?.args[1]).toEqual([ + "ratelimit:magic-link:ip:1.2.3.4", + "ratelimit:magic-link:email:a@b.c", + ]); + expect(evalStub?.calls[0]?.args[2]).toEqual([600]); + }); + + test("lowercases the email key", async () => { + stubCounts([1, 1]); + + await magicLinkRateLimit({ ip: "ip", email: " Gilfoyle@PiedPiper.dev " }); + + expect(evalStub?.calls[0]?.args[1]).toEqual([ + "ratelimit:magic-link:ip:ip", + "ratelimit:magic-link:email:gilfoyle@piedpiper.dev", + ]); + }); + + test("allows at the limits", async () => { + stubCounts([10, 3]); + expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(true); + }); + + test("refuses past the ip limit", async () => { + stubCounts([11, 1]); + expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(false); + }); + + test("refuses past the email limit", async () => { + stubCounts([1, 4]); + expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(false); + }); + + test("fails open with a warning when redis is down", async () => { + stubCounts(new Error("redis down")); + warnStub = stub(console, "warn"); + + expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(true); + expect(warnStub.calls).toHaveLength(1); + }); +}); diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.ts b/apps/dashboard/src/lib/rate-limit/magic-link.ts new file mode 100644 index 000000000..8ddbca22f --- /dev/null +++ b/apps/dashboard/src/lib/rate-limit/magic-link.ts @@ -0,0 +1,36 @@ +import { incrWithTtl, redis } from "@openstatus/upstash"; + +const WINDOW_SECONDS = 60 * 10; +const MAX_PER_IP = 10; +const MAX_PER_EMAIL = 3; + +/** + * Throttle magic-link requests per sender IP and per target address: without + * it the login form sends one email to any inbox per submit. + * + * Accepted trade-offs: refused requests count too, so three submits for + * someone else's address block that inbox for the window (they keep GitHub + * and Google); every request without a resolvable IP shares one bucket. + * Auth.js already lowercases the identifier; the key does so again so direct + * callers cannot multiply the per-address budget with case variants. + */ +export async function magicLinkRateLimit(args: { + ip: string; + email: string; +}): Promise { + try { + const [byIp, byEmail] = await incrWithTtl( + redis, + [ + `ratelimit:magic-link:ip:${args.ip}`, + `ratelimit:magic-link:email:${args.email.trim().toLowerCase()}`, + ], + WINDOW_SECONDS, + ); + return byIp <= MAX_PER_IP && byEmail <= MAX_PER_EMAIL; + } catch (e) { + // Redis unavailable: allow the request rather than locking everyone out. + console.warn("magic link rate limit unavailable, allowing request", e); + return true; + } +} diff --git a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts index 81e988f6d..fcf558527 100644 --- a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts +++ b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts @@ -1,33 +1,24 @@ -import { redis } from "@openstatus/upstash"; +import { incrWithTtl, redis } from "@openstatus/upstash"; const WINDOW_SECONDS = 60 * 10; const MAX_ATTEMPTS = 10; -// INCR + conditional EXPIRE in one round-trip so a crash between the two can't -// leave a TTL-less key. Mirrors `rate-limit/chat.ts`. -const INCR_WITH_TTL = ` - local count = redis.call('INCR', KEYS[1]) - if count == 1 then - redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1])) - end - return count -`; - /** * Throttle unauthenticated SSO domain lookups — without this the login form is * a free oracle for enumerating which companies have SSO configured. */ export async function ssoLookupRateLimit(ip: string): Promise { try { - const count = await redis.eval<[number], number>( - INCR_WITH_TTL, + const [count] = await incrWithTtl( + redis, [`ratelimit:sso-lookup:${ip}`], - [WINDOW_SECONDS], + WINDOW_SECONDS, ); return count <= MAX_ATTEMPTS; - } catch { + } catch (e) { // Redis unavailable: allow the lookup rather than locking everyone out of // SSO. The verified-domain check downstream is the real security boundary. + console.warn("sso lookup rate limit unavailable, allowing request", e); return true; } } diff --git a/apps/dashboard/src/test-preload.ts b/apps/dashboard/src/test-preload.ts new file mode 100644 index 000000000..1fe4ba7a1 --- /dev/null +++ b/apps/dashboard/src/test-preload.ts @@ -0,0 +1,5 @@ +// Import FIRST in every test file: `@openstatus/emails` validates +// RESEND_API_KEY and snapshots NODE_ENV on import. Development keeps +// EmailClient off the network. +Object.assign(process.env, { NODE_ENV: "development" }); +process.env.RESEND_API_KEY ||= "test-key"; diff --git a/apps/dashboard/src/test/upstash.mock.ts b/apps/dashboard/src/test/upstash.mock.ts new file mode 100644 index 000000000..c20a9af77 --- /dev/null +++ b/apps/dashboard/src/test/upstash.mock.ts @@ -0,0 +1,11 @@ +// Test double for @openstatus/upstash, swapped in via --import-map. The real +// client is a pipelining Proxy that `stub()` cannot intercept. +export { incrWithTtl } from "../../../../packages/upstash/src/redis/incr-with-ttl"; + +export const redis = { + eval: ( + _script: string, + _keys: string[], + _args: unknown[], + ): Promise => Promise.resolve([1, 1]), +}; diff --git a/apps/dashboard/test.importmap.json b/apps/dashboard/test.importmap.json new file mode 100644 index 000000000..b9dd92bcf --- /dev/null +++ b/apps/dashboard/test.importmap.json @@ -0,0 +1,6 @@ +{ + "imports": { + "@/": "./src/", + "@openstatus/upstash": "./src/test/upstash.mock.ts" + } +} diff --git a/apps/status-page/src/app/api/auth/[...nextauth]/route.ts b/apps/status-page/src/app/api/auth/[...nextauth]/route.ts index 9f1bce8e8..4407a90f5 100644 --- a/apps/status-page/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/status-page/src/app/api/auth/[...nextauth]/route.ts @@ -1,3 +1,9 @@ import { handlers } from "../../../../lib/auth"; export const { GET, POST } = handlers; + +// Mail link scanners probe magic links with HEAD. Next would route HEAD to +// GET, which consumes the token; refuse the method before Auth.js sees it. +export function HEAD() { + return new Response(null, { status: 405, headers: { Allow: "GET, POST" } }); +} diff --git a/apps/web/src/content/pages/changelog/saml-sso.mdx b/apps/web/src/content/pages/changelog/saml-sso.mdx index 0e87437a0..d05fc284d 100644 --- a/apps/web/src/content/pages/changelog/saml-sso.mdx +++ b/apps/web/src/content/pages/changelog/saml-sso.mdx @@ -11,7 +11,7 @@ Your team can now sign in to openstatus through your own identity provider. **Ok Head to **Settings > SSO**, enable it, and verify your domain with a DNS TXT record. Verification is not optional: openstatus only accepts an SSO sign-in when the email your identity provider asserts belongs to a domain you own. Then connect your provider and you're done. -On the login page your team selects **Sign in with SSO** and enters their work email — they're redirected to your identity provider and added to the workspace as members on first login. Signing in from your provider's app tile works too. Anyone who already has an openstatus account with the same email keeps it, along with their role. +On the login page your team selects **Continue with SSO** and enters their work email — they're redirected to your identity provider and added to the workspace as members on first login. Signing in from your provider's app tile works too. Anyone who already has an openstatus account with the same email keeps it, along with their role. GitHub and Google stay available — SSO is an additional way in, not a replacement. diff --git a/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx b/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx index e3ac0822f..22514c1d8 100644 --- a/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx +++ b/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx @@ -41,7 +41,7 @@ When the connection goes live, the SSO page shows **Ready — your team can sign ## Signing in -On the login page your team selects **Sign in with SSO** and enters their work email. The domain is matched against your verified domains and they're redirected to your identity provider. +On the login page your team selects **Continue with SSO** and enters their work email. The domain is matched against your verified domains and they're redirected to your identity provider. An address whose domain isn't verified receives a magic link instead. Users signing in this way are added to your workspace automatically as **members** on first login. Someone who already has an openstatus account with the same email keeps that account, along with any role they already hold — an existing owner stays an owner. diff --git a/apps/web/src/content/pages/unrelated/security.mdx b/apps/web/src/content/pages/unrelated/security.mdx index cff073bb2..e1c01148f 100644 --- a/apps/web/src/content/pages/unrelated/security.mdx +++ b/apps/web/src/content/pages/unrelated/security.mdx @@ -46,7 +46,7 @@ Application secrets — third-party tokens, webhook URLs, integration credential ## Authentication and access -You sign in with GitHub or Google OAuth. We never see or store your password. +You sign in with GitHub or Google OAuth, an email magic link, or your company's SSO provider. Account sign-in never involves a password, so there is none for openstatus to see or store. API access uses scoped keys: a key with the `read` scope can only call read-only endpoints; a key with the `write` scope can mutate data. Scopes are enforced before any database lookup, so a read-only key can't even reach a write code path. diff --git a/deno.lock b/deno.lock index af5e05d4b..212bdcce3 100644 --- a/deno.lock +++ b/deno.lock @@ -60,6 +60,14 @@ ] }, "members": { + "apps/dashboard": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, "apps/server": { "packageJson": { "dependencies": [ @@ -292,6 +300,14 @@ ] } }, + "packages/upstash": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, "packages/utils": { "packageJson": { "dependencies": [ diff --git a/packages/api/src/router/stripe/trial.test.ts b/packages/api/src/router/stripe/trial.test.ts index 4bb08c9e6..2864d1296 100644 --- a/packages/api/src/router/stripe/trial.test.ts +++ b/packages/api/src/router/stripe/trial.test.ts @@ -162,6 +162,20 @@ describe("maybeStartSignupTrial", () => { assertSpyCalls(createCustomer, 0); }); + test("skips magic-link sign-ins", async () => { + const { user } = await freeWorkspace(); + + const result = await maybeStartSignupTrial({ + userId: user.id, + email: user.email ?? "", + provider: "resend", + currency: "USD", + }); + + expect(result).toEqual({ started: false, reason: "email" }); + assertSpyCalls(createCustomer, 0); + }); + test("skips users with a pending invitation", async () => { const { workspace: ws, user } = await freeWorkspace(); await db.insert(invitation).values({ diff --git a/packages/api/src/router/stripe/trial.ts b/packages/api/src/router/stripe/trial.ts index 427044e34..51cb1ec28 100644 --- a/packages/api/src/router/stripe/trial.ts +++ b/packages/api/src/router/stripe/trial.ts @@ -26,6 +26,7 @@ export const TRIAL_DAYS = 14; export type TrialSkipReason = | "disabled" | "sso" + | "email" | "invited" | "disposable" | "already_trialed" @@ -71,6 +72,9 @@ export async function maybeStartSignupTrial(args: { return { started: false, reason: "disabled" }; } if (args.provider === "workos") return { started: false, reason: "sso" }; + // A magic link proves only inbox access, so email signups start on free and + // upgrade through checkout. + if (args.provider === "resend") return { started: false, reason: "email" }; if (await hasPendingInvitation({ email, db })) { return { started: false, reason: "invited" }; } diff --git a/packages/emails/emails/dashboard-magic-link.tsx b/packages/emails/emails/dashboard-magic-link.tsx new file mode 100644 index 000000000..e1f824ad7 --- /dev/null +++ b/packages/emails/emails/dashboard-magic-link.tsx @@ -0,0 +1,42 @@ +/** @jsxRuntime automatic @jsxImportSource react */ + +import { Text } from "react-email"; + +import { Actions } from "./_components/actions"; +import { CodeBlock } from "./_components/code-block"; +import { Footer } from "./_components/footer"; +import { Heading } from "./_components/heading"; +import { Layout } from "./_components/layout"; +import { styles } from "./_components/styles"; + +export interface DashboardMagicLinkProps { + link: string; +} + +const DashboardMagicLinkEmail = ({ link }: DashboardMagicLinkProps) => { + return ( + + } + > + + The link below signs you in and is valid for 24 hours. It only works + once. + + + + If the button doesn’t work, copy this link into your browser: + + {link} + + ); +}; + +DashboardMagicLinkEmail.PreviewProps = { + link: "https://app.openstatus.dev/api/auth/callback/resend?token=token-xyz", +} satisfies DashboardMagicLinkProps; + +export default DashboardMagicLinkEmail; diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index b582f434c..f267bf344 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -5,6 +5,8 @@ import { type Duration, Effect, Schedule } from "effect"; import { render } from "react-email"; import { Resend } from "resend"; +import DashboardMagicLinkEmail from "../emails/dashboard-magic-link"; +import type { DashboardMagicLinkProps } from "../emails/dashboard-magic-link"; import FollowUpEmail from "../emails/followup"; import MonitorAlertEmail, { monitorAlertSubject, @@ -385,6 +387,34 @@ export class EmailClient { } } + /** Throws on a Resend failure so the login form can say the email did not go out. */ + public async sendDashboardMagicLink( + req: DashboardMagicLinkProps & { to: string }, + ) { + if (env.NODE_ENV === "development") { + console.log(`Sending dashboard magic link email to ${req.to}`); + console.log(`>>> Magic Link: ${req.link}`); + return; + } + + const html = await render(); + const result = await this.client.emails.send({ + from: SYSTEM_FROM, + subject: "Sign in to openstatus", + to: req.to, + html, + }); + + if (result.error) { + console.error( + `Error sending dashboard magic link to ${req.to}`, + result.error, + ); + throw result.error; + } + console.log(`Sent dashboard magic link email to ${req.to}`); + } + public async sendMaintenanceNotification(req: { subscribers: Array<{ email: string; token: string }>; pageTitle: string; diff --git a/packages/emails/src/index.ts b/packages/emails/src/index.ts index c52579063..6725d158f 100644 --- a/packages/emails/src/index.ts +++ b/packages/emails/src/index.ts @@ -9,6 +9,7 @@ export { default as MonitorPausedEmail } from "../emails/monitor-paused"; export { default as MonitorDeactivationEmail } from "../emails/monitor-deactivation"; export { default as PrivateLocationAlertEmail } from "../emails/private-location-alert"; export { default as StatusPageMagicLinkEmail } from "../emails/status-page-magic-link"; +export { default as DashboardMagicLinkEmail } from "../emails/dashboard-magic-link"; export { monitorDeactivationEmail, monitorPausedEmail } from "./render"; export { diff --git a/packages/emails/src/templates.test.tsx b/packages/emails/src/templates.test.tsx index 456cdc1a7..5bc2d1597 100644 --- a/packages/emails/src/templates.test.tsx +++ b/packages/emails/src/templates.test.tsx @@ -13,6 +13,7 @@ import { renderMarkdown } from "../emails/_components/markdown"; import { Pill } from "../emails/_components/pill"; import { Steps } from "../emails/_components/steps"; import { tones } from "../emails/_components/styles"; +import DashboardMagicLinkEmail from "../emails/dashboard-magic-link"; import IncidentCommanderEmail, { incidentCommanderSubject, } from "../emails/incident-commander"; @@ -660,6 +661,17 @@ describe("account and status page mail", () => { expect(html).toContain("24 hours"); expect(html).toContain('href="https://acme.openstatus.dev/verify/t"'); }); + + test("dashboard magic link", async () => { + const link = "https://app.openstatus.dev/api/auth/callback/resend?token=t"; + const html = await render(); + expect(html).toContain("Sign in to openstatus"); + expect(html).toContain("24 hours"); + expect(html.split(`href="${link}"`).length - 1).toBe(1); + // raw link rendered as copyable text, not a second anchor + expect(html).toContain(`>${link}

`); + expect(html).toContain('href="https://www.openstatus.dev"'); + }); }); describe("every transactional template", () => { @@ -682,6 +694,7 @@ describe("every transactional template", () => { invitation: , subscription: , magicLink: , + dashboardMagicLink: , welcome: , incidentCommander: ( diff --git a/packages/services/src/attribution.ts b/packages/services/src/attribution.ts index c9b9dbc2d..95157d289 100644 --- a/packages/services/src/attribution.ts +++ b/packages/services/src/attribution.ts @@ -1,5 +1,6 @@ import { inArray } from "@openstatus/db"; import { user } from "@openstatus/db/src/schema"; +import { personName } from "@openstatus/utils"; import { z } from "zod"; import type { DB } from "./context"; @@ -37,8 +38,7 @@ export function displayName(row: { lastName: string | null; email: string | null; }): string { - const full = [row.firstName, row.lastName].filter(Boolean).join(" "); - return row.name || full || row.email || "Unknown user"; + return personName(row) ?? "Unknown user"; } // `user/delete.ts` soft-deletes and blanks every name field, so the row diff --git a/packages/upstash/package.json b/packages/upstash/package.json index 68403d46a..fd768f2e7 100644 --- a/packages/upstash/package.json +++ b/packages/upstash/package.json @@ -4,7 +4,8 @@ "license": "MIT", "main": "./src/index.ts", "scripts": { - "check": "deno check --sloppy-imports ." + "check": "deno check --sloppy-imports .", + "test": "deno test --parallel -A --no-check --sloppy-imports" }, "dependencies": { "@upstash/qstash": "catalog:", @@ -12,6 +13,8 @@ }, "devDependencies": { "@openstatus/tsconfig": "workspace:*", + "@std/expect": "jsr:^1.0.19", + "@std/testing": "jsr:^1.0.19", "@types/node": "catalog:", "tsup": "catalog:", "typescript": "catalog:" diff --git a/packages/upstash/src/index.ts b/packages/upstash/src/index.ts index 4ecbe4f1f..9b7ec4017 100644 --- a/packages/upstash/src/index.ts +++ b/packages/upstash/src/index.ts @@ -1,2 +1,3 @@ export * from "./redis/client"; +export * from "./redis/incr-with-ttl"; export * from "@upstash/redis"; diff --git a/packages/upstash/src/redis/incr-with-ttl.test.ts b/packages/upstash/src/redis/incr-with-ttl.test.ts new file mode 100644 index 000000000..17befdcd5 --- /dev/null +++ b/packages/upstash/src/redis/incr-with-ttl.test.ts @@ -0,0 +1,49 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { incrWithTtl } from "./incr-with-ttl"; + +function fakeClient(counts: number[]) { + const calls: { script: string; keys: string[]; args: unknown[] }[] = []; + const client = { + eval: (script: string, keys: string[], args: unknown[]) => { + calls.push({ script, keys, args }); + return Promise.resolve(counts); + }, + }; + return { client: client as never, calls }; +} + +describe("incrWithTtl", () => { + test("sends every key in one eval with the shared window", async () => { + const { client, calls } = fakeClient([3, 1]); + + const counts = await incrWithTtl(client, ["a", "b"], 600); + + expect(counts).toEqual([3, 1]); + expect(calls).toHaveLength(1); + expect(calls[0]?.keys).toEqual(["a", "b"]); + expect(calls[0]?.args).toEqual([600]); + }); + + // Source-level only: no Lua runtime here, so this pins the shape of the + // script, not its behaviour. + test("ships INCR followed by a first-hit-only EXPIRE per key", () => { + const { client, calls } = fakeClient([1]); + incrWithTtl(client, ["a"], 600); + + const script = calls[0]?.script ?? ""; + expect(script).toContain("for i, key in ipairs(KEYS)"); + expect(script).toContain("redis.call('INCR', key)"); + expect(script).toContain("if count == 1 then"); + expect(script).toContain("redis.call('EXPIRE', key, tonumber(ARGV[1]))"); + }); + + test("propagates client failures", async () => { + const client = { + eval: () => Promise.reject(new Error("redis down")), + } as never; + + await expect(incrWithTtl(client, ["a"], 600)).rejects.toThrow("redis down"); + }); +}); diff --git a/packages/upstash/src/redis/incr-with-ttl.ts b/packages/upstash/src/redis/incr-with-ttl.ts new file mode 100644 index 000000000..221ed63c2 --- /dev/null +++ b/packages/upstash/src/redis/incr-with-ttl.ts @@ -0,0 +1,24 @@ +import type { Redis } from "@upstash/redis"; + +// INCR + conditional EXPIRE in one round-trip so a crash between the two can't +// leave a TTL-less key. Every key shares the window. +const INCR_WITH_TTL = ` + local counts = {} + for i, key in ipairs(KEYS) do + local count = redis.call('INCR', key) + if count == 1 then + redis.call('EXPIRE', key, tonumber(ARGV[1])) + end + counts[i] = count + end + return counts +`; + +/** Current count per key after this hit, in `keys` order. */ +export function incrWithTtl( + client: Pick, + keys: string[], + windowSeconds: number, +) { + return client.eval<[number], number[]>(INCR_WITH_TTL, keys, [windowSeconds]); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 1aa6a38f4..dd4161fb6 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -24,6 +24,7 @@ export { } from "./constants"; export { buildCurlCommand, type CurlRequest } from "./curl"; export { type HeaderPair, headerPairSchema } from "./headers"; +export { personName } from "./person-name"; export { iteratorToStream, yieldMany } from "./stream"; export { type PageUpdateStatus, statusLabel } from "./status"; diff --git a/packages/utils/src/person-name.ts b/packages/utils/src/person-name.ts new file mode 100644 index 000000000..ee6887d5d --- /dev/null +++ b/packages/utils/src/person-name.ts @@ -0,0 +1,13 @@ +/** Display name for a user row: name, then first/last, then email. */ +export function personName( + person: { + name: string | null; + firstName: string | null; + lastName: string | null; + email: string | null; + } | null, +): string | null { + if (!person) return null; + const full = [person.firstName, person.lastName].filter(Boolean).join(" "); + return person.name || full || person.email || null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98948c25e..f370ecea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -901,6 +901,9 @@ importers: lucide-react: specifier: 'catalog:' version: 0.525.0(react@19.3.0) + mailchecker: + specifier: 'catalog:' + version: 6.0.21 next: specifier: 'catalog:' version: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -912,7 +915,7 @@ importers: version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nuqs: specifier: 'catalog:' - version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) random-word-slugs: specifier: 'catalog:' version: 0.1.7 @@ -962,6 +965,12 @@ importers: specifier: 'catalog:' version: 4.6.5 devDependencies: + '@std/expect': + specifier: jsr:^1.0.19 + version: '@jsr/std__expect@1.0.20' + '@std/testing': + specifier: jsr:^1.0.19 + version: '@jsr/std__testing@1.0.20' '@tailwindcss/postcss': specifier: 'catalog:' version: 4.3.3 @@ -1404,7 +1413,7 @@ importers: version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nuqs: specifier: 'catalog:' - version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) react: specifier: 'catalog:' version: 19.3.0 @@ -1669,7 +1678,7 @@ importers: version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nuqs: specifier: 'catalog:' - version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) random-word-slugs: specifier: 'catalog:' version: 0.1.7 @@ -3230,7 +3239,7 @@ importers: version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nuqs: specifier: 'catalog:' - version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) qr-code-styling: specifier: 'catalog:' version: 1.9.2 @@ -3299,6 +3308,12 @@ importers: '@openstatus/tsconfig': specifier: workspace:* version: link:../tsconfig + '@std/expect': + specifier: jsr:^1.0.19 + version: '@jsr/std__expect@1.0.20' + '@std/testing': + specifier: jsr:^1.0.19 + version: '@jsr/std__testing@1.0.20' '@types/node': specifier: 'catalog:' version: 26.6.2 @@ -18792,7 +18807,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): + nuqs@2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): dependencies: '@standard-schema/spec': 1.1.0 react: 19.3.0 -- 2.51.2 From 8ded2369d1c2c825fc1e320eb456fc539a3e992f Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Mon, 5 Oct 2026 13:39:59 +0200 Subject: [PATCH 04/56] stripe: improve webhook (#2847) --- packages/api/src/router/stripe/index.ts | 11 ++- packages/api/src/router/stripe/shared.ts | 16 +++- packages/api/src/router/stripe/utils.test.ts | 13 +-- packages/api/src/router/stripe/utils.ts | 15 ++-- .../api/src/router/stripe/webhook.test.ts | 79 +++++++++++++++++++ packages/api/src/router/stripe/webhook.ts | 57 ++++++------- packages/emails/src/billing.tsx | 5 +- packages/emails/src/send.ts | 7 ++ packages/services/src/workspace/schemas.ts | 5 +- packages/services/src/workspace/update.ts | 4 +- 10 files changed, 160 insertions(+), 52 deletions(-) diff --git a/packages/api/src/router/stripe/index.ts b/packages/api/src/router/stripe/index.ts index 90ac92837..d6d7d4b99 100644 --- a/packages/api/src/router/stripe/index.ts +++ b/packages/api/src/router/stripe/index.ts @@ -32,6 +32,7 @@ import { getCurrentSubscription, hasPaymentMethod, stripe, + syncedLimits, trialEndsAtOf, } from "./shared"; import { @@ -232,11 +233,9 @@ export const stripeRouter = createTRPCRouter({ }); } - // Classify before mutating Stripe. An item on a price neither table - // knows throws, and throwing *after* the update would leave the - // customer re-priced and billed while the workspace kept the old plan - // — a split the webhook cannot repair either, since it throws on the - // same item. + // Classify before mutating Stripe: throwing *after* the update would + // leave the customer re-priced and billed while the workspace kept the + // old plan. Custom-deal prices were already refused above. buildFromSubscriptionOrThrow(current); // Every existing item is listed by id, so Stripe re-prices it in place @@ -275,7 +274,7 @@ export const stripeRouter = createTRPCRouter({ endsAt: getCurrentPeriodEnd(updated), paidUntil: getCurrentPeriodEnd(updated), trialEndsAt: trialEndsAtOf(updated), - limits: built.limits, + limits: syncedLimits(updated, built), reason: "plan_changed", }, }); diff --git a/packages/api/src/router/stripe/shared.ts b/packages/api/src/router/stripe/shared.ts index faabc0d18..fe325152e 100644 --- a/packages/api/src/router/stripe/shared.ts +++ b/packages/api/src/router/stripe/shared.ts @@ -14,8 +14,8 @@ export const stripe = new Stripe(env.STRIPE_SECRET_KEY || "sk_unset", { }, }); -// An unsupported price is a permanent misconfiguration; surface it as a 400 so -// Stripe stops retrying instead of hammering the endpoint on a 5xx. +// A subscription we cannot classify is a permanent misconfiguration; surface +// it as a 400 so Stripe stops retrying instead of hammering the endpoint. export function buildFromSubscriptionOrThrow( subscription: Stripe.Subscription, ) { @@ -30,6 +30,18 @@ export function buildFromSubscriptionOrThrow( } } +/** The limits to write, or undefined to keep a custom deal's hand-set ones. */ +export function syncedLimits( + subscription: Stripe.Subscription, + built: NonNullable>, +) { + if (built.customPriceIds.length === 0) return built.limits; + console.warn( + `Subscription ${subscription.id} has custom prices (${built.customPriceIds.join(", ")}); keeping workspace limits`, + ); + return undefined; +} + // Statuses that mean the customer still has a subscription. `incomplete` and // `incomplete_expired` are abandoned checkouts, `unpaid` and `paused` no longer // entitle anything, and `canceled` is gone. diff --git a/packages/api/src/router/stripe/utils.test.ts b/packages/api/src/router/stripe/utils.test.ts index 54be41d5c..50378bae3 100644 --- a/packages/api/src/router/stripe/utils.test.ts +++ b/packages/api/src/router/stripe/utils.test.ts @@ -103,12 +103,13 @@ describe("buildLimitsFromSubscription", () => { expect(built?.limits["status-pages"]).toBe(planDefault + 5); }); - test("throws on an unsupported price when a plan is present", () => { - expect(() => - buildLimitsFromSubscription( - subscriptionWith([{ priceId: STARTER }, { priceId: "price_unknown" }]), - ), - ).toThrow(/unsupported stripe price/i); + test("reports a custom price instead of throwing", () => { + const built = buildLimitsFromSubscription( + subscriptionWith([{ priceId: STARTER }, { priceId: "price_custom" }]), + ); + expect(built?.plan).toBe("starter"); + expect(built?.customPriceIds).toEqual(["price_custom"]); + expect(built?.limits).toEqual(getLimits("starter")); }); test("one pack addon unit grants `packSize` limit units", () => { diff --git a/packages/api/src/router/stripe/utils.ts b/packages/api/src/router/stripe/utils.ts index b15ad4cca..6abffec0c 100644 --- a/packages/api/src/router/stripe/utils.ts +++ b/packages/api/src/router/stripe/utils.ts @@ -22,12 +22,13 @@ type PriceIds = { priceIds: { test: string; production: string } }; * The plan item sets the baseline; each addon item then re-applies its flag or * quantity on top, so purchased addons survive subscription updates instead of * being reset to the plan default. Returns null when no plan item is present. - * Throws on a line item whose price is neither a known plan nor a known addon, - * so misconfigured prices surface instead of silently drifting from billing. + * A line item on a price neither table knows is a custom deal: its id is + * returned in `customPriceIds` and left out of `limits`, so callers keep the + * hand-set limits instead of overwriting them. */ export function buildLimitsFromSubscription( subscription: Stripe.Subscription, -): { plan: WorkspacePlan; limits: Limits } | null { +): { plan: WorkspacePlan; limits: Limits; customPriceIds: string[] } | null { const detectedPlan = subscription.items.data .map((item) => getPlanFromPriceId(item.price.id)) .find((plan) => plan !== undefined); @@ -35,14 +36,14 @@ export function buildLimitsFromSubscription( if (!detectedPlan) return null; let limits: Limits = getLimits(detectedPlan.plan); + const customPriceIds: string[] = []; for (const item of subscription.items.data) { if (getPlanFromPriceId(item.price.id)) continue; const feature = getFeatureFromPriceId(item.price.id); if (!feature) { - throw new Error( - `Unsupported Stripe price on subscription: ${item.price.id}`, - ); + customPriceIds.push(item.price.id); + continue; } // Accumulate onto the running value so repeated addon items add up; boolean // addons just flip on. One unit of a pack addon grants `packSize` units. @@ -54,7 +55,7 @@ export function buildLimitsFromSubscription( limits = updateAddonInLimits(limits, feature.feature, value); } - return { plan: detectedPlan.plan, limits }; + return { plan: detectedPlan.plan, limits, customPriceIds }; } /** diff --git a/packages/api/src/router/stripe/webhook.test.ts b/packages/api/src/router/stripe/webhook.test.ts index 212f5c058..b928588e6 100644 --- a/packages/api/src/router/stripe/webhook.test.ts +++ b/packages/api/src/router/stripe/webhook.test.ts @@ -466,6 +466,46 @@ describe("stripe webhook emails", () => { expect(keys.slice(0, 2)).toEqual(keys.slice(2, 4)); }); + test("a custom-deal price syncs the plan but keeps hand-set limits", async () => { + const s = await seed(); + const handSet = JSON.stringify({ monitors: 999 }); + await db + .update(workspace) + .set({ limits: handSet }) + .where(eq(workspace.id, s.workspace.id)); + const sub = subscription(s.stripeId, { + id: "sub_custom_deal", + items: { + data: [ + { + price: { id: TEAM_PRICE }, + quantity: 1, + current_period_end: now() + 10 * DAY, + }, + { + price: { id: "price_custom_deal" }, + quantity: 1, + current_period_end: now() + 10 * DAY, + }, + ], + }, + } as Partial); + live = [sub]; + + await caller().customerSubscriptionUpdated( + event("customer.subscription.updated", sub, { metadata: {} }), + ); + + const ws = await db + .select() + .from(workspace) + .where(eq(workspace.id, s.workspace.id)) + .get(); + expect(ws?.subscriptionId).toBe("sub_custom_deal"); + expect(ws?.plan).toBe("team"); + expect(ws?.limits).toBe(handSet); + }); + test("a mail failure does not fail the plan sync", async () => { const s = await seed(); const sub = subscription(s.stripeId, { cancel_at_period_end: true }); @@ -561,6 +601,45 @@ describe("stripe webhook emails", () => { assertSpyCalls(send, 0); }); + test("a mail failure fails the webhook so Stripe redelivers", async () => { + const s = await seed(); + failSends(); + const evt = event( + "customer.subscription.trial_will_end", + subscription(s.stripeId, { status: "trialing", trial_end: trialEnd }), + ); + + await expect( + caller().customerSubscriptionTrialWillEnd(evt), + ).rejects.toThrow(); + }); + + test("a Resend error response fails the webhook too", async () => { + const s = await seed(); + send.restore(); + stubs = stubs.filter((x) => x !== send); + send = stub(resend.emails, "send", () => + Promise.resolve({ + data: null, + error: { name: "application_error", message: "boom" }, + // biome-ignore lint/suspicious/noExplicitAny: Resend result double + } as any), + ); + stubs.push(send); + + await expect( + caller().customerSubscriptionTrialWillEnd( + event( + "customer.subscription.trial_will_end", + subscription(s.stripeId, { + status: "trialing", + trial_end: trialEnd, + }), + ), + ), + ).rejects.toThrow(); + }); + test("no trial_end → nothing sent", async () => { const s = await seed(); await caller().customerSubscriptionTrialWillEnd( diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 693775fab..7a3cfc211 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -36,6 +36,7 @@ import { isNewerSubscription, listLiveSubscriptions, stripe, + syncedLimits, trialEndsAtOf, } from "./shared"; @@ -267,7 +268,7 @@ export const webhookRouter = createTRPCRouter({ endsAt: getCurrentPeriodEnd(current), paidUntil: getCurrentPeriodEnd(current), trialEndsAt: trialEndsAtOf(current), - limits: built.limits, + limits: syncedLimits(current, built), }, }); @@ -427,7 +428,7 @@ export const webhookRouter = createTRPCRouter({ endsAt: getCurrentPeriodEnd(subscription), paidUntil: getCurrentPeriodEnd(subscription), trialEndsAt: trialEndsAtOf(subscription), - limits: built.limits, + limits: syncedLimits(subscription, built), reason: "checkout_session_completed", }, }); @@ -474,31 +475,33 @@ export const webhookRouter = createTRPCRouter({ }); } - try { - const withCard = await hasPaymentMethod(subscription); - const preview = withCard - ? undefined - : await previewWorkspaceDowngrade({ - ctx: { - workspace: ws, - actor: { type: "system", job: "stripe-trial-will-end" }, - db: opts.ctx.db, - }, - }); - await sendTrialEnding({ - to: await getBillingRecipients(opts.ctx.db, ws.id, customerId), - eventId: opts.input.event.id, - workspaceSlug: ws.slug, - trialEnd: new Date(subscription.trial_end * 1000), - plan: ws.plan ?? "starter", - hasPaymentMethod: withCard, - loss: preview - ? toPlanLoss(preview, preview.customDomains, preview.ssoEnabled) - : undefined, - }); - } catch (err) { - console.error("Failed to send trial ending email:", err); - } + // Stripe fires this once per trial, so a failure must reach Stripe as a + // non-2xx to be redelivered; the idempotency key dedupes the retry. + const withCard = await hasPaymentMethod(subscription); + const preview = withCard + ? undefined + : await previewWorkspaceDowngrade({ + ctx: { + workspace: ws, + actor: { type: "system", job: "stripe-trial-will-end" }, + db: opts.ctx.db, + }, + }).catch((err) => { + // The reminder matters more than the loss lines. + console.error("Failed to preview trial downgrade:", err); + return undefined; + }); + await sendTrialEnding({ + to: await getBillingRecipients(opts.ctx.db, ws.id, customerId), + eventId: opts.input.event.id, + workspaceSlug: ws.slug, + trialEnd: new Date(subscription.trial_end * 1000), + plan: ws.plan ?? "starter", + hasPaymentMethod: withCard, + loss: preview + ? toPlanLoss(preview, preview.customDomains, preview.ssoEnabled) + : undefined, + }); }), customerSubscriptionDeleted: webhookProcedure.mutation(async (opts) => { const subscription = opts.input.event.data.object as Stripe.Subscription; diff --git a/packages/emails/src/billing.tsx b/packages/emails/src/billing.tsx index 037818070..bb0060745 100644 --- a/packages/emails/src/billing.tsx +++ b/packages/emails/src/billing.tsx @@ -223,6 +223,9 @@ export async function sendTrialEnding( subject: trialEndingSubject(props), react: , }, - { idempotencyKey: stripeIdempotencyKey(eventId, "trial-ending") }, + { + idempotencyKey: stripeIdempotencyKey(eventId, "trial-ending"), + throwOnError: true, + }, ); } diff --git a/packages/emails/src/send.ts b/packages/emails/src/send.ts index 265521746..3fb4e790a 100644 --- a/packages/emails/src/send.ts +++ b/packages/emails/src/send.ts @@ -31,6 +31,8 @@ export interface SendOptions { idempotencyKey?: string; /** ISO 8601. Resend schedules at most 30 days out. */ scheduledAt?: string; + /** Throw instead of logging, so a webhook caller fails and gets redelivered. */ + throwOnError?: boolean; } /** Returns the Resend email id, or undefined when nothing was sent. */ @@ -49,6 +51,11 @@ export const sendEmail = async ( ); // Same key, different body: the first send already went out. if (error && error.name !== "invalid_idempotent_request") { + if (opts.throwOnError) { + throw new Error( + `Error sending email "${email.subject}": ${error.message}`, + ); + } console.error(`Error sending email "${email.subject}":`, error); } return data?.id; diff --git a/packages/services/src/workspace/schemas.ts b/packages/services/src/workspace/schemas.ts index ff795cbfc..7a0819d4c 100644 --- a/packages/services/src/workspace/schemas.ts +++ b/packages/services/src/workspace/schemas.ts @@ -83,7 +83,8 @@ export type UpdateWorkspaceNameInput = z.infer; * Set a workspace's billing plan and the columns that move with it * (subscription id, paid-until / ends-at dates, feature limits). Driven * by the Stripe webhook — `limits` is the structured object; the verb - * serialises it to the `text` column. `reason` is stamped into the audit + * serialises it to the `text` column. Omitted `limits` leaves the stored + * ones untouched (custom deals whose limits are set by hand). `reason` is stamped into the audit * row's `metadata` so a plan change from an involuntary cancellation is * distinguishable from a checkout upgrade. */ @@ -93,7 +94,7 @@ export const UpdateWorkspacePlanInput = z.object({ paidUntil: z.date().nullable(), endsAt: z.date().nullable(), trialEndsAt: z.date().nullable().optional(), - limits: limitsSchema, + limits: limitsSchema.optional(), reason: z.string().optional(), }); export type UpdateWorkspacePlanInput = z.infer; diff --git a/packages/services/src/workspace/update.ts b/packages/services/src/workspace/update.ts index 8b3492dba..cfd2ca2aa 100644 --- a/packages/services/src/workspace/update.ts +++ b/packages/services/src/workspace/update.ts @@ -87,7 +87,9 @@ export async function updateWorkspacePlan(args: { ...(input.trialEndsAt !== undefined && { trialEndsAt: input.trialEndsAt, }), - limits: JSON.stringify(input.limits), + ...(input.limits !== undefined && { + limits: JSON.stringify(input.limits), + }), updatedAt: new Date(), }) .where(eq(workspace.id, ctx.workspace.id)) -- 2.51.2 From dcfa88b629891b855c961894773dc012c173ce8b Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Mon, 5 Oct 2026 15:35:07 +0200 Subject: [PATCH 05/56] stripe: improve email from (#2848) --- packages/emails/src/billing.test.tsx | 8 ++++++-- packages/emails/src/billing.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/emails/src/billing.test.tsx b/packages/emails/src/billing.test.tsx index 3601fae5b..7586ea2e1 100644 --- a/packages/emails/src/billing.test.tsx +++ b/packages/emails/src/billing.test.tsx @@ -471,6 +471,10 @@ describe("send plumbing", () => { }); expect(id).toBe("email_123"); const [payload, options] = send.calls[0].args; + expect(payload.from).toBe( + "openstatus ", + ); + expect(payload.replyTo).toBe("ping@openstatus.dev"); expect(payload.scheduledAt).toBe("2026-09-22T00:00:00.000Z"); expect(options).toEqual({ idempotencyKey: "stripe:evt_2:plan-ending-soon", @@ -503,9 +507,9 @@ describe("send plumbing", () => { }); const [payload, options] = send.calls[0].args; expect(payload.from).toBe( - "Thibault from openstatus ", + "openstatus ", ); - expect(payload.replyTo).toBe("thibault@openstatus.dev"); + expect(payload.replyTo).toBe("ping@openstatus.dev"); expect(payload.subject).toBe( "Your trial ends on 25 Sep — add a payment method to keep starter", ); diff --git a/packages/emails/src/billing.tsx b/packages/emails/src/billing.tsx index bb0060745..f4cc46a98 100644 --- a/packages/emails/src/billing.tsx +++ b/packages/emails/src/billing.tsx @@ -197,8 +197,8 @@ export async function schedulePlanEndingSoon( if (!scheduledAt) return; return sendEmail( { - from: SIGNED_FROM, - reply_to: SIGNED_REPLY_TO, + from: SYSTEM_FROM, + reply_to: SUPPORT_EMAIL, to, subject: planEndingSoonSubject(props), react: , @@ -217,8 +217,8 @@ export async function sendTrialEnding( const { to, eventId, ...props } = req; return sendEmail( { - from: SIGNED_FROM, - reply_to: SIGNED_REPLY_TO, + from: SYSTEM_FROM, + reply_to: SUPPORT_EMAIL, to, subject: trialEndingSubject(props), react: , -- 2.51.2 From 3aa61e7cf25c8fda8579c581ef59e3e1a95bae55 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:50:41 +0200 Subject: [PATCH 06/56] chore: dashboard mobile form layout improvements (#2845) * chore: dashboard mobile form layout improvements * fix: --- .../overview/create-event-button-group.tsx | 5 +- .../app/(dashboard)/settings/account/page.tsx | 6 +- .../[id]/maintenances/(list)/page.tsx | 2 - .../[id]/status-reports/(list)/page.tsx | 3 +- .../forms/components/form-components.tsx | 58 +++++++++---------- .../forms/components/form-import.tsx | 16 ++--- .../components/forms/monitor/form-general.tsx | 6 +- .../components/forms/monitor/form-otel.tsx | 4 +- .../forms/monitor/form-response-time.tsx | 2 +- .../components/forms/monitor/form-retry.tsx | 4 +- .../components/forms/monitor/form-tags.tsx | 4 +- .../forms/settings/form-members.tsx | 4 +- .../forms/settings/form-workspace.tsx | 4 +- .../forms/status-page/form-configuration.tsx | 6 +- .../forms/status-page/form-custom-domain.tsx | 4 +- .../forms/status-page/form-general.tsx | 10 ++-- .../forms/status-page/form-links.tsx | 4 +- .../forms/status-page/form-locale.tsx | 6 +- .../forms/status-page/form-page-access.tsx | 12 ++-- .../status-reports/status-report-composer.tsx | 31 ++++++---- 20 files changed, 94 insertions(+), 97 deletions(-) diff --git a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx index 90e63c7b7..8481710d3 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronDown, Add } from "@openstatus/icons"; +import { ChevronDown } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { ButtonGroup } from "@openstatus/ui/components/ui/button-group"; import { @@ -26,7 +26,6 @@ export function CreateEventButtonGroup() { @@ -43,12 +42,10 @@ export function CreateEventButtonGroup() { setMaintenanceOpen(true)}> - Create Maintenance {incidentsEnabled ? ( setIncidentOpen(true)}> - Declare Incident ) : null} diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx index f9466526d..d56b7b71d 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx @@ -60,12 +60,12 @@ export default function Page() { -
-
+ +
-
+
diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx index 65930a078..88dd8ab03 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { Add } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; @@ -82,7 +81,6 @@ export default function Page() { }} > diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx index 5ef268c14..86c6689e3 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Impact, Add } from "@openstatus/icons"; +import { Impact } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import NextLink from "next/link"; @@ -129,7 +129,6 @@ export default function Page() { }} > diff --git a/apps/dashboard/src/components/forms/components/form-components.tsx b/apps/dashboard/src/components/forms/components/form-components.tsx index d44063e8f..50a09d4f4 100644 --- a/apps/dashboard/src/components/forms/components/form-components.tsx +++ b/apps/dashboard/src/components/forms/components/form-components.tsx @@ -11,7 +11,6 @@ import { Linked, Unlinked, Plug, - Add, Delete, } from "@openstatus/icons"; import { @@ -506,9 +505,8 @@ export function FormComponents({ Manage your page components - + @@ -738,15 +735,15 @@ function ComponentRow({ className={cn("rounded-md", className)} {...props} > -
-
- - +
+ + +
{fieldNamePrefix ? ( e.stopPropagation()} - className="flex w-full items-center gap-2 truncate py-1.5 text-sm" + className="flex w-full min-w-0 items-center gap-2 py-1.5 text-sm" > {" "} {component.monitor.name} ) : ( - + {" "} Static Component @@ -998,15 +995,15 @@ function ComponentGroupRow({ return ( -
-
- - +
+ + +
( - + Components - @@ -1153,7 +1149,7 @@ function ComponentGroupRow({ control={form.control} name={`groups.${groupIndex}.defaultOpen` as const} render={({ field }) => ( - + - - Open by default + + Open by default )} diff --git a/apps/dashboard/src/components/forms/components/form-import.tsx b/apps/dashboard/src/components/forms/components/form-import.tsx index c08b84116..10bf5d5e3 100644 --- a/apps/dashboard/src/components/forms/components/form-import.tsx +++ b/apps/dashboard/src/components/forms/components/form-import.tsx @@ -267,7 +267,7 @@ export function FormImport({ Checkly -
+
Missing a provider?{" "} Contact us
@@ -281,12 +281,12 @@ export function FormImport({ {watchProvider ? ( <> - + ( - + API Key ( - + Page ID (optional) @@ -352,7 +352,7 @@ export function FormImport({ control={form.control} name="betterstackStatusPageId" render={({ field }) => ( - + Status Page ID (optional) @@ -370,7 +370,7 @@ export function FormImport({ control={form.control} name="instatusPageId" render={({ field }) => ( - + Page ID (optional) @@ -389,7 +389,7 @@ export function FormImport({ control={form.control} name="checklyAccountId" render={({ field }) => ( - + Account ID @@ -406,7 +406,7 @@ export function FormImport({ control={form.control} name="checklyStatusPageId" render={({ field }) => ( - + Status Page ID (optional) diff --git a/apps/dashboard/src/components/forms/monitor/form-general.tsx b/apps/dashboard/src/components/forms/monitor/form-general.tsx index a86175cea..7f0d14783 100644 --- a/apps/dashboard/src/components/forms/monitor/form-general.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-general.tsx @@ -350,11 +350,7 @@ export function FormGeneral({ ); })} -
+
Missing a type?{" "} Contact us
diff --git a/apps/dashboard/src/components/forms/monitor/form-otel.tsx b/apps/dashboard/src/components/forms/monitor/form-otel.tsx index 43190c5d7..fad3f244e 100644 --- a/apps/dashboard/src/components/forms/monitor/form-otel.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-otel.tsx @@ -91,12 +91,12 @@ export function FormOtel({ Configure your OpenTelemetry Exporter. - + ( - + Endpoint - + - + ( - + Retry - + ( - + Tags diff --git a/apps/dashboard/src/components/forms/settings/form-members.tsx b/apps/dashboard/src/components/forms/settings/form-members.tsx index 8b6cd7196..cd0cbec79 100644 --- a/apps/dashboard/src/components/forms/settings/form-members.tsx +++ b/apps/dashboard/src/components/forms/settings/form-members.tsx @@ -104,13 +104,13 @@ export function FormMembers({ - + ( - + Add member - + ( - + Name diff --git a/apps/dashboard/src/components/forms/status-page/form-configuration.tsx b/apps/dashboard/src/components/forms/status-page/form-configuration.tsx index 9697a9fc2..8bc361b54 100644 --- a/apps/dashboard/src/components/forms/status-page/form-configuration.tsx +++ b/apps/dashboard/src/components/forms/status-page/form-configuration.tsx @@ -157,7 +157,7 @@ export function FormConfiguration({ @@ -259,7 +259,7 @@ export function FormConfiguration({ - + - + Learn more about{" "} - + ( - + diff --git a/apps/dashboard/src/components/forms/status-page/form-general.tsx b/apps/dashboard/src/components/forms/status-page/form-general.tsx index bc60ceb8d..d9b54c37c 100644 --- a/apps/dashboard/src/components/forms/status-page/form-general.tsx +++ b/apps/dashboard/src/components/forms/status-page/form-general.tsx @@ -172,12 +172,12 @@ export function FormGeneral({ - + ( - + Title @@ -193,7 +193,7 @@ export function FormGeneral({ control={form.control} name="slug" render={({ field }) => ( - + Slug @@ -215,7 +215,7 @@ export function FormGeneral({ control={form.control} name="icon" render={() => ( - + Icon
@@ -275,7 +275,7 @@ export function FormGeneral({ control={form.control} name="description" render={({ field }) => ( - + Description