/** * Long-lived camera-stream tokens (#1108). * * Exports two surfaces: * * - ``CameraTokensSection`` — the actual list+create+revoke UI. Designed to * drop into Settings → API Keys (or any other host card) without page * chrome of its own. * * - ``CameraTokensPage`` (default export) — a thin wrapper that puts the * section inside a standalone page layout. Kept around so direct * navigation to ``/camera-tokens`` keeps working for anyone who has * bookmarked it, but the canonical entry point is the Settings tab. * * The plaintext token is shown EXACTLY ONCE at create time inside a copy- * to-clipboard modal. Listings only ever show metadata. */ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Copy, Plus, Trash2, AlertTriangle } from 'lucide-react'; import { api, type LongLivedCameraToken } from '../api/client'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; const DEFAULT_LIFETIME_DAYS = 90; const MAX_LIFETIME_DAYS = 365; function formatDate(iso: string | null): string { if (!iso) return '—'; const d = new Date(iso); return d.toLocaleString(); } function isExpired(iso: string): boolean { return new Date(iso).getTime() < Date.now(); } interface CreateTokenFormProps { onCreated: (token: LongLivedCameraToken) => void; } function CreateTokenForm({ onCreated }: CreateTokenFormProps) { const { t } = useTranslation(); const { showToast } = useToast(); const [name, setName] = useState(''); const [days, setDays] = useState(DEFAULT_LIFETIME_DAYS); const [submitting, setSubmitting] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!name.trim()) return; setSubmitting(true); try { const created = await api.createLongLivedCameraToken({ name: name.trim(), expires_in_days: days, }); onCreated(created); setName(''); setDays(DEFAULT_LIFETIME_DAYS); showToast(t('cameraTokens.toast.created', 'Token created')); } catch (err) { showToast( err instanceof Error ? err.message : t('cameraTokens.toast.createFailed', 'Failed to create token'), 'error', ); } finally { setSubmitting(false); } }; return (

{t('cameraTokens.create.title', 'Create new token')}

setName(e.target.value)} placeholder={t('cameraTokens.create.namePlaceholder', 'e.g. Home Assistant')} className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none" aria-label={t('cameraTokens.create.nameLabel', 'Token name')} /> { const next = Number(e.target.value); // Clamp client-side too — backend will also enforce, but a clear // hard cap in the input matches the policy and avoids confusing // 400s on submit. setDays(Math.min(Math.max(next, 1), MAX_LIFETIME_DAYS)); }} className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none" aria-label={t('cameraTokens.create.daysLabel', 'Days until expiry')} />

{t( 'cameraTokens.create.hint', 'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.', )}

); } interface ConfirmRevokeModalProps { token: LongLivedCameraToken; onConfirm: () => void; onCancel: () => void; } function ConfirmRevokeModal({ token, onConfirm, onCancel }: ConfirmRevokeModalProps) { const { t } = useTranslation(); return (

{t('cameraTokens.confirmRevoke.title', 'Revoke this token?')}

{t( 'cameraTokens.confirmRevoke.body', 'Any device using "{{name}}" will lose access immediately. This cannot be undone.', { name: token.name }, )}

); } interface JustCreatedModalProps { token: LongLivedCameraToken; onClose: () => void; } function JustCreatedModal({ token, onClose }: JustCreatedModalProps) { const { t } = useTranslation(); const { showToast } = useToast(); const plaintext = token.token ?? ''; const handleCopy = async () => { if (!plaintext) return; try { // Modern clipboard API requires a secure context (HTTPS or localhost). // Fall back to a hidden textarea + execCommand so users on plain HTTP // (LAN deployments) can still copy the token. if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(plaintext); } else { const ta = document.createElement('textarea'); ta.value = plaintext; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); try { ta.select(); document.execCommand('copy'); } finally { document.body.removeChild(ta); } } showToast(t('cameraTokens.toast.copied', 'Copied to clipboard')); } catch { showToast(t('cameraTokens.toast.copyFailed', 'Copy failed — select and copy manually'), 'error'); } }; return (

{t('cameraTokens.created.title', 'Token created — copy it now')}

{t( 'cameraTokens.created.warning', 'This is the only time this token will be visible. After you close this dialog you can never view it again.', )}

{plaintext}
); } interface TokenRowProps { token: LongLivedCameraToken; showOwner?: boolean; ownerLabel?: string; onRevoke: (id: number) => Promise; } function TokenRow({ token, showOwner, ownerLabel, onRevoke }: TokenRowProps) { const { t } = useTranslation(); const expired = isExpired(token.expires_at); return ( {token.name} {showOwner && {ownerLabel}} {token.lookup_prefix}… {formatDate(token.created_at)} {formatDate(token.expires_at)} {expired && ( {t('cameraTokens.list.expired', 'Expired')} )} {formatDate(token.last_used_at)} ); } interface TokenTableProps { tokens: LongLivedCameraToken[]; showOwner?: boolean; userIdToName?: Map; onRevoke: (id: number) => Promise; emptyMessage: string; } function TokenTable({ tokens, showOwner, userIdToName, onRevoke, emptyMessage }: TokenTableProps) { const { t } = useTranslation(); if (tokens.length === 0) { return

{emptyMessage}

; } return (
{showOwner && } {tokens.map((tok) => ( ))}
{t('cameraTokens.list.name', 'Name')}{t('cameraTokens.list.owner', 'Owner')}{t('cameraTokens.list.prefix', 'Prefix')} {t('cameraTokens.list.created', 'Created')} {t('cameraTokens.list.expires', 'Expires')} {t('cameraTokens.list.lastUsed', 'Last used')}
); } /** * The actual UI block: create form + my-tokens table + admin all-tokens table. * Renders without any outer page chrome so it can be embedded inside * Settings → API Keys (the canonical home) or any other host card. */ export function CameraTokensSection() { const { t } = useTranslation(); const { user, isAdmin } = useAuth(); const { showToast } = useToast(); const [myTokens, setMyTokens] = useState([]); const [allTokens, setAllTokens] = useState([]); const [userIdToName, setUserIdToName] = useState>(new Map()); const [loading, setLoading] = useState(true); const [justCreated, setJustCreated] = useState(null); const [pendingRevoke, setPendingRevoke] = useState(null); const refresh = async () => { setLoading(true); try { const mine = await api.listMyLongLivedCameraTokens(); setMyTokens(mine); if (isAdmin) { const all = await api.listAllLongLivedCameraTokens(); setAllTokens(all); // Username lookup: best-effort from the users API. If it errors // (e.g. permission missing for some reason), the table still renders // with the numeric user_id as fallback. try { const users = await api.getUsers(); setUserIdToName(new Map(users.map((u: { id: number; username: string }) => [u.id, u.username]))); } catch { setUserIdToName(new Map()); } } } catch (err) { showToast( err instanceof Error ? err.message : t('cameraTokens.toast.loadFailed', 'Failed to load tokens'), 'error', ); } finally { setLoading(false); } }; useEffect(() => { void refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAdmin]); // Open the confirmation modal. The actual delete fires from // ``confirmRevoke`` once the user clicks through. const requestRevoke = async (id: number) => { const target = [...myTokens, ...allTokens].find((tok) => tok.id === id); if (target) { setPendingRevoke(target); } }; const confirmRevoke = async () => { if (!pendingRevoke) return; const id = pendingRevoke.id; setPendingRevoke(null); try { await api.revokeLongLivedCameraToken(id); showToast(t('cameraTokens.toast.revoked', 'Token revoked')); await refresh(); } catch (err) { showToast( err instanceof Error ? err.message : t('cameraTokens.toast.revokeFailed', 'Failed to revoke token'), 'error', ); } }; const otherUsersTokens = useMemo( () => allTokens.filter((t) => t.user_id !== user?.id), [allTokens, user?.id], ); return ( <>

{t( 'cameraTokens.description', 'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.', )}

{ setJustCreated(token); void refresh(); }} />

{t('cameraTokens.list.myTitle', 'My tokens')}

{loading ? (

{t('cameraTokens.loading', 'Loading…')}

) : ( )}
{isAdmin && (

{t('cameraTokens.list.allTitle', 'All users (admin view)')}

)} {justCreated && ( setJustCreated(null)} /> )} {pendingRevoke && ( void confirmRevoke()} onCancel={() => setPendingRevoke(null)} /> )} ); } export default function CameraTokensPage() { const { t } = useTranslation(); return (

{t('cameraTokens.title', 'Camera API Tokens')}

); }