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()}
+ >
+
+ {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 (
+
+
+
+
+ )
+}
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 (
+
+
+
+
+ {!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 (
-
-
-
- {!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 (
-
-
-
-
- )
-}
-
-/* ─── 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()}
- >
-
- {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')
-}