Skip to content

Commit aa406fd

Browse files
authored
Per-project API Keys (#92)
* sidebar component * use locked project name field * new props for project column area * new per-project keys page * rename project sidebar tab to "API Keys" * newline? * ran prettier * undo prettier * ran prettier (correctly) * resolve greptile suggestions
1 parent 4f3ad41 commit aa406fd

5 files changed

Lines changed: 400 additions & 135 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// src/app/(dashboard)/projects/[projectId]/keys/page.tsx
2+
"use client";
3+
4+
import CreateAPIKeyForm from "@/components/forms/CreateAPIKeyForm";
5+
import ApiKeyRevealCard from "@/components/forms/ApiKeyRevealForm";
6+
import { ApiKeyDataTable } from "@/components/apiKeyTable/apiKey-table";
7+
import { ApiKeyColumn } from "@/components/apiKeyTable/columns";
8+
import {
9+
Breadcrumb,
10+
BreadcrumbItem,
11+
BreadcrumbLink,
12+
BreadcrumbList,
13+
BreadcrumbPage,
14+
BreadcrumbSeparator,
15+
} from "@/components/ui/breadcrumb";
16+
import { Separator } from "@/components/ui/separator";
17+
import { getApiKeysAction, deleteApiKeyByIdAction } from "@/lib/actions";
18+
import { getProjectById } from "@/lib/project";
19+
import {
20+
UserType,
21+
useUserSession,
22+
} from "@/components/providers/SessionProvider";
23+
import { useQuery } from "@tanstack/react-query";
24+
import { ProjectResponse } from "juno-sdk/build/main/internal/index";
25+
import { useParams } from "next/navigation";
26+
import { useCallback, useEffect, useRef, useState } from "react";
27+
import { toast } from "sonner";
28+
29+
type CreatedKeyInfo = {
30+
value: string;
31+
description: string;
32+
environment: string;
33+
project: string;
34+
dateCreated: string;
35+
};
36+
37+
const PAGE_SIZE = 100;
38+
const MAX_PAGES = 50; // safety cap against a misbehaving `links.next`
39+
40+
export default function ProjectKeysPage() {
41+
const { projectId } = useParams<{ projectId: string }>();
42+
const { user } = useUserSession();
43+
44+
const { isLoading: projectLoading, data: project } =
45+
useQuery<ProjectResponse>({
46+
queryKey: ["project", projectId],
47+
queryFn: async () => {
48+
const result = await getProjectById(Number(projectId));
49+
if (!result.success) throw new Error(result.error);
50+
return result.project;
51+
},
52+
});
53+
54+
const [apiKeys, setApiKeys] = useState<ApiKeyColumn[]>([]);
55+
const [isLoading, setIsLoading] = useState(true);
56+
const [loadError, setLoadError] = useState<string | null>(null);
57+
const [possiblyIncomplete, setPossiblyIncomplete] = useState(false);
58+
const [createdKey, setCreatedKey] = useState<CreatedKeyInfo | null>(null);
59+
const [keysLoadTrigger, setKeysLoadTrigger] = useState(0);
60+
61+
const requestIdRef = useRef(0);
62+
63+
const fetchProjectKeys = useCallback(async () => {
64+
const requestId = ++requestIdRef.current;
65+
setIsLoading(true);
66+
setLoadError(null);
67+
setPossiblyIncomplete(false);
68+
try {
69+
const all: ApiKeyColumn[] = [];
70+
let offset = 0;
71+
let sawFailure = false;
72+
let hitCap = true; // assume the cap was hit unless the loop breaks naturally
73+
74+
for (let i = 0; i < MAX_PAGES; i++) {
75+
const result = await getApiKeysAction({ offset, limit: PAGE_SIZE });
76+
77+
if (requestId !== requestIdRef.current) return; // a newer fetch superseded this one
78+
79+
if (!result.success) {
80+
setLoadError(result.error ?? "Failed to fetch API keys");
81+
sawFailure = true;
82+
hitCap = false;
83+
break;
84+
}
85+
86+
const page = result.keys ?? [];
87+
for (const key of page) {
88+
if (String(key.project) === String(projectId)) {
89+
all.push({
90+
id: Number(key.id),
91+
description: key.description ?? "",
92+
dateCreated: key.createdAt
93+
? new Date(key.createdAt).toISOString().split("T")[0]
94+
: "N/A",
95+
linkedProject: project?.name ?? "",
96+
environment: key.environment ?? "N/A",
97+
});
98+
}
99+
}
100+
101+
if (!result.links?.next || page.length < PAGE_SIZE) {
102+
hitCap = false; // exhausted normally, not cut off
103+
break;
104+
}
105+
offset += PAGE_SIZE;
106+
}
107+
108+
if (requestId !== requestIdRef.current) return; // check again before committing
109+
110+
if (!sawFailure) {
111+
setApiKeys(all);
112+
setPossiblyIncomplete(hitCap);
113+
}
114+
} catch {
115+
if (requestId === requestIdRef.current) {
116+
setLoadError("Failed to fetch API keys");
117+
}
118+
} finally {
119+
if (requestId === requestIdRef.current) {
120+
setIsLoading(false);
121+
}
122+
}
123+
}, [projectId, project?.name]);
124+
125+
useEffect(() => {
126+
if (!projectLoading) fetchProjectKeys();
127+
}, [fetchProjectKeys, projectLoading, keysLoadTrigger]);
128+
129+
const handleDelete = async (key: ApiKeyColumn) => {
130+
const result = await deleteApiKeyByIdAction(String(key.id));
131+
if (result.success) {
132+
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
133+
toast.success("API key deleted successfully.");
134+
} else {
135+
toast.error(result.error ?? "Failed to delete API key");
136+
}
137+
};
138+
139+
const canCreate = user && user.type !== UserType.USER;
140+
const emptyLinks = { first: "", prev: "", next: "", last: "" };
141+
142+
return (
143+
<div className="flex flex-col">
144+
<Breadcrumb className="mb-4">
145+
<BreadcrumbList>
146+
<BreadcrumbItem>
147+
<BreadcrumbLink href="/projects">Projects</BreadcrumbLink>
148+
</BreadcrumbItem>
149+
<BreadcrumbSeparator />
150+
<BreadcrumbItem>
151+
<BreadcrumbLink href={`/projects/${projectId}`}>
152+
{projectLoading ? "****" : (project?.name ?? "Unknown")}
153+
</BreadcrumbLink>
154+
</BreadcrumbItem>
155+
<BreadcrumbSeparator />
156+
<BreadcrumbItem>
157+
<BreadcrumbPage>API Keys</BreadcrumbPage>
158+
</BreadcrumbItem>
159+
</BreadcrumbList>
160+
</Breadcrumb>
161+
<Separator className="mb-8" />
162+
163+
<div className="flex flex-col gap-8">
164+
{loadError && <p className="text-sm text-red-400">{loadError}</p>}
165+
166+
{possiblyIncomplete && (
167+
<p className="text-sm text-yellow-500">
168+
There may be more keys than shown — this project&apos;s list could
169+
not be fully loaded. Contact an admin if you expect to see
170+
additional keys.
171+
</p>
172+
)}
173+
174+
{canCreate ? (
175+
!projectLoading &&
176+
project && (
177+
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8 items-start">
178+
<CreateAPIKeyForm
179+
lockedProjectName={project.name}
180+
onKeyAdd={(newKey) => {
181+
setCreatedKey({
182+
value: newKey.value,
183+
description: newKey.description,
184+
environment: newKey.environment,
185+
project: newKey.project.name,
186+
dateCreated: new Date().toISOString().split("T")[0],
187+
});
188+
toast.success("Successfully created API key");
189+
setKeysLoadTrigger((t) => t + 1);
190+
}}
191+
/>
192+
<ApiKeyRevealCard
193+
keyValue={createdKey?.value ?? null}
194+
description={createdKey?.description ?? ""}
195+
environment={createdKey?.environment ?? ""}
196+
project={createdKey?.project ?? ""}
197+
dateCreated={createdKey?.dateCreated ?? ""}
198+
/>
199+
</div>
200+
)
201+
) : (
202+
<p className="text-sm text-muted-foreground">
203+
Only admins can create API keys for this project.
204+
</p>
205+
)}
206+
207+
<ApiKeyDataTable
208+
data={apiKeys}
209+
isLoading={isLoading}
210+
pageIndex={0}
211+
pageSize={apiKeys.length || 10}
212+
paginationLinks={emptyLinks}
213+
onPageIndexChange={() => {}}
214+
onPageSizeChange={() => {}}
215+
hideProjectColumn
216+
hidePagination
217+
onKeyAction={async (key, action) => {
218+
if (action === "delete") await handleDelete(key);
219+
}}
220+
/>
221+
</div>
222+
</div>
223+
);
224+
}

0 commit comments

Comments
 (0)