From cdfed5318ec930556dc9c9f868841e906625c796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 24 Jul 2026 07:34:09 -0600 Subject: [PATCH] feat[frontend](settings/api-key): added apikey on settings section --- frontend/src/app/routes/index.tsx | 6 +- .../api-keys/components/ConfirmDialog.tsx | 53 ++ .../features/api-keys/components/Field.tsx | 17 + .../api-keys/components/IconAction.tsx | 27 + .../features/api-keys/components/KeyRow.tsx | 70 ++ .../features/api-keys/components/Modal.tsx | 36 ++ .../api-keys/components/RevealModal.tsx | 40 ++ .../api-keys/components/StatusBadge.tsx | 29 + .../api-keys/components/UpsertDialog.tsx | 111 ++++ .../api-keys/components/api-key-error.ts | 12 + frontend/src/features/api-keys/index.ts | 1 + .../features/api-keys/pages/ApiKeysPage.tsx | 223 +++++++ .../services/api-keys-http.service.ts | 0 .../types/api-key.types.ts | 0 .../features/profile/pages/ProfilePage.tsx | 4 - .../features/settings/pages/ApiKeysPage.tsx | 602 ------------------ 16 files changed, 622 insertions(+), 609 deletions(-) create mode 100644 frontend/src/features/api-keys/components/ConfirmDialog.tsx create mode 100644 frontend/src/features/api-keys/components/Field.tsx create mode 100644 frontend/src/features/api-keys/components/IconAction.tsx create mode 100644 frontend/src/features/api-keys/components/KeyRow.tsx create mode 100644 frontend/src/features/api-keys/components/Modal.tsx create mode 100644 frontend/src/features/api-keys/components/RevealModal.tsx create mode 100644 frontend/src/features/api-keys/components/StatusBadge.tsx create mode 100644 frontend/src/features/api-keys/components/UpsertDialog.tsx create mode 100644 frontend/src/features/api-keys/components/api-key-error.ts create mode 100644 frontend/src/features/api-keys/index.ts create mode 100644 frontend/src/features/api-keys/pages/ApiKeysPage.tsx rename frontend/src/features/{settings => api-keys}/services/api-keys-http.service.ts (100%) rename frontend/src/features/{settings => api-keys}/types/api-key.types.ts (100%) delete mode 100644 frontend/src/features/settings/pages/ApiKeysPage.tsx diff --git a/frontend/src/app/routes/index.tsx b/frontend/src/app/routes/index.tsx index fe98e8967..6e7f02ee5 100644 --- a/frontend/src/app/routes/index.tsx +++ b/frontend/src/app/routes/index.tsx @@ -45,6 +45,7 @@ import { LanguagePage } from '@/features/settings/pages/LanguagePage' import { AboutPage } from '@/features/settings/pages/AboutPage' import { LicensePage } from '@/features/billing' import { NotificationsPage } from '@/features/notifications' +import { ApiKeysPage } from '@/features/api-keys' import { DashboardLayout } from '@/shared/layouts/DashboardLayout' export function AppRoutes() { @@ -154,9 +155,8 @@ export function AppRoutes() { } /> } /> } /> - {/* API keys are per-user (a key authenticates AS its owner), so they live - embedded in the profile page, not system settings. */} - } /> + {/* API keys are per-user (a key authenticates AS its owner). Not available in federation. */} + {!IS_FEDERATION && } />} } /> {/* Federation serves email config from its own admin chrome (top-level route). */} {!IS_FEDERATION && } />} diff --git a/frontend/src/features/api-keys/components/ConfirmDialog.tsx b/frontend/src/features/api-keys/components/ConfirmDialog.tsx new file mode 100644 index 000000000..bb720ebd2 --- /dev/null +++ b/frontend/src/features/api-keys/components/ConfirmDialog.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from 'react-i18next' +import { RefreshCw, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { ConfirmDialog as SharedConfirmDialog } from '@/shared/components/ui/confirm-dialog' +import { apiKeysHttpService } from '../services/api-keys-http.service' +import type { ApiKey } from '../types/api-key.types' +import { apiKeyError } from './api-key-error' + +export function ConfirmDialog({ + state, + onClose, + onDone, +}: { + state: { kind: 'rotate' | 'delete'; key: ApiKey } + onClose: () => void + onDone: (reveal?: { name: string; token: string }) => void +}) { + const { t } = useTranslation() + const isDelete = state.kind === 'delete' + + const run = async () => { + try { + if (isDelete) { + await apiKeysHttpService.remove(state.key.id) + toast.success(t('apiKeys.toast.deleted')) + onDone() + } else { + const { api_key } = await apiKeysHttpService.generate(state.key.id) + onDone({ name: state.key.name, token: api_key }) + } + } catch (err) { + toast.error(apiKeyError(err, t)) + } + } + + return ( + + ) +} diff --git a/frontend/src/features/api-keys/components/Field.tsx b/frontend/src/features/api-keys/components/Field.tsx new file mode 100644 index 000000000..08a100d8d --- /dev/null +++ b/frontend/src/features/api-keys/components/Field.tsx @@ -0,0 +1,17 @@ +export function Field({ + label, + hint, + children, +}: { + label: string + hint?: string + children: React.ReactNode +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} diff --git a/frontend/src/features/api-keys/components/IconAction.tsx b/frontend/src/features/api-keys/components/IconAction.tsx new file mode 100644 index 000000000..98bba381e --- /dev/null +++ b/frontend/src/features/api-keys/components/IconAction.tsx @@ -0,0 +1,27 @@ +import { cn } from '@/shared/lib/utils' + +export function IconAction({ + label, + onClick, + danger, + children, +}: { + label: string + onClick: () => void + danger?: boolean + children: React.ReactNode +}) { + return ( + + ) +} diff --git a/frontend/src/features/api-keys/components/KeyRow.tsx b/frontend/src/features/api-keys/components/KeyRow.tsx new file mode 100644 index 000000000..f00ab2443 --- /dev/null +++ b/frontend/src/features/api-keys/components/KeyRow.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from 'react-i18next' +import { Globe2, KeyRound, Pencil, RefreshCw, Trash2 } from 'lucide-react' +import type { ApiKey } from '../types/api-key.types' +import { IconAction } from './IconAction' +import { StatusBadge } from './StatusBadge' + +export const COLS = '1.4fr 1.2fr 110px 110px 110px 100px 110px' + +function formatDate(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '—' + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' }) +} + +export function KeyRow({ + apiKey: k, + onEdit, + onRotate, + onDelete, +}: { + apiKey: ApiKey + onEdit: () => void + onRotate: () => void + onDelete: () => void +}) { + const { t } = useTranslation() + return ( +
+
+ + + + {k.name} +
+
+ {k.allowed_ip.length === 0 ? ( + + {t('apiKeys.anyIp')} + + ) : ( + + {k.allowed_ip.join(', ')} + + )} +
+
{formatDate(k.created_at)}
+
{formatDate(k.generated_at)}
+
+ {k.expires_at ? formatDate(k.expires_at) : {t('apiKeys.never')}} +
+
+ +
+
+ + + + + + + + + +
+
+ ) +} diff --git a/frontend/src/features/api-keys/components/Modal.tsx b/frontend/src/features/api-keys/components/Modal.tsx new file mode 100644 index 000000000..c51b84077 --- /dev/null +++ b/frontend/src/features/api-keys/components/Modal.tsx @@ -0,0 +1,36 @@ +import { X, type LucideIcon } from 'lucide-react' + +export function Modal({ + title, + icon: Icon, + onClose, + children, +}: { + title: string + icon: LucideIcon + onClose: () => void + children: React.ReactNode +}) { + return ( +
+
e.stopPropagation()} + > +
+

+ + {title} +

+ +
+ {children} +
+
+ ) +} diff --git a/frontend/src/features/api-keys/components/RevealModal.tsx b/frontend/src/features/api-keys/components/RevealModal.tsx new file mode 100644 index 000000000..0d4b94eab --- /dev/null +++ b/frontend/src/features/api-keys/components/RevealModal.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Check, Copy, KeyRound, TriangleAlert } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import { Modal } from './Modal' + +export function RevealModal({ name, token, onClose }: { name: string; token: string; onClose: () => void }) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + const copy = () => { + navigator.clipboard.writeText(token).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + } + return ( + +
+

+ + {t('apiKeys.reveal.note', { name })} +

+
+ + {token} + + +
+
+
+ +
+
+ ) +} diff --git a/frontend/src/features/api-keys/components/StatusBadge.tsx b/frontend/src/features/api-keys/components/StatusBadge.tsx new file mode 100644 index 000000000..57b1f74d7 --- /dev/null +++ b/frontend/src/features/api-keys/components/StatusBadge.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' + +const EXPIRING_WINDOW_MS = 7 * 86_400_000 + +function keyStatus(expiresAt: string | null): 'active' | 'expiring' | 'expired' { + if (!expiresAt) return 'active' + const exp = new Date(expiresAt).getTime() + const now = Date.now() + if (exp <= now) return 'expired' + if (exp - now <= EXPIRING_WINDOW_MS) return 'expiring' + return 'active' +} + +export function StatusBadge({ expiresAt }: { expiresAt: string | null }) { + const { t } = useTranslation() + const status = keyStatus(expiresAt) + const meta = { + active: { label: t('apiKeys.status.active'), cls: 'bg-emerald-500/15 text-emerald-600 ring-emerald-500/30 dark:text-emerald-300', dot: 'bg-emerald-500' }, + expiring: { label: t('apiKeys.status.expiring'), cls: 'bg-amber-500/15 text-amber-600 ring-amber-500/30 dark:text-amber-300', dot: 'bg-amber-500' }, + expired: { label: t('apiKeys.status.expired'), cls: 'bg-red-500/15 text-red-500 ring-red-500/30 dark:text-red-300', dot: 'bg-red-500' }, + }[status] + return ( + + + {meta.label} + + ) +} diff --git a/frontend/src/features/api-keys/components/UpsertDialog.tsx b/frontend/src/features/api-keys/components/UpsertDialog.tsx new file mode 100644 index 000000000..be7a4455e --- /dev/null +++ b/frontend/src/features/api-keys/components/UpsertDialog.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { KeyRound, TriangleAlert } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { apiKeysHttpService } from '../services/api-keys-http.service' +import type { ApiKey } from '../types/api-key.types' +import { Modal } from './Modal' +import { Field } from './Field' +import { apiKeyError } from './api-key-error' + +function parseIps(s: string): string[] { + return s + .split(/[\s,]+/) + .map((x) => x.trim()) + .filter(Boolean) +} + +function toDateInput(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + return d.toISOString().slice(0, 10) +} + +export function UpsertDialog({ + state, + onClose, + onSaved, +}: { + state: { mode: 'create' } | { mode: 'edit'; key: ApiKey } + onClose: () => void + onSaved: (reveal?: { name: string; token: string }) => void +}) { + const { t } = useTranslation() + const editing = state.mode === 'edit' + const existing = editing ? state.key : null + + const [name, setName] = useState(existing?.name ?? '') + const [ips, setIps] = useState((existing?.allowed_ip ?? []).join(', ')) + const [expiresAt, setExpiresAt] = useState(existing?.expires_at ? toDateInput(existing.expires_at) : '') + const [busy, setBusy] = useState(false) + + const valid = name.trim().length > 0 + + const submit = async () => { + if (!valid || busy) return + setBusy(true) + const payload = { + name: name.trim(), + allowed_ip: parseIps(ips), + expires_at: expiresAt ? new Date(`${expiresAt}T23:59:59`).toISOString() : null, + } + try { + if (editing && existing) { + await apiKeysHttpService.update(existing.id, payload) + toast.success(t('apiKeys.toast.updated')) + onSaved() + } else { + const created = await apiKeysHttpService.create(payload) + // The secret is not returned by create — reveal it via a rotate. + try { + const { api_key } = await apiKeysHttpService.generate(created.id) + onSaved({ name: created.name, token: api_key }) + } catch { + toast.warning(t('apiKeys.toast.secretIssueFailed')) + onSaved() + } + } + } catch (err) { + toast.error(apiKeyError(err, t)) + } finally { + setBusy(false) + } + } + + return ( + +
+ + setName(e.target.value)} placeholder={t('apiKeys.dialog.namePlaceholder')} /> + + + setIps(e.target.value)} + placeholder="203.0.113.4, 198.51.100.0/24" + className="font-mono text-xs" + /> + + + setExpiresAt(e.target.value)} className="max-w-[200px]" /> + + {!editing && ( +

+ + {t('apiKeys.dialog.secretNote')} +

+ )} +
+
+ + +
+
+ ) +} diff --git a/frontend/src/features/api-keys/components/api-key-error.ts b/frontend/src/features/api-keys/components/api-key-error.ts new file mode 100644 index 000000000..675a43b45 --- /dev/null +++ b/frontend/src/features/api-keys/components/api-key-error.ts @@ -0,0 +1,12 @@ +import type { TFunction } from 'i18next' +import { ApiKeyHttpError } from '../services/api-keys-http.service' + +export function apiKeyError(err: unknown, t: TFunction): string { + if (err instanceof ApiKeyHttpError) { + if (err.status === 409) return t('apiKeys.error.nameTaken') + if (err.status === 403) return t('apiKeys.error.enterpriseRequired') + if (err.status === 404) return t('apiKeys.error.notFound') + if (err.status === 400) return err.message || t('apiKeys.error.invalidRequest') + } + return err instanceof Error ? err.message : t('apiKeys.error.failed') +} diff --git a/frontend/src/features/api-keys/index.ts b/frontend/src/features/api-keys/index.ts new file mode 100644 index 000000000..2e1b5a90a --- /dev/null +++ b/frontend/src/features/api-keys/index.ts @@ -0,0 +1 @@ +export { ApiKeysPage } from './pages/ApiKeysPage' diff --git a/frontend/src/features/api-keys/pages/ApiKeysPage.tsx b/frontend/src/features/api-keys/pages/ApiKeysPage.tsx new file mode 100644 index 000000000..550f68133 --- /dev/null +++ b/frontend/src/features/api-keys/pages/ApiKeysPage.tsx @@ -0,0 +1,223 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { + AlertTriangle, + Crown, + KeyRound, + Loader2, + Plus, + RefreshCw, + Search, +} from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' +import { useBilling } from '@/features/billing' +import { apiKeysHttpService } from '../services/api-keys-http.service' +import type { ApiKey, ApiKeyPageInfo } from '../types/api-key.types' +import { COLS, KeyRow } from '../components/KeyRow' +import { UpsertDialog } from '../components/UpsertDialog' +import { ConfirmDialog } from '../components/ConfirmDialog' +import { RevealModal } from '../components/RevealModal' + +const PAGE_SIZE = 50 + +type DialogState = { mode: 'create' } | { mode: 'edit'; key: ApiKey } | null +type ConfirmState = { kind: 'rotate' | 'delete'; key: ApiKey } | null + +export function ApiKeysPage() { + const { t } = useTranslation() + const { license } = useBilling() + const [keys, setKeys] = useState(null) + const [pageInfo, setPageInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + const [page, setPage] = useState(1) + const [search, setSearch] = useState('') + + const [dialog, setDialog] = useState(null) + const [confirm, setConfirm] = useState(null) + const [revealed, setRevealed] = useState<{ name: string; token: string } | null>(null) + + const load = useCallback(async () => { + setLoading(true) + setError(false) + try { + const resp = await apiKeysHttpService.list(page, PAGE_SIZE) + // Go marshals an empty slice as null, so allowed_ip can come back null — + // normalize it to an array so the rest of the page can treat it uniformly. + const normalized = resp.data.map((k) => ({ ...k, allowed_ip: k.allowed_ip ?? [] })) + setKeys((prev) => (page === 1 ? normalized : [...(prev ?? []), ...normalized])) + setPageInfo(resp.page_info) + } catch { + setError(true) + setKeys([]) + } finally { + setLoading(false) + } + }, [page]) + + useEffect(() => { + void load() + }, [load]) + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase() + return (keys ?? []).filter((k) => + q ? (k.name + ' ' + k.allowed_ip.join(' ')).toLowerCase().includes(q) : true, + ) + }, [keys, search]) + + // API keys creation/rotation is an Enterprise capability. When gated, the + // page still renders but shows an upgrade note instead of the manager. + const isEnterprise = license?.edition === 'enterprise' + const ENFORCE_ENTERPRISE_GATE = true + const showManager = !ENFORCE_ENTERPRISE_GATE || isEnterprise + + return ( +
+
+
+

+ + {t('settings.apiKeys')} +

+

+ + {pageInfo?.total_items ?? 0}{' '} + {t('apiKeys.title').toLowerCase()} + +

+
+ {showManager && ( + + )} +
+ +
+ {!showManager ? ( +
+ +
{t('apiKeys.enterprise.title')}
+

{t('apiKeys.enterprise.body')}

+ +
+ ) : ( + <> +
+
+ + setSearch(e.target.value)} + placeholder={t('apiKeys.searchPlaceholder')} + className="h-9 pl-9" + /> +
+ +
+ +
+
+
{t('apiKeys.col.name')}
+
{t('apiKeys.col.allowedIps')}
+
{t('apiKeys.col.created')}
+
{t('apiKeys.col.lastRotated')}
+
{t('apiKeys.col.expires')}
+
{t('apiKeys.col.status')}
+
{t('apiKeys.col.actions')}
+
+ + {loading && (!keys || keys.length === 0) && ( +
+ + {t('apiKeys.loading')} +
+ )} + + {!loading && error && ( +
+ + + {t('apiKeys.loadFailed')} + + +
+ )} + + {!loading && !error && filtered.length === 0 && ( +
+ {search ? t('apiKeys.noSearchMatch') : t('apiKeys.empty')} +
+ )} + + {filtered.length > 0 && + filtered.map((k) => ( + setDialog({ mode: 'edit', key: k })} + onRotate={() => setConfirm({ kind: 'rotate', key: k })} + onDelete={() => setConfirm({ kind: 'delete', key: k })} + /> + ))} +
+ + {!error && keys && keys.length > 0 && ( + setPage((p) => p + 1)} + hasMore={keys.length < (pageInfo?.total_items ?? 0)} + loading={loading} + endLabel={t('common.allLoaded', { count: pageInfo?.total_items ?? 0 })} + /> + )} + + )} +
+ + {dialog && ( + setDialog(null)} + onSaved={(reveal) => { + setDialog(null) + if (reveal) setRevealed(reveal) + void load() + }} + /> + )} + + {confirm && ( + setConfirm(null)} + onDone={(reveal) => { + setConfirm(null) + if (reveal) setRevealed(reveal) + void load() + }} + /> + )} + + {revealed && setRevealed(null)} />} +
+ ) +} diff --git a/frontend/src/features/settings/services/api-keys-http.service.ts b/frontend/src/features/api-keys/services/api-keys-http.service.ts similarity index 100% rename from frontend/src/features/settings/services/api-keys-http.service.ts rename to frontend/src/features/api-keys/services/api-keys-http.service.ts diff --git a/frontend/src/features/settings/types/api-key.types.ts b/frontend/src/features/api-keys/types/api-key.types.ts similarity index 100% rename from frontend/src/features/settings/types/api-key.types.ts rename to frontend/src/features/api-keys/types/api-key.types.ts diff --git a/frontend/src/features/profile/pages/ProfilePage.tsx b/frontend/src/features/profile/pages/ProfilePage.tsx index e3a08e2fa..c6c80fcef 100644 --- a/frontend/src/features/profile/pages/ProfilePage.tsx +++ b/frontend/src/features/profile/pages/ProfilePage.tsx @@ -12,7 +12,6 @@ import type { Session } from '@/features/auth/types/auth.types' import { federationAuthService } from '@/features/federation/services/federation-auth.service' import { SUPPORTED_LANGUAGES } from '@/shared/i18n' import { TwoFactorBody } from '../components/TwoFactorBody' -import { ApiKeysSection } from '@/features/settings/pages/ApiKeysPage' const AVATAR_MAX_BYTES = 2 * 1024 * 1024 // 2 MB const AVATAR_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif' @@ -369,9 +368,6 @@ export function ProfilePage() { )} - {/* API keys (per-user → embedded here, not in system settings). Not in federation. */} - {!IS_FEDERATION && } - {/* Active sessions (FS sessions in federation mode, instance sessions otherwise) */}
{sessionsLoading ? ( diff --git a/frontend/src/features/settings/pages/ApiKeysPage.tsx b/frontend/src/features/settings/pages/ApiKeysPage.tsx deleted file mode 100644 index 14f334e6e..000000000 --- a/frontend/src/features/settings/pages/ApiKeysPage.tsx +++ /dev/null @@ -1,602 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { Link } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import type { TFunction } from 'i18next' -import { - AlertTriangle, - Check, - Copy, - Crown, - Globe2, - KeyRound, - Loader2, - Pencil, - Plus, - RefreshCw, - Search, - Trash2, - TriangleAlert, - X, -} from 'lucide-react' -import { toast } from 'sonner' -import { cn } from '@/shared/lib/utils' -import { Button } from '@/shared/components/ui/button' -import { Input } from '@/shared/components/ui/input' -import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' -import { useBilling } from '@/features/billing' -import { ApiKeyHttpError, apiKeysHttpService } from '../services/api-keys-http.service' -import type { ApiKey, ApiKeyPageInfo } from '../types/api-key.types' - -const PAGE_SIZE = 50 -const EXPIRING_WINDOW_MS = 7 * 86_400_000 - -/* ───────────────────────────────────────────────────────────────────────── - * Page - * ───────────────────────────────────────────────────────────────────────── */ - -type DialogState = { mode: 'create' } | { mode: 'edit'; key: ApiKey } | null -type ConfirmState = { kind: 'rotate' | 'delete'; key: ApiKey } | null - -export function ApiKeysSection() { - const { t } = useTranslation() - const { license } = useBilling() - const [keys, setKeys] = useState(null) - const [pageInfo, setPageInfo] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - const [page, setPage] = useState(1) - const [search, setSearch] = useState('') - - const [dialog, setDialog] = useState(null) - const [confirm, setConfirm] = useState(null) - const [revealed, setRevealed] = useState<{ name: string; token: string } | null>(null) - - const load = useCallback(async () => { - setLoading(true) - setError(false) - try { - const resp = await apiKeysHttpService.list(page, PAGE_SIZE) - // Go marshals an empty slice as null, so allowed_ip can come back null — - // normalize it to an array so the rest of the page can treat it uniformly. - const normalized = resp.data.map((k) => ({ ...k, allowed_ip: k.allowed_ip ?? [] })) - setKeys((prev) => (page === 1 ? normalized : [...(prev ?? []), ...normalized])) - setPageInfo(resp.page_info) - } catch { - setError(true) - setKeys([]) - } finally { - setLoading(false) - } - }, [page]) - - useEffect(() => { - void load() - }, [load]) - - const filtered = useMemo(() => { - const q = search.trim().toLowerCase() - return (keys ?? []).filter((k) => - q ? (k.name + ' ' + k.allowed_ip.join(' ')).toLowerCase().includes(q) : true, - ) - }, [keys, search]) - - // API keys creation/rotation is an Enterprise capability. When gated, the - // section still renders but shows an upgrade note instead of the manager. - const isEnterprise = license?.edition === 'enterprise' - const ENFORCE_ENTERPRISE_GATE = true - const showManager = !ENFORCE_ENTERPRISE_GATE || isEnterprise - - return ( -
-
-
- - - {pageInfo?.total_items ?? 0} {t('apiKeys.title').toLowerCase()} - -
- {showManager && ( - - )} -
- - {!showManager ? ( -
- -
{t('apiKeys.enterprise.title')}
-

{t('apiKeys.enterprise.body')}

- -
- ) : ( - <> -
-
- - setSearch(e.target.value)} - placeholder={t('apiKeys.searchPlaceholder')} - className="h-9 pl-9" - /> -
- -
- -
-
-
{t('apiKeys.col.name')}
-
{t('apiKeys.col.allowedIps')}
-
{t('apiKeys.col.created')}
-
{t('apiKeys.col.lastRotated')}
-
{t('apiKeys.col.expires')}
-
{t('apiKeys.col.status')}
-
{t('apiKeys.col.actions')}
-
- - {loading && (!keys || keys.length === 0) && ( -
- - {t('apiKeys.loading')} -
- )} - - {!loading && error && ( -
- - - {t('apiKeys.loadFailed')} - - -
- )} - - {!loading && !error && filtered.length === 0 && ( -
- {search ? t('apiKeys.noSearchMatch') : t('apiKeys.empty')} -
- )} - - {filtered.length > 0 && - filtered.map((k) => ( - setDialog({ mode: 'edit', key: k })} - onRotate={() => setConfirm({ kind: 'rotate', key: k })} - onDelete={() => setConfirm({ kind: 'delete', key: k })} - /> - ))} -
- - {!error && keys && keys.length > 0 && ( - setPage((p) => p + 1)} - hasMore={keys.length < (pageInfo?.total_items ?? 0)} - loading={loading} - endLabel={t('common.allLoaded', { count: pageInfo?.total_items ?? 0 })} - /> - )} - - )} - - {dialog && ( - setDialog(null)} - onSaved={(reveal) => { - setDialog(null) - if (reveal) setRevealed(reveal) - void load() - }} - /> - )} - - {confirm && ( - setConfirm(null)} - onDone={(reveal) => { - setConfirm(null) - if (reveal) setRevealed(reveal) - void load() - }} - /> - )} - - {revealed && setRevealed(null)} />} -
- ) -} - -const COLS = '1.4fr 1.2fr 110px 110px 110px 100px 110px' - -/* ─── Row ──────────────────────────────────────────────────────────────── */ - -function KeyRow({ - apiKey: k, - onEdit, - onRotate, - onDelete, -}: { - apiKey: ApiKey - onEdit: () => void - onRotate: () => void - onDelete: () => void -}) { - const { t } = useTranslation() - return ( -
-
- - - - {k.name} -
-
- {k.allowed_ip.length === 0 ? ( - - {t('apiKeys.anyIp')} - - ) : ( - - {k.allowed_ip.join(', ')} - - )} -
-
{formatDate(k.created_at)}
-
{formatDate(k.generated_at)}
-
- {k.expires_at ? formatDate(k.expires_at) : {t('apiKeys.never')}} -
-
- -
-
- - - - - - - - - -
-
- ) -} - -function IconAction({ - label, - onClick, - danger, - children, -}: { - label: string - onClick: () => void - danger?: boolean - children: React.ReactNode -}) { - return ( - - ) -} - -function StatusBadge({ expiresAt }: { expiresAt: string | null }) { - const { t } = useTranslation() - const status = keyStatus(expiresAt) - const meta = { - active: { label: t('apiKeys.status.active'), cls: 'bg-emerald-500/15 text-emerald-600 ring-emerald-500/30 dark:text-emerald-300', dot: 'bg-emerald-500' }, - expiring: { label: t('apiKeys.status.expiring'), cls: 'bg-amber-500/15 text-amber-600 ring-amber-500/30 dark:text-amber-300', dot: 'bg-amber-500' }, - expired: { label: t('apiKeys.status.expired'), cls: 'bg-red-500/15 text-red-500 ring-red-500/30 dark:text-red-300', dot: 'bg-red-500' }, - }[status] - return ( - - - {meta.label} - - ) -} - -/* ─── Create / edit dialog ─────────────────────────────────────────────── */ - -function UpsertDialog({ - state, - onClose, - onSaved, -}: { - state: { mode: 'create' } | { mode: 'edit'; key: ApiKey } - onClose: () => void - onSaved: (reveal?: { name: string; token: string }) => void -}) { - const { t } = useTranslation() - const editing = state.mode === 'edit' - const existing = editing ? state.key : null - - const [name, setName] = useState(existing?.name ?? '') - const [ips, setIps] = useState((existing?.allowed_ip ?? []).join(', ')) - const [expiresAt, setExpiresAt] = useState(existing?.expires_at ? toDateInput(existing.expires_at) : '') - const [busy, setBusy] = useState(false) - - const valid = name.trim().length > 0 - - const submit = async () => { - if (!valid || busy) return - setBusy(true) - const payload = { - name: name.trim(), - allowed_ip: parseIps(ips), - expires_at: expiresAt ? new Date(`${expiresAt}T23:59:59`).toISOString() : null, - } - try { - if (editing && existing) { - await apiKeysHttpService.update(existing.id, payload) - toast.success(t('apiKeys.toast.updated')) - onSaved() - } else { - const created = await apiKeysHttpService.create(payload) - // The secret is not returned by create — reveal it via a rotate. - try { - const { api_key } = await apiKeysHttpService.generate(created.id) - onSaved({ name: created.name, token: api_key }) - } catch { - toast.warning(t('apiKeys.toast.secretIssueFailed')) - onSaved() - } - } - } catch (err) { - toast.error(apiKeyError(err, t)) - } finally { - setBusy(false) - } - } - - return ( - -
- - setName(e.target.value)} placeholder={t('apiKeys.dialog.namePlaceholder')} /> - - - setIps(e.target.value)} - placeholder="203.0.113.4, 198.51.100.0/24" - className="font-mono text-xs" - /> - - - setExpiresAt(e.target.value)} className="max-w-[200px]" /> - - {!editing && ( -

- - {t('apiKeys.dialog.secretNote')} -

- )} -
-
- - -
-
- ) -} - -/* ─── Confirm (rotate / delete) ────────────────────────────────────────── */ - -function ConfirmDialog({ - state, - onClose, - onDone, -}: { - state: { kind: 'rotate' | 'delete'; key: ApiKey } - onClose: () => void - onDone: (reveal?: { name: string; token: string }) => void -}) { - const { t } = useTranslation() - const [busy, setBusy] = useState(false) - const isDelete = state.kind === 'delete' - - const run = async () => { - setBusy(true) - try { - if (isDelete) { - await apiKeysHttpService.remove(state.key.id) - toast.success(t('apiKeys.toast.deleted')) - onDone() - } else { - const { api_key } = await apiKeysHttpService.generate(state.key.id) - onDone({ name: state.key.name, token: api_key }) - } - } catch (err) { - toast.error(apiKeyError(err, t)) - } finally { - setBusy(false) - } - } - - return ( - -
- {isDelete - ? t('apiKeys.confirm.deleteBody', { name: state.key.name }) - : t('apiKeys.confirm.rotateBody', { name: state.key.name })} -
-
- - -
-
- ) -} - -/* ─── Reveal token (shown once) ────────────────────────────────────────── */ - -function RevealModal({ name, token, onClose }: { name: string; token: string; onClose: () => void }) { - const { t } = useTranslation() - const [copied, setCopied] = useState(false) - const copy = () => { - navigator.clipboard.writeText(token).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 1500) - }) - } - return ( - -
-

- - {t('apiKeys.reveal.note', { name })} -

-
- - {token} - - -
-
-
- -
-
- ) -} - -/* ─── Shared bits ──────────────────────────────────────────────────────── */ - -function Modal({ - title, - icon: Icon, - onClose, - children, -}: { - title: string - icon: typeof KeyRound - onClose: () => void - children: React.ReactNode -}) { - return ( -
-
e.stopPropagation()} - > -
-

- - {title} -

- -
- {children} -
-
- ) -} - -function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ) -} - -/* ─── helpers ──────────────────────────────────────────────────────────── */ - -function keyStatus(expiresAt: string | null): 'active' | 'expiring' | 'expired' { - if (!expiresAt) return 'active' - const exp = new Date(expiresAt).getTime() - const now = Date.now() - if (exp <= now) return 'expired' - if (exp - now <= EXPIRING_WINDOW_MS) return 'expiring' - return 'active' -} - -function parseIps(s: string): string[] { - return s - .split(/[\s,]+/) - .map((x) => x.trim()) - .filter(Boolean) -} - -function toDateInput(iso: string): string { - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return '' - return d.toISOString().slice(0, 10) -} - -function formatDate(iso: string): string { - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return '—' - return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' }) -} - -function apiKeyError(err: unknown, t: TFunction): string { - if (err instanceof ApiKeyHttpError) { - if (err.status === 409) return t('apiKeys.error.nameTaken') - if (err.status === 403) return t('apiKeys.error.enterpriseRequired') - if (err.status === 404) return t('apiKeys.error.notFound') - if (err.status === 400) return err.message || t('apiKeys.error.invalidRequest') - } - return err instanceof Error ? err.message : t('apiKeys.error.failed') -}