@cantera/token-statuscomponent
Status line for an OAuth grant: connection state, token expiry, and held scopes. Server-safe.
npx shadcn@latest add @cantera/token-statusExpiring soon and expired are the same tone — warning, because both are a refresh away. Only a real failure takes danger.
| Prop | Type | Default | Description |
|---|---|---|---|
| connection | OAuthConnection | — | The grant to summarize: status badge, expiry, error text, scopes. |
| showExpiry | boolean | true | Show a relative expiry while connected. Expiry is recoverable, so it takes the warning tone — never danger — as the deadline approaches. |
| showScopes | boolean | false | Render each held scope as an outline badge. |
| locale | string | string[] | runtime locale | BCP 47 locale(s) for the relative expiry. Left undefined, Intl resolves the runtime locale — nothing is hardcoded to English. |
| expiringSoonMs | number | 300000 | How far ahead counts as "expiring soon", in milliseconds. Five minutes by default. |
| ...props | ComponentProps<'div'> | — | Remaining props are spread onto the wrapping div. |
| Export | Type | Description |
|---|---|---|
| StatusTone | 'success' | 'warning' | 'danger' | 'neutral' | The four semantic tones. One color, one meaning — reuse this type instead of inventing a parallel vocabulary. |
| statusToneClasses | Record<StatusTone, string> | Solid fill plus its ink, e.g. "bg-status-success text-status-success-foreground". Solid, not a low-alpha tint, so it survives direct sunlight. |
| statusInkClasses | Record<StatusTone, string> | The same tones as text color, for ink on the page or on a -surface companion. |
| Attribute | Values | Description |
|---|---|---|
| data-status | 'connected' | 'expired' | 'error' | 'disconnected' | The raw connection status, for styling or querying from the outside. |
| data-tone | 'success' | 'warning' | 'danger' | 'neutral' | The tone actually rendered — connected but expiring soon resolves to warning, not success. |
This is the exact code the CLI installs into your project — you own it from there.
import type * as React from 'react'
import { Badge } from '@/components/ui/badge'
import {
connectionExpiry,
isExpiringSoon,
type OAuthConnection,
type OAuthConnectionStatus,
} from '@/lib/oauth-types'
import { cn } from '@/lib/utils'
/** One color, one meaning — never a generic badge variant. Requires the
* `@cantera/status-tokens` CSS variables. */
type StatusTone = 'success' | 'warning' | 'danger' | 'neutral'
const statusToneClasses = {
success: 'bg-status-success text-status-success-foreground',
warning: 'bg-status-warning text-status-warning-foreground',
danger: 'bg-status-danger text-status-danger-foreground',
neutral: 'bg-status-neutral text-status-neutral-foreground',
} satisfies Record<StatusTone, string>
/** The same tones as ink, for text sitting on the page or on a `-surface`. */
const statusInkClasses = {
success: 'text-status-success',
warning: 'text-status-warning',
danger: 'text-status-danger',
neutral: 'text-status-neutral',
} satisfies Record<StatusTone, string>
const statusTone = {
connected: 'success',
expired: 'warning',
error: 'danger',
disconnected: 'neutral',
} satisfies Record<OAuthConnectionStatus, StatusTone>
const statusLabel = {
connected: 'Connected',
expired: 'Expired',
error: 'Error',
disconnected: 'Not connected',
} satisfies Record<OAuthConnectionStatus, string>
// One formatter per locale, not per render: construction is the expensive part of Intl.
const expiryFormatters = new Map<string, Intl.RelativeTimeFormat>()
function expiryFormatter(locale?: string | string[]): Intl.RelativeTimeFormat {
const key = Array.isArray(locale) ? locale.join(',') : (locale ?? '')
let formatter = expiryFormatters.get(key)
if (!formatter) {
formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always', style: 'narrow' })
expiryFormatters.set(key, formatter)
}
return formatter
}
// Clamped at zero: an expired token reads "expired", never "expires 5 min. ago".
function formatExpiry(expiry: Date, locale?: string | string[]): string {
const deltaMs = expiry.getTime() - Date.now()
if (deltaMs <= 0) return 'expired'
const rtf = expiryFormatter(locale)
const minutes = Math.round(deltaMs / 60_000)
if (minutes < 1) return 'expires now'
if (minutes < 60) return `expires ${rtf.format(minutes, 'minute')}`
const hours = Math.round(minutes / 60)
if (hours < 48) return `expires ${rtf.format(hours, 'hour')}`
return `expires ${rtf.format(Math.round(hours / 24), 'day')}`
}
interface TokenStatusProps extends React.ComponentProps<'div'> {
connection: OAuthConnection
showExpiry?: boolean
showScopes?: boolean
locale?: string | string[]
/** How far ahead counts as "expiring soon". Default five minutes. */
expiringSoonMs?: number
}
function TokenStatus({
connection,
showExpiry = true,
showScopes = false,
locale,
expiringSoonMs,
className,
...props
}: TokenStatusProps) {
const expiry = connectionExpiry(connection)
const expiringSoon =
connection.status === 'connected' && isExpiringSoon(connection, expiringSoonMs)
const tone = expiringSoon ? 'warning' : statusTone[connection.status]
const label = expiringSoon ? 'Expiring soon' : statusLabel[connection.status]
return (
<div
data-slot="token-status"
data-status={connection.status}
data-tone={tone}
className={cn('flex flex-wrap items-center gap-x-2 gap-y-1.5', className)}
{...props}
>
<Badge className={cn('h-6 px-2.5 transition-colors', statusToneClasses[tone])}>{label}</Badge>
{showExpiry && expiry && connection.status === 'connected' && (
<time
dateTime={expiry.toISOString()}
suppressHydrationWarning
className={cn(
'text-xs tabular-nums transition-colors',
expiringSoon ? statusInkClasses.warning : 'text-muted-foreground',
)}
>
{formatExpiry(expiry, locale)}
</time>
)}
{connection.status === 'error' && connection.error && (
<span className={cn('text-xs', statusInkClasses.danger)}>{connection.error}</span>
)}
{showScopes &&
connection.scopes?.map((scope) => (
<Badge key={scope} variant="outline" className="font-mono text-xs">
{scope}
</Badge>
))}
</div>
)
}
export { type StatusTone, statusInkClasses, statusToneClasses, TokenStatus, type TokenStatusProps }