Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -178,6 +179,14 @@ export default function App() {
</Suspense>
}
/>
<Route
path={ROUTES.CREDENTIALS}
element={
<Suspense fallback={<PageLoader />}>
<CredentialsPage />
</Suspense>
}
/>
<Route
path={ROUTES.SETTINGS}
element={
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/components/layout/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const routeTitles: Record<string, string> = {
[ROUTES.ALERTS]: "Alerts",
[ROUTES.SPEND]: "Spend Analytics",
[ROUTES.ONCHAIN]: "Onchain Permits",
[ROUTES.CREDENTIALS]: "Credentials",
};

const routeSubtitles: Record<string, string> = {
Expand All @@ -21,6 +22,7 @@ const routeSubtitles: Record<string, string> = {
[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() {
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ interface NavItemData {

const mainNavItems: NavItemData[] = [
{ to: ROUTES.HOME, label: "Dashboard", icon: <LayoutDashboard size={18} /> },
{ label: "Credentials", icon: <Key size={18} />, disabled: true },
{ to: ROUTES.CREDENTIALS, label: "Credentials", icon: <Key size={18} /> },
{ label: "Policies", icon: <Sliders size={18} />, disabled: true },
{ label: "Audit Log", icon: <FileText size={18} />, disabled: true },
{ to: ROUTES.SPEND, label: "Spend", icon: <BarChart3 size={18} /> },
Expand Down
66 changes: 66 additions & 0 deletions dashboard/src/hooks/use-credentials.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
remove: (id: string) => Promise<void>;
refetch: () => void;
}

export function useCredentials(): UseCredentialsReturn {
const { data, loading, error, refetch } = useFetch(fetchCredentials);

const [optimisticRemoved, setOptimisticRemoved] = useState<Set<string>>(
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<boolean> => {
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 };
}
1 change: 1 addition & 0 deletions dashboard/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
149 changes: 149 additions & 0 deletions dashboard/src/pages/credentials/AddCredentialModal.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

export function AddCredentialModal({
open,
onClose,
onSubmit,
}: AddCredentialModalProps) {
const [service, setService] = useState<string>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-text">Add Credential</h2>
<button
onClick={onClose}
className="rounded-md p-1 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text"
>
<X size={16} />
</button>
</div>

{/* Form */}
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
{/* Service */}
<div>
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-text-tertiary">
Service
</label>
<select
value={service}
onChange={(e) => setService(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-input px-3 py-2 text-sm text-text focus:border-brand/50 focus:outline-none focus:ring-1 focus:ring-brand/20"
>
{SERVICES.map((s) => (
<option key={s} value={s}>
{SERVICE_LABELS[s]}
</option>
))}
</select>
</div>

{/* Name */}
<div>
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-text-tertiary">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>

{/* API Key */}
<div>
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-text-tertiary">
API Key
</label>
<input
type="password"
value={apiKey}
onChange={(e) => 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"
/>
</div>

{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg px-3 py-1.5 text-sm text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text"
>
Cancel
</button>
<button
type="submit"
disabled={!canSubmit || submitting}
className="rounded-lg bg-brand px-4 py-1.5 text-sm font-medium text-white transition-all duration-150 hover:bg-brand-hover disabled:opacity-40"
>
{submitting ? "Adding..." : "Add Credential"}
</button>
</div>
</form>
</div>
</div>
);
}
89 changes: 89 additions & 0 deletions dashboard/src/pages/credentials/CredentialRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<tr className="group border-b border-border-subtle transition-colors duration-150 hover:bg-surface-hover">
{/* Service */}
<td className="py-3 pl-5 pr-3">
<div className="flex items-center gap-2.5">
<span
className={cn("inline-block h-2 w-2 shrink-0 rounded-full", dotClass)}
/>
<span className="text-sm text-text">{serviceLabel}</span>
</div>
</td>

{/* Name */}
<td className="py-3 pr-3">
<span className="text-sm text-text-secondary">{credential.name}</span>
</td>

{/* Created */}
<td className="py-3 pr-3">
<span className="font-mono text-xs text-text-tertiary">
{timeAgo(credential.created_at)}
</span>
</td>

{/* Last Used */}
<td className="py-3 pr-3">
<span className="font-mono text-xs text-text-tertiary">
{credential.last_used_at ? timeAgo(credential.last_used_at) : "Never"}
</span>
</td>

{/* Action */}
<td className="py-3 pr-5">
{confirming ? (
<div className="flex items-center gap-2">
<span className="text-[11px] text-danger">Remove?</span>
<button
onClick={handleConfirmRemove}
className="rounded-md bg-danger/15 px-2 py-0.5 text-[11px] font-medium text-danger transition-colors hover:bg-danger/25"
>
Confirm
</button>
<button
onClick={() => setConfirming(false)}
className="rounded-md px-1.5 py-0.5 text-[11px] text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirming(true)}
className="rounded-md p-1 text-text-tertiary opacity-0 transition-all group-hover:opacity-100 hover:bg-danger-dim hover:text-danger"
title="Remove credential"
>
<Trash2 size={13} />
</button>
)}
</td>
</tr>
);
}
Loading