From 3332d4418eddd1adb040d4bbe9be9fd8e2ba4c55 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Thu, 1 Oct 2026 21:50:46 +0200 Subject: [PATCH] fix: --- apps/dashboard/README.md | 2 +- apps/dashboard/src/app/login/_components/actions.ts | 12 ++++++++---- .../src/app/login/_components/email-form.tsx | 5 ++++- apps/dashboard/src/lib/auth/providers.ts | 13 ++++++------- apps/dashboard/src/lib/formatter.ts | 6 +++++- .../dashboard/src/lib/rate-limit/magic-link.test.ts | 1 + apps/dashboard/src/lib/rate-limit/magic-link.ts | 4 ++++ apps/dashboard/src/test-preload.ts | 2 +- packages/emails/src/client.tsx | 1 + packages/upstash/src/redis/incr-with-ttl.test.ts | 2 +- 10 files changed, 32 insertions(+), 16 deletions(-) diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 1682c156..e4ffd5a0 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 diff --git a/apps/dashboard/src/app/login/_components/actions.ts b/apps/dashboard/src/app/login/_components/actions.ts index 8e9bd86e..4ca765c6 100644 --- a/apps/dashboard/src/app/login/_components/actions.ts +++ b/apps/dashboard/src/app/login/_components/actions.ts @@ -30,7 +30,9 @@ const EMAIL_ERROR = /** * Routes by domain: a verified SSO domain goes to the identity provider, - * everything else gets a magic link. + * 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, @@ -43,9 +45,11 @@ export async function continueWithEmail( if (hasWorkOS) { const ip = resolveClientIp(await headers()) ?? "unknown"; - if (!(await ssoLookupRateLimit(ip))) return { error: EMAIL_ERROR }; - - const workspace = await getWorkspaceByVerifiedSsoDomain(email); + // 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) + : null; if (workspace?.workosOrganizationId) { const cookieStore = await cookies(); cookieStore.set(SSO_ORG_COOKIE, workspace.workosOrganizationId, { diff --git a/apps/dashboard/src/app/login/_components/email-form.tsx b/apps/dashboard/src/app/login/_components/email-form.tsx index c233b3aa..a4eb4972 100644 --- a/apps/dashboard/src/app/login/_components/email-form.tsx +++ b/apps/dashboard/src/app/login/_components/email-form.tsx @@ -38,9 +38,12 @@ export function EmailForm({ redirectTo }: { redirectTo?: string }) { placeholder="gilfoyle@piedpiper.dev" aria-label="Email" aria-invalid={state.error ? true : undefined} + aria-describedby={state.error ? "email-error" : undefined} /> {state.error ? ( -

{state.error}

+ ) : null} >> Magic Link: ${params.url}`); - } 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) { - if (selfHosted) { + // 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/formatter.ts b/apps/dashboard/src/lib/formatter.ts index 7114b130..47b3e25a 100644 --- a/apps/dashboard/src/lib/formatter.ts +++ b/apps/dashboard/src/lib/formatter.ts @@ -104,7 +104,11 @@ 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. */ +/** + * 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; 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 35e35f54..3cfd94f5 100644 --- a/apps/dashboard/src/lib/rate-limit/magic-link.test.ts +++ b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts @@ -30,6 +30,7 @@ describe("magicLinkRateLimit", () => { "ratelimit:magic-link:ip:1.2.3.4", "ratelimit:magic-link:email:a@b.c", ]); + expect(evalStub?.calls[0]?.args[2]).toEqual([600]); }); test("allows at the limits", async () => { diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.ts b/apps/dashboard/src/lib/rate-limit/magic-link.ts index d69e2245..616ad63f 100644 --- a/apps/dashboard/src/lib/rate-limit/magic-link.ts +++ b/apps/dashboard/src/lib/rate-limit/magic-link.ts @@ -7,6 +7,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. */ export async function magicLinkRateLimit(args: { ip: string; diff --git a/apps/dashboard/src/test-preload.ts b/apps/dashboard/src/test-preload.ts index b471ee4a..1fe4ba7a 100644 --- a/apps/dashboard/src/test-preload.ts +++ b/apps/dashboard/src/test-preload.ts @@ -2,4 +2,4 @@ // 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"; +process.env.RESEND_API_KEY ||= "test-key"; diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index 096c7f76..f267bf34 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -412,6 +412,7 @@ export class EmailClient { ); throw result.error; } + console.log(`Sent dashboard magic link email to ${req.to}`); } public async sendMaintenanceNotification(req: { diff --git a/packages/upstash/src/redis/incr-with-ttl.test.ts b/packages/upstash/src/redis/incr-with-ttl.test.ts index 03183e50..083e374e 100644 --- a/packages/upstash/src/redis/incr-with-ttl.test.ts +++ b/packages/upstash/src/redis/incr-with-ttl.test.ts @@ -26,7 +26,7 @@ describe("incrWithTtl", () => { expect(calls[0]?.args).toEqual([600]); }); - test("the script sets the TTL only on the key's first hit", () => { + test("the script guards EXPIRE behind the first INCR of each key", () => { const { client, calls } = fakeClient([1]); incrWithTtl(client, ["a"], 600); -- 2.51.2