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