Skip to content
  •  
  •  
  •  
3 changes: 2 additions & 1 deletion apps/local-ui/src/components/service-map-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { Button } from "@maple/ui/components/ui/button"
import { ScrollArea } from "@maple/ui/components/ui/scroll-area"
import { formatErrorRate, formatLatency } from "@maple/ui/lib/format"
import { errorRateClass, errorRateLevel } from "@maple/ui/lib/error-rate"
import { formatRate, getHealthDotClass } from "@maple/ui/components/service-map/service-map-node"
import { getHealthDotClass } from "@maple/ui/components/service-map/service-map-node"
import { formatRate } from "@maple/ui/lib/format"
import { getServiceColor } from "@maple/ui/lib/colors"
import { latencyToneClass } from "@maple/ui/lib/latency-tone"
import { cn } from "@maple/ui/lib/utils"
Expand Down
7 changes: 5 additions & 2 deletions apps/web/perf/check-bundle-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,11 @@ const forbidden = [...staticGraph].filter((key) =>
console.log(
`Initial static JS: ${(gzipBytes / 1024).toFixed(1)} KB gzip across ${chunks.length} chunks (budget: ${budgetLabel})`,
)
for (const chunk of chunks.sort((a, b) => b.gzipBytes - a.gzipBytes).slice(0, 10)) {
console.log(` ${(chunk.gzipBytes / 1024).toFixed(1).padStart(7)} KB ${chunk.file}`)
// Over budget, list every chunk with its source key so the culprit is in the CI log.
const overBudget = gzipBytes > maxGzipBytes
for (const chunk of chunks.sort((a, b) => b.gzipBytes - a.gzipBytes).slice(0, overBudget ? undefined : 10)) {
const label = overBudget ? `${chunk.file} ${chunk.key}` : chunk.file
console.log(` ${(chunk.gzipBytes / 1024).toFixed(1).padStart(7)} KB ${label}`)
}

if (forbidden.length > 0) {
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/account/account-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,23 @@ export function toastAccountError(err: unknown, fallback: string) {
if (isReverificationCancelledError(err)) return
toastManager.add({ title: accountErrorMessage(err, fallback), type: "error" })
}

/**
* Settles a Clerk call into a boolean: toasts `success` (when given) or Clerk's own failure
* message, so call sites never wrap Clerk promises in try/catch.
*/
export function settleClerk(
promise: Promise<unknown>,
options: { readonly success?: string; readonly error: string },
): Promise<boolean> {
return promise.then(
() => {
if (options.success !== undefined) toastManager.add({ title: options.success, type: "success" })
return true
},
(err: unknown) => {
toastAccountError(err, options.error)
return false
},
)
}
24 changes: 13 additions & 11 deletions apps/web/src/components/account/account-section-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import type { ReactNode } from "react"
import { Card, CardContent, CardHeader } from "@maple/ui/components/ui/card"
import { Panel } from "@maple/ui/components/ui/panel"
import { Skeleton } from "@maple/ui/components/ui/skeleton"

import { SettingsSections } from "@/components/settings/settings-section"

/**
* Placeholder a settings or account section renders while its Clerk resource loads.
* `children` replaces the default single-input body.
* Placeholder a settings or account section renders while its Clerk resource loads, in the
* `SettingsSection` shape. `children` replaces the default single-input body.
*/
export function AccountSectionSkeleton({ children }: { children?: ReactNode }) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
<SettingsSections>
<div className="flex flex-col gap-3">
<div className="space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>{children ?? <Skeleton className="h-9 w-full" />}</CardContent>
</Card>
</div>
</div>
<Panel padded>{children ?? <Skeleton className="h-9 w-full" />}</Panel>
</div>
</SettingsSections>
)
}
196 changes: 94 additions & 102 deletions apps/web/src/components/account/active-sessions-section.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
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 { TONE_TEXT } from "@maple/ui/lib/tone"
import { cn } from "@maple/ui/lib/utils"

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 { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog"
import { SkeletonList } from "@maple/ui/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table"
import { ComputerIcon, MobileIcon } from "@/components/icons"
import { RelativeTime } from "@/components/common/relative-time"
import { useMountEffect } from "@/hooks/use-mount-effect"
import { accountErrorMessage, toastAccountError } from "@/components/account/account-errors"
import { accountErrorMessage, settleClerk } from "@/components/account/account-errors"
import { SettingsSection, SettingsSections } from "@/components/settings/settings-section"
import { useAsyncAction } from "@/hooks/use-mutation-action"

type LoadState =
Expand Down Expand Up @@ -41,120 +42,111 @@ export function ActiveSessionsSection() {

const revokeSession = useReverification((session: SessionWithActivities) => session.revoke())

// `user.getSessions()` is imperative — Clerk exposes no hook for other-device sessions — so
// `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: accountErrorMessage(err, "Failed to load your sessions"),
})
}
function load() {
if (!user) return Promise.resolve()
return user.getSessions().then(
(sessions) => setState({ status: "ready", sessions }),
(err: unknown) =>
setState({
status: "error",
message: accountErrorMessage(err, "Failed to load your sessions"),
}),
)
}

function handleRevoke() {
if (!pendingRevoke) return
const session = pendingRevoke
return withRevoking(async () => {
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")
}
const ok = await settleClerk(revokeSession(session), {
success: "Session signed out",
error: "Failed to sign out that session",
})
if (!ok) return
setPendingRevoke(null)
// `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()
})
}

return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Active Sessions</CardTitle>
<CardDescription>
Devices currently signed in to your account. Sign out any you do not recognise.
</CardDescription>
</CardHeader>
<CardContent>
{!isLoaded || state.status === "loading" ? (
<SkeletonList rows={2} rowClassName="h-9" gap="2" />
) : state.status === "error" ? (
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-destructive">{state.message}</p>
<Button variant="outline" size="sm" onClick={() => void load()}>
Retry
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead>Location</TableHead>
<TableHead>Last active</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{state.sessions.map((session) => {
const isCurrent = session.id === currentSession?.id
const DeviceIcon = session.latestActivity.isMobile
? MobileIcon
: ComputerIcon
<SettingsSections>
<SettingsSection
title="Active sessions"
description="Devices currently signed in to your account. Sign out any you do not recognise."
padded={!isLoaded || state.status !== "ready"}
>
{!isLoaded || state.status === "loading" ? (
<SkeletonList rows={2} rowClassName="h-9" gap="2" />
) : state.status === "error" ? (
<div className="flex items-center justify-between gap-4">
<p className={cn("text-sm", TONE_TEXT.crit)}>{state.message}</p>
<Button variant="outline" size="sm" onClick={() => void load()}>
Retry
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead>Location</TableHead>
<TableHead>Last active</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{state.sessions.map((session) => {
const isCurrent = session.id === currentSession?.id
const DeviceIcon = session.latestActivity.isMobile ? MobileIcon : ComputerIcon

return (
<TableRow key={session.id}>
<TableCell>
<div className="flex items-center gap-2.5">
<DeviceIcon
size={16}
className="shrink-0 text-muted-foreground"
/>
<span className="text-xs font-medium">
{describeDevice(session)}
</span>
{isCurrent && (
<Badge variant="secondary">This device</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{describeLocation(session)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
<RelativeTime value={session.lastActiveAt} />
</TableCell>
<TableCell>
{/* Revoking the current session would sign the user out from
return (
<TableRow key={session.id}>
<TableCell>
<div className="flex items-center gap-2.5">
<DeviceIcon
size={16}
className="shrink-0 text-muted-foreground"
/>
<span className="text-xs font-medium">
{describeDevice(session)}
</span>
{isCurrent && <Badge variant="secondary">This device</Badge>}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{describeLocation(session)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
<RelativeTime value={session.lastActiveAt} />
</TableCell>
<TableCell>
{/* Revoking the current session would sign the user out from
inside the page they are using; use Log out for that. */}
{!isCurrent && (
<Button
variant="ghost"
size="sm"
onClick={() => setPendingRevoke(session)}
>
Sign out
</Button>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{!isCurrent && (
<Button
variant="ghost"
size="sm"
onClick={() => setPendingRevoke(session)}
>
Sign out
</Button>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</SettingsSection>

<ConfirmDialog
open={pendingRevoke !== null}
Expand All @@ -167,6 +159,6 @@ export function ActiveSessionsSection() {
pending={isRevoking}
onConfirm={() => void handleRevoke()}
/>
</div>
</SettingsSections>
)
}
Loading
Loading