Files
doktainer-upstream/src/app/api-keys/page.tsx
T

1206 lines
38 KiB
TypeScript

"use client";
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
import GuardedPage from "@/components/GuardedPage";
import IssueDetailsSummary from "@/components/IssueDetailsSummary";
import {
apiKeys as apiKeysApi,
type ApiKeyExpiryOption,
type ApiKeyRecord,
type CreateApiKeyBody,
} from "@/lib/api";
import {
Activity,
AlertCircle,
CheckCircle,
Copy,
Eye,
EyeOff,
Key,
Loader2,
Plus,
RefreshCw,
Shield,
Trash2,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
type GeneratedKeyState = {
id: string;
name: string;
rawKey: string;
};
type PendingConfirmAction = {
title: string;
description: string;
confirmLabel: string;
tone?: "danger" | "warning" | "info";
note?: string;
onConfirm: () => void;
};
const permissionOptions: Array<{
id: string;
label: string;
description: string;
defaultChecked?: boolean;
}> = [
{
id: "read:servers",
label: "View servers",
description: "Read server identity, health, and status.",
defaultChecked: true,
},
{
id: "write:servers",
label: "Manage servers",
description: "Create, update, test, reboot, and maintain server resources.",
},
{
id: "read:containers",
label: "View containers",
description: "Read container inventory and runtime state.",
defaultChecked: true,
},
{
id: "write:containers",
label: "Manage containers",
description: "Deploy, restart, stop, or remove containers.",
},
{
id: "read:logs",
label: "View logs",
description: "Read audit and runtime logs.",
defaultChecked: true,
},
{
id: "read:metrics",
label: "View metrics",
description: "Read dashboard and server metrics.",
},
{
id: "read:domains",
label: "View domains and SSL",
description: "Read domain inventory, routing, and certificate state.",
},
{
id: "write:domains",
label: "Manage domains and SSL",
description: "Create, update, and revoke domain-related resources.",
},
{
id: "read:security",
label: "View server security",
description: "Read firewall, fail2ban, and hardening status.",
},
{
id: "write:security",
label: "Manage server security",
description: "Change firewall and fail2ban state on managed servers.",
},
{
id: "write:backups",
label: "Manage backups",
description: "Create and restore backups from automation.",
},
];
const expiryOptions: Array<{ id: ApiKeyExpiryOption; label: string }> = [
{ id: "never", label: "Never expires" },
{ id: "30d", label: "30 days" },
{ id: "90d", label: "90 days" },
{ id: "1y", label: "1 year" },
];
const initialForm: CreateApiKeyBody = {
name: "",
permissions: permissionOptions
.filter((option) => option.defaultChecked)
.map((option) => option.id),
expiresIn: "90d",
};
function formatRelativeDate(value: string | null) {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Never";
const diffSeconds = Math.max(
0,
Math.floor((Date.now() - date.getTime()) / 1000),
);
if (diffSeconds < 60) return `${diffSeconds}s ago`;
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
if (diffSeconds < 604800) return `${Math.floor(diffSeconds / 86400)}d ago`;
return date.toLocaleString();
}
function formatDate(value: string | null) {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Unknown";
return date.toLocaleDateString();
}
function maskKey(prefix: string) {
return `${prefix}${"•".repeat(24)}`;
}
export default function ApiKeysPage() {
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<CreateApiKeyBody>(initialForm);
const [revealedKeyId, setRevealedKeyId] = useState<string | null>(null);
const [copiedKeyId, setCopiedKeyId] = useState<string | null>(null);
const [generatedKey, setGeneratedKey] = useState<GeneratedKeyState | null>(
null,
);
const [sessionRawKeys, setSessionRawKeys] = useState<Record<string, string>>(
{},
);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] =
useState<PendingConfirmAction | null>(null);
const stats = useMemo(() => {
const now = Date.now();
const active = keys.filter((key) => key.isActive).length;
const revoked = keys.length - active;
const totalRequests = keys.reduce((sum, key) => sum + key.requestCount, 0);
const expiringSoon = keys.filter((key) => {
if (!key.isActive || !key.expiresAt) return false;
const expiresAt = new Date(key.expiresAt).getTime();
return expiresAt > now && expiresAt - now <= 30 * 86400000;
}).length;
return { active, revoked, totalRequests, expiringSoon };
}, [keys]);
useEffect(() => {
void loadKeys();
}, []);
async function loadKeys() {
setLoading(true);
setError("");
try {
const response = await apiKeysApi.list();
setKeys(response.data ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load API keys");
} finally {
setLoading(false);
}
}
function resetCreateState() {
setForm(initialForm);
setGeneratedKey(null);
setSubmitting(false);
}
function closeCreateModal() {
setShowCreate(false);
resetCreateState();
}
function togglePermission(permissionId: string) {
setForm((current) => ({
...current,
permissions: current.permissions.includes(permissionId)
? current.permissions.filter((value) => value !== permissionId)
: [...current.permissions, permissionId],
}));
}
async function handleCopy(id: string, value: string) {
try {
await navigator.clipboard.writeText(value);
setCopiedKeyId(id);
window.setTimeout(() => setCopiedKeyId(null), 2000);
} catch {
setError("Clipboard access failed. Copy the key manually.");
}
}
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!form.name.trim()) {
setError("Key name is required.");
return;
}
setSubmitting(true);
setError("");
try {
const response = await apiKeysApi.create({
...form,
name: form.name.trim(),
});
const createdKey = response.data;
setKeys((current) => [
{
id: createdKey.id,
name: createdKey.name,
keyPrefix: createdKey.keyPrefix,
permissions: createdKey.permissions,
lastUsed: createdKey.lastUsed,
expiresAt: createdKey.expiresAt,
isActive: createdKey.isActive,
requestCount: createdKey.requestCount,
createdAt: createdKey.createdAt,
},
...current,
]);
setSessionRawKeys((current) => ({
...current,
[createdKey.id]: createdKey.rawKey,
}));
setGeneratedKey({
id: createdKey.id,
name: createdKey.name,
rawKey: createdKey.rawKey,
});
setRevealedKeyId(createdKey.id);
setForm(initialForm);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create API key");
} finally {
setSubmitting(false);
}
}
async function revokeKey(id: string) {
const target = keys.find((key) => key.id === id);
if (!target) return;
setRevokingId(id);
setError("");
try {
await apiKeysApi.revoke(id);
setKeys((current) =>
current.map((key) =>
key.id === id ? { ...key, isActive: false } : key,
),
);
if (revealedKeyId === id) {
setRevealedKeyId(null);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to revoke API key");
} finally {
setRevokingId(null);
}
}
function handleRevoke(id: string) {
const target = keys.find((key) => key.id === id);
if (!target) return;
setConfirmDialog({
title: "Revoke API Key",
description: `Revoke API key "${target.name}"? This action cannot be undone.`,
confirmLabel: "Revoke Key",
tone: "danger",
note: "Automation and integrations using this key will stop working immediately.",
onConfirm: () => {
void revokeKey(id);
},
});
}
return (
<GuardedPage
route="/api-keys"
title="API Keys"
subtitle="Manage API keys for programmatic access"
redirectSubtitle="Redirecting to a page allowed for your role"
>
<ConfirmActionDialog
open={confirmDialog !== null}
title={confirmDialog?.title ?? ""}
description={confirmDialog?.description ?? ""}
confirmLabel={confirmDialog?.confirmLabel ?? "Confirm"}
tone={confirmDialog?.tone ?? "danger"}
note={confirmDialog?.note}
onClose={() => setConfirmDialog(null)}
onConfirm={() => {
const current = confirmDialog;
setConfirmDialog(null);
current?.onConfirm();
}}
/>
<div
className="animate-slide-in"
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
<div
className="card"
style={{
padding: 16,
display: "flex",
gap: 14,
alignItems: "flex-start",
border: "1px solid rgba(59,130,246,0.2)",
background:
"linear-gradient(135deg, rgba(59,130,246,0.12), rgba(16,185,129,0.07))",
}}
>
<div
style={{
width: 42,
height: 42,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(59,130,246,0.14)",
border: "1px solid rgba(59,130,246,0.2)",
flexShrink: 0,
}}
>
<Shield size={18} style={{ color: "#3b82f6" }} />
</div>
<div style={{ flex: 1 }}>
<p
style={{
fontSize: 14,
fontWeight: 700,
color: "var(--text-primary)",
marginBottom: 4,
}}
>
API keys are for automation and external integrations.
</p>
<p style={{ fontSize: 12, color: "var(--text-secondary)" }}>
Use them from scripts, CI/CD, bots, or internal tools through
<span style={{ fontFamily: "JetBrains Mono, monospace" }}>
{" "}
x-api-key
</span>{" "}
or
<span style={{ fontFamily: "JetBrains Mono, monospace" }}>
{" "}
Authorization: Bearer vpk_...
</span>
. Raw keys are shown only once when created.
</p>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 12,
}}
>
{[
{
label: "Active Keys",
value: stats.active,
color: "#10b981",
icon: Key,
},
{
label: "Total Requests",
value: stats.totalRequests.toLocaleString(),
color: "#3b82f6",
icon: Activity,
},
{
label: "Expiring Soon",
value: stats.expiringSoon,
color: "#f59e0b",
icon: AlertCircle,
},
{
label: "Revoked",
value: stats.revoked,
color: "#ef4444",
icon: Trash2,
},
].map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="card"
style={{
padding: 16,
display: "flex",
alignItems: "center",
gap: 14,
}}
>
<div
style={{
width: 42,
height: 42,
borderRadius: 10,
background: `${item.color}15`,
border: `1px solid ${item.color}25`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icon size={18} style={{ color: item.color }} />
</div>
<div>
<p
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--text-primary)",
lineHeight: 1,
}}
>
{item.value}
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 3,
}}
>
{item.label}
</p>
</div>
</div>
);
})}
</div>
<div
className="card"
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
>
<p style={{ fontSize: 13, color: "var(--text-muted)" }}>
{keys.length} API keys total
</p>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
className="btn btn-ghost"
style={{ fontSize: 12 }}
onClick={() => void loadKeys()}
disabled={loading}
>
{loading ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} />
)}
Refresh
</button>
<button
className="btn btn-primary"
style={{ fontSize: 12 }}
onClick={() => {
resetCreateState();
setShowCreate(true);
}}
>
<Plus size={12} /> Generate New Key
</button>
</div>
</div>
{error && (
<IssueDetailsSummary
label="API Keys"
message={error}
description="API key data could not be loaded or updated."
/>
)}
{loading ? (
<div
className="card"
style={{
padding: 28,
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 10,
color: "var(--text-muted)",
}}
>
<Loader2 size={16} className="animate-spin" />
<span style={{ fontSize: 13 }}>Loading API keys...</span>
</div>
) : keys.length === 0 ? (
<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 yet
</p>
<p style={{ fontSize: 12, marginTop: 6 }}>
Create a key to allow scripts, bots, or deployment pipelines to
call this panel without logging in through the browser.
</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{keys.map((key) => {
const canReveal = Boolean(sessionRawKeys[key.id]);
const isRevealed = revealedKeyId === key.id && canReveal;
const rawValue = sessionRawKeys[key.id];
return (
<div
key={key.id}
className="card"
style={{ padding: 18, opacity: key.isActive ? 1 : 0.6 }}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
marginBottom: 14,
flexWrap: "wrap",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 12 }}
>
<div
style={{
width: 38,
height: 38,
borderRadius: 9,
background: key.isActive
? "rgba(59,130,246,0.1)"
: "rgba(100,116,139,0.1)",
border: `1px solid ${key.isActive ? "rgba(59,130,246,0.3)" : "rgba(100,116,139,0.3)"}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Key
size={16}
style={{
color: key.isActive ? "#3b82f6" : "#64748b",
}}
/>
</div>
<div>
<p
style={{
fontSize: 14,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
{key.name}
</p>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
Created {formatDate(key.createdAt)} · Last used{" "}
{formatRelativeDate(key.lastUsed)}
</p>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
{key.expiresAt && (
<span
style={{
background: "rgba(245,158,11,0.1)",
color: "#f59e0b",
border: "1px solid rgba(245,158,11,0.25)",
padding: "3px 10px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
}}
>
Expires {formatDate(key.expiresAt)}
</span>
)}
<span
style={{
background: key.isActive
? "rgba(16,185,129,0.1)"
: "rgba(239,68,68,0.1)",
color: key.isActive ? "#10b981" : "#ef4444",
border: `1px solid ${key.isActive ? "rgba(16,185,129,0.3)" : "rgba(239,68,68,0.3)"}`,
padding: "3px 10px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
textTransform: "capitalize",
}}
>
{key.isActive ? "active" : "revoked"}
</span>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "8px 14px",
marginBottom: 8,
}}
>
<Key
size={12}
style={{ color: "var(--text-muted)", flexShrink: 0 }}
/>
<code
style={{
flex: 1,
fontSize: 12,
fontFamily: "JetBrains Mono, monospace",
color: "var(--text-secondary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{isRevealed && rawValue
? rawValue
: maskKey(key.keyPrefix)}
</code>
<button
onClick={() =>
setRevealedKeyId(isRevealed ? null : key.id)
}
disabled={!canReveal}
style={{
background: "none",
border: "none",
color: canReveal ? "var(--text-muted)" : "#475569",
cursor: canReveal ? "pointer" : "not-allowed",
padding: 2,
}}
title={
canReveal
? "Show or hide raw key"
: "Raw key is only available right after creation"
}
>
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
</button>
<button
onClick={() =>
rawValue && void handleCopy(key.id, rawValue)
}
disabled={!rawValue}
style={{
background: "none",
border: "none",
color:
copiedKeyId === key.id
? "#10b981"
: rawValue
? "var(--text-muted)"
: "#475569",
cursor: rawValue ? "pointer" : "not-allowed",
padding: 2,
}}
title={
rawValue
? "Copy raw API key"
: "Raw key is only available right after creation"
}
>
{copiedKeyId === key.id ? (
<CheckCircle size={13} />
) : (
<Copy size={13} />
)}
</button>
</div>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginBottom: 12,
}}
>
{canReveal
? "Raw key is still available in this browser session. Store it securely now."
: "For security, the raw key is permanently hidden after creation. Create a new key if you lost it."}
</p>
<div
style={{
display: "flex",
gap: 5,
flexWrap: "wrap",
marginBottom: 14,
}}
>
{key.permissions.length > 0 ? (
key.permissions.map((permission) => (
<span
key={permission}
style={{
fontSize: 10,
background: "rgba(59,130,246,0.08)",
color: "#3b82f6",
border: "1px solid rgba(59,130,246,0.2)",
padding: "2px 8px",
borderRadius: 4,
fontFamily: "JetBrains Mono, monospace",
}}
>
{permission}
</span>
))
) : (
<span
style={{ fontSize: 11, color: "var(--text-muted)" }}
>
No scoped permissions stored.
</span>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
>
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
<Activity
size={10}
style={{
display: "inline",
marginRight: 4,
verticalAlign: "middle",
}}
/>
{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>
</div>
);
})}
</div>
)}
</div>
{showCreate && (
<div className="modal-overlay" onClick={closeCreateModal}>
<div
className="modal-shell"
style={{ maxWidth: 520 }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={closeCreateModal}
className="modal-close"
aria-label="Close API key modal"
>
<X size={22} />
</button>
<div
className="modal"
style={{
maxWidth: 520,
maxHeight: "min(90vh, calc(100vh - 32px))",
overflowY: "auto",
padding: "clamp(18px, 3vw, 24px)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: 20,
paddingRight: 36,
}}
>
<div>
<h3
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
{generatedKey ? "API Key Created" : "Generate API Key"}
</h3>
<p
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 2,
}}
>
{generatedKey
? "Save the raw key now. It will not be shown again after this session."
: "Create a new API key for scripts, bots, and CI/CD jobs."}
</p>
</div>
</div>
{generatedKey ? (
<div
style={{ display: "flex", flexDirection: "column", gap: 14 }}
>
<div
style={{
padding: 14,
borderRadius: 10,
background: "rgba(16,185,129,0.08)",
border: "1px solid rgba(16,185,129,0.22)",
display: "flex",
alignItems: "flex-start",
gap: 10,
}}
>
<CheckCircle
size={16}
style={{ color: "#10b981", marginTop: 1 }}
/>
<div>
<p
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
{generatedKey.name} is ready
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginTop: 4,
}}
>
Store this value in a password manager, secrets vault, or
CI variable.
</p>
</div>
</div>
<div
style={{
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: 14,
}}
>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginBottom: 8,
}}
>
Raw API key
</p>
<code
style={{
display: "block",
wordBreak: "break-all",
fontSize: 12,
lineHeight: 1.6,
color: "var(--text-primary)",
fontFamily: "JetBrains Mono, monospace",
}}
>
{generatedKey.rawKey}
</code>
</div>
<div
style={{
background: "rgba(59,130,246,0.08)",
border: "1px solid rgba(59,130,246,0.18)",
borderRadius: 8,
padding: 12,
}}
>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginBottom: 6,
}}
>
Example usage
</p>
<code
style={{
display: "block",
wordBreak: "break-all",
fontSize: 11,
lineHeight: 1.6,
color: "var(--text-primary)",
fontFamily: "JetBrains Mono, monospace",
}}
>
{`curl -H "x-api-key: ${generatedKey.rawKey}" http://localhost:4000/api/v1/servers`}
</code>
</div>
<div
style={{
display: "flex",
gap: 10,
marginTop: 4,
flexWrap: "wrap",
}}
>
<button
className="btn btn-ghost"
style={{ flex: "1 1 180px" }}
onClick={() =>
void handleCopy(generatedKey.id, generatedKey.rawKey)
}
>
{copiedKeyId === generatedKey.id ? (
<CheckCircle size={12} />
) : (
<Copy size={12} />
)}
{copiedKeyId === generatedKey.id ? "Copied" : "Copy Key"}
</button>
<button
className="btn btn-primary"
style={{ flex: "1 1 180px" }}
onClick={closeCreateModal}
>
Done
</button>
</div>
</div>
) : (
<form
onSubmit={(event) => void handleCreate(event)}
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
<div>
<label
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 5,
display: "block",
fontWeight: 500,
}}
>
Key Name
</label>
<input
className="input"
placeholder="e.g. CI/CD Pipeline"
value={form.name}
onChange={(event) =>
setForm((current) => ({
...current,
name: event.target.value,
}))
}
/>
</div>
<div>
<label
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 8,
display: "block",
fontWeight: 500,
}}
>
Permissions
</label>
<div
style={{ display: "flex", flexDirection: "column", gap: 6 }}
>
{permissionOptions.map((option) => {
const checked = form.permissions.includes(option.id);
return (
<label
key={option.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
background: checked
? "rgba(59,130,246,0.08)"
: "var(--bg-input)",
padding: "8px 12px",
borderRadius: 7,
border: `1px solid ${checked ? "rgba(59,130,246,0.22)" : "var(--border)"}`,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => togglePermission(option.id)}
style={{ accentColor: "#3b82f6" }}
/>
<div>
<p
style={{
fontSize: 12,
fontFamily: "JetBrains Mono, monospace",
color: "#3b82f6",
}}
>
{option.id}
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
}}
>
{option.description}
</p>
</div>
</label>
);
})}
</div>
</div>
<div>
<label
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 5,
display: "block",
fontWeight: 500,
}}
>
Expiry
</label>
<select
className="input"
style={{ appearance: "none" }}
value={form.expiresIn}
onChange={(event) =>
setForm((current) => ({
...current,
expiresIn: event.target.value as ApiKeyExpiryOption,
}))
}
>
{expiryOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ flex: 1 }}
onClick={closeCreateModal}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={submitting || !form.name.trim()}
>
{submitting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Key size={12} />
)}
Generate Key
</button>
</div>
</form>
)}
</div>
</div>
</div>
)}
</GuardedPage>
);
}