From 375afe15ab4b968e3b89f3c836bc0ade2d773792 Mon Sep 17 00:00:00 2001 From: Vicky Kumar Prasad Date: Fri, 27 Feb 2026 00:40:00 +0530 Subject: [PATCH] Add Credentials page with CRUD UI --- dashboard/src/App.tsx | 9 ++ dashboard/src/components/layout/Shell.tsx | 2 + dashboard/src/components/layout/Sidebar.tsx | 2 +- dashboard/src/hooks/use-credentials.ts | 66 ++++++++ dashboard/src/lib/constants.ts | 1 + .../pages/credentials/AddCredentialModal.tsx | 149 ++++++++++++++++++ .../src/pages/credentials/CredentialRow.tsx | 89 +++++++++++ .../src/pages/credentials/CredentialsPage.tsx | 84 ++++++++++ 8 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/hooks/use-credentials.ts create mode 100644 dashboard/src/pages/credentials/AddCredentialModal.tsx create mode 100644 dashboard/src/pages/credentials/CredentialRow.tsx create mode 100644 dashboard/src/pages/credentials/CredentialsPage.tsx diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 23f03ee..d738cd7 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -14,6 +14,7 @@ const SettingsPage = lazy(() => import("@/pages/settings/SettingsPage")); const AlertsPage = lazy(() => import("@/pages/alerts/AlertsPage")); const SpendPage = lazy(() => import("@/pages/spend/SpendPage")); const OnchainPage = lazy(() => import("@/pages/onchain/OnchainPage")); +const CredentialsPage = lazy(() => import("@/pages/credentials/CredentialsPage")); const LoginPage = lazy(() => import("@/pages/login/LoginPage")); const LandingPage = lazy(() => import("@/pages/landing/LandingPage")); const DocsLayout = lazy(() => import("@/pages/docs/DocsLayout")); @@ -178,6 +179,14 @@ export default function App() { } /> + }> + + + } + /> = { [ROUTES.ALERTS]: "Alerts", [ROUTES.SPEND]: "Spend Analytics", [ROUTES.ONCHAIN]: "Onchain Permits", + [ROUTES.CREDENTIALS]: "Credentials", }; const routeSubtitles: Record = { @@ -21,6 +22,7 @@ const routeSubtitles: Record = { [ROUTES.ALERTS]: "Monitor and manage security and budget alerts", [ROUTES.SPEND]: "Budget tracking and daily spend breakdown", [ROUTES.ONCHAIN]: "Contract whitelist, permit history, and signer status", + [ROUTES.CREDENTIALS]: "Manage API keys for external services", }; export function Shell() { diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx index e086cb9..fa2b665 100644 --- a/dashboard/src/components/layout/Sidebar.tsx +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -34,7 +34,7 @@ interface NavItemData { const mainNavItems: NavItemData[] = [ { to: ROUTES.HOME, label: "Dashboard", icon: }, - { label: "Credentials", icon: , disabled: true }, + { to: ROUTES.CREDENTIALS, label: "Credentials", icon: }, { label: "Policies", icon: , disabled: true }, { label: "Audit Log", icon: , disabled: true }, { to: ROUTES.SPEND, label: "Spend", icon: }, diff --git a/dashboard/src/hooks/use-credentials.ts b/dashboard/src/hooks/use-credentials.ts new file mode 100644 index 0000000..91e2097 --- /dev/null +++ b/dashboard/src/hooks/use-credentials.ts @@ -0,0 +1,66 @@ +import { useState, useCallback, useMemo } from "react"; +import { useFetch } from "./use-fetch"; +import { + fetchCredentials, + createCredential, + deleteCredential, +} from "@/api/endpoints/credentials"; +import type { Credential, CreateCredentialPayload } from "@/api/types"; + +interface UseCredentialsReturn { + credentials: Credential[]; + loading: boolean; + error: Error | null; + add: (payload: CreateCredentialPayload) => Promise; + remove: (id: string) => Promise; + refetch: () => void; +} + +export function useCredentials(): UseCredentialsReturn { + const { data, loading, error, refetch } = useFetch(fetchCredentials); + + const [optimisticRemoved, setOptimisticRemoved] = useState>( + new Set(), + ); + + const credentials = useMemo(() => { + if (!data) return []; + return data.credentials + .filter((c) => !optimisticRemoved.has(c.id)) + .sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + }, [data, optimisticRemoved]); + + const add = useCallback( + async (payload: CreateCredentialPayload): Promise => { + try { + await createCredential(payload); + refetch(); + return true; + } catch { + return false; + } + }, + [refetch], + ); + + const remove = useCallback( + async (id: string) => { + setOptimisticRemoved((prev) => new Set(prev).add(id)); + try { + await deleteCredential(id); + } catch { + setOptimisticRemoved((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, + [], + ); + + return { credentials, loading, error, add, remove, refetch }; +} diff --git a/dashboard/src/lib/constants.ts b/dashboard/src/lib/constants.ts index fd74c33..8deb35b 100644 --- a/dashboard/src/lib/constants.ts +++ b/dashboard/src/lib/constants.ts @@ -11,6 +11,7 @@ export const ROUTES = { DOCS_OPENCLAW: "/docs/openclaw", DOCS_POLICIES: "/docs/policies", DOCS_SECURITY: "/docs/security", + CREDENTIALS: "/credentials", } as const; export const POLLING_INTERVALS = { diff --git a/dashboard/src/pages/credentials/AddCredentialModal.tsx b/dashboard/src/pages/credentials/AddCredentialModal.tsx new file mode 100644 index 0000000..d457e0f --- /dev/null +++ b/dashboard/src/pages/credentials/AddCredentialModal.tsx @@ -0,0 +1,149 @@ +import { useState, useCallback, useEffect } from "react"; +import { X } from "lucide-react"; +import { SERVICES, SERVICE_LABELS } from "@/lib/constants"; +import type { CreateCredentialPayload } from "@/api/types"; + +interface AddCredentialModalProps { + open: boolean; + onClose: () => void; + onSubmit: (payload: CreateCredentialPayload) => Promise; +} + +export function AddCredentialModal({ + open, + onClose, + onSubmit, +}: AddCredentialModalProps) { + const [service, setService] = useState(SERVICES[0]); + const [name, setName] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [submitting, setSubmitting] = useState(false); + + // Reset form when modal opens + useEffect(() => { + if (open) { + setService(SERVICES[0]); + setName(""); + setApiKey(""); + } + }, [open]); + + // Escape key closes modal + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + const canSubmit = name.trim().length > 0 && apiKey.trim().length > 0; + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit || submitting) return; + setSubmitting(true); + const ok = await onSubmit({ + service, + name: name.trim(), + api_key: apiKey.trim(), + }); + setSubmitting(false); + if (ok) onClose(); + }, + [service, name, apiKey, canSubmit, submitting, onSubmit, onClose], + ); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+

Add Credential

+ +
+ + {/* Form */} +
+ {/* Service */} +
+ + +
+ + {/* Name */} +
+ + setName(e.target.value)} + placeholder="e.g. Production API Key" + className="w-full rounded-lg border border-border bg-surface-input px-3 py-2 text-sm text-text placeholder:text-text-tertiary/50 focus:border-brand/50 focus:outline-none focus:ring-1 focus:ring-brand/20" + /> +
+ + {/* API Key */} +
+ + setApiKey(e.target.value)} + placeholder="sk-..." + className="w-full rounded-lg border border-border bg-surface-input px-3 py-2 font-mono text-sm text-text placeholder:text-text-tertiary/50 focus:border-brand/50 focus:outline-none focus:ring-1 focus:ring-brand/20" + /> +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/dashboard/src/pages/credentials/CredentialRow.tsx b/dashboard/src/pages/credentials/CredentialRow.tsx new file mode 100644 index 0000000..81cc340 --- /dev/null +++ b/dashboard/src/pages/credentials/CredentialRow.tsx @@ -0,0 +1,89 @@ +import { useState, useCallback } from "react"; +import { Trash2 } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { timeAgo } from "@/lib/format"; +import { SERVICE_LABELS, SERVICE_DOT_CLASSES } from "@/lib/constants"; +import type { Credential } from "@/api/types"; + +interface CredentialRowProps { + credential: Credential; + onRemove: (id: string) => void; +} + +export function CredentialRow({ credential, onRemove }: CredentialRowProps) { + const [confirming, setConfirming] = useState(false); + + const handleConfirmRemove = useCallback(() => { + setConfirming(false); + onRemove(credential.id); + }, [credential.id, onRemove]); + + const serviceLabel = + SERVICE_LABELS[credential.service as keyof typeof SERVICE_LABELS] ?? + credential.service; + + const dotClass = + SERVICE_DOT_CLASSES[credential.service] ?? "bg-text-tertiary"; + + return ( + + {/* Service */} + +
+ + {serviceLabel} +
+ + + {/* Name */} + + {credential.name} + + + {/* Created */} + + + {timeAgo(credential.created_at)} + + + + {/* Last Used */} + + + {credential.last_used_at ? timeAgo(credential.last_used_at) : "Never"} + + + + {/* Action */} + + {confirming ? ( +
+ Remove? + + +
+ ) : ( + + )} + + + ); +} diff --git a/dashboard/src/pages/credentials/CredentialsPage.tsx b/dashboard/src/pages/credentials/CredentialsPage.tsx new file mode 100644 index 0000000..1a4ca91 --- /dev/null +++ b/dashboard/src/pages/credentials/CredentialsPage.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { Key, Plus } from "lucide-react"; +import { Card } from "@/components/ui/Card"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { SkeletonCard } from "@/components/ui/Skeleton"; +import { useCredentials } from "@/hooks/use-credentials"; +import { CredentialRow } from "./CredentialRow"; +import { AddCredentialModal } from "./AddCredentialModal"; + +export default function CredentialsPage() { + const { credentials, loading, add, remove } = useCredentials(); + const [showAddModal, setShowAddModal] = useState(false); + + return ( +
+ {/* Toolbar */} +
+

+ {loading + ? "Loading..." + : `${credentials.length} credential${credentials.length !== 1 ? "s" : ""}`} +

+ +
+ + {/* Content */} + {loading ? ( + + ) : credentials.length === 0 ? ( + } + title="No credentials stored" + subtitle="Add your first API key to get started." + /> + ) : ( + + + + + + + + + + + + + {credentials.map((cred) => ( + + ))} + +
+ Service + + Name + + Created + + Last Used + + Actions +
+
+ )} + + {/* Add modal */} + setShowAddModal(false)} + onSubmit={add} + /> +
+ ); +}