diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApiKeys.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApiKeys.tsx new file mode 100644 index 00000000..55b0fe92 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApiKeys.tsx @@ -0,0 +1,200 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Copy, Ban, Plus, KeyRound, Loader2 } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; + +// Your own API keys: one per app or device, so a phone holds a credential you can revoke on its own +// instead of a session everything shares. +// +// The key is returned by POST and never again — the column holds a SHA-256, so there is nothing to read +// back. That drives the whole screen: the new key sits in a panel that stays put until dismissed, because +// the moment it disappears the only remedy is to revoke and mint another. Same reasoning as the DAV app +// passwords beside this. +// +// A key carries your full account authority. It is not more than you already had — it is what your +// password could already do — but it does mean a leaked key is a leaked account, so revoke is one click +// and "last used" is on every row: a key that has never been used is the tell that something was set up +// wrong, and one used from somewhere you did not expect is the tell that matters more. + +type ApiKey = { + id: number; + name: string; + prefix: string; + lastUsedAt: string | null; + expiresAt: string | null; + revokedAt: string | null; + createdAt: string; +}; + +type Minted = { key: string; name: string }; + +const API_KEYS_QUERY_KEY = ['API_KEYS']; + +const formatDate = (value: string | null) => + value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null; + +const copy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success('Key copied'); + } catch { + toast.error('Could not copy — select and copy manually'); + } +}; + +export const ApiKeys = () => { + const client = useClient(); + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [minted, setMinted] = useState(null); + + const { data, isLoading } = useQuery<{ keys: ApiKey[] }>({ + queryKey: API_KEYS_QUERY_KEY, + queryFn: () => client.get<{ keys: ApiKey[] }>('/api-keys'), + }); + + const refresh = () => queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY }); + + const create = async () => { + const trimmed = name.trim(); + if (!trimmed || isCreating) return; + setIsCreating(true); + try { + const res = await client.post<{ key: string }>('/api-keys', { name: trimmed }); + setMinted({ key: res.key, name: trimmed }); + setName(''); + await refresh(); + } catch (ex) { + toast.error(ex instanceof Error ? ex.message : 'Could not create the key'); + } finally { + setIsCreating(false); + } + }; + + const revoke = async (id: number) => { + try { + await client.delete(`/api-keys/${id}`); + toast.success('Revoked — anything using that key is signed out'); + await refresh(); + } catch { + toast.error('Could not revoke'); + } + }; + + const keys = data?.keys ?? []; + + if (isLoading) { + return ( +
+ Loading keys… +
+ ); + } + + return ( +
+

+ An API key signs in as you, without a password and without expiring. Give each app or device its own, so you can + cut one off without touching the rest. Send it as{' '} + Authorization: Bearer …. +

+ + {minted && ( +
+
+ Key for “{minted.name}” — shown once +
+

+ This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it, + revoke this entry and make another. +

+ +
+ + {minted.key} + + +
+ + +
+ )} + +
+ + +
+ + {keys.length === 0 ? ( +
+ No keys yet. +
+ ) : ( +
+ {keys.map((entry) => { + const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now(); + const dead = !!entry.revokedAt || expired; + const lastUsed = formatDate(entry.lastUsedAt); + return ( +
+ +
+
+ {entry.name} + {entry.revokedAt && revoked} + {!entry.revokedAt && expired && expired} +
+
+ {entry.prefix}… + {' · '} + {/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */} + {lastUsed ? `last used ${lastUsed}` : 'never used'} +
+
+ {!dead && ( + + )} +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx index 06925b68..87808b3e 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx @@ -13,6 +13,7 @@ import { BrowserRelay } from './BrowserRelay'; import { ApifyConfig } from './ApifyConfig'; import { EmailAccounts } from './EmailAccounts'; import { DavAppPasswords } from './DavAppPasswords'; +import { ApiKeys } from './ApiKeys'; const BASE_PATH = '/settings/integrations'; @@ -57,6 +58,13 @@ const personalSections: SettingsSection[] = [ description: 'Per-device passwords for CalDAV/CardDAV clients', content: , }, + { + key: 'api-keys', + icon: KeyRound, + title: 'API keys', + description: 'Per-app keys that sign in as you, revocable one at a time', + content: , + }, { key: 'browser-relay', icon: Globe,