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
32 changes: 27 additions & 5 deletions apps/server/src/controllers/apiKeyController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/routes/apiKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
56 changes: 39 additions & 17 deletions apps/server/src/services/apiKeyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -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 };

Expand Down Expand Up @@ -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,
},
}),
Expand All @@ -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 },
Expand Down Expand Up @@ -352,7 +374,7 @@ export class ApiKeyService {
input: Partial<ApiKeyCreateInput> = {},
): Promise<ApiKeyRotateResult | null> {
const current = await prisma.apiKey.findFirst({
where: { id: keyId, userId },
where: { id: keyId, userId, isActive: true, revokedAt: null },
select: {
id: true,
keyHash: true,
Expand Down
52 changes: 52 additions & 0 deletions apps/studio/app/(main)/(dashboard)/api-keys/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<ApiKeyDetailRecord | null> {
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 <ApiKeyDetailView initialData={keyDetails} />;
}
57 changes: 57 additions & 0 deletions apps/studio/app/(main)/(dashboard)/api-keys/create/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="space-y-6" aria-labelledby="create-api-key-title">
<section className="border-border bg-card overflow-hidden rounded-2xl border">
<div className="grid gap-0 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="p-5 sm:p-6">
<p className="text-accent text-xs font-bold tracking-[0.2em] uppercase">API keys</p>

<h1
id="create-api-key-title"
className="mt-3 text-3xl font-black tracking-tight sm:text-4xl"
>
Create key
</h1>

<p className="text-muted mt-2 max-w-2xl text-base">
Pick exact scopes first, then generate one secret token.
</p>
</div>

<div className="border-border/70 grid gap-3 border-t p-5 lg:border-t-0 lg:border-l">
<QuickLink href="/api-keys" icon={ArrowLeft} label="Back to keys" />
<QuickLink href="/settings" icon={Settings} label="Settings" />
</div>
</div>
</section>

<ApiKeyCreateClient />
</main>
);
}

function QuickLink({ href, icon: Icon, label }: { href: string; icon: LucideIcon; label: string }) {
return (
<Link
href={href}
className="bg-background/70 hover:border-accent/40 flex items-center gap-3 rounded-xl border border-transparent p-3 text-sm font-bold transition"
>
<Icon className="text-accent h-4 w-4" />
{label}
</Link>
);
}
102 changes: 102 additions & 0 deletions apps/studio/app/(main)/(dashboard)/api-keys/page.tsx
Original file line number Diff line number Diff line change
@@ -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<ApiKeyRecord> };

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 (
<div className="bg-background/70 rounded-xl p-4">
<div className="text-muted flex items-center gap-2 text-[11px] font-bold tracking-wider uppercase">
<Icon className="h-3.5 w-3.5" />
{label}
</div>
<p className="mt-2 text-2xl font-black">{value}</p>
</div>
);
}

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 (
<main className="space-y-6" aria-labelledby="api-keys-title">
<section className="border-border bg-card grid gap-0 overflow-hidden rounded-2xl border lg:grid-cols-[minmax(0,1fr)_24rem]">
<div className="p-5 sm:p-6">
<p className="text-accent text-xs font-bold tracking-[0.2em] uppercase">API keys</p>

<h1 id="api-keys-title" className="mt-3 text-3xl font-black tracking-tight sm:text-4xl">
Developer access
</h1>

<p className="text-muted mt-2 max-w-2xl text-base">
Review existing tokens before creating another. Rotate keys when access changes.
</p>
</div>

<div className="border-border/70 grid gap-3 border-t p-5 sm:grid-cols-2 lg:border-t-0 lg:border-l">
<Metric icon={KeyRound} label="Total keys" value={total.toString()} />
<Metric icon={ShieldCheck} label="Active keys" value={active.toString()} />
</div>
</section>

<section className="border-border bg-card rounded-2xl border p-5 sm:p-6">
<ApiKeySection
initialKeys={initialApiKeys.keys}
initialKeysLoaded={initialApiKeys.loaded}
initialPagination={initialApiKeys.pagination}
/>
</section>
</main>
);
};

export default ApiKeysPage;
Loading
Loading