CameraTokensPage.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. /**
  2. * Long-lived camera-stream tokens (#1108).
  3. *
  4. * Exports two surfaces:
  5. *
  6. * - ``CameraTokensSection`` — the actual list+create+revoke UI. Designed to
  7. * drop into Settings → API Keys (or any other host card) without page
  8. * chrome of its own.
  9. *
  10. * - ``CameraTokensPage`` (default export) — a thin wrapper that puts the
  11. * section inside a standalone page layout. Kept around so direct
  12. * navigation to ``/camera-tokens`` keeps working for anyone who has
  13. * bookmarked it, but the canonical entry point is the Settings tab.
  14. *
  15. * The plaintext token is shown EXACTLY ONCE at create time inside a copy-
  16. * to-clipboard modal. Listings only ever show metadata.
  17. */
  18. import { useEffect, useMemo, useState } from 'react';
  19. import { useTranslation } from 'react-i18next';
  20. import { Copy, Plus, Trash2, AlertTriangle } from 'lucide-react';
  21. import { api, type LongLivedCameraToken, type LongLivedTokenScope } from '../api/client';
  22. import { useToast } from '../contexts/ToastContext';
  23. import { useAuth } from '../contexts/AuthContext';
  24. import { parseUTCDate } from '../utils/date';
  25. const DEFAULT_LIFETIME_DAYS = 90;
  26. const MAX_LIFETIME_DAYS = 365;
  27. function formatDate(iso: string | null): string {
  28. if (!iso) return '—';
  29. const d = parseUTCDate(iso);
  30. return d ? d.toLocaleString() : '—';
  31. }
  32. function isExpired(iso: string): boolean {
  33. const d = parseUTCDate(iso);
  34. return d ? d.getTime() < Date.now() : false;
  35. }
  36. interface CreateTokenFormProps {
  37. onCreated: (token: LongLivedCameraToken) => void;
  38. }
  39. function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
  40. const { t } = useTranslation();
  41. const { showToast } = useToast();
  42. const [name, setName] = useState('');
  43. const [days, setDays] = useState<number>(DEFAULT_LIFETIME_DAYS);
  44. const [scope, setScope] = useState<LongLivedTokenScope>('camera_stream');
  45. const [submitting, setSubmitting] = useState(false);
  46. const handleSubmit = async (e: React.FormEvent) => {
  47. e.preventDefault();
  48. if (!name.trim()) return;
  49. setSubmitting(true);
  50. try {
  51. const created = await api.createLongLivedCameraToken({
  52. name: name.trim(),
  53. expires_in_days: days,
  54. scope,
  55. });
  56. onCreated(created);
  57. setName('');
  58. setDays(DEFAULT_LIFETIME_DAYS);
  59. setScope('camera_stream');
  60. showToast(t('cameraTokens.toast.created', 'Token created'));
  61. } catch (err) {
  62. showToast(
  63. err instanceof Error ? err.message : t('cameraTokens.toast.createFailed', 'Failed to create token'),
  64. 'error',
  65. );
  66. } finally {
  67. setSubmitting(false);
  68. }
  69. };
  70. return (
  71. <form
  72. onSubmit={handleSubmit}
  73. className="bg-bambu-dark-secondary rounded-lg p-4 mb-6 border border-bambu-dark-tertiary"
  74. >
  75. <h3 className="text-base font-semibold text-white mb-3">
  76. {t('cameraTokens.create.title', 'Create new token')}
  77. </h3>
  78. <div className="grid gap-3 md:grid-cols-[1fr_180px_140px_auto]">
  79. <input
  80. type="text"
  81. maxLength={100}
  82. required
  83. value={name}
  84. onChange={(e) => setName(e.target.value)}
  85. placeholder={t('cameraTokens.create.namePlaceholder', 'e.g. Home Assistant')}
  86. className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
  87. aria-label={t('cameraTokens.create.nameLabel', 'Token name')}
  88. />
  89. <select
  90. value={scope}
  91. onChange={(e) => setScope(e.target.value as LongLivedTokenScope)}
  92. className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
  93. aria-label={t('cameraTokens.create.scopeLabel', 'Scope')}
  94. >
  95. <option value="camera_stream">{t('cameraTokens.scope.camera_stream', 'Camera stream')}</option>
  96. <option value="camwall">{t('cameraTokens.scope.camwall', 'Cam Wall')}</option>
  97. <option value="overlay">{t('cameraTokens.scope.overlay', 'Streaming Overlay')}</option>
  98. </select>
  99. <input
  100. type="number"
  101. min={1}
  102. max={MAX_LIFETIME_DAYS}
  103. required
  104. value={days}
  105. onChange={(e) => {
  106. const next = Number(e.target.value);
  107. // Clamp client-side too — backend will also enforce, but a clear
  108. // hard cap in the input matches the policy and avoids confusing
  109. // 400s on submit.
  110. setDays(Math.min(Math.max(next, 1), MAX_LIFETIME_DAYS));
  111. }}
  112. className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
  113. aria-label={t('cameraTokens.create.daysLabel', 'Days until expiry')}
  114. />
  115. <button
  116. type="submit"
  117. disabled={submitting || !name.trim()}
  118. 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"
  119. >
  120. <Plus className="w-4 h-4" />
  121. {t('cameraTokens.create.submit', 'Create')}
  122. </button>
  123. </div>
  124. <p className="text-xs text-bambu-gray mt-2">
  125. {scope === 'camwall'
  126. ? t(
  127. 'cameraTokens.create.hintCamWall',
  128. '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.',
  129. )
  130. : scope === 'overlay'
  131. ? t(
  132. 'cameraTokens.create.hintOverlay',
  133. '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.',
  134. )
  135. : t(
  136. 'cameraTokens.create.hintCameraStream',
  137. 'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
  138. )}
  139. </p>
  140. <p className="text-xs text-bambu-gray mt-1">
  141. {t(
  142. 'cameraTokens.create.hint',
  143. 'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
  144. )}
  145. </p>
  146. </form>
  147. );
  148. }
  149. interface ConfirmRevokeModalProps {
  150. token: LongLivedCameraToken;
  151. onConfirm: () => void;
  152. onCancel: () => void;
  153. }
  154. function ConfirmRevokeModal({ token, onConfirm, onCancel }: ConfirmRevokeModalProps) {
  155. const { t } = useTranslation();
  156. return (
  157. <div
  158. className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
  159. role="dialog"
  160. aria-modal="true"
  161. >
  162. <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full border border-red-500/40">
  163. <div className="flex items-start gap-3 mb-4">
  164. <AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
  165. <div>
  166. <h2 className="text-lg font-semibold text-white">
  167. {t('cameraTokens.confirmRevoke.title', 'Revoke this token?')}
  168. </h2>
  169. <p className="text-sm text-bambu-gray mt-1">
  170. {t(
  171. 'cameraTokens.confirmRevoke.body',
  172. 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
  173. { name: token.name },
  174. )}
  175. </p>
  176. </div>
  177. </div>
  178. <div className="flex justify-end gap-2">
  179. <button
  180. type="button"
  181. onClick={onCancel}
  182. className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
  183. >
  184. {t('cameraTokens.confirmRevoke.cancel', 'Cancel')}
  185. </button>
  186. <button
  187. type="button"
  188. onClick={onConfirm}
  189. className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
  190. >
  191. {t('cameraTokens.confirmRevoke.confirm', 'Revoke')}
  192. </button>
  193. </div>
  194. </div>
  195. </div>
  196. );
  197. }
  198. interface JustCreatedModalProps {
  199. token: LongLivedCameraToken;
  200. onClose: () => void;
  201. }
  202. function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
  203. const { t } = useTranslation();
  204. const { showToast } = useToast();
  205. const plaintext = token.token ?? '';
  206. // For a Cam Wall token the useful artefact isn't the token, it's the URL you
  207. // paste into the kiosk browser. Build it here so nobody has to assemble it by
  208. // hand from the docs.
  209. const camWallUrl =
  210. token.scope === 'camwall' && plaintext
  211. ? `${window.location.origin}/camwall?token=${encodeURIComponent(plaintext)}`
  212. : null;
  213. // For an overlay token, likewise the artefact is the URL. It targets one
  214. // printer, so we template printer 1 and tell the user to swap in the number
  215. // from the printer's URL on the main page (#2613).
  216. const overlayUrl =
  217. token.scope === 'overlay' && plaintext
  218. ? `${window.location.origin}/overlay/1?token=${encodeURIComponent(plaintext)}`
  219. : null;
  220. const copyText = async (value: string) => {
  221. if (!value) return;
  222. try {
  223. // Modern clipboard API requires a secure context (HTTPS or localhost).
  224. // Fall back to a hidden textarea + execCommand so users on plain HTTP
  225. // (LAN deployments) can still copy the token.
  226. if (navigator.clipboard && window.isSecureContext) {
  227. await navigator.clipboard.writeText(value);
  228. } else {
  229. const ta = document.createElement('textarea');
  230. ta.value = value;
  231. ta.style.position = 'fixed';
  232. ta.style.opacity = '0';
  233. document.body.appendChild(ta);
  234. try {
  235. ta.select();
  236. document.execCommand('copy');
  237. } finally {
  238. document.body.removeChild(ta);
  239. }
  240. }
  241. showToast(t('cameraTokens.toast.copied', 'Copied to clipboard'));
  242. } catch {
  243. showToast(t('cameraTokens.toast.copyFailed', 'Copy failed — select and copy manually'), 'error');
  244. }
  245. };
  246. return (
  247. <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
  248. <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-2xl w-full border border-bambu-green/40">
  249. <div className="flex items-start gap-3 mb-4">
  250. <AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
  251. <div>
  252. <h2 className="text-lg font-semibold text-white">
  253. {t('cameraTokens.created.title', 'Token created — copy it now')}
  254. </h2>
  255. <p className="text-sm text-bambu-gray mt-1">
  256. {t(
  257. 'cameraTokens.created.warning',
  258. 'This is the only time this token will be visible. After you close this dialog you can never view it again.',
  259. )}
  260. </p>
  261. </div>
  262. </div>
  263. <div className="flex items-center gap-2 mb-4">
  264. <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
  265. {plaintext}
  266. </code>
  267. <button
  268. type="button"
  269. onClick={() => copyText(plaintext)}
  270. className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
  271. >
  272. <Copy className="w-4 h-4" />
  273. {t('cameraTokens.created.copy', 'Copy')}
  274. </button>
  275. </div>
  276. {camWallUrl && (
  277. <div className="mb-4">
  278. <p className="text-sm font-medium text-white mb-1">
  279. {t('cameraTokens.created.camWallUrlTitle', 'Cam Wall URL for this display')}
  280. </p>
  281. <p className="text-xs text-bambu-gray mb-2">
  282. {t(
  283. 'cameraTokens.created.camWallUrlHint',
  284. '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.',
  285. )}
  286. </p>
  287. <div className="flex items-center gap-2">
  288. <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
  289. {camWallUrl}
  290. </code>
  291. <button
  292. type="button"
  293. onClick={() => copyText(camWallUrl)}
  294. className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
  295. >
  296. <Copy className="w-4 h-4" />
  297. {t('cameraTokens.created.copy', 'Copy')}
  298. </button>
  299. </div>
  300. </div>
  301. )}
  302. {overlayUrl && (
  303. <div className="mb-4">
  304. <p className="text-sm font-medium text-white mb-1">
  305. {t('cameraTokens.created.overlayUrlTitle', 'Overlay URL for OBS')}
  306. </p>
  307. <p className="text-xs text-bambu-gray mb-2">
  308. {t(
  309. 'cameraTokens.created.overlayUrlHint',
  310. '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.',
  311. )}
  312. </p>
  313. <div className="flex items-center gap-2">
  314. <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
  315. {overlayUrl}
  316. </code>
  317. <button
  318. type="button"
  319. onClick={() => copyText(overlayUrl)}
  320. className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
  321. >
  322. <Copy className="w-4 h-4" />
  323. {t('cameraTokens.created.copy', 'Copy')}
  324. </button>
  325. </div>
  326. </div>
  327. )}
  328. <div className="flex justify-end">
  329. <button
  330. type="button"
  331. onClick={onClose}
  332. className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
  333. >
  334. {t('cameraTokens.created.dismiss', "I've saved it")}
  335. </button>
  336. </div>
  337. </div>
  338. </div>
  339. );
  340. }
  341. interface TokenRowProps {
  342. token: LongLivedCameraToken;
  343. showOwner?: boolean;
  344. ownerLabel?: string;
  345. onRevoke: (id: number) => Promise<void>;
  346. }
  347. function TokenRow({ token, showOwner, ownerLabel, onRevoke }: TokenRowProps) {
  348. const { t } = useTranslation();
  349. const expired = isExpired(token.expires_at);
  350. return (
  351. <tr className="border-b border-bambu-dark-tertiary last:border-b-0">
  352. <td className="py-3 px-3 text-white">{token.name}</td>
  353. {showOwner && <td className="py-3 px-3 text-bambu-gray">{ownerLabel}</td>}
  354. <td className="py-3 px-3">
  355. <span className="rounded bg-bambu-dark-tertiary px-2 py-0.5 text-xs text-bambu-gray">
  356. {t(`cameraTokens.scope.${token.scope}`, token.scope)}
  357. </span>
  358. </td>
  359. <td className="py-3 px-3 text-bambu-gray font-mono text-xs">{token.lookup_prefix}…</td>
  360. <td className="py-3 px-3 text-bambu-gray">{formatDate(token.created_at)}</td>
  361. <td className={`py-3 px-3 ${expired ? 'text-red-700 dark:text-red-400' : 'text-bambu-gray'}`}>
  362. {formatDate(token.expires_at)}
  363. {expired && (
  364. <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">
  365. {t('cameraTokens.list.expired', 'Expired')}
  366. </span>
  367. )}
  368. </td>
  369. <td className="py-3 px-3 text-bambu-gray">{formatDate(token.last_used_at)}</td>
  370. <td className="py-3 px-3 text-right">
  371. <button
  372. type="button"
  373. onClick={() => onRevoke(token.id)}
  374. 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"
  375. title={t('cameraTokens.list.revoke', 'Revoke')}
  376. >
  377. <Trash2 className="w-4 h-4" />
  378. {t('cameraTokens.list.revoke', 'Revoke')}
  379. </button>
  380. </td>
  381. </tr>
  382. );
  383. }
  384. interface TokenTableProps {
  385. tokens: LongLivedCameraToken[];
  386. showOwner?: boolean;
  387. userIdToName?: Map<number, string>;
  388. onRevoke: (id: number) => Promise<void>;
  389. emptyMessage: string;
  390. }
  391. function TokenTable({ tokens, showOwner, userIdToName, onRevoke, emptyMessage }: TokenTableProps) {
  392. const { t } = useTranslation();
  393. if (tokens.length === 0) {
  394. return <p className="text-sm text-bambu-gray italic">{emptyMessage}</p>;
  395. }
  396. return (
  397. <div className="overflow-x-auto">
  398. <table className="w-full text-sm">
  399. <thead className="text-bambu-gray text-left border-b border-bambu-dark-tertiary">
  400. <tr>
  401. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.name', 'Name')}</th>
  402. {showOwner && <th className="py-2 px-3 font-medium">{t('cameraTokens.list.owner', 'Owner')}</th>}
  403. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.scope', 'Scope')}</th>
  404. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.prefix', 'Prefix')}</th>
  405. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.created', 'Created')}</th>
  406. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.expires', 'Expires')}</th>
  407. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.lastUsed', 'Last used')}</th>
  408. <th className="py-2 px-3" />
  409. </tr>
  410. </thead>
  411. <tbody>
  412. {tokens.map((tok) => (
  413. <TokenRow
  414. key={tok.id}
  415. token={tok}
  416. showOwner={showOwner}
  417. ownerLabel={userIdToName?.get(tok.user_id) ?? `#${tok.user_id}`}
  418. onRevoke={onRevoke}
  419. />
  420. ))}
  421. </tbody>
  422. </table>
  423. </div>
  424. );
  425. }
  426. /**
  427. * The actual UI block: create form + my-tokens table + admin all-tokens table.
  428. * Renders without any outer page chrome so it can be embedded inside
  429. * Settings → API Keys (the canonical home) or any other host card.
  430. */
  431. export function CameraTokensSection() {
  432. const { t } = useTranslation();
  433. const { user, isAdmin } = useAuth();
  434. const { showToast } = useToast();
  435. const [myTokens, setMyTokens] = useState<LongLivedCameraToken[]>([]);
  436. const [allTokens, setAllTokens] = useState<LongLivedCameraToken[]>([]);
  437. const [userIdToName, setUserIdToName] = useState<Map<number, string>>(new Map());
  438. const [loading, setLoading] = useState(true);
  439. const [justCreated, setJustCreated] = useState<LongLivedCameraToken | null>(null);
  440. const [pendingRevoke, setPendingRevoke] = useState<LongLivedCameraToken | null>(null);
  441. const refresh = async () => {
  442. setLoading(true);
  443. try {
  444. const mine = await api.listMyLongLivedCameraTokens();
  445. setMyTokens(mine);
  446. if (isAdmin) {
  447. const all = await api.listAllLongLivedCameraTokens();
  448. setAllTokens(all);
  449. // Username lookup: best-effort from the users API. If it errors
  450. // (e.g. permission missing for some reason), the table still renders
  451. // with the numeric user_id as fallback.
  452. try {
  453. const users = await api.getUsers();
  454. setUserIdToName(new Map(users.map((u: { id: number; username: string }) => [u.id, u.username])));
  455. } catch {
  456. setUserIdToName(new Map());
  457. }
  458. }
  459. } catch (err) {
  460. showToast(
  461. err instanceof Error ? err.message : t('cameraTokens.toast.loadFailed', 'Failed to load tokens'),
  462. 'error',
  463. );
  464. } finally {
  465. setLoading(false);
  466. }
  467. };
  468. useEffect(() => {
  469. void refresh();
  470. // eslint-disable-next-line react-hooks/exhaustive-deps
  471. }, [isAdmin]);
  472. // Open the confirmation modal. The actual delete fires from
  473. // ``confirmRevoke`` once the user clicks through.
  474. const requestRevoke = async (id: number) => {
  475. const target = [...myTokens, ...allTokens].find((tok) => tok.id === id);
  476. if (target) {
  477. setPendingRevoke(target);
  478. }
  479. };
  480. const confirmRevoke = async () => {
  481. if (!pendingRevoke) return;
  482. const id = pendingRevoke.id;
  483. setPendingRevoke(null);
  484. try {
  485. await api.revokeLongLivedCameraToken(id);
  486. showToast(t('cameraTokens.toast.revoked', 'Token revoked'));
  487. await refresh();
  488. } catch (err) {
  489. showToast(
  490. err instanceof Error ? err.message : t('cameraTokens.toast.revokeFailed', 'Failed to revoke token'),
  491. 'error',
  492. );
  493. }
  494. };
  495. const otherUsersTokens = useMemo(
  496. () => allTokens.filter((t) => t.user_id !== user?.id),
  497. [allTokens, user?.id],
  498. );
  499. return (
  500. <>
  501. <p className="text-sm text-bambu-gray mb-4">
  502. {t(
  503. 'cameraTokens.description',
  504. '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.',
  505. )}
  506. </p>
  507. <CreateTokenForm
  508. onCreated={(token) => {
  509. setJustCreated(token);
  510. void refresh();
  511. }}
  512. />
  513. <div className="mb-6">
  514. <h3 className="text-base font-semibold text-white mb-3">
  515. {t('cameraTokens.list.myTitle', 'My tokens')}
  516. </h3>
  517. {loading ? (
  518. <p className="text-sm text-bambu-gray">{t('cameraTokens.loading', 'Loading…')}</p>
  519. ) : (
  520. <TokenTable
  521. tokens={myTokens}
  522. onRevoke={requestRevoke}
  523. emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
  524. />
  525. )}
  526. </div>
  527. {isAdmin && (
  528. <div>
  529. <h3 className="text-base font-semibold text-white mb-3">
  530. {t('cameraTokens.list.allTitle', 'All users (admin view)')}
  531. </h3>
  532. <TokenTable
  533. tokens={otherUsersTokens}
  534. showOwner
  535. userIdToName={userIdToName}
  536. onRevoke={requestRevoke}
  537. emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
  538. />
  539. </div>
  540. )}
  541. {justCreated && (
  542. <JustCreatedModal token={justCreated} onClose={() => setJustCreated(null)} />
  543. )}
  544. {pendingRevoke && (
  545. <ConfirmRevokeModal
  546. token={pendingRevoke}
  547. onConfirm={() => void confirmRevoke()}
  548. onCancel={() => setPendingRevoke(null)}
  549. />
  550. )}
  551. </>
  552. );
  553. }
  554. export default function CameraTokensPage() {
  555. const { t } = useTranslation();
  556. return (
  557. <div className="p-6 max-w-5xl mx-auto">
  558. <h1 className="text-2xl font-bold text-white mb-2">
  559. {t('cameraTokens.title', 'Camera API Tokens')}
  560. </h1>
  561. <CameraTokensSection />
  562. </div>
  563. );
  564. }