Files
chaos-api/web/default/src/features/profile/components/passkey-card.tsx
T
Calcium-Ion 8b2b03d276 feat(web/default): unified UI overhaul — Base UI migration, theme presets, rankings dashboard, and table toolbar refactor (#4633)
* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges

Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage.

Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx.

Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark).

Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi.

* 🎨 fix(web): align segmented controls with theme radius tokens

- Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart
  toolbars with radius-md so the active state follows --radius when users
  change Radius in Theme Settings.
- Use nested radii consistent with TabsList/TabsTrigger: outer
  rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px))
  so the track and active thumb stay concentric at small scales (e.g.
  0.3rem) instead of a squared “focus” block inside a rounded shell.
- Apply the same pattern to pricing SegmentedControl and the segmented
  groups in consumption-distribution-chart, model-charts, and user-charts.

Verified: bun run typecheck (web/default)

*  feat(pricing): enrich model details with uptime sparkline and API documentation

Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with
per-day tooltips, surface it in a status row under quick stats and in the
per-group performance table, and extend mock data so uptime series are stable
and optionally scoped by group.

Introduce an API tab with Shiki-highlighted code samples (cURL, Python,
TypeScript, JavaScript), endpoint-type switching, authentication guidance, a
supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer
vendor, tokenizer, license, and data-retention hints for a provider & data
privacy card on the Overview tab (capabilities/modalities stay with model
identity; rate limits stay with the API tab).

Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi.

* 🏆 feat(rankings): add comprehensive rankings dashboard

Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending.

Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales.

* fix(theme): correct theme preset selection state

- update Base UI Radio selectors to use data-checked/data-unchecked states.
- fix unchecked theme options still showing selected indicators.
- isolate the default theme preview tokens to prevent preset changes from leaking into it.

* fix(setup): correct usage mode radio state

- use Base UI data-checked/data-unchecked states for RadioGroup styling.
- hide radio indicators when options are unchecked to avoid setup page display issues.
- drive usage mode card and icon selection styles from Base UI state.

* fix(auth): submit sign-in and sign-up forms

* 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization

Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`)
and reinstall full component registry with `--overwrite`, including Hugeicons-backed
widgets and newly added registry components.

- Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies,
  preset UI from config drawer); rely on official semantic CSS tokens + light/dark only.
- Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and
  `@theme inline` mappings (plus skeleton token vars for existing shimmer usage).
- Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`;
  clarify scroll-lock override comment.

Application compatibility:
- Keep minimal shims where app code diverged from official APIs (popover collision props,
  combobox legacy `options` callers, Spinner prop typing).
- Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI
  semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.)

Tooling / docs / build:
- Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`.
- Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack.
- Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.)

Verified: `bun run typecheck` passes.

Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere;
not addressed in this change.

* 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters

- Refactor DataTableToolbar to a single wrapping flex row with a
  right-aligned action cluster (Reset / Search / View / Expand) for a
  cleaner Ant Design Pro–style filter bar; remove the dedicated stats row
  and the toolbar `stats` prop.
- Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive-
  data visibility toggle into the page header via CommonLogsHeaderActions
  and SectionPageLayout.Actions so the toolbar stays focused on filters.
- Slim CommonLogsFilterBar props (no stats / preActions eye control).
- Improve CompactDateTimeRangePicker: show minute-precision labels on the
  trigger (seconds omitted; aligns with datetime-local inputs); widen the
  trigger on sm+ breakpoints so the full range is visible without truncation;
  apply the same width in task logs filters.
- Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon.
- Earlier layout tweak: extra top padding on SectionPageLayout scroll
  content so control focus rings are not clipped by overflow.

* feat(web/default): Base UI migration and component foundation

Migrate from Radix UI to Base UI, rewrite core UI primitives,
update dependencies (recharts, date-fns, next-themes), add
shadcn agent skill documentation, and refresh AI element components.

This is the foundational work from the v2/localmain lineage that
was not covered by the individual feature commits above.

---------

Co-authored-by: t0ng7u <dev@aiass.cc>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
2026-05-06 12:39:36 +08:00

342 lines
11 KiB
TypeScript
Vendored

import { useCallback, useMemo, useState } from 'react'
import { AlertTriangle, KeyRound, Loader2, ShieldAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import dayjs from '@/lib/dayjs'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadge } from '@/components/status-badge'
import { usePasskeyManagement } from '@/features/auth/passkey'
import {
SecureVerificationDialog,
useSecureVerification,
type VerificationMethod,
type VerificationMethods,
} from '@/features/auth/secure-verification'
interface PasskeyCardProps {
loading: boolean
}
export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
const { t } = useTranslation()
const [confirmOpen, setConfirmOpen] = useState(false)
const [restrictedMethod, setRestrictedMethod] =
useState<VerificationMethod | null>(null)
const {
status,
loading,
registering,
removing,
supported,
enabled,
lastUsed,
register,
remove,
} = usePasskeyManagement()
const {
open: verificationOpen,
setOpen: setVerificationOpen,
methods: verificationMethods,
state: verificationState,
startVerification,
executeVerification,
cancel: cancelVerification,
setCode,
switchMethod,
fetchVerificationMethods,
} = useSecureVerification({
onSuccess: () => {
setRestrictedMethod(null)
},
})
const dialogMethods = useMemo<VerificationMethods>(() => {
if (!restrictedMethod) return verificationMethods
return {
...verificationMethods,
has2FA: restrictedMethod === '2fa' && verificationMethods.has2FA,
hasPasskey:
restrictedMethod === 'passkey' && verificationMethods.hasPasskey,
}
}, [restrictedMethod, verificationMethods])
const handleRegister = useCallback(async () => {
if (!supported) {
toast.info(t('This device does not support Passkey'))
return
}
const methods = await fetchVerificationMethods()
if (!methods.has2FA) {
// Without 2FA enabled, register directly. The browser-level Passkey prompt
// is itself a strong proof of presence, so no extra verification is needed.
await register()
return
}
setRestrictedMethod('2fa')
await startVerification(register, {
preferredMethod: '2fa',
title: t('Security verification'),
description: t(
'Confirm your identity with Two-factor Authentication before registering a Passkey.'
),
})
}, [fetchVerificationMethods, register, startVerification, supported, t])
const handleRemove = useCallback(async () => {
const methods = await fetchVerificationMethods()
const required: VerificationMethod | null = methods.has2FA
? '2fa'
: methods.hasPasskey
? 'passkey'
: null
if (!required) {
toast.error(
t(
'Please enable Two-factor Authentication or Passkey before proceeding'
)
)
return
}
if (required === 'passkey' && !methods.passkeySupported) {
toast.info(t('This device does not support Passkey'))
return
}
setConfirmOpen(false)
setRestrictedMethod(required)
await startVerification(remove, {
preferredMethod: required,
title: t('Security verification'),
description: t(
'Confirm your identity before removing this Passkey from your account.'
),
})
}, [fetchVerificationMethods, remove, startVerification, t])
const handleVerificationCancel = useCallback(() => {
setRestrictedMethod(null)
cancelVerification()
}, [cancelVerification])
const handleVerificationOpenChange = useCallback(
(next: boolean) => {
if (!next) {
setRestrictedMethod(null)
}
setVerificationOpen(next)
},
[setVerificationOpen]
)
// Adapt the hook's `Promise<unknown>` return into the dialog's
// `void | Promise<void>` signature without losing error propagation
// semantics (errors are surfaced via toast inside the hook).
const handleDialogVerify = useCallback(
async (method: VerificationMethod, code?: string) => {
try {
await executeVerification(method, code)
} catch {
// Errors are already surfaced by useSecureVerification via toast.
}
},
[executeVerification]
)
if (pageLoading || loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' />
</CardHeader>
<CardContent className='p-3 sm:p-5'>
<Skeleton className='h-20 w-full' />
</CardContent>
</Card>
)
}
const formattedLastUsed =
lastUsed && !Number.isNaN(Date.parse(lastUsed))
? dayjs(lastUsed).fromNow()
: t('Not used yet')
const showUnsupportedNotice = !supported && !enabled
return (
<>
<Card className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Passkey Login')}
</CardTitle>
<CardDescription className='text-xs sm:text-sm'>
{t('Use Passkey to sign in without entering your password.')}
</CardDescription>
</CardHeader>
<CardContent className='p-3 sm:p-5'>
<div className='space-y-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
<div className='flex items-start gap-4'>
<div className='bg-muted rounded-md p-2'>
<KeyRound className='h-5 w-5' />
</div>
<div className='space-y-1'>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{t('Passkey Authentication')}</p>
<StatusBadge
label={enabled ? t('Enabled') : t('Disabled')}
variant={enabled ? 'success' : 'neutral'}
showDot
copyable={false}
/>
{status?.backup_eligible !== undefined && (
<StatusBadge
label={
status.backup_eligible
? status.backup_state
? t('Backed up')
: t('Not backed up')
: t('No backup')
}
variant={
status.backup_eligible
? status.backup_state
? 'success'
: 'warning'
: 'neutral'
}
showDot
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{t('Last used:')} {formattedLastUsed}
</p>
</div>
</div>
{!enabled && (
<Button
className='w-full sm:w-auto xl:w-full 2xl:w-auto'
onClick={handleRegister}
disabled={!supported || registering}
>
{registering && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{t('Enable Passkey')}
</Button>
)}
</div>
{enabled && (
<div className='flex flex-col gap-3 border-t pt-6 sm:flex-row xl:flex-col 2xl:flex-row'>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger
render={
<Button
variant='destructive'
className='flex-1'
disabled={removing}
/>
}
>
{removing ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<AlertTriangle className='mr-2 h-4 w-4' />
)}
{t('Remove Passkey')}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('Remove Passkey?')}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
'Removing Passkey will require you to sign in with your password next time. You can re-register anytime.'
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={removing}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={removing}
onClick={(event) => {
event.preventDefault()
handleRemove()
}}
>
{t('Remove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{showUnsupportedNotice && (
<div className='bg-muted/60 text-muted-foreground flex items-start gap-3 rounded-md p-4 text-sm'>
<ShieldAlert className='mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500' />
<div>
<p className='text-foreground font-medium'>
{t('Passkey not supported on this device')}
</p>
<p>
{t(
'Use a compatible browser or device with biometric authentication or a security key to register a Passkey.'
)}
</p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
<SecureVerificationDialog
open={verificationOpen}
onOpenChange={handleVerificationOpenChange}
methods={dialogMethods}
state={verificationState}
onVerify={handleDialogVerify}
onCancel={handleVerificationCancel}
onCodeChange={setCode}
onMethodChange={switchMethod}
/>
</>
)
}