diff --git a/web/src/app/(dashboard)/settings/api-keys/page.tsx b/web/src/app/(dashboard)/settings/api-keys/page.tsx new file mode 100644 index 0000000..d3dd182 --- /dev/null +++ b/web/src/app/(dashboard)/settings/api-keys/page.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Copy, Check } from "lucide-react"; + +import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { + getApiKeys, + createApiKey, + revokeApiKey, +} from "@/lib/api"; +import type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys"; +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from "@/components/ui/responsive-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const PERMISSION_CATEGORIES: Record = { + Lexicons: ["lexicons:create", "lexicons:read", "lexicons:delete"], + Records: ["records:read", "records:delete", "records:delete-collection"], + "Script Variables": [ + "script-variables:create", + "script-variables:read", + "script-variables:delete", + ], + Users: ["users:create", "users:read", "users:update", "users:delete"], + "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], + Backfill: ["backfill:create", "backfill:read"], + System: ["stats:read", "events:read"], +}; + +const ALL_PERMISSIONS = Object.values(PERMISSION_CATEGORIES).flat(); + +export default function ApiKeysPage() { + const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); + const [keys, setKeys] = useState([]); + const [error, setError] = useState(null); + + const load = useCallback(() => { + getApiKeys(getToken) + .then(setKeys) + .catch((e) => setError(e.message)); + }, [getToken]); + + useEffect(() => { + load(); + }, [load]); + + async function handleRevoke(id: string) { + try { + await revokeApiKey(getToken, id); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + <> + +
+ {error &&

{error}

} + +
+

API Keys

+ {hasPermission("api-keys:create") && ( + + )} +
+ +
+ + + + Name + Key + Permissions + Created + Last Used + + + + + {keys.length === 0 && ( + + + No API keys yet. + + + )} + {keys.map((key) => ( + + + {key.name} + + + {key.key_prefix}... + + + + {key.permissions?.length ?? 0} perms + + + + {new Date(key.created_at).toLocaleString()} + + + {key.last_used_at + ? new Date(key.last_used_at).toLocaleString() + : "Never"} + + + {!key.revoked_at && hasPermission("api-keys:delete") && ( + + + + + + + + Revoke API Key + + + This will permanently revoke the key “ + {key.name}”. Any services using this key + will lose access. + + + + + + + + + + + + + )} + + + ))} + +
+
+
+ + ); +} + +function CreateApiKeyDialog({ + getToken, + onSuccess, +}: { + getToken: () => Promise; + onSuccess: () => void; +}) { + const [name, setName] = useState(""); + const [selectedPermissions, setSelectedPermissions] = + useState(ALL_PERMISSIONS); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + const [createdKey, setCreatedKey] = useState( + null + ); + const [copied, setCopied] = useState(false); + + function handleOpenChange(nextOpen: boolean) { + setOpen(nextOpen); + if (!nextOpen) { + setName(""); + setSelectedPermissions(ALL_PERMISSIONS); + setError(null); + if (createdKey) { + setCreatedKey(null); + onSuccess(); + } + } + } + + function togglePermission(perm: string) { + setSelectedPermissions((prev) => + prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] + ); + } + + function toggleCategory(perms: string[]) { + const allSelected = perms.every((p) => selectedPermissions.includes(p)); + if (allSelected) { + setSelectedPermissions((prev) => prev.filter((p) => !perms.includes(p))); + } else { + setSelectedPermissions((prev) => [ + ...prev, + ...perms.filter((p) => !prev.includes(p)), + ]); + } + } + + async function handleCreate() { + setError(null); + try { + const result = await createApiKey(getToken, { + name, + permissions: selectedPermissions, + }); + setCreatedKey(result); + setCopied(false); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + async function handleCopy() { + if (!createdKey) return; + await navigator.clipboard.writeText(createdKey.key); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + + + + + + + {createdKey ? "API Key Created" : "Create API Key"} + + + {createdKey + ? "Copy your API key now. It won\u2019t be shown again." + : "Give this key a name to identify its purpose."} + + + + {createdKey ? ( +
+
+ +
+ + +
+

+ Store this key securely. You will not be able to see it again. +

+
+
+ ) : ( +
+ {error &&

{error}

} +
+ + setName(e.target.value)} + placeholder="e.g., CI Deploy" + /> +
+
+ +
+ {Object.entries(PERMISSION_CATEGORIES).map( + ([category, perms]) => { + const allSelected = perms.every((p) => + selectedPermissions.includes(p) + ); + const someSelected = perms.some((p) => + selectedPermissions.includes(p) + ); + return ( +
+ +
+ {perms.map((perm) => ( + + ))} +
+
+ ); + } + )} +
+

+ {selectedPermissions.length} of {ALL_PERMISSIONS.length}{" "} + permissions selected +

+
+
+ )} + + + + + + {!createdKey && ( + + )} + +
+
+ ); +} diff --git a/web/src/app/(dashboard)/settings/env-variables/page.tsx b/web/src/app/(dashboard)/settings/env-variables/page.tsx new file mode 100644 index 0000000..7e0bd10 --- /dev/null +++ b/web/src/app/(dashboard)/settings/env-variables/page.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Trash2, Pencil } from "lucide-react"; + +import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { + getScriptVariables, + upsertScriptVariable, + deleteScriptVariable, +} from "@/lib/api"; +import type { ScriptVariableSummary } from "@/types/script-variables"; +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from "@/components/ui/responsive-dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export default function EnvVariablesPage() { + const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); + const [vars, setVars] = useState([]); + const [error, setError] = useState(null); + + const load = useCallback(() => { + getScriptVariables(getToken) + .then(setVars) + .catch((e) => setError(e.message)); + }, [getToken]); + + useEffect(() => { + load(); + }, [load]); + + async function handleDeleteVar(key: string) { + try { + await deleteScriptVariable(getToken, key); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + <> + +
+ {error &&

{error}

} + +
+
+

Script Variables

+

+ Define variables that Lua scripts can access via the{" "} + env global table. +

+
+ {hasPermission("script-variables:create") && ( + + )} +
+ +
+ + + + Key + Preview + Updated + + + + + {vars.length === 0 && ( + + + No script variables yet. + + + )} + {vars.map((v) => ( + + + {v.key} + + + {v.preview} + + + {new Date(v.updated_at).toLocaleString()} + + +
+ + {hasPermission("script-variables:delete") && ( + + )} +
+
+
+ ))} +
+
+
+
+ + ); +} + +function UpsertVariableDialog({ + getToken, + onSuccess, + editKey, +}: { + getToken: () => Promise; + onSuccess: () => void; + editKey?: string; +}) { + const [key, setKey] = useState(editKey ?? ""); + const [value, setValue] = useState(""); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + + const isEdit = !!editKey; + + async function handleSave() { + setError(null); + try { + await upsertScriptVariable(getToken, { + key: isEdit ? editKey : key, + value, + }); + setKey(editKey ?? ""); + setValue(""); + setOpen(false); + onSuccess(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + { + setOpen(o); + if (o) { + setKey(editKey ?? ""); + setValue(""); + setError(null); + } + }} + > + + {isEdit ? ( + + ) : ( + + )} + + + + + {isEdit ? "Edit Variable" : "Add Variable"} + + + {isEdit + ? "Update the value for this script variable." + : "Add a new script variable accessible via env.KEY in Lua scripts."} + + +
+ {error &&

{error}

} +
+ + setKey(e.target.value)} + placeholder="VARIABLE_NAME" + disabled={isEdit} + className={isEdit ? "font-mono" : ""} + /> +
+
+ +