Skip to content

Commit 8a59b0b

Browse files
committed
Add Credentials page with CRUD UI
1 parent fb726aa commit 8a59b0b

10 files changed

Lines changed: 441 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

1920
function PageLoader() {
@@ -97,6 +98,14 @@ export default function App() {
9798
</Suspense>
9899
}
99100
/>
101+
<Route
102+
path={ROUTES.CREDENTIALS}
103+
element={
104+
<Suspense fallback={<PageLoader />}>
105+
<CredentialsPage />
106+
</Suspense>
107+
}
108+
/>
100109
<Route
101110
path={ROUTES.SETTINGS}
102111
element={
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { apiFetch } from "../client";
2+
import type { CredentialsResponse, CreateCredentialPayload } from "../types";
3+
4+
export function fetchCredentials(): Promise<CredentialsResponse> {
5+
return apiFetch<CredentialsResponse>("/credentials");
6+
}
7+
8+
export function createCredential(
9+
payload: CreateCredentialPayload,
10+
): Promise<{ success: boolean }> {
11+
return apiFetch("/credentials", {
12+
method: "POST",
13+
body: JSON.stringify(payload),
14+
});
15+
}
16+
17+
export function deleteCredential(
18+
id: string,
19+
): Promise<{ success: boolean }> {
20+
return apiFetch(`/credentials/${id}`, { method: "DELETE" });
21+
}

dashboard/src/api/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,25 @@ export interface PermitsResponse {
203203
permits: Permit[];
204204
}
205205

206+
// --- Credentials ---
207+
export interface Credential {
208+
id: string;
209+
service: string;
210+
name: string;
211+
created_at: string;
212+
last_used_at: string | null;
213+
}
214+
215+
export interface CredentialsResponse {
216+
credentials: Credential[];
217+
}
218+
219+
export interface CreateCredentialPayload {
220+
service: string;
221+
name: string;
222+
api_key: string;
223+
}
224+
206225
// --- Generic ---
207226
export interface ApiError {
208227
error: string;

dashboard/src/components/layout/Shell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const routeTitles: Record<string, string> = {
1010
[ROUTES.ALERTS]: "Alerts",
1111
[ROUTES.SPEND]: "Spend Analytics",
1212
[ROUTES.ONCHAIN]: "Onchain Permits",
13+
[ROUTES.CREDENTIALS]: "Credentials",
1314
};
1415

1516
const routeSubtitles: Record<string, string> = {
@@ -18,6 +19,7 @@ const routeSubtitles: Record<string, string> = {
1819
[ROUTES.ALERTS]: "Monitor and manage security and budget alerts",
1920
[ROUTES.SPEND]: "Budget tracking and daily spend breakdown",
2021
[ROUTES.ONCHAIN]: "Contract whitelist, permit history, and signer status",
22+
[ROUTES.CREDENTIALS]: "Manage API keys for external services",
2123
};
2224

2325
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
@@ -5,6 +5,7 @@ export const ROUTES = {
55
ALERTS: "/alerts",
66
SPEND: "/spend",
77
ONCHAIN: "/onchain",
8+
CREDENTIALS: "/credentials",
89
} as const;
910

1011
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+
}

0 commit comments

Comments
 (0)