feat(api-keys): add pagination, edit, revoke, and permanent removal
This commit is contained in:
1 parent
626cadbde6
commit
9536dba3a1
3 files changed
+268
-34
No files matched your search
+167
-21
@@ -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<string | null>(null);
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const [editingKey, setEditingKey] = useState<ApiKeyRecord | null>(null);
|
||||
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ name: "", permissions: [] as string[], expiresIn: "keep" as ApiKeyExpiryOption | "keep" });
|
||||
const [confirmDialog, setConfirmDialog] =
|
||||
useState<PendingConfirmAction | null>(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<HTMLFormElement>) {
|
||||
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 (
|
||||
<GuardedPage
|
||||
route="/api-keys"
|
||||
@@ -500,9 +577,16 @@ export default function ApiKeysPage() {
|
||||
className="card ui-responsive-toolbar"
|
||||
style={{ padding: "12px 16px" }}
|
||||
>
|
||||
<SearchField placeholder="Search API keys, prefixes, or permissions..." value={search} onChange={(event) => setSearch(event.target.value)} containerStyle={{ flex: "1 1 360px", minWidth: 240 }} />
|
||||
<SearchField
|
||||
placeholder="Search API keys, prefixes, or permissions..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
containerStyle={{ flex: "1 1 360px", minWidth: 240 }}
|
||||
/>
|
||||
<div className="ui-toolbar-actions">
|
||||
<span style={{ fontSize: 13, color: "var(--text-muted)" }}>{keys.length} API keys total</span>
|
||||
<span style={{ fontSize: 13, color: "var(--text-muted)" }}>
|
||||
{keys.length} API keys total
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
@@ -577,13 +661,28 @@ export default function ApiKeysPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<div className="card" style={{ padding: 24, textAlign: "center", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 24,
|
||||
textAlign: "center",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<Key size={20} style={{ marginBottom: 10, color: "#3b82f6" }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 700, color: "var(--text-primary)" }}>No API keys match your search</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
No API keys match your search
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{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
|
||||
</span>
|
||||
{key.isActive && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ fontSize: 11, padding: "5px 12px" }}
|
||||
onClick={() => void handleRevoke(key.id)}
|
||||
disabled={revokingId === key.id}
|
||||
>
|
||||
{revokingId === key.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={11} />
|
||||
)}
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{key.isActive ? (
|
||||
<>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "5px 12px" }} onClick={() => openEdit(key)}>
|
||||
<Pencil size={11} /> Edit
|
||||
</button>
|
||||
<button className="btn btn-danger" style={{ fontSize: 11, padding: "5px 12px" }} onClick={() => void handleRevoke(key.id)} disabled={revokingId === key.id}>
|
||||
{revokingId === key.id ? <Loader2 size={11} className="animate-spin" /> : <Ban size={11} />}
|
||||
Revoke
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ fontSize: 11, padding: "5px 12px" }}
|
||||
onClick={() => handleRemove(key.id)}
|
||||
disabled={removingId === key.id}
|
||||
>
|
||||
{removingId === key.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={11} />
|
||||
)}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<TablePagination
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.totalItems}
|
||||
startItem={pagination.startItem}
|
||||
endItem={pagination.endItem}
|
||||
itemLabel="API keys"
|
||||
onPageChange={pagination.setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1204,6 +1326,30 @@ export default function ApiKeysPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingKey && (
|
||||
<div className="modal-overlay" onClick={() => !editSubmitting && setEditingKey(null)}>
|
||||
<div className="modal-shell" style={{ maxWidth: 520 }} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal" style={{ padding: "clamp(18px, 3vw, 24px)" }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700, color: "var(--text-primary)" }}>Edit API Key</h3>
|
||||
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 4 }}>Update the key name, permissions, or expiry. Changes apply immediately.</p>
|
||||
<form onSubmit={(event) => void handleEdit(event)} style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 18 }}>
|
||||
<input className="input" aria-label="API key name" value={editForm.name} onChange={(event) => setEditForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{permissionOptions.map((option) => {
|
||||
const checked = editForm.permissions.includes(option.id);
|
||||
return <label key={option.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 12px", borderRadius: 7, border: "1px solid var(--border)", background: checked ? "rgba(59,130,246,0.08)" : "var(--bg-input)", cursor: "pointer" }}><input type="checkbox" checked={checked} onChange={() => setEditForm((current) => ({ ...current, permissions: checked ? current.permissions.filter((value) => value !== option.id) : [...current.permissions, option.id] }))} /><span style={{ fontSize: 12, color: "var(--text-secondary)" }}>{option.label}</span></label>;
|
||||
})}
|
||||
</div>
|
||||
<select className="input" aria-label="API key expiry" value={editForm.expiresIn} onChange={(event) => setEditForm((current) => ({ ...current, expiresIn: event.target.value as ApiKeyExpiryOption | "keep" }))}>
|
||||
<option value="keep">Keep current expiry</option>{expiryOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
|
||||
</select>
|
||||
<div style={{ display: "flex", gap: 10 }}><button type="button" className="btn btn-ghost" style={{ flex: 1 }} onClick={() => setEditingKey(null)} disabled={editSubmitting}>Cancel</button><button type="submit" className="btn btn-primary" style={{ flex: 1 }} disabled={editSubmitting || !editForm.name.trim()}>{editSubmitting ? <Loader2 size={12} className="animate-spin" /> : <Pencil size={12} />} Save Changes</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GuardedPage>
|
||||
);
|
||||
}
|
||||
+45
-13
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user