diff --git a/apps/web/package.json b/apps/web/package.json index 6505376ad..7fb86a7fb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -62,6 +62,7 @@ "sugar-high": "^1.2.1", "tailwindcss": "catalog:tailwind", "tw-animate-css": "catalog:tailwind", + "uqr": "^0.1.3", "web-vitals": "^5.3.0" }, "devDependencies": { diff --git a/apps/web/src/components/account/account-errors.ts b/apps/web/src/components/account/account-errors.ts new file mode 100644 index 000000000..418bd3d74 --- /dev/null +++ b/apps/web/src/components/account/account-errors.ts @@ -0,0 +1,23 @@ +import { isClerkAPIResponseError, isReverificationCancelledError } from "@clerk/clerk-react/errors" +import { toastManager } from "@maple/ui/components/ui/toast" + +/** + * Clerk's instance-level toggles — authenticator app, backup codes, passkeys, self-serve + * deletion — are not readable from the client (only through the internal + * `clerk.__unstable__environment`), so an account section cannot pre-gate itself on them. + * Surfacing Clerk's own message is what turns "something went wrong" into "authenticator app + * is disabled for this instance". + */ +export function accountErrorMessage(err: unknown, fallback: string): string { + if (isClerkAPIResponseError(err)) { + const first = err.errors[0] + return first?.longMessage ?? first?.message ?? fallback + } + return err instanceof Error ? err.message : fallback +} + +/** Toast a failed Clerk call. Dismissing the reverification challenge is a no-op, not an error. */ +export function toastAccountError(err: unknown, fallback: string) { + if (isReverificationCancelledError(err)) return + toastManager.add({ title: accountErrorMessage(err, fallback), type: "error" }) +} diff --git a/apps/web/src/components/account/account-nav.test.ts b/apps/web/src/components/account/account-nav.test.ts new file mode 100644 index 000000000..06bf7107f --- /dev/null +++ b/apps/web/src/components/account/account-nav.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest" +import { + accountNavSections, + accountTabLabels, + accountTabValues, + DEFAULT_ACCOUNT_TAB, + resolveActiveAccountTab, +} from "./account-nav" + +describe("resolveActiveAccountTab", () => { + it("honours every real tab", () => { + for (const tab of accountTabValues) { + expect(resolveActiveAccountTab(tab)).toBe(tab) + } + }) + + it("falls back to the default with no tab requested", () => { + expect(resolveActiveAccountTab(undefined)).toBe(DEFAULT_ACCOUNT_TAB) + }) + + it("ignores an unknown tab value", () => { + expect(resolveActiveAccountTab("nonsense")).toBe(DEFAULT_ACCOUNT_TAB) + expect(resolveActiveAccountTab("")).toBe(DEFAULT_ACCOUNT_TAB) + // A `/settings` tab is not an `/account` tab; the two unions are deliberately disjoint. + expect(resolveActiveAccountTab("billing")).toBe(DEFAULT_ACCOUNT_TAB) + }) +}) + +describe("accountNavSections", () => { + it("renders a row for every tab, and no row that is not a tab", () => { + // Catches both halves of the same mistake: adding a tab without a nav row (unreachable + // except by URL) and leaving a nav row for a tab the page no longer renders (dead row). + const rowIds = accountNavSections.flatMap((section) => section.items.map((item) => item.id)) + expect([...rowIds].sort()).toEqual([...accountTabValues].sort()) + }) + + it("has a unique id per section", () => { + const sectionIds = accountNavSections.map((section) => section.id) + expect(new Set(sectionIds).size).toBe(sectionIds.length) + }) + + it("labels each row with its tab label", () => { + for (const section of accountNavSections) { + for (const item of section.items) { + expect(item.label).toBe(accountTabLabels[item.id]) + } + } + }) +}) + +describe("DEFAULT_ACCOUNT_TAB", () => { + it("is a real tab", () => { + expect(accountTabValues).toContain(DEFAULT_ACCOUNT_TAB) + }) + + it("never lands users on a tab that does work on mount", () => { + // `sessions` fetches the user's sessions from Clerk when it mounts. It must stay an + // explicit navigation, never the landing tab. + expect(DEFAULT_ACCOUNT_TAB).not.toBe("sessions") + }) +}) diff --git a/apps/web/src/components/account/account-nav.tsx b/apps/web/src/components/account/account-nav.tsx new file mode 100644 index 000000000..3dab35ef8 --- /dev/null +++ b/apps/web/src/components/account/account-nav.tsx @@ -0,0 +1,81 @@ +import { + ComputerIcon, + EnvelopeIcon, + FingerprintIcon, + LinkIcon, + LockIcon, + ShieldIcon, + UserIcon, +} from "@/components/icons" +import { SettingsNavShell, type NavShellSection } from "@/components/settings/settings-nav-shell" + +export const accountTabValues = [ + "profile", + "emails", + "password", + "two-factor", + "passkeys", + "connections", + "sessions", +] as const +export type AccountTab = (typeof accountTabValues)[number] + +export const accountTabLabels: Record = { + profile: "Profile", + emails: "Email Addresses", + password: "Password", + "two-factor": "Two-Factor Auth", + passkeys: "Passkeys", + connections: "Connected Accounts", + sessions: "Active Sessions", +} + +/** + * Landing tab when `/account` is opened with no `?tab=`. Unlike `/settings`, every account tab is + * visible to any signed-in user, so this is a single value rather than a preference order — there + * is no permission filtering that could make it unavailable. + */ +export const DEFAULT_ACCOUNT_TAB: AccountTab = "profile" + +export const accountNavSections: ReadonlyArray> = [ + { + id: "profile", + title: "Profile", + items: [ + { id: "profile", label: accountTabLabels.profile, icon: UserIcon }, + { id: "emails", label: accountTabLabels.emails, icon: EnvelopeIcon }, + ], + }, + { + id: "security", + title: "Security", + items: [ + { id: "password", label: accountTabLabels.password, icon: LockIcon }, + { id: "two-factor", label: accountTabLabels["two-factor"], icon: ShieldIcon }, + { id: "passkeys", label: accountTabLabels.passkeys, icon: FingerprintIcon }, + ], + }, + { + id: "access", + title: "Access", + items: [ + { id: "connections", label: accountTabLabels.connections, icon: LinkIcon }, + { id: "sessions", label: accountTabLabels.sessions, icon: ComputerIcon }, + ], + }, +] + +/** Which tab `/account` should render: the requested one when it is a real tab, else the default. */ +export function resolveActiveAccountTab(requestedTab: string | undefined): AccountTab { + return accountTabValues.find((tab) => tab === requestedTab) ?? DEFAULT_ACCOUNT_TAB +} + +export function AccountNav({ + active, + onSelectTab, +}: { + active: AccountTab + onSelectTab: (tab: AccountTab) => void +}) { + return +} diff --git a/apps/web/src/components/account/account-section-skeleton.tsx b/apps/web/src/components/account/account-section-skeleton.tsx new file mode 100644 index 000000000..acf6a26c6 --- /dev/null +++ b/apps/web/src/components/account/account-section-skeleton.tsx @@ -0,0 +1,19 @@ +import { Card, CardContent, CardHeader } from "@maple/ui/components/ui/card" +import { Skeleton } from "@maple/ui/components/ui/skeleton" + +/** Placeholder every account section renders while Clerk's `user` resource loads. */ +export function AccountSectionSkeleton() { + return ( +
+ + + + + + + + + +
+ ) +} diff --git a/apps/web/src/components/account/account-types.ts b/apps/web/src/components/account/account-types.ts new file mode 100644 index 000000000..ee80708a6 --- /dev/null +++ b/apps/web/src/components/account/account-types.ts @@ -0,0 +1,16 @@ +import type { useUser } from "@clerk/clerk-react" + +/** + * Clerk's resource types live in `@clerk/types`, which is not a dependency of this app — only + * `@clerk/clerk-react` is, and it re-exports just a handful of them. Deriving what we need from + * the `user` resource keeps these in lockstep with whatever version is installed instead of + * pinning a second Clerk package that could skew from it. + */ +export type ClerkUser = NonNullable["user"]> + +export type EmailAddress = ClerkUser["emailAddresses"][number] +export type Passkey = ClerkUser["passkeys"][number] +export type ExternalAccount = ClerkUser["externalAccounts"][number] +export type SessionWithActivities = Awaited>[number] +export type OAuthStrategy = Parameters[0]["strategy"] +export type UpdatePasswordParams = Parameters[0] diff --git a/apps/web/src/components/account/active-sessions-section.test.tsx b/apps/web/src/components/account/active-sessions-section.test.tsx new file mode 100644 index 000000000..77ec43161 --- /dev/null +++ b/apps/web/src/components/account/active-sessions-section.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +const session = (id: string, isMobile: boolean) => ({ + id, + status: "active", + lastActiveAt: new Date("2026-08-07T12:00:00Z"), + latestActivity: { + id: `act_${id}`, + browserName: "Chrome", + browserVersion: "140", + deviceType: isMobile ? "iPhone" : "Macbook Pro", + city: "Berlin", + country: "DE", + isMobile, + }, + revoke: vi.fn().mockResolvedValue(undefined), +}) + +describe("ActiveSessionsSection", () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.resetModules() + }) + + it("marks the current session and gives it no Sign out control", async () => { + const current = session("sess_current", false) + const other = session("sess_other", true) + vi.doMock("@clerk/clerk-react", () => ({ + useUser: () => ({ + user: { id: "user_1", getSessions: vi.fn().mockResolvedValue([current, other]) }, + isLoaded: true, + }), + useSession: () => ({ session: { id: "sess_current" } }), + useReverification: (fetcher: unknown) => fetcher, + })) + + const { ActiveSessionsSection } = await import("./active-sessions-section") + render() + + expect(await screen.findByText("This device")).toBeTruthy() + // Exactly one row is revocable: revoking the current session from inside the page would + // sign the user out of the very tab they are using. + expect(screen.getAllByRole("button", { name: "Sign out" })).toHaveLength(1) + }, 30_000) +}) diff --git a/apps/web/src/components/account/active-sessions-section.tsx b/apps/web/src/components/account/active-sessions-section.tsx new file mode 100644 index 000000000..c6f998e98 --- /dev/null +++ b/apps/web/src/components/account/active-sessions-section.tsx @@ -0,0 +1,210 @@ +import { useState } from "react" +import { useReverification, useSession, useUser } from "@clerk/clerk-react" +import type { SessionWithActivities } from "@/components/account/account-types" +import { toastManager } from "@maple/ui/components/ui/toast" + +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@maple/ui/components/ui/alert-dialog" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { AlertWarningIcon, ComputerIcon, MobileIcon } from "@/components/icons" +import { useMountEffect } from "@/hooks/use-mount-effect" +import { toastAccountError } from "@/components/account/account-errors" + +type LoadState = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready"; sessions: ReadonlyArray } + +const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) + +/** "3 minutes ago" from a timestamp, coarsening upwards so long-idle sessions read as days. */ +function formatLastActive(date: Date): string { + const seconds = Math.round((date.getTime() - Date.now()) / 1000) + const magnitude = Math.abs(seconds) + if (magnitude < 60) return relative.format(seconds, "second") + if (magnitude < 3600) return relative.format(Math.round(seconds / 60), "minute") + if (magnitude < 86_400) return relative.format(Math.round(seconds / 3600), "hour") + return relative.format(Math.round(seconds / 86_400), "day") +} + +function describeDevice(session: SessionWithActivities): string { + const { browserName, browserVersion, deviceType } = session.latestActivity + const browser = [browserName, browserVersion].filter(Boolean).join(" ") + return [deviceType, browser].filter(Boolean).join(" · ") || "Unknown device" +} + +function describeLocation(session: SessionWithActivities): string { + const { city, country, ipAddress } = session.latestActivity + return [city, country].filter(Boolean).join(", ") || ipAddress || "Unknown location" +} + +export function ActiveSessionsSection() { + const { user, isLoaded } = useUser() + const { session: currentSession } = useSession() + + const [state, setState] = useState({ status: "loading" }) + const [pendingRevoke, setPendingRevoke] = useState(null) + const [isRevoking, setIsRevoking] = useState(false) + + const revokeSession = useReverification((session: SessionWithActivities) => session.revoke()) + + // `user.getSessions()` is imperative — Clerk exposes no hook for other-device sessions — so + // this is the sanctioned mount-effect escape hatch rather than a `useEffect`. + useMountEffect(() => { + void load() + }) + + async function load() { + if (!user) return + try { + const sessions = await user.getSessions() + setState({ status: "ready", sessions }) + } catch (err) { + setState({ + status: "error", + message: err instanceof Error ? err.message : "Failed to load your sessions", + }) + } + } + + async function handleRevoke() { + if (!pendingRevoke) return + setIsRevoking(true) + try { + await revokeSession(pendingRevoke) + setPendingRevoke(null) + toastManager.add({ title: "Session signed out", type: "success" }) + // `revoke()` returns only the one session, so refetch rather than patching state and + // risking a list that disagrees with Clerk about what is still active. + await load() + } catch (err) { + toastAccountError(err, "Failed to sign out that session") + } finally { + setIsRevoking(false) + } + } + + return ( +
+ + + Active Sessions + + Devices currently signed in to your account. Sign out any you do not recognise. + + + + {!isLoaded || state.status === "loading" ? ( +
+ + +
+ ) : state.status === "error" ? ( +
+

{state.message}

+ +
+ ) : ( + + + + Device + Location + Last active + + + + + {state.sessions.map((session) => { + const isCurrent = session.id === currentSession?.id + const DeviceIcon = session.latestActivity.isMobile + ? MobileIcon + : ComputerIcon + + return ( + + +
+ + + {describeDevice(session)} + + {isCurrent && ( + This device + )} +
+
+ + {describeLocation(session)} + + + {formatLastActive(session.lastActiveAt)} + + + {/* Revoking the current session would sign the user out from + inside the page they are using; use Log out for that. */} + {!isCurrent && ( + + )} + +
+ ) + })} +
+
+ )} +
+
+ + { + if (!open) setPendingRevoke(null) + }} + > + + + + + + Sign out this device? + + {pendingRevoke ? describeDevice(pendingRevoke) : "This session"} will need to sign + in again to reach Maple. + + + + Cancel + + {isRevoking ? "Signing out..." : "Sign out"} + + + + +
+ ) +} diff --git a/apps/web/src/components/account/code-field.test.tsx b/apps/web/src/components/account/code-field.test.tsx new file mode 100644 index 000000000..fae9b1cee --- /dev/null +++ b/apps/web/src/components/account/code-field.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { CodeField } from "./code-field" + +function renderField() { + return render( + , + ) +} + +describe("CodeField", () => { + afterEach(cleanup) + + it("gives every slot an accessible name", () => { + // Base UI ignores `aria-label` on the first input and warns about it, so slot one must be + // named by a real