CameraTokensPage.tsx 17 KB

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