Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/account/account-errors.ts
Original file line number Diff line number Diff line change
@@ -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" })
}
61 changes: 61 additions & 0 deletions apps/web/src/components/account/account-nav.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
81 changes: 81 additions & 0 deletions apps/web/src/components/account/account-nav.tsx
Original file line number Diff line number Diff line change
@@ -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<AccountTab, string> = {
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<NavShellSection<AccountTab>> = [
{
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 <SettingsNavShell sections={accountNavSections} active={active} onSelectTab={onSelectTab} />
}
19 changes: 19 additions & 0 deletions apps/web/src/components/account/account-section-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-9 w-full" />
</CardContent>
</Card>
</div>
)
}
16 changes: 16 additions & 0 deletions apps/web/src/components/account/account-types.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useUser>["user"]>

export type EmailAddress = ClerkUser["emailAddresses"][number]
export type Passkey = ClerkUser["passkeys"][number]
export type ExternalAccount = ClerkUser["externalAccounts"][number]
export type SessionWithActivities = Awaited<ReturnType<ClerkUser["getSessions"]>>[number]
export type OAuthStrategy = Parameters<ClerkUser["createExternalAccount"]>[0]["strategy"]
export type UpdatePasswordParams = Parameters<ClerkUser["updatePassword"]>[0]
49 changes: 49 additions & 0 deletions apps/web/src/components/account/active-sessions-section.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ActiveSessionsSection />)

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)
})
Loading
Loading