From 8de95870d63eb23d95322688034af4bd18643d68 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Thu, 1 Oct 2026 20:56:00 +0200 Subject: [PATCH] chore: magic link dashboard login --- apps/dashboard/README.md | 4 +- apps/dashboard/package.json | 1 + .../src/app/login/_components/actions.ts | 40 +++++++++--- .../app/login/_components/magic-link-form.tsx | 64 ++++++++++--------- apps/dashboard/src/app/login/page.tsx | 22 ++++--- .../dashboard/src/components/nav/nav-user.tsx | 3 +- apps/dashboard/src/lib/auth/adapter.ts | 27 +++++--- apps/dashboard/src/lib/auth/helpers.ts | 7 +- apps/dashboard/src/lib/auth/index.ts | 8 +-- apps/dashboard/src/lib/auth/providers.ts | 45 +++++++++++-- .../src/lib/rate-limit/incr-with-ttl.ts | 20 ++++++ .../src/lib/rate-limit/magic-link.ts | 28 ++++++++ .../src/lib/rate-limit/sso-lookup.ts | 17 +---- packages/api/src/router/stripe/trial.test.ts | 14 ++++ packages/api/src/router/stripe/trial.ts | 4 ++ .../emails/emails/dashboard-magic-link.tsx | 34 ++++++++++ packages/emails/src/client.tsx | 29 +++++++++ packages/emails/src/index.ts | 1 + packages/emails/src/templates.test.tsx | 11 ++++ pnpm-lock.yaml | 13 ++-- 20 files changed, 301 insertions(+), 91 deletions(-) create mode 100644 apps/dashboard/src/lib/rate-limit/incr-with-ttl.ts create mode 100644 apps/dashboard/src/lib/rate-limit/magic-link.ts create mode 100644 packages/emails/emails/dashboard-magic-link.tsx diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 097643e8..558cc3b1 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -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 sending an email. No OAuth credentials required. Everywhere else, including self-hosted deployments, the link is emailed through Resend, so `RESEND_API_KEY` must be a real key. To log in: diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index b640d621..c775eb4f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -83,6 +83,7 @@ "cmdk": "catalog:", "date-fns": "catalog:", "lucide-react": "catalog:", + "mailchecker": "catalog:", "next": "catalog:", "next-auth": "catalog:", "next-themes": "catalog:", diff --git a/apps/dashboard/src/app/login/_components/actions.ts b/apps/dashboard/src/app/login/_components/actions.ts index fb8e6cb1..0971e43a 100644 --- a/apps/dashboard/src/app/login/_components/actions.ts +++ b/apps/dashboard/src/app/login/_components/actions.ts @@ -1,6 +1,8 @@ "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"; @@ -14,16 +16,40 @@ function sanitizeRedirectTo(raw: FormDataEntryValue | null) { return value.startsWith("/") && !value.startsWith("//") ? value : undefined; } -export async function signInWithResendAction(formData: FormData) { +export type MagicLinkFormState = { sent?: boolean; error?: string }; + +// 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 MAGIC_LINK_ERROR = + "We couldn't send a sign-in link to that address. Try GitHub or Google."; + +export async function signInWithMagicLink( + _prevState: MagicLinkFormState, + formData: FormData, +): Promise { + const email = String(formData.get("email") ?? "").trim(); + if (!email.includes("@")) return { error: MAGIC_LINK_ERROR }; + + // 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 { - // next-auth lifts `redirectTo` into the magic link's `callbackUrl` itself. - await signIn("resend", { - email: String(formData.get("email") ?? ""), + const url = await signIn("resend", { + email, redirectTo: sanitizeRedirectTo(formData.get("redirectTo")), + redirect: false, }); + if (typeof url === "string" && new URL(url).searchParams.has("error")) { + return { error: MAGIC_LINK_ERROR }; + } } catch (e) { - console.error(e); + if (!(e instanceof AuthError)) throw e; + console.error("magic link sign-in failed", e); + return { error: MAGIC_LINK_ERROR }; } + + return { sent: true }; } export type SsoFormState = { error?: string }; @@ -44,9 +70,7 @@ export async function startSsoSignIn( if (!email.includes("@")) return { error: GENERIC_ERROR }; - const headerList = await headers(); - const ip = - headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const ip = resolveClientIp(await headers()) ?? "unknown"; if (!(await ssoLookupRateLimit(ip))) return { error: GENERIC_ERROR }; const workspace = await getWorkspaceByVerifiedSsoDomain(email); diff --git a/apps/dashboard/src/app/login/_components/magic-link-form.tsx b/apps/dashboard/src/app/login/_components/magic-link-form.tsx index 879196d5..b8141a21 100644 --- a/apps/dashboard/src/app/login/_components/magic-link-form.tsx +++ b/apps/dashboard/src/app/login/_components/magic-link-form.tsx @@ -1,45 +1,49 @@ "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 { useActionState } from "react"; -import { signInWithResendAction } from "./actions"; +import { type MagicLinkFormState, signInWithMagicLink } from "./actions"; import { LoginButton } from "./login-button"; -interface MagicLinkFormProps { - redirectTo?: string; -} +const initialState: MagicLinkFormState = {}; + +export function MagicLinkForm({ redirectTo }: { redirectTo?: string }) { + const [state, formAction, isPending] = useActionState( + signInWithMagicLink, + initialState, + ); -/** - * @deprecated - only to be used in development mode - */ -export function MagicLinkForm({ redirectTo }: MagicLinkFormProps) { - const { pending } = useFormStatus(); + if (state.sent) { + return ( +
+

Check your inbox

+

+ We sent you a sign-in link. It is valid for 24 hours and works once. +

+
+ ); + } return ( -
{ - try { - await signInWithResendAction(formData); - toast.success("Check your terminal for the magic link."); - } catch (e) { - console.error(e); - toast.error("Error sending magic link."); - } - }} - className="grid gap-2" - > + {redirectTo ? ( ) : null} -
- - -
- - {pending ? "Logging..." : "Log Magic Link"} + + {state.error ? ( +

{state.error}

+ ) : null} + + {isPending ? "Sending…" : "Continue with email"} ); diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx index 9b1eaa2c..b6ecf7b8 100644 --- a/apps/dashboard/src/app/login/page.tsx +++ b/apps/dashboard/src/app/login/page.tsx @@ -16,6 +16,14 @@ 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.", +}; + export const metadata: Metadata = { title: "Sign In", description: @@ -43,20 +51,14 @@ 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"; diff --git a/apps/dashboard/src/components/nav/nav-user.tsx b/apps/dashboard/src/components/nav/nav-user.tsx index 16e4ccfa..e63fb7bd 100644 --- a/apps/dashboard/src/components/nav/nav-user.tsx +++ b/apps/dashboard/src/components/nav/nav-user.tsx @@ -40,6 +40,7 @@ import { useTheme } from "next-themes"; import Link from "next/link"; import { toast } from "sonner"; +import { personName } from "@/data/managed-incidents.client"; import { useTRPC } from "@/lib/trpc/client"; export function NavUser() { @@ -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/lib/auth/adapter.ts b/apps/dashboard/src/lib/auth/adapter.ts index 5ca66ef1..784dc503 100644 --- a/apps/dashboard/src/lib/auth/adapter.ts +++ b/apps/dashboard/src/lib/auth/adapter.ts @@ -8,18 +8,25 @@ import { } from "@openstatus/db/src/schema"; import type { Adapter } from "next-auth/adapters"; -import { createUser, getUser } from "./helpers"; +import { createUser, getUser, normalizeEmail } 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: (email) => + drizzleAdapter.getUserByEmail?.(normalizeEmail(email)) ?? null, 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..9222d1c6 100644 --- a/apps/dashboard/src/lib/auth/helpers.ts +++ b/apps/dashboard/src/lib/auth/helpers.ts @@ -3,11 +3,16 @@ 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(); +} + 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.ts b/apps/dashboard/src/lib/auth/providers.ts index 82312540..ca0c9b16 100644 --- a/apps/dashboard/src/lib/auth/providers.ts +++ b/apps/dashboard/src/lib/auth/providers.ts @@ -1,4 +1,6 @@ -import type { Profile } from "next-auth"; +import { EmailClient } from "@openstatus/emails"; +import { resolveClientIp } from "@openstatus/services/page-access"; +import { AuthError, type Profile } from "next-auth"; import type { OIDCConfig } from "next-auth/providers"; import GitHub from "next-auth/providers/github"; import Google from "next-auth/providers/google"; @@ -41,11 +43,44 @@ 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"); + } + + const emailClient = new EmailClient({ + apiKey: process.env.RESEND_API_KEY ?? "", + }); + try { + await emailClient.sendDashboardMagicLink({ link: params.url, to: email }); + } catch (cause) { + throw new MagicLinkRefused("send failed", { cause }); + } }, }); diff --git a/apps/dashboard/src/lib/rate-limit/incr-with-ttl.ts b/apps/dashboard/src/lib/rate-limit/incr-with-ttl.ts new file mode 100644 index 00000000..caebda9c --- /dev/null +++ b/apps/dashboard/src/lib/rate-limit/incr-with-ttl.ts @@ -0,0 +1,20 @@ +import { redis } from "@openstatus/upstash"; + +// 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(keys: string[], windowSeconds: number) { + return redis.eval<[number], number[]>(INCR_WITH_TTL, keys, [windowSeconds]); +} 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..81fbba85 --- /dev/null +++ b/apps/dashboard/src/lib/rate-limit/magic-link.ts @@ -0,0 +1,28 @@ +import { incrWithTtl } from "./incr-with-ttl"; + +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. + */ +export async function magicLinkRateLimit(args: { + ip: string; + email: string; +}): Promise { + try { + const [byIp, byEmail] = await incrWithTtl( + [ + `ratelimit:magic-link:ip:${args.ip}`, + `ratelimit:magic-link:email:${args.email}`, + ], + WINDOW_SECONDS, + ); + return byIp <= MAX_PER_IP && byEmail <= MAX_PER_EMAIL; + } catch { + // Redis unavailable: allow the request rather than locking everyone out. + 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..eab9369f 100644 --- a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts +++ b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts @@ -1,28 +1,17 @@ -import { redis } from "@openstatus/upstash"; +import { incrWithTtl } from "./incr-with-ttl"; 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( [`ratelimit:sso-lookup:${ip}`], - [WINDOW_SECONDS], + WINDOW_SECONDS, ); return count <= MAX_ATTEMPTS; } catch { 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..ece4e8ed --- /dev/null +++ b/packages/emails/emails/dashboard-magic-link.tsx @@ -0,0 +1,34 @@ +/** @jsxRuntime automatic @jsxImportSource react */ + +import { Actions } from "./_components/actions"; +import { Footer } from "./_components/footer"; +import { Heading } from "./_components/heading"; +import { Layout } from "./_components/layout"; + +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. + + + + ); +}; + +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..096c7f76 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,33 @@ 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; + } + } + 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..8ad1da96 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,15 @@ 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); + expect(html).toContain('href="https://www.openstatus.dev"'); + }); }); describe("every transactional template", () => { @@ -682,6 +692,7 @@ describe("every transactional template", () => { invitation: , subscription: , magicLink: , + dashboardMagicLink: , welcome: , incidentCommander: ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98948c25..99315954 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 @@ -1404,7 +1407,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 +1672,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 +3233,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 @@ -18792,7 +18795,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