diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md
index 097643e8..9d0c3757 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 b640d621..a3c5a88e 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 e800b6b7..2f81159f 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 c55a45ed..cb7e5343 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 fb8e6cb1..95484790 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 00000000..8a00211a
--- /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 = (
+
+ );
+
+ return (
+ <>
+ {children}
+ {sso ? (
+ mode === "sso" ? (
+ form
+ ) : (
+ switchMode("sso")}
+ >
+ Continue with SSO
+
+ )
+ ) : null}
+
+
+ or
+
+
+ {mode === "email" ? (
+ form
+ ) : (
+ switchMode("email")}
+ >
+ Continue with email
+
+ )}
+ >
+ );
+}
diff --git a/apps/dashboard/src/app/login/_components/login-button.tsx b/apps/dashboard/src/app/login/_components/login-button.tsx
index 01f55753..2225549e 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 (
Last used
diff --git a/apps/dashboard/src/app/login/_components/magic-link-form.tsx b/apps/dashboard/src/app/login/_components/magic-link-form.tsx
deleted file mode 100644
index 879196d5..00000000
--- a/apps/dashboard/src/app/login/_components/magic-link-form.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-"use client";
-
-import { Input } from "@openstatus/ui/components/ui/input";
-import { Label } from "@openstatus/ui/components/ui/label";
-import { useFormStatus } from "react-dom";
-import { toast } from "sonner";
-
-import { signInWithResendAction } from "./actions";
-import { LoginButton } from "./login-button";
-
-interface MagicLinkFormProps {
- redirectTo?: string;
-}
-
-/**
- * @deprecated - only to be used in development mode
- */
-export function MagicLinkForm({ redirectTo }: MagicLinkFormProps) {
- const { pending } = useFormStatus();
-
- return (
-
- );
-}
diff --git a/apps/dashboard/src/app/login/_components/sso-form.tsx b/apps/dashboard/src/app/login/_components/sso-form.tsx
deleted file mode 100644
index d63e2f92..00000000
--- a/apps/dashboard/src/app/login/_components/sso-form.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-"use client";
-
-import { Button } from "@openstatus/ui/components/ui/button";
-import { Input } from "@openstatus/ui/components/ui/input";
-import { useActionState, useState } from "react";
-
-import { type SsoFormState, startSsoSignIn } from "./actions";
-import { LoginButton } from "./login-button";
-
-const initialState: SsoFormState = {};
-
-export function SsoForm({ redirectTo }: { redirectTo?: string }) {
- const [expanded, setExpanded] = useState(false);
- const [state, formAction, isPending] = useActionState(
- startSsoSignIn,
- initialState,
- );
-
- if (!expanded) {
- return (
- setExpanded(true)}
- >
- Sign in with SSO
-
- );
- }
-
- return (
-
- );
-}
diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx
index 9b1eaa2c..b3a547a8 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}
-
-
- {process.env.AUTH_OIDC_ISSUER ? (
+
+
+
- ) : null}
- {hasWorkOS ? : null}
+ {process.env.AUTH_OIDC_ISSUER ? (
+
+ ) : 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 3ddaa962..42ffffe6 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 792e6503..b8ae9de8 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 589cc178..1fde9879 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 be903cf2..0992b865 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 16e4ccfa..2e2fd880 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 27839efd..25229bde 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 3302b0d5..9a41b7fd 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 5ca66ef1..06fdbb37 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 367e2155..9cebbde4 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 9a731b36..da98a158 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 00000000..734f8242
--- /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 82312540..2199ba63 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 00000000..966e5959
--- /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 00000000..8ddbca22
--- /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 81e988f6..fcf55852 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 00000000..1fe4ba7a
--- /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 00000000..c20a9af7
--- /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 00000000..b9dd92bc
--- /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 9f1bce8e..4407a90f 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 0e87437a..d05fc284 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 e3ac0822..22514c1d 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 cff073bb..e1c01148 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 af5e05d4..212bdcce 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 4bb08c9e..2864d129 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 427044e3..51cb1ec2 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 00000000..e1f824ad
--- /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 b582f434..f267bf34 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 c5257906..6725d158 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 456cdc1a..5bc2d159 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 c9b9dbc2..95157d28 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 68403d46..fd768f2e 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 4ecbe4f1..9b7ec401 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 00000000..17befdcd
--- /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 00000000..221ed63c
--- /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 1aa6a38f..dd4161fb 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 00000000..ee6887d5
--- /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 98948c25..f370ecea 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