diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 558cc3b1..1682c156 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`, `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. +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. 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 e800b6b7..b6f957e9 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -40,8 +40,9 @@ 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 { 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 c55a45ed..9dff1f86 100644 --- a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/dashboard/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 routes HEAD to GET; +// Auth.js then rejects the method with a 500 anyway, so answer it up front. +export function HEAD() { + return new Response(null, { status: 500 }); +} 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 b8141a21..e9402136 100644 --- a/apps/dashboard/src/app/login/_components/magic-link-form.tsx +++ b/apps/dashboard/src/app/login/_components/magic-link-form.tsx @@ -35,7 +35,7 @@ export function MagicLinkForm({ redirectTo }: { redirectTo?: string }) { type="email" required autoComplete="email" - placeholder="you@company.com" + placeholder="gilfoyle@piedpiper.dev" aria-label="Email" aria-invalid={state.error ? true : undefined} /> diff --git a/apps/dashboard/src/app/login/_components/sso-form.tsx b/apps/dashboard/src/app/login/_components/sso-form.tsx index d63e2f92..92976c96 100644 --- a/apps/dashboard/src/app/login/_components/sso-form.tsx +++ b/apps/dashboard/src/app/login/_components/sso-form.tsx @@ -36,7 +36,7 @@ export function SsoForm({ redirectTo }: { redirectTo?: string }) { type="email" required autoFocus - placeholder="you@company.com" + placeholder="gilfoyle@piedpiper.dev" aria-label="Work email" aria-invalid={state.error ? true : undefined} /> diff --git a/apps/dashboard/src/components/forms/incident/form.tsx b/apps/dashboard/src/components/forms/incident/form.tsx index 9e01685b..dfaf3ed5 100644 --- a/apps/dashboard/src/components/forms/incident/form.tsx +++ b/apps/dashboard/src/components/forms/incident/form.tsx @@ -34,8 +34,8 @@ import { FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; -import { personName, severityConfig } from "@/data/managed-incidents.client"; -import { formatDateForInput } from "@/lib/formatter"; +import { severityConfig } from "@/data/managed-incidents.client"; +import { formatDateForInput, 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-composer.tsx b/apps/dashboard/src/components/incidents/incident-composer.tsx index 792e6503..7ed154f0 100644 --- a/apps/dashboard/src/components/incidents/incident-composer.tsx +++ b/apps/dashboard/src/components/incidents/incident-composer.tsx @@ -22,7 +22,8 @@ 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 { 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 589cc178..998cd1bb 100644 --- a/apps/dashboard/src/components/incidents/incident-properties.tsx +++ b/apps/dashboard/src/components/incidents/incident-properties.tsx @@ -32,10 +32,10 @@ import { } from "@/components/content/property-list"; import { incidentEndedAt, - personName, 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 be903cf2..54812ef4 100644 --- a/apps/dashboard/src/components/incidents/incident-timeline.tsx +++ b/apps/dashboard/src/components/incidents/incident-timeline.tsx @@ -42,11 +42,8 @@ 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 { 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 e63fb7bd..ae626c8a 100644 --- a/apps/dashboard/src/components/nav/nav-user.tsx +++ b/apps/dashboard/src/components/nav/nav-user.tsx @@ -40,7 +40,7 @@ import { useTheme } from "next-themes"; import Link from "next/link"; import { toast } from "sonner"; -import { personName } from "@/data/managed-incidents.client"; +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 7fd807c5..84bcdeab 100644 --- a/apps/dashboard/src/components/status-reports/status-report-composer.tsx +++ b/apps/dashboard/src/components/status-reports/status-report-composer.tsx @@ -42,14 +42,13 @@ 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, statusVariants, toCreateStatusReportUpdateInput, } from "@/data/status-report-updates.client"; -import { formatDateForInput } from "@/lib/formatter"; +import { formatDateForInput, personName } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; 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 784dc503..06fdbb37 100644 --- a/apps/dashboard/src/lib/auth/adapter.ts +++ b/apps/dashboard/src/lib/auth/adapter.ts @@ -8,7 +8,7 @@ import { } from "@openstatus/db/src/schema"; import type { Adapter } from "next-auth/adapters"; -import { createUser, getUser, normalizeEmail } from "./helpers"; +import { createUser, getUser, getUserByEmail } from "./helpers"; const drizzleAdapter = DrizzleAdapter(db, { // @ts-expect-error: problem with type @@ -25,8 +25,15 @@ export const adapter: Adapter = { // 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, + 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 9222d1c6..6ab4f50c 100644 --- a/apps/dashboard/src/lib/auth/helpers.ts +++ b/apps/dashboard/src/lib/auth/helpers.ts @@ -1,4 +1,4 @@ -import { db, eq } from "@openstatus/db"; +import { asc, 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"; @@ -8,6 +8,26 @@ 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. +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}`) + .orderBy(asc(user.id)) + .get(); + return legacy ?? null; +} + export async function createUser(data: AdapterUser) { const newUser = await db .insert(user) diff --git a/apps/dashboard/src/lib/auth/providers.ts b/apps/dashboard/src/lib/auth/providers.ts index ca0c9b16..f4f9ef70 100644 --- a/apps/dashboard/src/lib/auth/providers.ts +++ b/apps/dashboard/src/lib/auth/providers.ts @@ -74,12 +74,22 @@ export const ResendProvider = Resend({ throw new MagicLinkRefused("rate limited"); } - const emailClient = new EmailClient({ - apiKey: process.env.RESEND_API_KEY ?? "", - }); + // Self-hosted installs may run without a Resend key: print the link to the + // dashboard log as before and treat a failed send as non-fatal. + const selfHosted = process.env.SELF_HOST === "true"; + if (selfHosted && process.env.NODE_ENV !== "development") { + console.log(`>>> Magic Link: ${params.url}`); + } try { + const emailClient = new EmailClient({ + apiKey: process.env.RESEND_API_KEY ?? "", + }); await emailClient.sendDashboardMagicLink({ link: params.url, to: email }); } catch (cause) { + if (selfHosted) { + console.warn("magic link email not sent, use the printed link", cause); + return; + } throw new MagicLinkRefused("send failed", { cause }); } }, diff --git a/apps/dashboard/src/lib/formatter.ts b/apps/dashboard/src/lib/formatter.ts index a2b4e076..7114b130 100644 --- a/apps/dashboard/src/lib/formatter.ts +++ b/apps/dashboard/src/lib/formatter.ts @@ -103,3 +103,17 @@ 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. */ +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.ts b/apps/dashboard/src/lib/rate-limit/magic-link.ts index 81fbba85..8d541443 100644 --- a/apps/dashboard/src/lib/rate-limit/magic-link.ts +++ b/apps/dashboard/src/lib/rate-limit/magic-link.ts @@ -21,8 +21,9 @@ export async function magicLinkRateLimit(args: { WINDOW_SECONDS, ); return byIp <= MAX_PER_IP && byEmail <= MAX_PER_EMAIL; - } catch { + } 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/status-page/src/app/api/auth/[...nextauth]/route.ts b/apps/status-page/src/app/api/auth/[...nextauth]/route.ts index 9f1bce8e..1afffead 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 routes HEAD to GET; +// Auth.js then rejects the method with a 500 anyway, so answer it up front. +export function HEAD() { + return new Response(null, { status: 500 }); +}