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'; import { copyToClipboard } from 'helpers/clipboard'; // 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 copyToClipboard(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 && ( )}
); })}
)}
); };