From 5bff4ea77ac20ff4a849c77a596eb74f5f22d517 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 8 Aug 2026 00:14:56 +0200 Subject: [PATCH 1/3] feat(web): personal account settings at /account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a hand-built account settings surface. `/settings` was entirely org-scoped and the Clerk user was only ever read (the sidebar avatar), so there was no way to change your own name, avatar, emails, password, 2FA, passkeys, connected accounts or sessions. Everything runs through Clerk's client `user.*` methods — no new API routes, DB tables or domain schemas. Sections: profile (avatar + name + delete), email addresses (add/verify by code, set primary, remove), password (set vs change), TOTP (QR enrollment + backup codes), passkeys, connected accounts, active sessions. Notes on the shape: - `/account` gets its own nav rather than widening `settingsTabValues`. `useVisibleSettingsSections` is org-permission machinery (admin checks, an Autumn customer round-trip) that account tabs must not wait on, and merging the unions would put unrenderable tabs in /settings' search schema. The sidebar chrome is shared via SettingsNavShell. - Hidden entirely when `isClerkAuthEnabled` is false; self-hosted mode has no user records, just a `root` pseudo-user. - Clerk types are derived from the `user` resource (account-types.ts) because `@clerk/types` is not a declared dependency of this app. - `useReverification` wraps every mutating call. That renders Clerk's own challenge modal, so ClerkProvider now takes `appearance` — it had none, and the modal would otherwise appear in Clerk's default light theme. - `uqr` (3.9 kB gzip tree-shaken, zero deps) renders the TOTP QR. Clerk exports no QR component, and /account is code-split so it never enters the startup graph. TOTP, backup codes and passkeys each need their Clerk Dashboard toggle enabled. Those instance settings aren't readable from the client, so sections surface Clerk's own error rather than pre-gating; only `deleteSelfEnabled` is public and gates itself. --- apps/web/package.json | 1 + .../src/components/account/account-errors.ts | 23 ++ .../components/account/account-nav.test.ts | 61 +++ .../src/components/account/account-nav.tsx | 81 ++++ .../account/account-section-skeleton.tsx | 19 + .../src/components/account/account-types.ts | 16 + .../account/active-sessions-section.test.tsx | 49 +++ .../account/active-sessions-section.tsx | 210 ++++++++++ .../components/account/code-field.test.tsx | 50 +++ .../web/src/components/account/code-field.tsx | 57 +++ .../account/connected-accounts-section.tsx | 229 +++++++++++ .../account/email-addresses-section.test.tsx | 64 +++ .../account/email-addresses-section.tsx | 366 ++++++++++++++++++ .../components/account/passkeys-section.tsx | 295 ++++++++++++++ .../components/account/password-section.tsx | 140 +++++++ .../account/profile-section.test.tsx | 101 +++++ .../components/account/profile-section.tsx | 337 ++++++++++++++++ .../src/components/account/qr-code.test.tsx | 39 ++ apps/web/src/components/account/qr-code.tsx | 35 ++ .../account/two-factor-section.test.tsx | 78 ++++ .../components/account/two-factor-section.tsx | 344 ++++++++++++++++ .../command-palette/command-palette.tsx | 16 + .../src/components/dashboard/app-sidebar.tsx | 36 +- .../src/components/dashboard/user-avatar.tsx | 36 ++ apps/web/src/components/icons/fingerprint.tsx | 30 ++ apps/web/src/components/icons/google.tsx | 32 ++ apps/web/src/components/icons/index.ts | 3 + apps/web/src/components/icons/lock.tsx | 27 ++ .../settings/settings-nav-shell.tsx | 99 +++++ .../src/components/settings/settings-nav.tsx | 54 +-- apps/web/src/main.tsx | 5 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/account.tsx | 72 ++++ bun.lock | 5 + 34 files changed, 2950 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/components/account/account-errors.ts create mode 100644 apps/web/src/components/account/account-nav.test.ts create mode 100644 apps/web/src/components/account/account-nav.tsx create mode 100644 apps/web/src/components/account/account-section-skeleton.tsx create mode 100644 apps/web/src/components/account/account-types.ts create mode 100644 apps/web/src/components/account/active-sessions-section.test.tsx create mode 100644 apps/web/src/components/account/active-sessions-section.tsx create mode 100644 apps/web/src/components/account/code-field.test.tsx create mode 100644 apps/web/src/components/account/code-field.tsx create mode 100644 apps/web/src/components/account/connected-accounts-section.tsx create mode 100644 apps/web/src/components/account/email-addresses-section.test.tsx create mode 100644 apps/web/src/components/account/email-addresses-section.tsx create mode 100644 apps/web/src/components/account/passkeys-section.tsx create mode 100644 apps/web/src/components/account/password-section.tsx create mode 100644 apps/web/src/components/account/profile-section.test.tsx create mode 100644 apps/web/src/components/account/profile-section.tsx create mode 100644 apps/web/src/components/account/qr-code.test.tsx create mode 100644 apps/web/src/components/account/qr-code.tsx create mode 100644 apps/web/src/components/account/two-factor-section.test.tsx create mode 100644 apps/web/src/components/account/two-factor-section.tsx create mode 100644 apps/web/src/components/dashboard/user-avatar.tsx create mode 100644 apps/web/src/components/icons/fingerprint.tsx create mode 100644 apps/web/src/components/icons/google.tsx create mode 100644 apps/web/src/components/icons/lock.tsx create mode 100644 apps/web/src/components/settings/settings-nav-shell.tsx create mode 100644 apps/web/src/routes/account.tsx 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