From 0c08ed32fe868f7487c33c5db695802289933fd1 Mon Sep 17 00:00:00 2001
From: Maximilian Kaske
Date: Thu, 1 Oct 2026 22:22:33 +0200
Subject: [PATCH] fix: more stuff
---
apps/dashboard/README.md | 2 +-
.../app/(dashboard)/incidents/[id]/client.tsx | 2 +-
.../src/app/api/auth/[...nextauth]/route.ts | 33 +++++++++--
.../src/app/login/_components/actions.ts | 6 +-
.../src/app/login/_components/email-form.tsx | 44 +++++++++++----
apps/dashboard/src/app/login/page.tsx | 55 ++++++++++---------
.../src/components/forms/incident/form.tsx | 3 +-
.../incidents/incident-composer.tsx | 2 +-
.../incidents/incident-properties.tsx | 2 +-
.../incidents/incident-timeline.tsx | 2 +-
.../dashboard/src/components/nav/nav-user.tsx | 2 +-
.../status-reports/status-report-composer.tsx | 3 +-
apps/dashboard/src/lib/auth/helpers.ts | 16 ++++--
apps/dashboard/src/lib/formatter.ts | 18 ------
.../src/lib/rate-limit/magic-link.test.ts | 11 ++++
.../src/lib/rate-limit/magic-link.ts | 4 +-
.../src/lib/rate-limit/sso-lookup.ts | 3 +-
.../src/app/api/auth/[...nextauth]/route.ts | 6 +-
.../src/content/pages/unrelated/security.mdx | 2 +-
packages/services/src/attribution.ts | 4 +-
.../upstash/src/redis/incr-with-ttl.test.ts | 4 +-
packages/utils/src/index.ts | 1 +
packages/utils/src/person-name.ts | 13 +++++
23 files changed, 156 insertions(+), 82 deletions(-)
create mode 100644 packages/utils/src/person-name.ts
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("closed");
+ // The action state outlives a mode switch; an error from the email form
+ // must not show up, or mark the input invalid, on the SSO form.
+ const [staleState, setStaleState] = useState(null);
+ const error = state === staleState ? undefined : state.error;
+ const switchMode = (next: Mode) => {
+ setStaleState(state);
+ setMode(next);
+ };
useEffect(() => {
const last = localStorage.getItem(STORAGE_KEY);
@@ -39,12 +57,13 @@ export function EmailForm({
if (state.sent) {
return (
-
-
Check your inbox
-
+
+
+ Check your inbox
+
We sent you a sign-in link. It is valid for 24 hours and works once.
-
-
+
+
);
}
@@ -63,12 +82,12 @@ export function EmailForm({
mode === "sso" ? "you@company.com" : "gilfoyle@piedpiper.dev"
}
aria-label={mode === "sso" ? "Work email" : "Email"}
- aria-invalid={state.error ? true : undefined}
- aria-describedby={state.error ? "email-error" : undefined}
+ aria-invalid={error ? true : undefined}
+ aria-describedby={error ? "email-error" : undefined}
/>
- {state.error ? (
+ {error ? (
- {state.error}
+ {error}
) : null}
+ {children}
{sso ? (
mode === "sso" ? (
form
@@ -97,7 +117,7 @@ export function EmailForm({
type="button"
variant="secondary"
className="w-full"
- onClick={() => setMode("sso")}
+ onClick={() => switchMode("sso")}
>
Continue with SSO
@@ -115,7 +135,7 @@ export function EmailForm({
type="button"
variant="ghost"
className="text-muted-foreground w-full"
- onClick={() => setMode("email")}
+ onClick={() => switchMode("email")}
>
Continue with email
diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx
index ec6d6e0d..b3a547a8 100644
--- a/apps/dashboard/src/app/login/page.tsx
+++ b/apps/dashboard/src/app/login/page.tsx
@@ -20,6 +20,8 @@ const ERROR_MESSAGES: Record = {
"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.",
+ Configuration:
+ "We couldn't complete your sign-in. Try again or contact support.",
};
export const metadata: Metadata = {
@@ -55,39 +57,40 @@ export default async function Page(props: {
) : null}
-
-
- {process.env.AUTH_OIDC_ISSUER ? (
+
+
- ) : null}
-
+ {process.env.AUTH_OIDC_ISSUER ? (
+
+ ) : null}
+
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 {
WINDOW_SECONDS,
);
return count <= MAX_ATTEMPTS;
- } catch {
+ } catch (e) {
// Redis unavailable: allow the lookup rather than locking everyone out of
// SSO. The verified-domain check downstream is the real security boundary.
+ console.warn("sso lookup 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 1afffead..4407a90f 100644
--- a/apps/status-page/src/app/api/auth/[...nextauth]/route.ts
+++ b/apps/status-page/src/app/api/auth/[...nextauth]/route.ts
@@ -2,8 +2,8 @@ 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.
+// 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/web/src/content/pages/unrelated/security.mdx b/apps/web/src/content/pages/unrelated/security.mdx
index 809448a5..e1c01148 100644
--- a/apps/web/src/content/pages/unrelated/security.mdx
+++ b/apps/web/src/content/pages/unrelated/security.mdx
@@ -46,7 +46,7 @@ Application secrets — third-party tokens, webhook URLs, integration credential
## Authentication and access
-You sign in with GitHub or Google OAuth, an email magic link, or your company's SSO provider. openstatus has no passwords to see or store.
+You sign in with GitHub or Google OAuth, an email magic link, or your company's SSO provider. Account sign-in never involves a password, so there is none for openstatus to see or store.
API access uses scoped keys: a key with the `read` scope can only call read-only endpoints; a key with the `write` scope can mutate data. Scopes are enforced before any database lookup, so a read-only key can't even reach a write code path.
diff --git a/packages/services/src/attribution.ts b/packages/services/src/attribution.ts
index c9b9dbc2..95157d28 100644
--- a/packages/services/src/attribution.ts
+++ b/packages/services/src/attribution.ts
@@ -1,5 +1,6 @@
import { inArray } from "@openstatus/db";
import { user } from "@openstatus/db/src/schema";
+import { personName } from "@openstatus/utils";
import { z } from "zod";
import type { DB } from "./context";
@@ -37,8 +38,7 @@ export function displayName(row: {
lastName: string | null;
email: string | null;
}): string {
- const full = [row.firstName, row.lastName].filter(Boolean).join(" ");
- return row.name || full || row.email || "Unknown user";
+ return personName(row) ?? "Unknown user";
}
// `user/delete.ts` soft-deletes and blanks every name field, so the row
diff --git a/packages/upstash/src/redis/incr-with-ttl.test.ts b/packages/upstash/src/redis/incr-with-ttl.test.ts
index 083e374e..17befdcd 100644
--- a/packages/upstash/src/redis/incr-with-ttl.test.ts
+++ b/packages/upstash/src/redis/incr-with-ttl.test.ts
@@ -26,7 +26,9 @@ describe("incrWithTtl", () => {
expect(calls[0]?.args).toEqual([600]);
});
- test("the script guards EXPIRE behind the first INCR of each key", () => {
+ // Source-level only: no Lua runtime here, so this pins the shape of the
+ // script, not its behaviour.
+ test("ships INCR followed by a first-hit-only EXPIRE per key", () => {
const { client, calls } = fakeClient([1]);
incrWithTtl(client, ["a"], 600);
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index 1aa6a38f..dd4161fb 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -24,6 +24,7 @@ export {
} from "./constants";
export { buildCurlCommand, type CurlRequest } from "./curl";
export { type HeaderPair, headerPairSchema } from "./headers";
+export { personName } from "./person-name";
export { iteratorToStream, yieldMany } from "./stream";
export { type PageUpdateStatus, statusLabel } from "./status";
diff --git a/packages/utils/src/person-name.ts b/packages/utils/src/person-name.ts
new file mode 100644
index 00000000..ee6887d5
--- /dev/null
+++ b/packages/utils/src/person-name.ts
@@ -0,0 +1,13 @@
+/** 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;
+}
--
2.51.2