| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597 |
- /**
- * 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, type LongLivedTokenScope } from '../api/client';
- import { useToast } from '../contexts/ToastContext';
- import { useAuth } from '../contexts/AuthContext';
- import { parseUTCDate } from '../utils/date';
- const DEFAULT_LIFETIME_DAYS = 90;
- const MAX_LIFETIME_DAYS = 365;
- function formatDate(iso: string | null): string {
- if (!iso) return '—';
- const d = parseUTCDate(iso);
- return d ? d.toLocaleString() : '—';
- }
- function isExpired(iso: string): boolean {
- const d = parseUTCDate(iso);
- return d ? d.getTime() < Date.now() : false;
- }
- interface CreateTokenFormProps {
- onCreated: (token: LongLivedCameraToken) => void;
- }
- function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
- const { t } = useTranslation();
- const { showToast } = useToast();
- const [name, setName] = useState('');
- const [days, setDays] = useState<number>(DEFAULT_LIFETIME_DAYS);
- const [scope, setScope] = useState<LongLivedTokenScope>('camera_stream');
- 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,
- scope,
- });
- onCreated(created);
- setName('');
- setDays(DEFAULT_LIFETIME_DAYS);
- setScope('camera_stream');
- 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 (
- <form
- onSubmit={handleSubmit}
- className="bg-bambu-dark-secondary rounded-lg p-4 mb-6 border border-bambu-dark-tertiary"
- >
- <h3 className="text-base font-semibold text-white mb-3">
- {t('cameraTokens.create.title', 'Create new token')}
- </h3>
- <div className="grid gap-3 md:grid-cols-[1fr_180px_140px_auto]">
- <input
- type="text"
- maxLength={100}
- required
- value={name}
- onChange={(e) => 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')}
- />
- <select
- value={scope}
- onChange={(e) => setScope(e.target.value as LongLivedTokenScope)}
- 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.scopeLabel', 'Scope')}
- >
- <option value="camera_stream">{t('cameraTokens.scope.camera_stream', 'Camera stream')}</option>
- <option value="camwall">{t('cameraTokens.scope.camwall', 'Cam Wall')}</option>
- <option value="overlay">{t('cameraTokens.scope.overlay', 'Streaming Overlay')}</option>
- </select>
- <input
- type="number"
- min={1}
- max={MAX_LIFETIME_DAYS}
- required
- value={days}
- onChange={(e) => {
- 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')}
- />
- <button
- type="submit"
- disabled={submitting || !name.trim()}
- className="flex items-center gap-2 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <Plus className="w-4 h-4" />
- {t('cameraTokens.create.submit', 'Create')}
- </button>
- </div>
- <p className="text-xs text-bambu-gray mt-2">
- {scope === 'camwall'
- ? t(
- 'cameraTokens.create.hintCamWall',
- 'A Cam Wall token opens /camwall on a screen with no login — it can see every printer\'s name and state, and their camera streams. It cannot see filenames, addresses or access codes.',
- )
- : scope === 'overlay'
- ? t(
- 'cameraTokens.create.hintOverlay',
- 'A Streaming Overlay token opens /overlay/{printerId} on a screen with no login — for OBS or any live stream. It can see one printer\'s camera stream plus its live print status, including the filename shown on screen. It cannot see addresses or access codes.',
- )
- : t(
- 'cameraTokens.create.hintCameraStream',
- 'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
- )}
- </p>
- <p className="text-xs text-bambu-gray mt-1">
- {t(
- 'cameraTokens.create.hint',
- 'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
- )}
- </p>
- </form>
- );
- }
- interface ConfirmRevokeModalProps {
- token: LongLivedCameraToken;
- onConfirm: () => void;
- onCancel: () => void;
- }
- function ConfirmRevokeModal({ token, onConfirm, onCancel }: ConfirmRevokeModalProps) {
- const { t } = useTranslation();
- return (
- <div
- className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
- role="dialog"
- aria-modal="true"
- >
- <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full border border-red-500/40">
- <div className="flex items-start gap-3 mb-4">
- <AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
- <div>
- <h2 className="text-lg font-semibold text-white">
- {t('cameraTokens.confirmRevoke.title', 'Revoke this token?')}
- </h2>
- <p className="text-sm text-bambu-gray mt-1">
- {t(
- 'cameraTokens.confirmRevoke.body',
- 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
- { name: token.name },
- )}
- </p>
- </div>
- </div>
- <div className="flex justify-end gap-2">
- <button
- type="button"
- onClick={onCancel}
- className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
- >
- {t('cameraTokens.confirmRevoke.cancel', 'Cancel')}
- </button>
- <button
- type="button"
- onClick={onConfirm}
- className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
- >
- {t('cameraTokens.confirmRevoke.confirm', 'Revoke')}
- </button>
- </div>
- </div>
- </div>
- );
- }
- interface JustCreatedModalProps {
- token: LongLivedCameraToken;
- onClose: () => void;
- }
- function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
- const { t } = useTranslation();
- const { showToast } = useToast();
- const plaintext = token.token ?? '';
- // For a Cam Wall token the useful artefact isn't the token, it's the URL you
- // paste into the kiosk browser. Build it here so nobody has to assemble it by
- // hand from the docs.
- const camWallUrl =
- token.scope === 'camwall' && plaintext
- ? `${window.location.origin}/camwall?token=${encodeURIComponent(plaintext)}`
- : null;
- // For an overlay token, likewise the artefact is the URL. It targets one
- // printer, so we template printer 1 and tell the user to swap in the number
- // from the printer's URL on the main page (#2613).
- const overlayUrl =
- token.scope === 'overlay' && plaintext
- ? `${window.location.origin}/overlay/1?token=${encodeURIComponent(plaintext)}`
- : null;
- const copyText = async (value: string) => {
- if (!value) 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(value);
- } else {
- const ta = document.createElement('textarea');
- ta.value = value;
- 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 (
- <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
- <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-2xl w-full border border-bambu-green/40">
- <div className="flex items-start gap-3 mb-4">
- <AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
- <div>
- <h2 className="text-lg font-semibold text-white">
- {t('cameraTokens.created.title', 'Token created — copy it now')}
- </h2>
- <p className="text-sm text-bambu-gray mt-1">
- {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.',
- )}
- </p>
- </div>
- </div>
- <div className="flex items-center gap-2 mb-4">
- <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
- {plaintext}
- </code>
- <button
- type="button"
- onClick={() => copyText(plaintext)}
- className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
- >
- <Copy className="w-4 h-4" />
- {t('cameraTokens.created.copy', 'Copy')}
- </button>
- </div>
- {camWallUrl && (
- <div className="mb-4">
- <p className="text-sm font-medium text-white mb-1">
- {t('cameraTokens.created.camWallUrlTitle', 'Cam Wall URL for this display')}
- </p>
- <p className="text-xs text-bambu-gray mb-2">
- {t(
- 'cameraTokens.created.camWallUrlHint',
- 'Open this on the screen. Anyone who can read the URL can watch the wall, so treat it like a key — revoke the token to cut the display off.',
- )}
- </p>
- <div className="flex items-center gap-2">
- <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
- {camWallUrl}
- </code>
- <button
- type="button"
- onClick={() => copyText(camWallUrl)}
- className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
- >
- <Copy className="w-4 h-4" />
- {t('cameraTokens.created.copy', 'Copy')}
- </button>
- </div>
- </div>
- )}
- {overlayUrl && (
- <div className="mb-4">
- <p className="text-sm font-medium text-white mb-1">
- {t('cameraTokens.created.overlayUrlTitle', 'Overlay URL for OBS')}
- </p>
- <p className="text-xs text-bambu-gray mb-2">
- {t(
- 'cameraTokens.created.overlayUrlHint',
- 'Add this as a Browser Source in OBS. Change the /overlay/1 number to your printer\'s number (from its URL on the Printers page). Anyone who can read the URL can watch the stream, so treat it like a key — revoke the token to cut it off.',
- )}
- </p>
- <div className="flex items-center gap-2">
- <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
- {overlayUrl}
- </code>
- <button
- type="button"
- onClick={() => copyText(overlayUrl)}
- className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
- >
- <Copy className="w-4 h-4" />
- {t('cameraTokens.created.copy', 'Copy')}
- </button>
- </div>
- </div>
- )}
- <div className="flex justify-end">
- <button
- type="button"
- onClick={onClose}
- className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
- >
- {t('cameraTokens.created.dismiss', "I've saved it")}
- </button>
- </div>
- </div>
- </div>
- );
- }
- interface TokenRowProps {
- token: LongLivedCameraToken;
- showOwner?: boolean;
- ownerLabel?: string;
- onRevoke: (id: number) => Promise<void>;
- }
- function TokenRow({ token, showOwner, ownerLabel, onRevoke }: TokenRowProps) {
- const { t } = useTranslation();
- const expired = isExpired(token.expires_at);
- return (
- <tr className="border-b border-bambu-dark-tertiary last:border-b-0">
- <td className="py-3 px-3 text-white">{token.name}</td>
- {showOwner && <td className="py-3 px-3 text-bambu-gray">{ownerLabel}</td>}
- <td className="py-3 px-3">
- <span className="rounded bg-bambu-dark-tertiary px-2 py-0.5 text-xs text-bambu-gray">
- {t(`cameraTokens.scope.${token.scope}`, token.scope)}
- </span>
- </td>
- <td className="py-3 px-3 text-bambu-gray font-mono text-xs">{token.lookup_prefix}…</td>
- <td className="py-3 px-3 text-bambu-gray">{formatDate(token.created_at)}</td>
- <td className={`py-3 px-3 ${expired ? 'text-red-700 dark:text-red-400' : 'text-bambu-gray'}`}>
- {formatDate(token.expires_at)}
- {expired && (
- <span className="ml-2 px-2 py-0.5 text-xs bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300 rounded">
- {t('cameraTokens.list.expired', 'Expired')}
- </span>
- )}
- </td>
- <td className="py-3 px-3 text-bambu-gray">{formatDate(token.last_used_at)}</td>
- <td className="py-3 px-3 text-right">
- <button
- type="button"
- onClick={() => onRevoke(token.id)}
- className="inline-flex items-center gap-1 px-2 py-1 text-sm text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300"
- title={t('cameraTokens.list.revoke', 'Revoke')}
- >
- <Trash2 className="w-4 h-4" />
- {t('cameraTokens.list.revoke', 'Revoke')}
- </button>
- </td>
- </tr>
- );
- }
- interface TokenTableProps {
- tokens: LongLivedCameraToken[];
- showOwner?: boolean;
- userIdToName?: Map<number, string>;
- onRevoke: (id: number) => Promise<void>;
- emptyMessage: string;
- }
- function TokenTable({ tokens, showOwner, userIdToName, onRevoke, emptyMessage }: TokenTableProps) {
- const { t } = useTranslation();
- if (tokens.length === 0) {
- return <p className="text-sm text-bambu-gray italic">{emptyMessage}</p>;
- }
- return (
- <div className="overflow-x-auto">
- <table className="w-full text-sm">
- <thead className="text-bambu-gray text-left border-b border-bambu-dark-tertiary">
- <tr>
- <th className="py-2 px-3 font-medium">{t('cameraTokens.list.name', 'Name')}</th>
- {showOwner && <th className="py-2 px-3 font-medium">{t('cameraTokens.list.owner', 'Owner')}</th>}
- <th className="py-2 px-3 font-medium">{t('cameraTokens.list.scope', 'Scope')}</th>
- <th className="py-2 px-3 font-medium">{t('cameraTokens.list.prefix', 'Prefix')}</th>
- <th className="py-2 px-3 font-medium">{t('cameraTokens.list.created', 'Created')}</th>
- <th className="py-2 px-3 font-medium">{t('cameraTokens.list.expires', 'Expires')}</th>
- <th className="py-2 px-3 font-medium">{t('cameraTokens.list.lastUsed', 'Last used')}</th>
- <th className="py-2 px-3" />
- </tr>
- </thead>
- <tbody>
- {tokens.map((tok) => (
- <TokenRow
- key={tok.id}
- token={tok}
- showOwner={showOwner}
- ownerLabel={userIdToName?.get(tok.user_id) ?? `#${tok.user_id}`}
- onRevoke={onRevoke}
- />
- ))}
- </tbody>
- </table>
- </div>
- );
- }
- /**
- * 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<LongLivedCameraToken[]>([]);
- const [allTokens, setAllTokens] = useState<LongLivedCameraToken[]>([]);
- const [userIdToName, setUserIdToName] = useState<Map<number, string>>(new Map());
- const [loading, setLoading] = useState(true);
- const [justCreated, setJustCreated] = useState<LongLivedCameraToken | null>(null);
- const [pendingRevoke, setPendingRevoke] = useState<LongLivedCameraToken | null>(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 (
- <>
- <p className="text-sm text-bambu-gray mb-4">
- {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.',
- )}
- </p>
- <CreateTokenForm
- onCreated={(token) => {
- setJustCreated(token);
- void refresh();
- }}
- />
- <div className="mb-6">
- <h3 className="text-base font-semibold text-white mb-3">
- {t('cameraTokens.list.myTitle', 'My tokens')}
- </h3>
- {loading ? (
- <p className="text-sm text-bambu-gray">{t('cameraTokens.loading', 'Loading…')}</p>
- ) : (
- <TokenTable
- tokens={myTokens}
- onRevoke={requestRevoke}
- emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
- />
- )}
- </div>
- {isAdmin && (
- <div>
- <h3 className="text-base font-semibold text-white mb-3">
- {t('cameraTokens.list.allTitle', 'All users (admin view)')}
- </h3>
- <TokenTable
- tokens={otherUsersTokens}
- showOwner
- userIdToName={userIdToName}
- onRevoke={requestRevoke}
- emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
- />
- </div>
- )}
- {justCreated && (
- <JustCreatedModal token={justCreated} onClose={() => setJustCreated(null)} />
- )}
- {pendingRevoke && (
- <ConfirmRevokeModal
- token={pendingRevoke}
- onConfirm={() => void confirmRevoke()}
- onCancel={() => setPendingRevoke(null)}
- />
- )}
- </>
- );
- }
- export default function CameraTokensPage() {
- const { t } = useTranslation();
- return (
- <div className="p-6 max-w-5xl mx-auto">
- <h1 className="text-2xl font-bold text-white mb-2">
- {t('cameraTokens.title', 'Camera API Tokens')}
- </h1>
- <CameraTokensSection />
- </div>
- );
- }
|