import type { FormEvent } from 'react'; import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useFormat } from 'i18n'; import { useTranslation } from '@/lib/i18n'; import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } 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']; // `toLocaleDateString(undefined, …)` followed the BROWSER's locale, so a Portuguese UI showed the // browser's dates. `useFormat` binds it to the locale the rest of the app is in (D7). export const ApiKeys = () => { const { t } = useTranslation(); const { date: formatDate } = useFormat(); const copy = async (text: string) => { try { await copyToClipboard(text); toast.success(t('settings.profile.apiKeys.copied')); } catch { toast.error(t('settings.profile.apiKeys.copyFailed')); } }; const client = useClient(); const queryClient = useQueryClient(); const [name, setName] = useState(''); const [isCreating, setIsCreating] = useState(false); const [showRevoked, setShowRevoked] = 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 (ev?: FormEvent) => { ev?.preventDefault(); 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 : t('settings.profile.apiKeys.createFailed')); } finally { setIsCreating(false); } }; const forget = async (id: number) => { try { await client.delete(`/api-keys/${id}/record`); await refresh(); } catch { toast.error(t('settings.profile.apiKeys.forgetFailed')); } }; const revoke = async (id: number) => { try { await client.delete(`/api-keys/${id}`); toast.success(t('settings.profile.apiKeys.revokeDone')); await refresh(); } catch { toast.error(t('settings.profile.apiKeys.revokeFailed')); } }; const allKeys = data?.keys ?? []; // Dead means revoked OR past its expiry — the same test the row rendering uses, lifted here so the // count in the toggle and the rows it reveals can never disagree. const isDead = (entry: ApiKey) => !!entry.revokedAt || (!!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now()); // Dead keys are HIDDEN by default rather than merely dimmed. Revoking is the normal end of a key's // life, so on any account that has been used for a while the dead ones outnumber the live ones and the // screen stops answering the question it exists for — which of my keys still work. The rows are kept // and reachable; they are just not the first thing shown. const deadKeys = allKeys.filter(isDead); const keys = showRevoked ? allKeys : allKeys.filter((entry) => !isDead(entry)); if (isLoading) { return (
{t('settings.profile.apiKeys.loading')}
); } return (

{t('settings.profile.apiKeys.intro')} Authorization: Bearer ….

{minted && (
{t('settings.profile.apiKeys.mintedTitle', { name: minted.name })}

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" would be false on an account whose keys are all revoked, and it is the sentence that would send somebody looking for a bug in the list. */} {allKeys.length === 0 ? t('settings.profile.apiKeys.noneYet') : t('settings.profile.apiKeys.noneInUse')}
) : (
{keys.map((entry) => { const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now(); const dead = !!entry.revokedAt || expired; const lastUsed = entry.lastUsedAt ? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' }) : null; return (
{entry.name} {entry.revokedAt && ( {t('settings.profile.apiKeys.revoked')} )} {!entry.revokedAt && expired && ( {t('settings.profile.apiKeys.expired')} )}
{entry.prefix}… {' · '} {/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */} {lastUsed ? t('settings.profile.apiKeys.lastUsed', { date: lastUsed }) : t('settings.profile.apiKeys.neverUsed')}
{dead ? ( // Only offered once the key is already dead — the server refuses it otherwise, and the // button matching that rule is what keeps the two from drifting. ) : ( )}
); })}
)} {deadKeys.length > 0 && ( )}
); };