Skip to content

Commit 00b52ec

Browse files
authored
Merge pull request #37 from fishnetproxy/feature/credentials-page
Add Credentials page with CRUD UI
2 parents ee4fc23 + 375afe1 commit 00b52ec

8 files changed

Lines changed: 401 additions & 1 deletion

File tree

dashboard/src/App.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const SettingsPage = lazy(() => import("@/pages/settings/SettingsPage"));
1414
const AlertsPage = lazy(() => import("@/pages/alerts/AlertsPage"));
1515
const SpendPage = lazy(() => import("@/pages/spend/SpendPage"));
1616
const OnchainPage = lazy(() => import("@/pages/onchain/OnchainPage"));
17+
const CredentialsPage = lazy(() => import("@/pages/credentials/CredentialsPage"));
1718
const LoginPage = lazy(() => import("@/pages/login/LoginPage"));
1819
const LandingPage = lazy(() => import("@/pages/landing/LandingPage"));
1920
const DocsLayout = lazy(() => import("@/pages/docs/DocsLayout"));
@@ -178,6 +179,14 @@ export default function App() {
178179
</Suspense>
179180
}
180181
/>
182+
<Route
183+
path={ROUTES.CREDENTIALS}
184+
element={
185+
<Suspense fallback={<PageLoader />}>
186+
<CredentialsPage />
187+
</Suspense>
188+
}
189+
/>
181190
<Route
182191
path={ROUTES.SETTINGS}
183192
element={

dashboard/src/components/layout/Shell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const routeTitles: Record<string, string> = {
1313
[ROUTES.ALERTS]: "Alerts",
1414
[ROUTES.SPEND]: "Spend Analytics",
1515
[ROUTES.ONCHAIN]: "Onchain Permits",
16+
[ROUTES.CREDENTIALS]: "Credentials",
1617
};
1718

1819
const routeSubtitles: Record<string, string> = {
@@ -21,6 +22,7 @@ const routeSubtitles: Record<string, string> = {
2122
[ROUTES.ALERTS]: "Monitor and manage security and budget alerts",
2223
[ROUTES.SPEND]: "Budget tracking and daily spend breakdown",
2324
[ROUTES.ONCHAIN]: "Contract whitelist, permit history, and signer status",
25+
[ROUTES.CREDENTIALS]: "Manage API keys for external services",
2426
};
2527

2628
export function Shell() {

dashboard/src/components/layout/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ interface NavItemData {
3434

3535
const mainNavItems: NavItemData[] = [
3636
{ to: ROUTES.HOME, label: "Dashboard", icon: <LayoutDashboard size={18} /> },
37-
{ label: "Credentials", icon: <Key size={18} />, disabled: true },
37+
{ to: ROUTES.CREDENTIALS, label: "Credentials", icon: <Key size={18} /> },
3838
{ label: "Policies", icon: <Sliders size={18} />, disabled: true },
3939
{ label: "Audit Log", icon: <FileText size={18} />, disabled: true },
4040
{ to: ROUTES.SPEND, label: "Spend", icon: <BarChart3 size={18} /> },
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useState, useCallback, useMemo } from "react";
2+
import { useFetch } from "./use-fetch";
3+
import {
4+
fetchCredentials,
5+
createCredential,
6+
deleteCredential,
7+
} from "@/api/endpoints/credentials";
8+
import type { Credential, CreateCredentialPayload } from "@/api/types";
9+
10+
interface UseCredentialsReturn {
11+
credentials: Credential[];
12+
loading: boolean;
13+
error: Error | null;
14+
add: (payload: CreateCredentialPayload) => Promise<boolean>;
15+
remove: (id: string) => Promise<void>;
16+
refetch: () => void;
17+
}
18+
19+
export function useCredentials(): UseCredentialsReturn {
20+
const { data, loading, error, refetch } = useFetch(fetchCredentials);
21+
22+
const [optimisticRemoved, setOptimisticRemoved] = useState<Set<string>>(
23+
new Set(),
24+
);
25+
26+
const credentials = useMemo(() => {
27+
if (!data) return [];
28+
return data.credentials
29+
.filter((c) => !optimisticRemoved.has(c.id))
30+
.sort(
31+
(a, b) =>
32+
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
33+
);
34+
}, [data, optimisticRemoved]);
35+
36+
const add = useCallback(
37+
async (payload: CreateCredentialPayload): Promise<boolean> => {
38+
try {
39+
await createCredential(payload);
40+
refetch();
41+
return true;
42+
} catch {
43+
return false;
44+
}
45+
},
46+
[refetch],
47+
);
48+
49+
const remove = useCallback(
50+
async (id: string) => {
51+
setOptimisticRemoved((prev) => new Set(prev).add(id));
52+
try {
53+
await deleteCredential(id);
54+
} catch {
55+
setOptimisticRemoved((prev) => {
56+
const next = new Set(prev);
57+
next.delete(id);
58+
return next;
59+
});
60+
}
61+
},
62+
[],
63+
);
64+
65+
return { credentials, loading, error, add, remove, refetch };
66+
}

dashboard/src/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const ROUTES = {
1111
DOCS_OPENCLAW: "/docs/openclaw",
1212
DOCS_POLICIES: "/docs/policies",
1313
DOCS_SECURITY: "/docs/security",
14+
CREDENTIALS: "/credentials",
1415
} as const;
1516

1617
export const POLLING_INTERVALS = {
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { useState, useCallback, useEffect } from "react";
2+
import { X } from "lucide-react";
3+
import { SERVICES, SERVICE_LABELS } from "@/lib/constants";
4+
import type { CreateCredentialPayload } from "@/api/types";
5+
6+
interface AddCredentialModalProps {
7+
open: boolean;
8+
onClose: () => void;
9+
onSubmit: (payload: CreateCredentialPayload) => Promise<boolean>;
10+
}
11+
12+
export function AddCredentialModal({
13+
open,
14+
onClose,
15+
onSubmit,
16+
}: AddCredentialModalProps) {
17+
const [service, setService] = useState<string>(SERVICES[0]);
18+
const [name, setName] = useState("");
19+
const [apiKey, setApiKey] = useState("");
20+
const [submitting, setSubmitting] = useState(false);
21+
22+
// Reset form when modal opens
23+
useEffect(() => {
24+
if (open) {
25+
setService(SERVICES[0]);
26+
setName("");
27+
setApiKey("");
28+
}
29+
}, [open]);
30+
31+
// Escape key closes modal
32+
useEffect(() => {
33+
if (!open) return;
34+
const handler = (e: KeyboardEvent) => {
35+
if (e.key === "Escape") onClose();
36+
};
37+
window.addEventListener("keydown", handler);
38+
return () => window.removeEventListener("keydown", handler);
39+
}, [open, onClose]);
40+
41+
const canSubmit = name.trim().length > 0 && apiKey.trim().length > 0;
42+
43+
const handleSubmit = useCallback(
44+
async (e: React.FormEvent) => {
45+
e.preventDefault();
46+
if (!canSubmit || submitting) return;
47+
setSubmitting(true);
48+
const ok = await onSubmit({
49+
service,
50+
name: name.trim(),
51+
api_key: apiKey.trim(),
52+
});
53+
setSubmitting(false);
54+
if (ok) onClose();
55+
},
56+
[service, name, apiKey, canSubmit, submitting, onSubmit, onClose],
57+
);
58+
59+
if (!open) return null;
60+
61+
return (
62+
<div
63+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
64+
onClick={(e) => {
65+
if (e.target === e.currentTarget) onClose();
66+
}}
67+
>
68+
<div className="w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-2xl">
69+
{/* Header */}
70+
<div className="flex items-center justify-between">
71+
<h2 className="text-lg font-semibold text-text">Add Credential</h2>
72+
<button
73+
onClick={onClose}
74+
className="rounded-md p-1 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text"
75+
>
76+
<X size={16} />
77+
</button>
78+
</div>
79+
80+
{/* Form */}
81+
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
82+
{/* Service */}
83+
<div>
84+
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-text-tertiary">
85+
Service
86+
</label>
87+
<select
88+
value={service}
89+
onChange={(e) => setService(e.target.value)}
90+
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"
91+
>
92+
{SERVICES.map((s) => (
93+
<option key={s} value={s}>
94+
{SERVICE_LABELS[s]}
95+
</option>
96+
))}
97+
</select>
98+
</div>
99+
100+
{/* Name */}
101+
<div>
102+
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-text-tertiary">
103+
Name
104+
</label>
105+
<input
106+
type="text"
107+
value={name}
108+
onChange={(e) => setName(e.target.value)}
109+
placeholder="e.g. Production API Key"
110+
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"
111+
/>
112+
</div>
113+
114+
{/* API Key */}
115+
<div>
116+
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-text-tertiary">
117+
API Key
118+
</label>
119+
<input
120+
type="password"
121+
value={apiKey}
122+
onChange={(e) => setApiKey(e.target.value)}
123+
placeholder="sk-..."
124+
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"
125+
/>
126+
</div>
127+
128+
{/* Actions */}
129+
<div className="flex items-center justify-end gap-2 pt-2">
130+
<button
131+
type="button"
132+
onClick={onClose}
133+
className="rounded-lg px-3 py-1.5 text-sm text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text"
134+
>
135+
Cancel
136+
</button>
137+
<button
138+
type="submit"
139+
disabled={!canSubmit || submitting}
140+
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"
141+
>
142+
{submitting ? "Adding..." : "Add Credential"}
143+
</button>
144+
</div>
145+
</form>
146+
</div>
147+
</div>
148+
);
149+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useState, useCallback } from "react";
2+
import { Trash2 } from "lucide-react";
3+
import { cn } from "@/lib/cn";
4+
import { timeAgo } from "@/lib/format";
5+
import { SERVICE_LABELS, SERVICE_DOT_CLASSES } from "@/lib/constants";
6+
import type { Credential } from "@/api/types";
7+
8+
interface CredentialRowProps {
9+
credential: Credential;
10+
onRemove: (id: string) => void;
11+
}
12+
13+
export function CredentialRow({ credential, onRemove }: CredentialRowProps) {
14+
const [confirming, setConfirming] = useState(false);
15+
16+
const handleConfirmRemove = useCallback(() => {
17+
setConfirming(false);
18+
onRemove(credential.id);
19+
}, [credential.id, onRemove]);
20+
21+
const serviceLabel =
22+
SERVICE_LABELS[credential.service as keyof typeof SERVICE_LABELS] ??
23+
credential.service;
24+
25+
const dotClass =
26+
SERVICE_DOT_CLASSES[credential.service] ?? "bg-text-tertiary";
27+
28+
return (
29+
<tr className="group border-b border-border-subtle transition-colors duration-150 hover:bg-surface-hover">
30+
{/* Service */}
31+
<td className="py-3 pl-5 pr-3">
32+
<div className="flex items-center gap-2.5">
33+
<span
34+
className={cn("inline-block h-2 w-2 shrink-0 rounded-full", dotClass)}
35+
/>
36+
<span className="text-sm text-text">{serviceLabel}</span>
37+
</div>
38+
</td>
39+
40+
{/* Name */}
41+
<td className="py-3 pr-3">
42+
<span className="text-sm text-text-secondary">{credential.name}</span>
43+
</td>
44+
45+
{/* Created */}
46+
<td className="py-3 pr-3">
47+
<span className="font-mono text-xs text-text-tertiary">
48+
{timeAgo(credential.created_at)}
49+
</span>
50+
</td>
51+
52+
{/* Last Used */}
53+
<td className="py-3 pr-3">
54+
<span className="font-mono text-xs text-text-tertiary">
55+
{credential.last_used_at ? timeAgo(credential.last_used_at) : "Never"}
56+
</span>
57+
</td>
58+
59+
{/* Action */}
60+
<td className="py-3 pr-5">
61+
{confirming ? (
62+
<div className="flex items-center gap-2">
63+
<span className="text-[11px] text-danger">Remove?</span>
64+
<button
65+
onClick={handleConfirmRemove}
66+
className="rounded-md bg-danger/15 px-2 py-0.5 text-[11px] font-medium text-danger transition-colors hover:bg-danger/25"
67+
>
68+
Confirm
69+
</button>
70+
<button
71+
onClick={() => setConfirming(false)}
72+
className="rounded-md px-1.5 py-0.5 text-[11px] text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text"
73+
>
74+
Cancel
75+
</button>
76+
</div>
77+
) : (
78+
<button
79+
onClick={() => setConfirming(true)}
80+
className="rounded-md p-1 text-text-tertiary opacity-0 transition-all group-hover:opacity-100 hover:bg-danger-dim hover:text-danger"
81+
title="Remove credential"
82+
>
83+
<Trash2 size={13} />
84+
</button>
85+
)}
86+
</td>
87+
</tr>
88+
);
89+
}

0 commit comments

Comments
 (0)