diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md
index e4ffd5a0..9d0c3757 100644
--- a/apps/dashboard/README.md
+++ b/apps/dashboard/README.md
@@ -69,7 +69,7 @@ Turbo runs the dashboard (`apps/dashboard`) and `@openstatus/db` together.
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` **prints the magic link to the dashboard's terminal stdout**; self-hosted deployments additionally email it when `RESEND_API_KEY` is a real key. No OAuth credentials required. Everywhere else the link is only emailed through Resend.
+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/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx
index b6f957e9..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";
@@ -42,7 +43,6 @@ import { IncidentTimelineItem } from "@/components/incidents/incident-timeline";
import { ResolveReportDialog } from "@/components/incidents/resolve-report-dialog";
import { incidentEndedAt } from "@/data/managed-incidents.client";
import { useFeature } from "@/hooks/use-feature";
-import { personName } from "@/lib/formatter";
import { useTRPC } from "@/lib/trpc/client";
function slackChannelUrl(teamId: string, channelId: string): string {
diff --git a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts
index 9dff1f86..c3b5d741 100644
--- a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts
+++ b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts
@@ -1,9 +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 routes HEAD to GET;
-// Auth.js then rejects the method with a 500 anyway, so answer it up front.
+// 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: 500 });
+ return new Response(null, { status: 405, headers: { Allow: "GET, POST" } });
}
diff --git a/apps/dashboard/src/app/login/_components/actions.ts b/apps/dashboard/src/app/login/_components/actions.ts
index 4ca765c6..95484790 100644
--- a/apps/dashboard/src/app/login/_components/actions.ts
+++ b/apps/dashboard/src/app/login/_components/actions.ts
@@ -48,7 +48,11 @@ export async function continueWithEmail(
// 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)
+ ? 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();
diff --git a/apps/dashboard/src/app/login/_components/email-form.tsx b/apps/dashboard/src/app/login/_components/email-form.tsx
index bb4313c6..8a00211a 100644
--- a/apps/dashboard/src/app/login/_components/email-form.tsx
+++ b/apps/dashboard/src/app/login/_components/email-form.tsx
@@ -1,10 +1,17 @@
"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";
@@ -16,20 +23,31 @@ 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.
+ * 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 Check your inbox
+
- {state.error}
+ {error}
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 dfaf3ed5..55edaf82 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 {
} from "@/components/forms/form-card";
import { useFormSheetDirty } from "@/components/forms/form-sheet";
import { severityConfig } from "@/data/managed-incidents.client";
-import { formatDateForInput, personName } from "@/lib/formatter";
+import { formatDateForInput } from "@/lib/formatter";
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 7ed154f0..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";
@@ -23,7 +24,6 @@ import {
} from "@/components/content/composer";
import { TimelineAvatar, TimelineItem } from "@/components/content/timeline";
import { statusConfig } from "@/data/managed-incidents.client";
-import { personName } from "@/lib/formatter";
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 998cd1bb..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,
@@ -35,7 +36,6 @@ import {
severityConfig,
statusConfig,
} from "@/data/managed-incidents.client";
-import { personName } from "@/lib/formatter";
import { useTRPC } from "@/lib/trpc/client";
import { errorMessage } from "@/lib/trpc/error";
diff --git a/apps/dashboard/src/components/incidents/incident-timeline.tsx b/apps/dashboard/src/components/incidents/incident-timeline.tsx
index 54812ef4..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";
@@ -43,7 +44,6 @@ import {
TimelineTitle,
} from "@/components/content/timeline";
import { severityConfig, statusConfig } from "@/data/managed-incidents.client";
-import { personName } from "@/lib/formatter";
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 ae626c8a..2e2fd880 100644
--- a/apps/dashboard/src/components/nav/nav-user.tsx
+++ b/apps/dashboard/src/components/nav/nav-user.tsx
@@ -34,13 +34,13 @@ 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";
import Link from "next/link";
import { toast } from "sonner";
-import { personName } from "@/lib/formatter";
import { useTRPC } from "@/lib/trpc/client";
export function NavUser() {
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 84bcdeab..189a9b08 100644
--- a/apps/dashboard/src/components/status-reports/status-report-composer.tsx
+++ b/apps/dashboard/src/components/status-reports/status-report-composer.tsx
@@ -19,6 +19,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";
@@ -48,7 +49,7 @@ import {
statusVariants,
toCreateStatusReportUpdateInput,
} from "@/data/status-report-updates.client";
-import { formatDateForInput, personName } from "@/lib/formatter";
+import { formatDateForInput } from "@/lib/formatter";
import { useTRPC } from "@/lib/trpc/client";
import { errorMessage } from "@/lib/trpc/error";
diff --git a/apps/dashboard/src/lib/auth/helpers.ts b/apps/dashboard/src/lib/auth/helpers.ts
index 6ab4f50c..9cebbde4 100644
--- a/apps/dashboard/src/lib/auth/helpers.ts
+++ b/apps/dashboard/src/lib/auth/helpers.ts
@@ -1,4 +1,4 @@
-import { asc, db, eq, sql } 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";
@@ -10,6 +10,8 @@ export function normalizeEmail(email: string) {
// 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
@@ -23,9 +25,15 @@ export async function getUserByEmail(email: string) {
.select()
.from(user)
.where(sql`lower(${user.email}) = ${normalized}`)
- .orderBy(asc(user.id))
- .get();
- return legacy ?? null;
+ .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) {
diff --git a/apps/dashboard/src/lib/formatter.ts b/apps/dashboard/src/lib/formatter.ts
index 47b3e25a..a2b4e076 100644
--- a/apps/dashboard/src/lib/formatter.ts
+++ b/apps/dashboard/src/lib/formatter.ts
@@ -103,21 +103,3 @@ export function formatDateForInput(date: Date): string {
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
-
-/**
- * Display name for a user row: name, then first/last, then email. Twin of
- * `displayName` in `@openstatus/services/attribution`, which imports the db
- * and cannot reach client components.
- */
-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/rate-limit/magic-link.test.ts b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts
index 3cfd94f5..966e5959 100644
--- a/apps/dashboard/src/lib/rate-limit/magic-link.test.ts
+++ b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts
@@ -33,6 +33,17 @@ describe("magicLinkRateLimit", () => {
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);
diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.ts b/apps/dashboard/src/lib/rate-limit/magic-link.ts
index 616ad63f..8ddbca22 100644
--- a/apps/dashboard/src/lib/rate-limit/magic-link.ts
+++ b/apps/dashboard/src/lib/rate-limit/magic-link.ts
@@ -11,6 +11,8 @@ const MAX_PER_EMAIL = 3;
* 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;
@@ -21,7 +23,7 @@ export async function magicLinkRateLimit(args: {
redis,
[
`ratelimit:magic-link:ip:${args.ip}`,
- `ratelimit:magic-link:email:${args.email}`,
+ `ratelimit:magic-link:email:${args.email.trim().toLowerCase()}`,
],
WINDOW_SECONDS,
);
diff --git a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts
index e63d7d27..fcf55852 100644
--- a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts
+++ b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts
@@ -15,9 +15,10 @@ export async function ssoLookupRateLimit(ip: string): Promise