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 (
-
);
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}
+
+