CameraTokensPage.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  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 } 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 [submitting, setSubmitting] = useState(false);
  45. const handleSubmit = async (e: React.FormEvent) => {
  46. e.preventDefault();
  47. if (!name.trim()) return;
  48. setSubmitting(true);
  49. try {
  50. const created = await api.createLongLivedCameraToken({
  51. name: name.trim(),
  52. expires_in_days: days,
  53. });
  54. onCreated(created);
  55. setName('');
  56. setDays(DEFAULT_LIFETIME_DAYS);
  57. showToast(t('cameraTokens.toast.created', 'Token created'));
  58. } catch (err) {
  59. showToast(
  60. err instanceof Error ? err.message : t('cameraTokens.toast.createFailed', 'Failed to create token'),
  61. 'error',
  62. );
  63. } finally {
  64. setSubmitting(false);
  65. }
  66. };
  67. return (
  68. <form
  69. onSubmit={handleSubmit}
  70. className="bg-bambu-dark-secondary rounded-lg p-4 mb-6 border border-bambu-dark-tertiary"
  71. >
  72. <h3 className="text-base font-semibold text-white mb-3">
  73. {t('cameraTokens.create.title', 'Create new token')}
  74. </h3>
  75. <div className="grid gap-3 md:grid-cols-[1fr_140px_auto]">
  76. <input
  77. type="text"
  78. maxLength={100}
  79. required
  80. value={name}
  81. onChange={(e) => setName(e.target.value)}
  82. placeholder={t('cameraTokens.create.namePlaceholder', 'e.g. Home Assistant')}
  83. className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
  84. aria-label={t('cameraTokens.create.nameLabel', 'Token name')}
  85. />
  86. <input
  87. type="number"
  88. min={1}
  89. max={MAX_LIFETIME_DAYS}
  90. required
  91. value={days}
  92. onChange={(e) => {
  93. const next = Number(e.target.value);
  94. // Clamp client-side too — backend will also enforce, but a clear
  95. // hard cap in the input matches the policy and avoids confusing
  96. // 400s on submit.
  97. setDays(Math.min(Math.max(next, 1), MAX_LIFETIME_DAYS));
  98. }}
  99. className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
  100. aria-label={t('cameraTokens.create.daysLabel', 'Days until expiry')}
  101. />
  102. <button
  103. type="submit"
  104. disabled={submitting || !name.trim()}
  105. 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"
  106. >
  107. <Plus className="w-4 h-4" />
  108. {t('cameraTokens.create.submit', 'Create')}
  109. </button>
  110. </div>
  111. <p className="text-xs text-bambu-gray mt-2">
  112. {t(
  113. 'cameraTokens.create.hint',
  114. 'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
  115. )}
  116. </p>
  117. </form>
  118. );
  119. }
  120. interface ConfirmRevokeModalProps {
  121. token: LongLivedCameraToken;
  122. onConfirm: () => void;
  123. onCancel: () => void;
  124. }
  125. function ConfirmRevokeModal({ token, onConfirm, onCancel }: ConfirmRevokeModalProps) {
  126. const { t } = useTranslation();
  127. return (
  128. <div
  129. className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
  130. role="dialog"
  131. aria-modal="true"
  132. >
  133. <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full border border-red-500/40">
  134. <div className="flex items-start gap-3 mb-4">
  135. <AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
  136. <div>
  137. <h2 className="text-lg font-semibold text-white">
  138. {t('cameraTokens.confirmRevoke.title', 'Revoke this token?')}
  139. </h2>
  140. <p className="text-sm text-bambu-gray mt-1">
  141. {t(
  142. 'cameraTokens.confirmRevoke.body',
  143. 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
  144. { name: token.name },
  145. )}
  146. </p>
  147. </div>
  148. </div>
  149. <div className="flex justify-end gap-2">
  150. <button
  151. type="button"
  152. onClick={onCancel}
  153. className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
  154. >
  155. {t('cameraTokens.confirmRevoke.cancel', 'Cancel')}
  156. </button>
  157. <button
  158. type="button"
  159. onClick={onConfirm}
  160. className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
  161. >
  162. {t('cameraTokens.confirmRevoke.confirm', 'Revoke')}
  163. </button>
  164. </div>
  165. </div>
  166. </div>
  167. );
  168. }
  169. interface JustCreatedModalProps {
  170. token: LongLivedCameraToken;
  171. onClose: () => void;
  172. }
  173. function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
  174. const { t } = useTranslation();
  175. const { showToast } = useToast();
  176. const plaintext = token.token ?? '';
  177. const handleCopy = async () => {
  178. if (!plaintext) return;
  179. try {
  180. // Modern clipboard API requires a secure context (HTTPS or localhost).
  181. // Fall back to a hidden textarea + execCommand so users on plain HTTP
  182. // (LAN deployments) can still copy the token.
  183. if (navigator.clipboard && window.isSecureContext) {
  184. await navigator.clipboard.writeText(plaintext);
  185. } else {
  186. const ta = document.createElement('textarea');
  187. ta.value = plaintext;
  188. ta.style.position = 'fixed';
  189. ta.style.opacity = '0';
  190. document.body.appendChild(ta);
  191. try {
  192. ta.select();
  193. document.execCommand('copy');
  194. } finally {
  195. document.body.removeChild(ta);
  196. }
  197. }
  198. showToast(t('cameraTokens.toast.copied', 'Copied to clipboard'));
  199. } catch {
  200. showToast(t('cameraTokens.toast.copyFailed', 'Copy failed — select and copy manually'), 'error');
  201. }
  202. };
  203. return (
  204. <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
  205. <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-2xl w-full border border-bambu-green/40">
  206. <div className="flex items-start gap-3 mb-4">
  207. <AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
  208. <div>
  209. <h2 className="text-lg font-semibold text-white">
  210. {t('cameraTokens.created.title', 'Token created — copy it now')}
  211. </h2>
  212. <p className="text-sm text-bambu-gray mt-1">
  213. {t(
  214. 'cameraTokens.created.warning',
  215. 'This is the only time this token will be visible. After you close this dialog you can never view it again.',
  216. )}
  217. </p>
  218. </div>
  219. </div>
  220. <div className="flex items-center gap-2 mb-4">
  221. <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
  222. {plaintext}
  223. </code>
  224. <button
  225. type="button"
  226. onClick={handleCopy}
  227. className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
  228. >
  229. <Copy className="w-4 h-4" />
  230. {t('cameraTokens.created.copy', 'Copy')}
  231. </button>
  232. </div>
  233. <div className="flex justify-end">
  234. <button
  235. type="button"
  236. onClick={onClose}
  237. className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
  238. >
  239. {t('cameraTokens.created.dismiss', "I've saved it")}
  240. </button>
  241. </div>
  242. </div>
  243. </div>
  244. );
  245. }
  246. interface TokenRowProps {
  247. token: LongLivedCameraToken;
  248. showOwner?: boolean;
  249. ownerLabel?: string;
  250. onRevoke: (id: number) => Promise<void>;
  251. }
  252. function TokenRow({ token, showOwner, ownerLabel, onRevoke }: TokenRowProps) {
  253. const { t } = useTranslation();
  254. const expired = isExpired(token.expires_at);
  255. return (
  256. <tr className="border-b border-bambu-dark-tertiary last:border-b-0">
  257. <td className="py-3 px-3 text-white">{token.name}</td>
  258. {showOwner && <td className="py-3 px-3 text-bambu-gray">{ownerLabel}</td>}
  259. <td className="py-3 px-3 text-bambu-gray font-mono text-xs">{token.lookup_prefix}…</td>
  260. <td className="py-3 px-3 text-bambu-gray">{formatDate(token.created_at)}</td>
  261. <td className={`py-3 px-3 ${expired ? 'text-red-700 dark:text-red-400' : 'text-bambu-gray'}`}>
  262. {formatDate(token.expires_at)}
  263. {expired && (
  264. <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">
  265. {t('cameraTokens.list.expired', 'Expired')}
  266. </span>
  267. )}
  268. </td>
  269. <td className="py-3 px-3 text-bambu-gray">{formatDate(token.last_used_at)}</td>
  270. <td className="py-3 px-3 text-right">
  271. <button
  272. type="button"
  273. onClick={() => onRevoke(token.id)}
  274. 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"
  275. title={t('cameraTokens.list.revoke', 'Revoke')}
  276. >
  277. <Trash2 className="w-4 h-4" />
  278. {t('cameraTokens.list.revoke', 'Revoke')}
  279. </button>
  280. </td>
  281. </tr>
  282. );
  283. }
  284. interface TokenTableProps {
  285. tokens: LongLivedCameraToken[];
  286. showOwner?: boolean;
  287. userIdToName?: Map<number, string>;
  288. onRevoke: (id: number) => Promise<void>;
  289. emptyMessage: string;
  290. }
  291. function TokenTable({ tokens, showOwner, userIdToName, onRevoke, emptyMessage }: TokenTableProps) {
  292. const { t } = useTranslation();
  293. if (tokens.length === 0) {
  294. return <p className="text-sm text-bambu-gray italic">{emptyMessage}</p>;
  295. }
  296. return (
  297. <div className="overflow-x-auto">
  298. <table className="w-full text-sm">
  299. <thead className="text-bambu-gray text-left border-b border-bambu-dark-tertiary">
  300. <tr>
  301. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.name', 'Name')}</th>
  302. {showOwner && <th className="py-2 px-3 font-medium">{t('cameraTokens.list.owner', 'Owner')}</th>}
  303. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.prefix', 'Prefix')}</th>
  304. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.created', 'Created')}</th>
  305. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.expires', 'Expires')}</th>
  306. <th className="py-2 px-3 font-medium">{t('cameraTokens.list.lastUsed', 'Last used')}</th>
  307. <th className="py-2 px-3" />
  308. </tr>
  309. </thead>
  310. <tbody>
  311. {tokens.map((tok) => (
  312. <TokenRow
  313. key={tok.id}
  314. token={tok}
  315. showOwner={showOwner}
  316. ownerLabel={userIdToName?.get(tok.user_id) ?? `#${tok.user_id}`}
  317. onRevoke={onRevoke}
  318. />
  319. ))}
  320. </tbody>
  321. </table>
  322. </div>
  323. );
  324. }
  325. /**
  326. * The actual UI block: create form + my-tokens table + admin all-tokens table.
  327. * Renders without any outer page chrome so it can be embedded inside
  328. * Settings → API Keys (the canonical home) or any other host card.
  329. */
  330. export function CameraTokensSection() {
  331. const { t } = useTranslation();
  332. const { user, isAdmin } = useAuth();
  333. const { showToast } = useToast();
  334. const [myTokens, setMyTokens] = useState<LongLivedCameraToken[]>([]);
  335. const [allTokens, setAllTokens] = useState<LongLivedCameraToken[]>([]);
  336. const [userIdToName, setUserIdToName] = useState<Map<number, string>>(new Map());
  337. const [loading, setLoading] = useState(true);
  338. const [justCreated, setJustCreated] = useState<LongLivedCameraToken | null>(null);
  339. const [pendingRevoke, setPendingRevoke] = useState<LongLivedCameraToken | null>(null);
  340. const refresh = async () => {
  341. setLoading(true);
  342. try {
  343. const mine = await api.listMyLongLivedCameraTokens();
  344. setMyTokens(mine);
  345. if (isAdmin) {
  346. const all = await api.listAllLongLivedCameraTokens();
  347. setAllTokens(all);
  348. // Username lookup: best-effort from the users API. If it errors
  349. // (e.g. permission missing for some reason), the table still renders
  350. // with the numeric user_id as fallback.
  351. try {
  352. const users = await api.getUsers();
  353. setUserIdToName(new Map(users.map((u: { id: number; username: string }) => [u.id, u.username])));
  354. } catch {
  355. setUserIdToName(new Map());
  356. }
  357. }
  358. } catch (err) {
  359. showToast(
  360. err instanceof Error ? err.message : t('cameraTokens.toast.loadFailed', 'Failed to load tokens'),
  361. 'error',
  362. );
  363. } finally {
  364. setLoading(false);
  365. }
  366. };
  367. useEffect(() => {
  368. void refresh();
  369. // eslint-disable-next-line react-hooks/exhaustive-deps
  370. }, [isAdmin]);
  371. // Open the confirmation modal. The actual delete fires from
  372. // ``confirmRevoke`` once the user clicks through.
  373. const requestRevoke = async (id: number) => {
  374. const target = [...myTokens, ...allTokens].find((tok) => tok.id === id);
  375. if (target) {
  376. setPendingRevoke(target);
  377. }
  378. };
  379. const confirmRevoke = async () => {
  380. if (!pendingRevoke) return;
  381. const id = pendingRevoke.id;
  382. setPendingRevoke(null);
  383. try {
  384. await api.revokeLongLivedCameraToken(id);
  385. showToast(t('cameraTokens.toast.revoked', 'Token revoked'));
  386. await refresh();
  387. } catch (err) {
  388. showToast(
  389. err instanceof Error ? err.message : t('cameraTokens.toast.revokeFailed', 'Failed to revoke token'),
  390. 'error',
  391. );
  392. }
  393. };
  394. const otherUsersTokens = useMemo(
  395. () => allTokens.filter((t) => t.user_id !== user?.id),
  396. [allTokens, user?.id],
  397. );
  398. return (
  399. <>
  400. <p className="text-sm text-bambu-gray mb-4">
  401. {t(
  402. 'cameraTokens.description',
  403. '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.',
  404. )}
  405. </p>
  406. <CreateTokenForm
  407. onCreated={(token) => {
  408. setJustCreated(token);
  409. void refresh();
  410. }}
  411. />
  412. <div className="mb-6">
  413. <h3 className="text-base font-semibold text-white mb-3">
  414. {t('cameraTokens.list.myTitle', 'My tokens')}
  415. </h3>
  416. {loading ? (
  417. <p className="text-sm text-bambu-gray">{t('cameraTokens.loading', 'Loading…')}</p>
  418. ) : (
  419. <TokenTable
  420. tokens={myTokens}
  421. onRevoke={requestRevoke}
  422. emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
  423. />
  424. )}
  425. </div>
  426. {isAdmin && (
  427. <div>
  428. <h3 className="text-base font-semibold text-white mb-3">
  429. {t('cameraTokens.list.allTitle', 'All users (admin view)')}
  430. </h3>
  431. <TokenTable
  432. tokens={otherUsersTokens}
  433. showOwner
  434. userIdToName={userIdToName}
  435. onRevoke={requestRevoke}
  436. emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
  437. />
  438. </div>
  439. )}
  440. {justCreated && (
  441. <JustCreatedModal token={justCreated} onClose={() => setJustCreated(null)} />
  442. )}
  443. {pendingRevoke && (
  444. <ConfirmRevokeModal
  445. token={pendingRevoke}
  446. onConfirm={() => void confirmRevoke()}
  447. onCancel={() => setPendingRevoke(null)}
  448. />
  449. )}
  450. </>
  451. );
  452. }
  453. export default function CameraTokensPage() {
  454. const { t } = useTranslation();
  455. return (
  456. <div className="p-6 max-w-5xl mx-auto">
  457. <h1 className="text-2xl font-bold text-white mb-2">
  458. {t('cameraTokens.title', 'Camera API Tokens')}
  459. </h1>
  460. <CameraTokensSection />
  461. </div>
  462. );
  463. }