From c9f73d516400f669c812c8f77b3574b71edc5436 Mon Sep 17 00:00:00 2001 From: Gautam25Raj Date: Tue, 19 May 2026 02:30:49 +0530 Subject: [PATCH] feat(api-keys): add API key management components and hooks - Introduced new components for creating and managing API keys, including forms and detail views. - Implemented hooks for API key creation and fetching, enhancing state management and user interactions. - Added types for API key records and pagination to improve type safety and clarity. - Created utility functions for handling API key details and formatting. - Enhanced UI with new cards, buttons, and badges for better user experience. - Implemented actions for rotating, revoking, and deleting API keys with appropriate feedback. --- .../src/controllers/apiKeyController.ts | 32 ++- apps/server/src/routes/apiKeys.ts | 4 +- apps/server/src/services/apiKeyService.ts | 56 +++-- .../(main)/(dashboard)/api-keys/[id]/page.tsx | 52 ++++ .../(dashboard)/api-keys/create/page.tsx | 57 +++++ .../app/(main)/(dashboard)/api-keys/page.tsx | 102 ++++++++ .../(main)/(dashboard)/dashboard/error.tsx | 49 ---- apps/studio/app/(main)/editor/[id]/error.tsx | 42 ---- .../studio/app/(main)/editor/[id]/loading.tsx | 12 - .../app/(main)/editor/[id]/not-found.tsx | 38 --- .../app/(main)/editor/[id]/preview/error.tsx | 40 --- .../(main)/editor/[id]/preview/loading.tsx | 10 - .../(main)/editor/[id]/preview/not-found.tsx | 37 --- .../api-keys/components/ApiKeyList.tsx | 227 +++++++++++++++++ .../api-keys/components/ApiKeyRotateModal.tsx | 236 ++++++++++++++++++ .../api-keys/components/ApiKeyScopes.ts | 103 ++++++++ .../api-keys/components/ApiKeySection.tsx | 107 ++++++++ .../api-keys/components/ApiKeyTypes.ts | 44 ++++ .../components/GeneratedApiKeyCard.tsx | 44 ++++ .../components/create/ApiKeyCreateClient.tsx | 48 ++++ .../components/create/ApiKeyDetailsForm.tsx | 84 +++++++ .../components/create/ApiKeyScopeRail.tsx | 106 ++++++++ .../create/ApiKeySelectedScopes.tsx | 79 ++++++ .../details/ApiKeyDetailActions.tsx | 58 +++++ .../components/details/ApiKeyDetailHeader.tsx | 71 ++++++ .../details/ApiKeyDetailOverview.tsx | 48 ++++ .../components/details/ApiKeyDetailScopes.tsx | 65 +++++ .../components/details/ApiKeyDetailView.tsx | 182 ++++++++++++++ .../details/api-key-detail-utils.ts | 20 ++ .../api-keys/hooks/useApiKeyCreate.ts | 109 ++++++++ .../features/api-keys/hooks/useApiKeys.ts | 201 +++++++++++++++ .../advanced/AdvancedProfileClient.tsx | 7 +- .../advanced/AdvancedProfilePanels.tsx | 4 +- packages/ui/src/components/ui/Checkbox.tsx | 3 - packages/ui/src/components/ui/Input.tsx | 2 +- 35 files changed, 2120 insertions(+), 259 deletions(-) create mode 100644 apps/studio/app/(main)/(dashboard)/api-keys/[id]/page.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/api-keys/create/page.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/api-keys/page.tsx delete mode 100644 apps/studio/app/(main)/(dashboard)/dashboard/error.tsx delete mode 100644 apps/studio/app/(main)/editor/[id]/error.tsx delete mode 100644 apps/studio/app/(main)/editor/[id]/loading.tsx delete mode 100644 apps/studio/app/(main)/editor/[id]/not-found.tsx delete mode 100644 apps/studio/app/(main)/editor/[id]/preview/error.tsx delete mode 100644 apps/studio/app/(main)/editor/[id]/preview/loading.tsx delete mode 100644 apps/studio/app/(main)/editor/[id]/preview/not-found.tsx create mode 100644 apps/studio/features/api-keys/components/ApiKeyList.tsx create mode 100644 apps/studio/features/api-keys/components/ApiKeyRotateModal.tsx create mode 100644 apps/studio/features/api-keys/components/ApiKeyScopes.ts create mode 100644 apps/studio/features/api-keys/components/ApiKeySection.tsx create mode 100644 apps/studio/features/api-keys/components/ApiKeyTypes.ts create mode 100644 apps/studio/features/api-keys/components/GeneratedApiKeyCard.tsx create mode 100644 apps/studio/features/api-keys/components/create/ApiKeyCreateClient.tsx create mode 100644 apps/studio/features/api-keys/components/create/ApiKeyDetailsForm.tsx create mode 100644 apps/studio/features/api-keys/components/create/ApiKeyScopeRail.tsx create mode 100644 apps/studio/features/api-keys/components/create/ApiKeySelectedScopes.tsx create mode 100644 apps/studio/features/api-keys/components/details/ApiKeyDetailActions.tsx create mode 100644 apps/studio/features/api-keys/components/details/ApiKeyDetailHeader.tsx create mode 100644 apps/studio/features/api-keys/components/details/ApiKeyDetailOverview.tsx create mode 100644 apps/studio/features/api-keys/components/details/ApiKeyDetailScopes.tsx create mode 100644 apps/studio/features/api-keys/components/details/ApiKeyDetailView.tsx create mode 100644 apps/studio/features/api-keys/components/details/api-key-detail-utils.ts create mode 100644 apps/studio/features/api-keys/hooks/useApiKeyCreate.ts create mode 100644 apps/studio/features/api-keys/hooks/useApiKeys.ts diff --git a/apps/server/src/controllers/apiKeyController.ts b/apps/server/src/controllers/apiKeyController.ts index a87be75..9f8dbf8 100644 --- a/apps/server/src/controllers/apiKeyController.ts +++ b/apps/server/src/controllers/apiKeyController.ts @@ -5,7 +5,7 @@ import { requireAuthUser } from "#middleware/auth"; import { ApiKeyService } from "#services/apiKeyService"; import { logger } from "#utils/logger"; -import { createSuccessResponse, createErrorResponse } from "#utils/errors"; +import { ApiError, createSuccessResponse, createErrorResponse } from "#utils/errors"; import { parseOffsetPagination, createOffsetPaginationMeta } from "#utils/pagination"; const DEFAULT_ALLOWED_SCOPES = [ @@ -32,7 +32,7 @@ function parseScopes(value: unknown) { const invalidScopes = scopes.filter((scope) => !DEFAULT_ALLOWED_SCOPES.includes(scope)); if (invalidScopes.length > 0) { - throw new Error(`Unsupported API key scope(s): ${invalidScopes.join(", ")}`); + throw new ApiError(400, `Unsupported API key scope(s): ${invalidScopes.join(", ")}`); } return scopes; @@ -48,7 +48,7 @@ function parseRateLimit(value: unknown) { const limit = Number(value); if (!Number.isInteger(limit) || limit < 1) { - throw new Error("rateLimit must be a positive integer"); + throw new ApiError(400, "rateLimit must be a positive integer"); } return limit; @@ -64,7 +64,7 @@ function parseExpiresAt(value: unknown) { const parsed = new Date(String(value)); if (Number.isNaN(parsed.getTime())) { - throw new Error("expiresAt must be a valid ISO date string"); + throw new ApiError(400, "expiresAt must be a valid ISO date string"); } return parsed; @@ -97,6 +97,27 @@ export class ApiKeyController { } } + /** + * Get full details for a single API key. + */ + static async getKey(req: Request, res: Response, next: NextFunction) { + try { + const userId = requireAuthUser(req).id; + const { id } = req.params; + + const apiKey = await ApiKeyService.getKey(userId, id); + + if (!apiKey) { + return res.status(404).json(createErrorResponse(404, "API key not found")); + } + + res.status(200).json(createSuccessResponse(apiKey, "API key fetched successfully")); + } catch (error) { + logger.error("Failed to get API key:", error); + next(error); + } + } + /** * Generate a new API key for the user. */ @@ -148,7 +169,8 @@ export class ApiKeyController { expiresAt: parseExpiresAt(expiresAt), }); - if (!rotated) return res.status(404).json(createErrorResponse(404, "API key not found")); + if (!rotated) + return res.status(404).json(createErrorResponse(404, "API key not found or inactive")); res .status(201) diff --git a/apps/server/src/routes/apiKeys.ts b/apps/server/src/routes/apiKeys.ts index b0db663..8922ede 100644 --- a/apps/server/src/routes/apiKeys.ts +++ b/apps/server/src/routes/apiKeys.ts @@ -10,9 +10,9 @@ router.use(authMiddleware); router.route("/").get(ApiKeyController.listKeys).post(ApiKeyController.createKey); +router.route("/:id").get(ApiKeyController.getKey).delete(ApiKeyController.deleteKey); + router.post("/:id/rotate", ApiKeyController.rotateKey); router.post("/:id/revoke", ApiKeyController.revokeKey); -router.delete("/:id", ApiKeyController.deleteKey); - export default router; diff --git a/apps/server/src/services/apiKeyService.ts b/apps/server/src/services/apiKeyService.ts index 0b0c1e0..ac4c479 100644 --- a/apps/server/src/services/apiKeyService.ts +++ b/apps/server/src/services/apiKeyService.ts @@ -49,17 +49,10 @@ type ApiKeyAuthRecord = { type ApiKeyListRecord = { id: string; - keyPrefix: string; - keySuffix: string; name: string; - userId: string; + keyPrefix: string; isActive: boolean; - rateLimit: number; - scopes: string[]; - expiresAt: Date | null; - revokedAt: Date | null; createdAt: Date; - updatedAt: Date; lastUsed: Date | null; }; @@ -75,7 +68,22 @@ type ApiKeyCreateInput = { expiresAt?: Date | null; }; -type ApiKeyCreateResult = ApiKeyListRecord & { key: string }; +type ApiKeyCreateResult = { + id: string; + keyPrefix: string; + keySuffix: string; + name: string; + userId: string; + isActive: boolean; + rateLimit: number; + scopes: string[]; + expiresAt: Date | null; + revokedAt: Date | null; + createdAt: Date; + updatedAt: Date; + lastUsed: Date | null; + key: string; +}; type ApiKeyRotateResult = ApiKeyCreateResult & { rotatedFromId: string }; @@ -303,16 +311,9 @@ export class ApiKeyService { select: { id: true, keyPrefix: true, - keySuffix: true, name: true, - userId: true, isActive: true, - rateLimit: true, - scopes: true, - expiresAt: true, - revokedAt: true, createdAt: true, - updatedAt: true, lastUsed: true, }, }), @@ -322,6 +323,27 @@ export class ApiKeyService { return { items: items satisfies ApiKeyListRecord[], total }; } + static async getKey(userId: string, keyId: string) { + return prisma.apiKey.findFirst({ + where: { id: keyId, userId }, + select: { + id: true, + keyPrefix: true, + keySuffix: true, + name: true, + userId: true, + isActive: true, + rateLimit: true, + scopes: true, + expiresAt: true, + revokedAt: true, + createdAt: true, + updatedAt: true, + lastUsed: true, + }, + }); + } + static async revokeKey(userId: string, keyId: string) { const existing = await prisma.apiKey.findFirst({ where: { id: keyId, userId }, @@ -352,7 +374,7 @@ export class ApiKeyService { input: Partial = {}, ): Promise { const current = await prisma.apiKey.findFirst({ - where: { id: keyId, userId }, + where: { id: keyId, userId, isActive: true, revokedAt: null }, select: { id: true, keyHash: true, diff --git a/apps/studio/app/(main)/(dashboard)/api-keys/[id]/page.tsx b/apps/studio/app/(main)/(dashboard)/api-keys/[id]/page.tsx new file mode 100644 index 0000000..10932d8 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/api-keys/[id]/page.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from "next"; + +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; + +import type { ApiKeyDetailRecord } from "@/features/api-keys/components/ApiKeyTypes"; + +import { backendApiUrl } from "@/lib/constants"; + +import ApiKeyDetailView from "@/features/api-keys/components/details/ApiKeyDetailView"; + +export const metadata: Metadata = { + title: "API Key Details", + robots: { index: false, follow: false }, +}; + +async function fetchKeyDetails(id: string): Promise { + try { + const requestHeaders = await headers(); + const cookie = requestHeaders.get("cookie"); + + const response = await fetch(backendApiUrl(`/api-keys/${id}`), { + method: "GET", + cache: "no-store", + headers: { + "Content-Type": "application/json", + ...(cookie ? { cookie } : {}), + }, + }); + + if (!response.ok) { + return null; + } + + const json = await response.json(); + return json.data as ApiKeyDetailRecord; + } catch { + return null; + } +} + +export default async function ApiKeyPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + const keyDetails = await fetchKeyDetails(id); + + if (!keyDetails) { + notFound(); + } + + return ; +} diff --git a/apps/studio/app/(main)/(dashboard)/api-keys/create/page.tsx b/apps/studio/app/(main)/(dashboard)/api-keys/create/page.tsx new file mode 100644 index 0000000..8aa8b84 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/api-keys/create/page.tsx @@ -0,0 +1,57 @@ +import type { Metadata } from "next"; +import type { LucideIcon } from "lucide-react"; + +import Link from "next/link"; +import { ArrowLeft, Settings } from "lucide-react"; + +import ApiKeyCreateClient from "@/features/api-keys/components/create/ApiKeyCreateClient"; + +export const metadata: Metadata = { + title: "Create API Key", + description: "Create a scoped VeriWorkly API key.", + robots: { index: false, follow: false }, +}; + +export default function CreateApiKeyPage() { + return ( +
+
+
+
+

API keys

+ +

+ Create key +

+ +

+ Pick exact scopes first, then generate one secret token. +

+
+ +
+ + +
+
+
+ + +
+ ); +} + +function QuickLink({ href, icon: Icon, label }: { href: string; icon: LucideIcon; label: string }) { + return ( + + + {label} + + ); +} diff --git a/apps/studio/app/(main)/(dashboard)/api-keys/page.tsx b/apps/studio/app/(main)/(dashboard)/api-keys/page.tsx new file mode 100644 index 0000000..e99906e --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/api-keys/page.tsx @@ -0,0 +1,102 @@ +import type { Metadata } from "next"; +import type { LucideIcon } from "lucide-react"; + +import { headers } from "next/headers"; +import { KeyRound, ShieldCheck } from "lucide-react"; + +import { backendApiUrl } from "@/lib/constants"; + +import type { + ApiKeyRecord, + OffsetPaginationPayload, +} from "@/features/api-keys/components/ApiKeyTypes"; + +import ApiKeySection from "@/features/api-keys/components/ApiKeySection"; + +export const metadata: Metadata = { + title: "API Keys", + description: "Create and manage API keys for VeriWorkly integrations.", + robots: { index: false, follow: false }, +}; + +async function fetchInitialApiKeys() { + try { + const requestHeaders = await headers(); + const cookie = requestHeaders.get("cookie"); + + const response = await fetch(backendApiUrl("/api-keys"), { + method: "GET", + cache: "no-store", + headers: { + "Content-Type": "application/json", + ...(cookie ? { cookie } : {}), + }, + }); + + if (!response.ok) { + return { keys: [] as ApiKeyRecord[], pagination: null, loaded: false }; + } + + const payload = (await response.json()) as { data?: OffsetPaginationPayload }; + + return { + keys: payload.data?.items ?? [], + pagination: payload.data ?? null, + loaded: true, + }; + } catch { + return { keys: [] as ApiKeyRecord[], pagination: null, loaded: false }; + } +} + +function Metric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) { + return ( +
+
+ + {label} +
+

{value}

+
+ ); +} + +const ApiKeysPage = async () => { + const initialApiKeys = await fetchInitialApiKeys(); + + const total = initialApiKeys.pagination?.total ?? initialApiKeys.keys.length; + const active = initialApiKeys.keys.filter((key) => key.isActive).length; + + return ( +
+
+
+

API keys

+ +

+ Developer access +

+ +

+ Review existing tokens before creating another. Rotate keys when access changes. +

+
+ +
+ + +
+
+ +
+ +
+
+ ); +}; + +export default ApiKeysPage; diff --git a/apps/studio/app/(main)/(dashboard)/dashboard/error.tsx b/apps/studio/app/(main)/(dashboard)/dashboard/error.tsx deleted file mode 100644 index 73d2f49..0000000 --- a/apps/studio/app/(main)/(dashboard)/dashboard/error.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import { Button } from "@veriworkly/ui"; - -export default function DashboardPageError({ - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( -
-
-
-
-

- Resume Workspace -

- -

- We could not load your resumes -

- -

- Browser storage may be blocked or temporarily unavailable. Retry this view, or open - templates to start a fresh resume. -

- -
- - - -
-
-
- -
- {Array.from({ length: 3 }).map((_, index) => ( -
- ))} -
-
- ); -} diff --git a/apps/studio/app/(main)/editor/[id]/error.tsx b/apps/studio/app/(main)/editor/[id]/error.tsx deleted file mode 100644 index 3f0bf70..0000000 --- a/apps/studio/app/(main)/editor/[id]/error.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { Button } from "@veriworkly/ui"; - -export default function EditorByIdError({ - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( -
-
-

- Resume Editor -

- -

- This resume could not be opened -

- -

- The draft may be unavailable in this browser session. Retry, or go back to dashboard to - choose another resume. -

- -
- - - -
-
- -
-
-
-
-
- ); -} diff --git a/apps/studio/app/(main)/editor/[id]/loading.tsx b/apps/studio/app/(main)/editor/[id]/loading.tsx deleted file mode 100644 index 59a82ac..0000000 --- a/apps/studio/app/(main)/editor/[id]/loading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export default function EditorByIdLoading() { - return ( -
-
- -
-
-
-
-
- ); -} diff --git a/apps/studio/app/(main)/editor/[id]/not-found.tsx b/apps/studio/app/(main)/editor/[id]/not-found.tsx deleted file mode 100644 index 73e56b3..0000000 --- a/apps/studio/app/(main)/editor/[id]/not-found.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import Link from "next/link"; - -import { Button } from "@veriworkly/ui"; - -export default function EditorByIdNotFound() { - return ( -
-
-

- Resume Editor -

- -

- Resume not found -

- -

- This resume link is invalid or the draft is not available in the current browser storage. -

- -
- - - -
-
- -
-
-
-
-
- ); -} diff --git a/apps/studio/app/(main)/editor/[id]/preview/error.tsx b/apps/studio/app/(main)/editor/[id]/preview/error.tsx deleted file mode 100644 index ed7bd90..0000000 --- a/apps/studio/app/(main)/editor/[id]/preview/error.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { Button } from "@veriworkly/ui"; - -export default function EditorPreviewError({ - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( -
-
-

- Resume Preview -

- -

- We could not render this preview -

- -

- Try rendering the preview again or return to the editor route. -

- -
- - - -
-
- -
-
-
-
- ); -} diff --git a/apps/studio/app/(main)/editor/[id]/preview/loading.tsx b/apps/studio/app/(main)/editor/[id]/preview/loading.tsx deleted file mode 100644 index 3be1e6b..0000000 --- a/apps/studio/app/(main)/editor/[id]/preview/loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function EditorPreviewLoading() { - return ( -
-
-
-
-
-
- ); -} diff --git a/apps/studio/app/(main)/editor/[id]/preview/not-found.tsx b/apps/studio/app/(main)/editor/[id]/preview/not-found.tsx deleted file mode 100644 index 7932427..0000000 --- a/apps/studio/app/(main)/editor/[id]/preview/not-found.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Link from "next/link"; - -import { Button } from "@veriworkly/ui"; - -export default function EditorPreviewNotFound() { - return ( -
-
-

- Resume Preview -

- -

- Preview not available -

- -

- This preview link is invalid or the resume is no longer available in local storage. -

- -
- - - -
-
- -
-
-
-
- ); -} diff --git a/apps/studio/features/api-keys/components/ApiKeyList.tsx b/apps/studio/features/api-keys/components/ApiKeyList.tsx new file mode 100644 index 0000000..e34a62d --- /dev/null +++ b/apps/studio/features/api-keys/components/ApiKeyList.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { + Eye, + Key, + Trash2, + Loader2, + KeyRound, + Calendar, + RefreshCw, + ShieldOff, + MoreHorizontal, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +import { Badge, Button, Card, Menu, MenuItem, MenuSeparator } from "@veriworkly/ui"; + +import type { ApiKeyRecord } from "./ApiKeyTypes"; + +type ApiKeyListProps = { + keys: ApiKeyRecord[]; + page: number; + totalPages: number; + hasMore: boolean; + loading: boolean; + onPrevPage: () => void; + onNextPage: () => void; + onRotate: (key: ApiKeyRecord) => void; + onDelete: (key: ApiKeyRecord) => void; + onRevoke: (key: ApiKeyRecord) => void; +}; + +export default function ApiKeyList({ + keys, + page, + totalPages, + hasMore, + loading, + onPrevPage, + onNextPage, + onRotate, + onDelete, + onRevoke, +}: ApiKeyListProps) { + const router = useRouter(); + + return ( +
+ {loading ? ( +
+ +
+ ) : keys.length === 0 ? ( +
+ +

No API keys found. Generate one to get started.

+
+ ) : ( +
+ {keys.map((key) => { + const href = `/api-keys/${key.id}`; + const statusClass = key.isActive + ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-300" + : "border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300"; + + return ( + +
+
+
+
+ +
+ +
+ + {key.name} + + +

+ {key.keyPrefix} ... +

+
+
+ + ( + + )} + > + {({ close }) => ( + <> + { + close(); + router.push(href); + }} + > + View details + + + {key.isActive && ( + <> + { + close(); + onRotate(key); + }} + > + Rotate + + { + close(); + onRevoke(key); + }} + > + Revoke + + + )} + + + + { + close(); + onDelete(key); + }} + className="text-red-600 hover:bg-red-50 hover:text-red-700" + > + Delete + + + )} + +
+ +
+
+ + Created {new Date(key.createdAt).toLocaleDateString()} +
+ +
+ {key.lastUsed + ? `Last used ${new Date(key.lastUsed).toLocaleDateString()}` + : "Never used"} +
+
+
+ + {!key.isActive && ( +

+ Disabled. Cannot authenticate requests. +

+ )} + +
+ + {key.isActive ? "Active" : "Revoked"} + + + +
+
+ ); + })} +
+ )} + + {!loading && keys.length > 0 && ( +
+ + Page {page} of {totalPages} + + +
+ + + +
+
+ )} +
+ ); +} diff --git a/apps/studio/features/api-keys/components/ApiKeyRotateModal.tsx b/apps/studio/features/api-keys/components/ApiKeyRotateModal.tsx new file mode 100644 index 0000000..f20bafe --- /dev/null +++ b/apps/studio/features/api-keys/components/ApiKeyRotateModal.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ArrowRightLeft, Calendar, Key, TrendingUp, UserPen, Loader2 } from "lucide-react"; + +import type { ApiKeyRecord, ApiKeyDetailRecord } from "./ApiKeyTypes"; + +import { Button, Input, Modal } from "@veriworkly/ui"; +import { fetchApiData, ApiRequestError } from "@/utils/fetchApiData"; + +type ApiKeyRotateModalProps = { + open: boolean; + keyRecord: ApiKeyRecord | null; + submitting: boolean; + onCloseAction: () => void; + onSubmitAction: (values: { + name: string; + rateLimit: number; + expiresAt?: string; + scopes: string[]; + }) => void; +}; + +function formatDateValue(dateValue: string | null) { + if (!dateValue) { + return ""; + } + const parsed = new Date(dateValue); + if (Number.isNaN(parsed.getTime())) { + return ""; + } + return parsed.toISOString().slice(0, 10); +} + +export default function ApiKeyRotateModal({ + open, + keyRecord, + submitting, + onCloseAction, + onSubmitAction, +}: ApiKeyRotateModalProps) { + const [name, setName] = useState(""); + const [rateLimit, setRateLimit] = useState(20); + const [expiresAt, setExpiresAt] = useState(""); + const [selectedScopes, setSelectedScopes] = useState([]); + + const [loadingDetails, setLoadingDetails] = useState(false); + const [detailError, setDetailError] = useState(""); + + useEffect(() => { + if (!open || !keyRecord) return; + + let cancelled = false; + + queueMicrotask(() => { + if (cancelled) return; + + setLoadingDetails(true); + setDetailError(""); + + fetchApiData(`/api-keys/${keyRecord.id}`, { method: "GET" }) + .then((data) => { + if (cancelled) return; + + setName(data.name); + setSelectedScopes(data.scopes); + setExpiresAt(formatDateValue(data.expiresAt)); + setRateLimit(Math.min(20, Math.max(1, data.rateLimit || 20))); + }) + .catch((err) => { + if (cancelled) return; + + setDetailError( + err instanceof ApiRequestError ? err.message : "Failed to load key details", + ); + }) + .finally(() => { + if (!cancelled) setLoadingDetails(false); + }); + }); + + return () => { + cancelled = true; + }; + }, [open, keyRecord]); + + const handleSubmit = (event: React.SyntheticEvent) => { + event.preventDefault(); + + if (!keyRecord) { + return; + } + + onSubmitAction({ + name: name.trim() || keyRecord.name, + rateLimit: Math.min(20, Math.max(1, rateLimit)), + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined, + scopes: selectedScopes, + }); + }; + + const handleClose = () => { + if (!submitting) { + onCloseAction(); + } + }; + + return ( + + +
+
+ +
+ +
+ Rotate API Key + + {keyRecord ? ( +

+ Rotating{" "} + + {keyRecord.keyPrefix}... + +

+ ) : null} +
+
+ + {loadingDetails ? ( +
+ +
+ ) : detailError ? ( +
{detailError}
+ ) : ( +
+
+
+ + + setName(event.target.value)} + /> +
+
+ +
+
+ + + { + const nextValue = Number(event.target.value || 1); + setRateLimit(Math.min(20, Math.max(1, nextValue))); + }} + /> +
+ +
+ + + setExpiresAt(event.target.value)} + /> +
+
+ +
+
+ + +

+ The old key is revoked as soon as this replacement is created. Share the new key + only after copying it securely. +

+
+
+ + + + + + +
+ )} +
+
+ ); +} diff --git a/apps/studio/features/api-keys/components/ApiKeyScopes.ts b/apps/studio/features/api-keys/components/ApiKeyScopes.ts new file mode 100644 index 0000000..6c15845 --- /dev/null +++ b/apps/studio/features/api-keys/components/ApiKeyScopes.ts @@ -0,0 +1,103 @@ +export const AVAILABLE_API_KEY_SCOPES = [ + "user:read", + "user:write", + "resume:read", + "resume:write", + "roadmap:read", + "github:read", +] as const; + +export const API_KEY_SCOPE_OPTIONS = [ + { + value: "user:read", + label: "Profile access", + description: "Read your account profile and identity details.", + }, + + { + value: "user:write", + label: "Profile updates", + description: "Update user-facing profile fields.", + }, + + { + value: "resume:read", + label: "Resume read", + description: "View resume content and metadata.", + }, + + { + value: "resume:write", + label: "Resume create/edit", + description: "Create, edit, delete, sync, and share resume-backed documents.", + }, + + { + value: "roadmap:read", + label: "Roadmap read", + description: "Inspect roadmap items and release planning data.", + }, + + { + value: "github:read", + label: "GitHub read", + description: "Read synced GitHub issue and stats data.", + }, +] as const; + +export const API_KEY_SCOPE_GROUPS = [ + { + key: "profile", + label: "Profile", + description: "Account identity and display-name APIs.", + scopes: ["user:read", "user:write"], + }, + + { + key: "resume", + label: "Resume", + description: "Resume document access. Write covers create/edit/delete/share for now.", + scopes: ["resume:read", "resume:write"], + }, + + { + key: "roadmap", + label: "Roadmap", + description: "Public roadmap APIs are read-only.", + scopes: ["roadmap:read"], + }, + + { + key: "github", + label: "GitHub", + description: "Synced GitHub stats and issue APIs are read-only.", + scopes: ["github:read"], + }, +] as const; + +export type ApiKeyScopeSummary = { + label: string; + access: string; +}; + +const summarizeApiKeyScopes = (scopes: string[]): ApiKeyScopeSummary[] => { + const selected = new Set(scopes); + + return API_KEY_SCOPE_GROUPS.flatMap((group) => { + const [readScope, writeScope] = group.scopes; + + const hasRead = Boolean(readScope && selected.has(readScope)); + const hasWrite = Boolean(writeScope && selected.has(writeScope)); + + if (!hasRead && !hasWrite) { + return []; + } + + return { + label: group.label, + access: hasRead && hasWrite ? "Read / Write" : hasWrite ? "Write" : "Read", + }; + }); +}; + +export default summarizeApiKeyScopes; diff --git a/apps/studio/features/api-keys/components/ApiKeySection.tsx b/apps/studio/features/api-keys/components/ApiKeySection.tsx new file mode 100644 index 0000000..5f5f3ca --- /dev/null +++ b/apps/studio/features/api-keys/components/ApiKeySection.tsx @@ -0,0 +1,107 @@ +"use client"; + +import Link from "next/link"; +import { Plus } from "lucide-react"; + +import type { ApiKeyRecord, OffsetPaginationPayload } from "./ApiKeyTypes"; + +import { Button } from "@veriworkly/ui"; + +import { useApiKeys } from "../hooks/useApiKeys"; + +import ApiKeyList from "./ApiKeyList"; +import ApiKeyRotateModal from "./ApiKeyRotateModal"; +import GeneratedApiKeyCard from "./GeneratedApiKeyCard"; + +import DestructiveModal from "@/components/modals/DestructiveModal"; +import ConfirmationModal from "@/components/modals/ConfirmationModal"; + +type ApiKeySectionProps = { + initialKeys: ApiKeyRecord[]; + initialPagination: OffsetPaginationPayload | null; + initialKeysLoaded: boolean; +}; + +export default function ApiKeySection({ + initialKeys, + initialPagination, + initialKeysLoaded, +}: ApiKeySectionProps) { + const apiKeys = useApiKeys({ initialKeys, initialPagination, initialKeysLoaded }); + + return ( +
+
+
+

Your keys

+ +

+ Review active tokens first. Create new keys from the dedicated create flow. +

+
+ + +
+ +
+ {apiKeys.generatedKey && ( + apiKeys.setGeneratedKey(null)} + copied={apiKeys.copiedId === apiKeys.generatedKey.id} + onCopy={() => + void apiKeys.copyToClipboard(apiKeys.generatedKey!.key, apiKeys.generatedKey!.id) + } + /> + )} + + + + apiKeys.setRotateTarget(null)} + /> + apiKeys.setDeleteTarget(null)} + warningText="This action permanently deletes the key from the database." + description="Use this only when you no longer need the key at all. Permanent deletion cannot be undone." + /> + + apiKeys.setRevokeTarget(null)} + description="This disables the key immediately while keeping the record for audit and history." + /> +
+
+ ); +} diff --git a/apps/studio/features/api-keys/components/ApiKeyTypes.ts b/apps/studio/features/api-keys/components/ApiKeyTypes.ts new file mode 100644 index 0000000..7947894 --- /dev/null +++ b/apps/studio/features/api-keys/components/ApiKeyTypes.ts @@ -0,0 +1,44 @@ +export interface ApiKeyListRecord { + id: string; + name: string; + keyPrefix: string; + isActive: boolean; + createdAt: string; + lastUsed: string | null; +} + +export type ApiKeyRecord = ApiKeyListRecord; + +export interface ApiKeyDetailRecord { + id: string; + keyPrefix: string; + keySuffix: string; + name: string; + isActive: boolean; + rateLimit: number; + scopes: string[]; + expiresAt: string | null; + revokedAt: string | null; + createdAt: string; + lastUsed: string | null; +} + +export interface GeneratedApiKeyRecord extends ApiKeyDetailRecord { + key: string; +} + +export interface OffsetPaginationPayload { + items: T[]; + total: number; + limit: number; + offset: number; + page: number; + pageSize: number; + totalPages: number; + hasMore: boolean; + pagination: { + mode: "offset"; + nextOffset: number | null; + nextCursor: string | null; + }; +} diff --git a/apps/studio/features/api-keys/components/GeneratedApiKeyCard.tsx b/apps/studio/features/api-keys/components/GeneratedApiKeyCard.tsx new file mode 100644 index 0000000..eb8ea8d --- /dev/null +++ b/apps/studio/features/api-keys/components/GeneratedApiKeyCard.tsx @@ -0,0 +1,44 @@ +import { AlertCircle, Check, Copy } from "lucide-react"; + +import { Button, Card } from "@veriworkly/ui"; + +type GeneratedApiKeyCardProps = { + generatedKey: string; + copied: boolean; + onCopy: () => void; + onDismiss: () => void; +}; + +export default function GeneratedApiKeyCard({ + generatedKey, + copied, + onCopy, + onDismiss, +}: GeneratedApiKeyCardProps) { + return ( + +
+
+ +

Save your API Key

+
+ +

+ For security reasons, this key will only be shown once. Please copy it now. +

+ +
+ {generatedKey} + + +
+ + +
+
+ ); +} diff --git a/apps/studio/features/api-keys/components/create/ApiKeyCreateClient.tsx b/apps/studio/features/api-keys/components/create/ApiKeyCreateClient.tsx new file mode 100644 index 0000000..f22e2a8 --- /dev/null +++ b/apps/studio/features/api-keys/components/create/ApiKeyCreateClient.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useApiKeyCreate } from "../../hooks/useApiKeyCreate"; + +import { ApiKeyScopeRail } from "./ApiKeyScopeRail"; +import { ApiKeyDetailsForm } from "./ApiKeyDetailsForm"; +import { ApiKeySelectedScopes } from "./ApiKeySelectedScopes"; + +export default function ApiKeyCreateClient() { + const create = useApiKeyCreate(); + + return ( +
+
+ + + create.setGeneratedKey(null)} + /> +
+ + + + ); +} diff --git a/apps/studio/features/api-keys/components/create/ApiKeyDetailsForm.tsx b/apps/studio/features/api-keys/components/create/ApiKeyDetailsForm.tsx new file mode 100644 index 0000000..164519c --- /dev/null +++ b/apps/studio/features/api-keys/components/create/ApiKeyDetailsForm.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { KeyRound } from "lucide-react"; + +import { Card, Input } from "@veriworkly/ui"; + +type ApiKeyDetailsFormProps = { + creating: boolean; + name: string; + rateLimit: number; + expiresAt: string; + onNameChange: (value: string) => void; + onRateLimitChange: (value: number) => void; + onExpiresAtChange: (value: string) => void; +}; + +export function ApiKeyDetailsForm({ + creating, + name, + rateLimit, + expiresAt, + onNameChange, + onRateLimitChange, + onExpiresAtChange, +}: ApiKeyDetailsFormProps) { + return ( + +
+ + + + +
+

Key details

+

Name keys by app, environment, or deployment target.

+
+
+ +
+ + + + + +
+
+ ); +} diff --git a/apps/studio/features/api-keys/components/create/ApiKeyScopeRail.tsx b/apps/studio/features/api-keys/components/create/ApiKeyScopeRail.tsx new file mode 100644 index 0000000..ed7ec82 --- /dev/null +++ b/apps/studio/features/api-keys/components/create/ApiKeyScopeRail.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { ShieldCheck } from "lucide-react"; + +import { Button, Card, Checkbox } from "@veriworkly/ui"; + +import { API_KEY_SCOPE_GROUPS, API_KEY_SCOPE_OPTIONS } from "../ApiKeyScopes"; + +import { cn } from "@/lib/utils"; + +type ApiKeyScopeRailProps = { + selectedScopes: string[]; + onToggleScope: (scope: string) => void; + onSelectAll: () => void; + onReset: () => void; +}; + +export function ApiKeyScopeRail({ + selectedScopes, + onToggleScope, + onSelectAll, + onReset, +}: ApiKeyScopeRailProps) { + return ( + + ); +} diff --git a/apps/studio/features/api-keys/components/create/ApiKeySelectedScopes.tsx b/apps/studio/features/api-keys/components/create/ApiKeySelectedScopes.tsx new file mode 100644 index 0000000..0bca1c8 --- /dev/null +++ b/apps/studio/features/api-keys/components/create/ApiKeySelectedScopes.tsx @@ -0,0 +1,79 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, Loader2, Plus } from "lucide-react"; + +import type { GeneratedApiKeyRecord } from "../ApiKeyTypes"; +import type { API_KEY_SCOPE_OPTIONS } from "../ApiKeyScopes"; + +import { Badge, Button, Card } from "@veriworkly/ui"; + +import GeneratedApiKeyCard from "../GeneratedApiKeyCard"; + +type ScopeOption = (typeof API_KEY_SCOPE_OPTIONS)[number]; + +type ApiKeySelectedScopesProps = { + creating: boolean; + canSubmit: boolean; + copied: boolean; + selectedScopes: string[]; + selectedScopeDetails: ScopeOption[]; + generatedKey: GeneratedApiKeyRecord | null; + onCopyGeneratedKey: () => void; + onDismissGeneratedKey: () => void; +}; + +export function ApiKeySelectedScopes({ + creating, + canSubmit, + copied, + selectedScopes, + selectedScopeDetails, + generatedKey, + onCopyGeneratedKey, + onDismissGeneratedKey, +}: ApiKeySelectedScopesProps) { + return ( + +
+
+

Selected scopes

+

Review before generating.

+
+ + {selectedScopes.length} selected +
+ +
+ {selectedScopeDetails.map((scope) => ( + + {scope.label} + + ))} +
+ + {generatedKey ? ( + + ) : null} + +
+ + + +
+
+ ); +} diff --git a/apps/studio/features/api-keys/components/details/ApiKeyDetailActions.tsx b/apps/studio/features/api-keys/components/details/ApiKeyDetailActions.tsx new file mode 100644 index 0000000..b1743dc --- /dev/null +++ b/apps/studio/features/api-keys/components/details/ApiKeyDetailActions.tsx @@ -0,0 +1,58 @@ +import { Ban, RotateCw, Trash2 } from "lucide-react"; + +import { Button, Card } from "@veriworkly/ui"; + +import type { ApiKeyDetailRecord } from "../ApiKeyTypes"; + +type ApiKeyDetailActionsProps = { + data: ApiKeyDetailRecord; + onDelete: () => void; + onRevoke: () => void; + onRotate: () => void; +}; + +export function ApiKeyDetailActions({ + data, + onDelete, + onRevoke, + onRotate, +}: ApiKeyDetailActionsProps) { + return ( + +
+
+

Actions

+

+ Rotate credentials, revoke access, or remove this key. +

+
+
+ +
+ {data.isActive && ( + <> + + + + + )} + + +
+
+ ); +} diff --git a/apps/studio/features/api-keys/components/details/ApiKeyDetailHeader.tsx b/apps/studio/features/api-keys/components/details/ApiKeyDetailHeader.tsx new file mode 100644 index 0000000..e5db5fb --- /dev/null +++ b/apps/studio/features/api-keys/components/details/ApiKeyDetailHeader.tsx @@ -0,0 +1,71 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, KeyRound } from "lucide-react"; + +import type { ApiKeyDetailRecord } from "../ApiKeyTypes"; + +import { Badge } from "@veriworkly/ui"; + +import { statusClass } from "./api-key-detail-utils"; + +type ApiKeyDetailHeaderProps = { + data: ApiKeyDetailRecord; +}; + +export function ApiKeyDetailHeader({ data }: ApiKeyDetailHeaderProps) { + return ( +
+
+ + + Back to keys + + +
+
+ +
+ +
+
+

+ {data.name} +

+ + + {data.isActive ? "Active" : "Revoked"} + +
+ +

+ {data.keyPrefix}........{data.keySuffix} +

+
+
+
+ +
+
+

+ Rate limit +

+ +

{data.rateLimit}/min

+
+ + {!data.isActive && ( +
+ This API key is disabled and cannot authenticate requests. +
+ )} +
+
+ ); +} diff --git a/apps/studio/features/api-keys/components/details/ApiKeyDetailOverview.tsx b/apps/studio/features/api-keys/components/details/ApiKeyDetailOverview.tsx new file mode 100644 index 0000000..afa7a0e --- /dev/null +++ b/apps/studio/features/api-keys/components/details/ApiKeyDetailOverview.tsx @@ -0,0 +1,48 @@ +import type { LucideIcon } from "lucide-react"; +import { Activity, Ban, Calendar, Clock3 } from "lucide-react"; + +import type { ApiKeyDetailRecord } from "../ApiKeyTypes"; + +import { Card } from "@veriworkly/ui"; + +import { formatDateTime } from "./api-key-detail-utils"; + +function DetailMetric({ + icon: Icon, + label, + value, +}: { + icon: LucideIcon; + label: string; + value: string; +}) { + return ( + +
+ + {label} +
+ +

{value}

+
+ ); +} + +export function ApiKeyDetailOverview({ data }: { data: ApiKeyDetailRecord }) { + return ( +
+ + + + +
+ ); +} diff --git a/apps/studio/features/api-keys/components/details/ApiKeyDetailScopes.tsx b/apps/studio/features/api-keys/components/details/ApiKeyDetailScopes.tsx new file mode 100644 index 0000000..2da04ac --- /dev/null +++ b/apps/studio/features/api-keys/components/details/ApiKeyDetailScopes.tsx @@ -0,0 +1,65 @@ +import { Shield } from "lucide-react"; + +import { Badge, Card } from "@veriworkly/ui"; + +import type { ApiKeyScopeSummary } from "../ApiKeyScopes"; + +import { scopeOptionMap } from "./api-key-detail-utils"; + +type ApiKeyDetailScopesProps = { + scopes: string[]; + scopeSummary: ApiKeyScopeSummary[]; +}; + +export function ApiKeyDetailScopes({ scopes, scopeSummary }: ApiKeyDetailScopesProps) { + return ( + + ); +} diff --git a/apps/studio/features/api-keys/components/details/ApiKeyDetailView.tsx b/apps/studio/features/api-keys/components/details/ApiKeyDetailView.tsx new file mode 100644 index 0000000..55bbc95 --- /dev/null +++ b/apps/studio/features/api-keys/components/details/ApiKeyDetailView.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { toast } from "sonner"; +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import DestructiveModal from "@/components/modals/DestructiveModal"; +import ConfirmationModal from "@/components/modals/ConfirmationModal"; + +import { fetchApiData, ApiRequestError } from "@/utils/fetchApiData"; + +import type { ApiKeyDetailRecord } from "../ApiKeyTypes"; + +import summarizeApiKeyScopes from "../ApiKeyScopes"; +import ApiKeyRotateModal from "../ApiKeyRotateModal"; +import GeneratedApiKeyCard from "../GeneratedApiKeyCard"; + +import { ApiKeyDetailActions } from "./ApiKeyDetailActions"; +import { ApiKeyDetailHeader } from "./ApiKeyDetailHeader"; +import { ApiKeyDetailOverview } from "./ApiKeyDetailOverview"; +import { ApiKeyDetailScopes } from "./ApiKeyDetailScopes"; + +export default function ApiKeyDetailView({ initialData }: { initialData: ApiKeyDetailRecord }) { + const router = useRouter(); + + const [copied, setCopied] = useState(false); + const [data, setData] = useState(initialData); + const [generatedKey, setGeneratedKey] = useState<{ id: string; key: string } | null>(null); + + const [rotateSubmitting, setRotateSubmitting] = useState(false); + const [revokeSubmitting, setRevokeSubmitting] = useState(false); + const [deleteSubmitting, setDeleteSubmitting] = useState(false); + + const [rotateTarget, setRotateTarget] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const scopeSummary = useMemo(() => summarizeApiKeyScopes(data.scopes), [data.scopes]); + + const handleCopy = async () => { + if (!generatedKey) return; + + try { + await navigator.clipboard.writeText(generatedKey.key); + setCopied(true); + toast.success("API key copied."); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Failed to copy API key."); + } + }; + + const handleRotate = async (values: { + name: string; + rateLimit: number; + expiresAt?: string; + scopes: string[]; + }) => { + if (!rotateTarget) return; + + setRotateSubmitting(true); + + try { + const response = await fetchApiData( + `/api-keys/${rotateTarget.id}/rotate`, + { method: "POST", body: JSON.stringify(values) }, + ); + + setData(response); + setGeneratedKey({ id: response.id, key: response.key }); + setRotateTarget(null); + + toast.success("API key rotated."); + + router.refresh(); + } catch (error) { + toast.error(error instanceof ApiRequestError ? error.message : "Failed to rotate API key."); + } finally { + setRotateSubmitting(false); + } + }; + + const handleRevoke = async () => { + if (!revokeTarget) return; + + setRevokeSubmitting(true); + + try { + await fetchApiData(`/api-keys/${revokeTarget.id}/revoke`, { method: "POST" }); + + setData((prev) => ({ ...prev, isActive: false, revokedAt: new Date().toISOString() })); + setRevokeTarget(null); + + toast.success("API key revoked."); + + router.refresh(); + } catch (error) { + toast.error(error instanceof ApiRequestError ? error.message : "Failed to revoke API key."); + } finally { + setRevokeSubmitting(false); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + + setDeleteSubmitting(true); + + try { + await fetchApiData(`/api-keys/${deleteTarget.id}`, { method: "DELETE" }); + + setDeleteTarget(null); + toast.success("API key deleted."); + router.push("/api-keys"); + + router.refresh(); + } catch (error) { + toast.error(error instanceof ApiRequestError ? error.message : "Failed to delete API key."); + } finally { + setDeleteSubmitting(false); + } + }; + + return ( +
+ + + {generatedKey && ( + setGeneratedKey(null)} + /> + )} + +
+
+ + setDeleteTarget(data)} + onRevoke={() => setRevokeTarget(data)} + onRotate={() => setRotateTarget(data)} + /> +
+ + +
+ + setRotateTarget(null)} + /> + + setRevokeTarget(null)} + description="This disables the key immediately while keeping the record for audit and history." + /> + + setDeleteTarget(null)} + warningText="This action permanently deletes the key from the database." + description="Use this only when you no longer need the key at all. Permanent deletion cannot be undone." + /> +
+ ); +} diff --git a/apps/studio/features/api-keys/components/details/api-key-detail-utils.ts b/apps/studio/features/api-keys/components/details/api-key-detail-utils.ts new file mode 100644 index 0000000..d669807 --- /dev/null +++ b/apps/studio/features/api-keys/components/details/api-key-detail-utils.ts @@ -0,0 +1,20 @@ +import { API_KEY_SCOPE_OPTIONS } from "../ApiKeyScopes"; + +export const scopeOptionMap = new Map( + API_KEY_SCOPE_OPTIONS.map((scope) => [scope.value, scope]), +); + +export function formatDateTime(value: string | null) { + if (!value) return "Never"; + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +export function statusClass(isActive: boolean) { + return isActive + ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-300" + : "border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300"; +} diff --git a/apps/studio/features/api-keys/hooks/useApiKeyCreate.ts b/apps/studio/features/api-keys/hooks/useApiKeyCreate.ts new file mode 100644 index 0000000..d507fce --- /dev/null +++ b/apps/studio/features/api-keys/hooks/useApiKeyCreate.ts @@ -0,0 +1,109 @@ +"use client"; + +import type { FormEvent } from "react"; + +import { toast } from "sonner"; +import { useMemo, useState } from "react"; + +import type { GeneratedApiKeyRecord } from "../components/ApiKeyTypes"; + +import { API_KEY_SCOPE_OPTIONS, AVAILABLE_API_KEY_SCOPES } from "../components/ApiKeyScopes"; + +import { ApiRequestError, fetchApiData } from "@/utils/fetchApiData"; + +const DEFAULT_SELECTED_SCOPES = ["user:read"]; + +function resolveErrorMessage(error: unknown) { + if (error instanceof ApiRequestError) return error.message; + return "Failed to create API key."; +} + +export function useApiKeyCreate() { + const [name, setName] = useState(""); + const [rateLimit, setRateLimit] = useState(20); + + const [copied, setCopied] = useState(false); + const [expiresAt, setExpiresAt] = useState(""); + + const [creating, setCreating] = useState(false); + + const [generatedKey, setGeneratedKey] = useState(null); + const [selectedScopes, setSelectedScopes] = useState(DEFAULT_SELECTED_SCOPES); + + const selectedScopeDetails = useMemo( + () => API_KEY_SCOPE_OPTIONS.filter((scope) => selectedScopes.includes(scope.value)), + [selectedScopes], + ); + + const toggleScope = (scope: string) => { + setSelectedScopes((current) => + current.includes(scope) ? current.filter((item) => item !== scope) : [...current, scope], + ); + }; + + const resetScopes = () => setSelectedScopes(DEFAULT_SELECTED_SCOPES); + const selectAllScopes = () => setSelectedScopes([...AVAILABLE_API_KEY_SCOPES]); + + const createKey = async (event: FormEvent) => { + event.preventDefault(); + + if (!name.trim() || selectedScopes.length === 0) return; + + try { + setCreating(true); + const data = await fetchApiData("/api-keys", { + method: "POST", + body: JSON.stringify({ + name, + scopes: selectedScopes, + rateLimit, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined, + }), + }); + + setGeneratedKey(data); + + toast.success("API key created."); + } catch (error) { + toast.error(resolveErrorMessage(error)); + } finally { + setCreating(false); + } + }; + + const copyGeneratedKey = async () => { + if (!generatedKey) return; + + try { + await navigator.clipboard.writeText(generatedKey.key); + + setCopied(true); + + toast.success("Copied to clipboard."); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Failed to copy API key."); + } + }; + + return { + creating, + copied, + name, + rateLimit, + expiresAt, + selectedScopes, + selectedScopeDetails, + generatedKey, + canSubmit: Boolean(name.trim() && selectedScopes.length > 0 && !creating), + setName, + setRateLimit, + setExpiresAt, + setGeneratedKey, + toggleScope, + selectAllScopes, + resetScopes, + createKey, + copyGeneratedKey, + }; +} diff --git a/apps/studio/features/api-keys/hooks/useApiKeys.ts b/apps/studio/features/api-keys/hooks/useApiKeys.ts new file mode 100644 index 0000000..fc0d0d1 --- /dev/null +++ b/apps/studio/features/api-keys/hooks/useApiKeys.ts @@ -0,0 +1,201 @@ +"use client"; + +import { toast } from "sonner"; +import { useCallback, useEffect, useState } from "react"; + +import type { + ApiKeyRecord, + GeneratedApiKeyRecord, + OffsetPaginationPayload, +} from "../components/ApiKeyTypes"; + +import { ApiRequestError, fetchApiData } from "@/utils/fetchApiData"; + +type UseApiKeysOptions = { + initialKeys: ApiKeyRecord[]; + initialKeysLoaded: boolean; + initialPagination: OffsetPaginationPayload | null; +}; + +function resolveErrorMessage(error: unknown, fallback: string) { + if (error instanceof ApiRequestError) return error.message; + + return fallback; +} + +export function useApiKeys({ + initialKeys, + initialPagination, + initialKeysLoaded, +}: UseApiKeysOptions) { + const [page, setPage] = useState(initialPagination?.page ?? 1); + const [pageSize] = useState(initialPagination?.pageSize ?? 20); + const [totalPages, setTotalPages] = useState(initialPagination?.totalPages ?? 1); + + const [loading, setLoading] = useState(!initialKeysLoaded); + const [hasMore, setHasMore] = useState(initialPagination?.hasMore ?? false); + + const [keys, setKeys] = useState(initialKeys); + const [generatedKey, setGeneratedKey] = useState(null); + + const [copiedId, setCopiedId] = useState(null); + + const [deleteSubmitting, setDeleteSubmitting] = useState(false); + const [rotateSubmitting, setRotateSubmitting] = useState(false); + const [revokeSubmitting, setRevokeSubmitting] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [rotateTarget, setRotateTarget] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); + + const applyPage = useCallback((data: OffsetPaginationPayload) => { + setPage(data.page); + setKeys(data.items); + setHasMore(data.hasMore); + setTotalPages(data.totalPages); + }, []); + + const fetchKeys = useCallback( + async (targetPage = 1) => { + try { + setLoading(true); + + const data = await fetchApiData>( + `/api-keys?page=${targetPage}&pageSize=${pageSize}`, + { method: "GET" }, + ); + + applyPage(data); + } catch (error) { + toast.error(resolveErrorMessage(error, "Failed to fetch API keys.")); + } finally { + setLoading(false); + } + }, + [applyPage, pageSize], + ); + + useEffect(() => { + if (initialKeysLoaded && initialPagination) { + queueMicrotask(() => { + applyPage(initialPagination); + setLoading(false); + }); + + return; + } + + queueMicrotask(() => { + void fetchKeys(1); + }); + }, [applyPage, fetchKeys, initialKeysLoaded, initialPagination]); + + const rotateKey = async (values: { + name: string; + rateLimit: number; + expiresAt?: string; + scopes: string[]; + }) => { + if (!rotateTarget) return; + + try { + setRotateSubmitting(true); + + const data = await fetchApiData( + `/api-keys/${rotateTarget.id}/rotate`, + { + method: "POST", + body: JSON.stringify(values), + }, + ); + + setGeneratedKey(data); + setRotateTarget(null); + + toast.success("API key rotated."); + + void fetchKeys(1); + } catch (error) { + toast.error(resolveErrorMessage(error, "Failed to rotate API key.")); + } finally { + setRotateSubmitting(false); + } + }; + + const deleteKey = async () => { + if (!deleteTarget) return; + + try { + setDeleteSubmitting(true); + + await fetchApiData(`/api-keys/${deleteTarget.id}`, { method: "DELETE" }); + + setDeleteTarget(null); + toast.success("API key deleted."); + void fetchKeys(1); + } catch (error) { + toast.error(resolveErrorMessage(error, "Failed to delete API key.")); + } finally { + setDeleteSubmitting(false); + } + }; + + const revokeKey = async () => { + if (!revokeTarget) return; + + try { + setRevokeSubmitting(true); + + await fetchApiData(`/api-keys/${revokeTarget.id}/revoke`, { method: "POST" }); + + setRevokeTarget(null); + toast.success("API key revoked."); + void fetchKeys(1); + } catch (error) { + toast.error(resolveErrorMessage(error, "Failed to revoke API key.")); + } finally { + setRevokeSubmitting(false); + } + }; + + const copyToClipboard = async (text: string, id: string) => { + try { + await navigator.clipboard.writeText(text); + + setCopiedId(id); + toast.success("Copied to clipboard."); + setTimeout(() => setCopiedId(null), 2000); + } catch { + toast.error("Failed to copy API key."); + } + }; + + return { + generatedKey, + copiedId, + keys, + page, + totalPages, + hasMore, + loading, + rotateTarget, + rotateSubmitting, + deleteTarget, + deleteSubmitting, + revokeTarget, + revokeSubmitting, + copyToClipboard, + setGeneratedKey, + setRotateTarget, + setRevokeTarget, + setDeleteTarget, + rotateKey, + deleteKey, + revokeKey, + goToPreviousPage: () => { + if (page > 1 && !loading) void fetchKeys(page - 1); + }, + goToNextPage: () => { + if (hasMore && !loading) void fetchKeys(page + 1); + }, + }; +} diff --git a/apps/studio/features/profile/components/advanced/AdvancedProfileClient.tsx b/apps/studio/features/profile/components/advanced/AdvancedProfileClient.tsx index 2b2dd23..11d2a36 100644 --- a/apps/studio/features/profile/components/advanced/AdvancedProfileClient.tsx +++ b/apps/studio/features/profile/components/advanced/AdvancedProfileClient.tsx @@ -98,7 +98,12 @@ const AdvancedProfileClient = () => {
- +
); }; diff --git a/apps/studio/features/profile/components/advanced/AdvancedProfilePanels.tsx b/apps/studio/features/profile/components/advanced/AdvancedProfilePanels.tsx index 85c53e0..b6e822a 100644 --- a/apps/studio/features/profile/components/advanced/AdvancedProfilePanels.tsx +++ b/apps/studio/features/profile/components/advanced/AdvancedProfilePanels.tsx @@ -14,7 +14,7 @@ function StatusMetric({ value: string; }) { return ( -
+
{label} @@ -27,7 +27,7 @@ function StatusMetric({ const AdvancedProfileStatusBand = ({ updatedAt }: { updatedAt: string | null }) => { return ( -
+
( const internalRef = React.useRef(null); const [internalChecked, setInternalChecked] = React.useState(controlledChecked || false); - // Sync internal state with controlled state React.useEffect(() => { if (controlledChecked !== undefined) { setInternalChecked(controlledChecked); @@ -53,10 +52,8 @@ export const Checkbox = React.forwardRef( const isChecked = controlledChecked !== undefined ? controlledChecked : internalChecked; - // Merge refs to allow external access while using it internally React.useImperativeHandle(ref, () => internalRef.current!); - // Sync indeterminate property with the native input element React.useEffect(() => { if (internalRef.current) { internalRef.current.indeterminate = !!indeterminate; diff --git a/packages/ui/src/components/ui/Input.tsx b/packages/ui/src/components/ui/Input.tsx index fa0562c..fc7c7eb 100644 --- a/packages/ui/src/components/ui/Input.tsx +++ b/packages/ui/src/components/ui/Input.tsx @@ -13,7 +13,7 @@ const inputVariants = { const inputSizes = { sm: "h-9 px-3 text-xs rounded-lg", - md: "h-11 px-4 text-sm rounded-2xl", + md: "h-11 px-4 text-sm rounded-xl", lg: "h-12 px-5 text-base rounded-2xl", } as const;