diff --git a/src/app/api-keys/page.tsx b/src/app/api-keys/page.tsx index 351031a..5370135 100644 --- a/src/app/api-keys/page.tsx +++ b/src/app/api-keys/page.tsx @@ -4,21 +4,25 @@ import ConfirmActionDialog from "@/components/ConfirmActionDialog"; import GuardedPage from "@/components/GuardedPage"; import IssueDetailsSummary from "@/components/IssueDetailsSummary"; import SearchField from "@/components/SearchField"; +import TablePagination from "@/components/TablePagination"; import { apiKeys as apiKeysApi, type ApiKeyExpiryOption, type ApiKeyRecord, type CreateApiKeyBody, } from "@/lib/api"; +import { useTablePagination } from "@/lib/use-table-pagination"; import { Activity, AlertCircle, + Ban, CheckCircle, Copy, Eye, EyeOff, Key, Loader2, + Pencil, Plus, RefreshCw, Shield, @@ -172,6 +176,10 @@ export default function ApiKeysPage() { {}, ); const [revokingId, setRevokingId] = useState(null); + const [removingId, setRemovingId] = useState(null); + const [editingKey, setEditingKey] = useState(null); + const [editSubmitting, setEditSubmitting] = useState(false); + const [editForm, setEditForm] = useState({ name: "", permissions: [] as string[], expiresIn: "keep" as ApiKeyExpiryOption | "keep" }); const [confirmDialog, setConfirmDialog] = useState(null); @@ -191,8 +199,16 @@ export default function ApiKeysPage() { const filteredKeys = useMemo(() => { const query = search.trim().toLowerCase(); if (!query) return keys; - return keys.filter((key) => [key.name, key.keyPrefix, key.isActive ? "active" : "revoked", ...key.permissions].some((value) => value.toLowerCase().includes(query))); + + return keys.filter((key) => + [key.name, key.keyPrefix, key.isActive ? "active" : "revoked", ...key.permissions] + .some((value) => value.toLowerCase().includes(query)), + ); }, [keys, search]); + const pagination = useTablePagination({ + items: filteredKeys, + resetKey: `${keys.length}|${search}`, + }); useEffect(() => { void loadKeys(); @@ -331,6 +347,67 @@ export default function ApiKeysPage() { }); } + async function removeKey(id: string) { + setRemovingId(id); + setError(""); + + try { + await apiKeysApi.remove(id); + setKeys((current) => current.filter((key) => key.id !== id)); + setSessionRawKeys((current) => { + const next = { ...current }; + delete next[id]; + return next; + }); + if (revealedKeyId === id) setRevealedKeyId(null); + if (copiedKeyId === id) setCopiedKeyId(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to remove API key"); + } finally { + setRemovingId(null); + } + } + + function handleRemove(id: string) { + const target = keys.find((key) => key.id === id); + if (!target) return; + + setConfirmDialog({ + title: "Remove API Key", + description: `Permanently remove revoked API key "${target.name}"?`, + confirmLabel: "Remove Key", + tone: "danger", + note: "This cannot be undone. The removal will remain recorded in the audit log.", + onConfirm: () => { + void removeKey(id); + }, + }); + } + + function openEdit(key: ApiKeyRecord) { + setEditForm({ name: key.name, permissions: key.permissions, expiresIn: "keep" }); + setEditingKey(key); + } + + async function handleEdit(event: React.FormEvent) { + event.preventDefault(); + if (!editingKey || !editForm.name.trim()) return; + setEditSubmitting(true); + try { + const response = await apiKeysApi.update(editingKey.id, { + name: editForm.name.trim(), + permissions: editForm.permissions, + ...(editForm.expiresIn === "keep" ? {} : { expiresIn: editForm.expiresIn }), + }); + setKeys((current) => current.map((key) => key.id === editingKey.id ? response.data : key)); + setEditingKey(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update API key"); + } finally { + setEditSubmitting(false); + } + } + return ( - setSearch(event.target.value)} containerStyle={{ flex: "1 1 360px", minWidth: 240 }} /> + setSearch(event.target.value)} + containerStyle={{ flex: "1 1 360px", minWidth: 240 }} + />
- {keys.length} API keys total + + {keys.length} API keys total +
) : filteredKeys.length === 0 ? ( -
+
-

No API keys match your search

+

+ No API keys match your search +

) : (
- {filteredKeys.map((key) => { + {pagination.paginatedItems.map((key) => { const canReveal = Boolean(sessionRawKeys[key.id]); const isRevealed = revealedKeyId === key.id && canReveal; const rawValue = sessionRawKeys[key.id]; @@ -835,25 +934,48 @@ export default function ApiKeysPage() { /> {key.requestCount.toLocaleString()} requests - {key.isActive && ( - - )} +
+ {key.isActive ? ( + <> + + + + ) : ( + + )} +
); })} +
+ +
)} @@ -1204,6 +1326,30 @@ export default function ApiKeysPage() { )} + + {editingKey && ( +
!editSubmitting && setEditingKey(null)}> +
event.stopPropagation()}> +
+

Edit API Key

+

Update the key name, permissions, or expiry. Changes apply immediately.

+
void handleEdit(event)} style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 18 }}> + setEditForm((current) => ({ ...current, name: event.target.value }))} /> +
+ {permissionOptions.map((option) => { + const checked = editForm.permissions.includes(option.id); + return ; + })} +
+ +
+
+
+
+
+ )}
); } diff --git a/src/lib/api.ts b/src/lib/api.ts index f22a5a0..ea46218 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -672,7 +672,11 @@ async function requestRaw( const isMultipartBody = typeof FormData !== "undefined" && options.body instanceof FormData; - if (options.body !== undefined && !isMultipartBody && !("Content-Type" in headers)) { + if ( + options.body !== undefined && + !isMultipartBody && + !("Content-Type" in headers) + ) { headers["Content-Type"] = "application/json"; } @@ -1070,6 +1074,12 @@ export interface CreateApiKeyBody { expiresIn: ApiKeyExpiryOption; } +export interface UpdateApiKeyBody { + name: string; + permissions: string[]; + expiresIn?: ApiKeyExpiryOption; +} + export interface CreatedApiKeyRecord extends ApiKeyRecord { rawKey: string; } @@ -1112,14 +1122,23 @@ export const apiKeys = { revoke: (id: string) => del<{ success: boolean; message?: string }>(`/api-keys/${id}`), + + remove: (id: string) => + del<{ success: boolean; message?: string }>(`/api-keys/${id}/permanent`), + + update: (id: string, body: UpdateApiKeyBody) => + patch<{ success: boolean; data: ApiKeyRecord }>(`/api-keys/${id}`, body), }; export const settingsApi = { get: () => get<{ success: boolean; data: SettingsRecord }>("/settings"), downloadDatabaseBackup: async () => { - const res = await requestRaw("/settings/database-backup", { method: "GET" }); - if (!res.ok) throw new Error(await readApiErrorMessage(res, "Database backup failed")); + const res = await requestRaw("/settings/database-backup", { + method: "GET", + }); + if (!res.ok) + throw new Error(await readApiErrorMessage(res, "Database backup failed")); const disposition = res.headers.get("content-disposition") || ""; const serverFileName = disposition.match(/filename="?([^";]+)"?/i)?.[1]; const timestamp = new Date() @@ -1128,7 +1147,8 @@ export const settingsApi = { .replace(/\.\d{3}Z$/, "Z") .replace("T", "-"); const fileName = - serverFileName && /^doktainer-database-\d{8}-\d{6}Z\.dump$/i.test(serverFileName) + serverFileName && + /^doktainer-database-\d{8}-\d{6}Z\.dump$/i.test(serverFileName) ? serverFileName : `doktainer-database-${timestamp}.dump`; return { blob: await res.blob(), fileName }; @@ -1137,9 +1157,18 @@ export const settingsApi = { restoreDatabaseBackup: async (file: File) => { const body = new FormData(); body.append("file", file); - const res = await requestRaw("/settings/database-restore", { method: "POST", body }, { timeoutMs: 120000 }); - const json = await readJsonResponse<{ success?: boolean; message?: string; error?: string }>(res, "Database restore failed"); - if (!res.ok) throw new Error(json.error || json.message || "Database restore failed"); + const res = await requestRaw( + "/settings/database-restore", + { method: "POST", body }, + { timeoutMs: 120000 }, + ); + const json = await readJsonResponse<{ + success?: boolean; + message?: string; + error?: string; + }>(res, "Database restore failed"); + if (!res.ok) + throw new Error(json.error || json.message || "Database restore failed"); return json; }, @@ -1612,11 +1641,9 @@ export const servers = { success: boolean; data: ServerSystemUserUpdateResult; message: string; - }>( - `/servers/${id}/system-users/${encodeURIComponent(username)}`, - body, - { timeoutMs: 30000 }, - ), + }>(`/servers/${id}/system-users/${encodeURIComponent(username)}`, body, { + timeoutMs: 30000, + }), setSystemUserPassword: ( id: string, username: string, @@ -1676,7 +1703,12 @@ export const servers = { ) => del<{ success: boolean; - data: { username: string; uid: number; home: string; homeRemoved: boolean }; + data: { + username: string; + uid: number; + home: string; + homeRemoved: boolean; + }; message: string; }>(`/servers/${id}/system-users/${encodeURIComponent(username)}`, body, { timeoutMs: 130000, diff --git a/src/server/routes/api-keys.ts b/src/server/routes/api-keys.ts index 91efab4..5d73fbf 100644 --- a/src/server/routes/api-keys.ts +++ b/src/server/routes/api-keys.ts @@ -114,6 +114,24 @@ export async function apiKeysRoutes(app: FastifyInstance) { ); // DELETE /api-keys/:id — revoke + app.patch( + "/:id", + { preHandler: [requireRole("DEVELOPER")] }, + async (req, reply) => { + const { id } = req.params as { id: string }; + const body = z.object({ name: z.string().min(1).max(64), permissions: z.array(z.string()), expiresIn: z.enum(["never", "30d", "90d", "1y"]).optional() }).safeParse(req.body); + if (!body.success) return reply.status(400).send({ success: false, error: body.error.flatten() }); + const invalidPermissions = body.data.permissions.filter((permission) => !isValidApiKeyPermission(permission)); + if (invalidPermissions.length > 0) return reply.status(400).send({ success: false, error: `Invalid API key permissions: ${invalidPermissions.join(", ")}` }); + const key = await prisma.apiKey.findFirst({ where: { id, userId: req.userId!, organizationId: req.organizationId!, isActive: true } }); + if (!key) return reply.status(404).send({ success: false, error: "Active API key not found" }); + const expiresAt = body.data.expiresIn === undefined ? undefined : body.data.expiresIn === "never" ? null : new Date(Date.now() + (body.data.expiresIn === "30d" ? 30 : body.data.expiresIn === "90d" ? 90 : 365) * 86400000); + const updated = await prisma.apiKey.update({ where: { id: key.id }, data: { name: body.data.name.trim(), permissions: normalizeApiKeyPermissions(body.data.permissions), ...(expiresAt === undefined ? {} : { expiresAt }) } }); + await auditLog({ userId: req.userId, organizationId: req.organizationId, action: "API_KEY_UPDATE", category: "AUTH", level: "WARNING", message: `API key "${key.name}" updated`, meta: { apiKeyId: key.id } }); + return reply.send({ success: true, data: updated }); + }, + ); + app.delete( "/:id", { preHandler: [requireRole("DEVELOPER")] }, @@ -141,4 +159,42 @@ export async function apiKeysRoutes(app: FastifyInstance) { return reply.send({ success: true, message: "API key revoked" }); }, ); + + // DELETE /api-keys/:id/permanent — remove a revoked key + app.delete( + "/:id/permanent", + { preHandler: [requireRole("DEVELOPER")] }, + async (req, reply) => { + const { id } = req.params as { id: string }; + + const key = await prisma.apiKey.findFirst({ + where: { id, userId: req.userId!, organizationId: req.organizationId! }, + select: { id: true, name: true, isActive: true }, + }); + if (!key) { + return reply + .status(404) + .send({ success: false, error: "API key not found" }); + } + if (key.isActive) { + return reply.status(409).send({ + success: false, + error: "Revoke the API key before removing it", + }); + } + + await prisma.apiKey.delete({ where: { id: key.id } }); + await auditLog({ + userId: req.userId, + organizationId: req.organizationId, + action: "API_KEY_REMOVE", + category: "AUTH", + level: "WARNING", + message: `Revoked API key "${key.name}" permanently removed`, + meta: { apiKeyId: key.id }, + }); + + return reply.send({ success: true, message: "API key removed" }); + }, + ); }