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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### ✨ New Features

- **feat(api-keys):** add rename support in the permissions modal — editable key name field with validation

---

## [3.7.4] — 2026-04-28
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useTranslations } from "next-intl";

// Constants for validation
const MAX_KEY_NAME_LENGTH = 100;
const MAX_KEY_NAME_LENGTH = 200;
const MAX_SELECTED_MODELS = 500;

// Debounce hook for search optimization
Expand Down Expand Up @@ -232,14 +232,13 @@ export default function ApiManagerPageClient() {
const clearError = useCallback(() => setError(null), []);

const handleCreateKey = async () => {
// Validate and sanitize input
const sanitizedName = sanitizeInput(newKeyName);
const validation = validateKeyName(sanitizedName, t);

// Validate raw input first, then sanitize
const validation = validateKeyName(newKeyName, t);
if (!validation.valid) {
setError(validation.error || t("invalidKeyName"));
return;
}
const sanitizedName = sanitizeInput(newKeyName);

setIsSubmitting(true);
clearError();
Expand Down Expand Up @@ -322,6 +321,7 @@ export default function ApiManagerPageClient() {
};

const handleUpdatePermissions = async (
name: string,
allowedModels: string[],
noLog: boolean,
allowedConnections: string[],
Expand All @@ -332,6 +332,14 @@ export default function ApiManagerPageClient() {
) => {
if (!editingKey || !editingKey.id) return;

// Validate raw input first, then sanitize
const nameValidation = validateKeyName(name, t);
if (!nameValidation.valid) {
setError(nameValidation.error || t("invalidKeyName"));
return;
}
const sanitizedName = sanitizeInput(name);

// Validate models array
if (!Array.isArray(allowedModels)) {
setError(t("invalidModelsSelection"));
Expand Down Expand Up @@ -366,6 +374,7 @@ export default function ApiManagerPageClient() {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: sanitizedName,
allowedModels: validModels,
allowedConnections: validConnections,
noLog,
Expand Down Expand Up @@ -764,6 +773,7 @@ export default function ApiManagerPageClient() {
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder={t("keyNamePlaceholder")}
maxLength={MAX_KEY_NAME_LENGTH}
autoFocus
/>
<p className="text-xs text-text-muted mt-1.5">{t("keyNameDesc")}</p>
Expand Down Expand Up @@ -862,6 +872,7 @@ const PermissionsModal = memo(function PermissionsModal({
searchModel: string;
onSearchChange: (v: string) => void;
onSave: (
name: string,
models: string[],
noLog: boolean,
connections: string[],
Expand All @@ -879,6 +890,7 @@ const PermissionsModal = memo(function PermissionsModal({
const initialConnections = Array.isArray(apiKey?.allowedConnections)
? apiKey.allowedConnections
: [];
const [keyName, setKeyName] = useState(apiKey?.name ?? "");
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
Expand Down Expand Up @@ -993,6 +1005,7 @@ const PermissionsModal = memo(function PermissionsModal({
}
: null;
onSave(
keyName,
allowAll ? [] : selectedModels,
noLogEnabled,
allowAllConnections ? [] : selectedConnections,
Expand All @@ -1003,6 +1016,7 @@ const PermissionsModal = memo(function PermissionsModal({
);
}, [
onSave,
keyName,
allowAll,
selectedModels,
noLogEnabled,
Expand All @@ -1028,6 +1042,22 @@ const PermissionsModal = memo(function PermissionsModal({
onClose={onClose}
>
<div className="flex flex-col gap-4">
{/* Key Name */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("keyName")}</p>
<p className="text-xs text-text-muted">{t("keyNameDesc")}</p>
</div>
<div className="w-48 shrink-0">
<Input
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
placeholder={t("keyNamePlaceholder")}
maxLength={MAX_KEY_NAME_LENGTH}
/>
</div>
</div>

{/* Access Mode Toggle */}
<div className="flex gap-2 p-1 bg-surface rounded-lg">
<button
Expand Down
168 changes: 168 additions & 0 deletions tests/e2e/api-keys-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,21 @@ test.describe("API keys flow", () => {
return;
}

if (route.request().method() === "PATCH") {
const keyId = route.request().url().split("/").pop() || "";
const payload = (await route.request().postDataJSON()) as { name?: string };
const record = state.keys.find((key) => key.id === keyId);
if (!record) {
await fulfillJson(route, { error: "Key not found" }, 404);
return;
}
if (payload.name) {
record.name = payload.name;
}
await fulfillJson(route, { message: "API key settings updated successfully", ...payload });
return;
}

await fulfillJson(route, { error: "Method not allowed in api key detail stub" }, 405);
});

Expand Down Expand Up @@ -213,4 +228,157 @@ test.describe("API keys flow", () => {
await expect(page.getByText("Team Key")).toHaveCount(0);
await expect(createFirstKeyButton).toBeVisible();
});

test("renames a key through the permissions modal", async ({ page }) => {
const state: {
keys: ApiKeyRecord[];
nextId: number;
} = {
keys: [],
nextId: 1,
};

await page.route("**/v1/models", async (route) => {
await fulfillJson(route, { data: [] });
});

await page.route("**/api/settings", async (route) => {
await fulfillJson(route, {});
});

await page.route("**/api/providers", async (route) => {
await fulfillJson(route, {
connections: [
{ id: "conn-openai", name: "OpenAI Main", provider: "openai", isActive: true },
],
});
});

await page.route(/\/api\/usage\/call-logs(?:\?.*)?$/, async (route) => {
await fulfillJson(route, []);
});

await page.route("**/api/sessions", async (route) => {
await fulfillJson(route, { byApiKey: {} });
});

await page.route(/\/api\/keys\/[^/]+$/, async (route) => {
if (route.request().method() === "PATCH") {
const keyId = route.request().url().split("/").pop() || "";
const payload = (await route.request().postDataJSON()) as { name?: string };
const record = state.keys.find((key) => key.id === keyId);
if (!record) {
await fulfillJson(route, { error: "Key not found" }, 404);
return;
}
if (payload.name) {
record.name = payload.name;
}
await fulfillJson(route, { message: "API key settings updated successfully", ...payload });
return;
}

await fulfillJson(route, { error: "Method not allowed" }, 405);
});

await page.route("**/api/keys", async (route) => {
const method = route.request().method();

if (method === "GET") {
await fulfillJson(route, {
keys: state.keys.map(({ fullKey, ...record }) => record),
allowKeyReveal: true,
});
return;
}

if (method === "POST") {
const payload = (route.request().postDataJSON() as { name?: string }) || {};
const id = `key-${state.nextId++}`;
const suffix = String(1000 + state.nextId);
const fullKey = `sk-live-${suffix}-demo-secret`;
const maskedKey = `sk-live-****${suffix}`;
state.keys.push({
id,
name: payload.name || "New Key",
key: maskedKey,
fullKey,
allowedModels: null,
allowedConnections: null,
createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
});

await fulfillJson(route, { key: fullKey, id });
return;
}

await fulfillJson(route, { error: "Method not allowed" }, 405);
});

await gotoDashboardRoute(page, "/dashboard/api-manager", {
timeoutMs: NAVIGATION_TIMEOUT_MS,
});
await waitForPageToSettle(page);
await waitForNextDevCompileToFinish(page);

// --- Create a key ---
const createFirstKeyButton = page.getByRole("button", {
name: /create (your )?first key/i,
});
await expect(createFirstKeyButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
await waitForPageToSettle(page);
await waitForNextDevCompileToFinish(page);
await createFirstKeyButton.click();

const createDialog = page.getByRole("dialog", { name: /create api key/i });
await expect(createDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
await createDialog.locator("input").first().fill("Original Name");
const createKeyButton = createDialog.getByRole("button", { name: /create api key/i });
await expect(createKeyButton).toBeEnabled({ timeout: UI_STABILITY_TIMEOUT_MS });
await createKeyButton.click({ force: true });
await expect.poll(() => state.keys.length).toBe(1);

// Dismiss the "key created" dialog
const createdDialog = page.getByRole("dialog", { name: /api key created/i });
await createdDialog.getByRole("button", { name: /done/i }).click();

// Wait for the key list to settle after re-fetch
await waitForPageToSettle(page);
await waitForNextDevCompileToFinish(page);

// Verify the key appears in the list
await expect(page.getByText("Original Name")).toBeVisible({
timeout: UI_STABILITY_TIMEOUT_MS,
});

// --- Open the permissions modal ---
// The edit-permissions button is opacity-0 until hover; use title attribute locator
const keyRow = page
.locator("div")
.filter({ has: page.getByText("Original Name", { exact: true }) })
.first();
await keyRow.locator('button[title="Edit permissions"]').click({ force: true });

// --- Rename the key ---
const permissionsDialog = page.getByRole("dialog", { name: /permissions: original name/i });
await expect(permissionsDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });

// The key name input is inside the permissions modal
const nameInput = permissionsDialog.locator("input").first();
await expect(nameInput).toHaveValue("Original Name");
await nameInput.clear();
await nameInput.fill("Renamed Key");

// Save
await permissionsDialog
.getByRole("button", { name: /save permissions/i })
.click({ force: true });

// Verify the mock state was updated
await expect.poll(() => state.keys[0]?.name).toBe("Renamed Key");

// Verify the dialog closes and the list reflects the new name
await expect(permissionsDialog).not.toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
await expect(page.getByText("Renamed Key")).toBeVisible();
});
});
41 changes: 41 additions & 0 deletions tests/integration/api-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,47 @@ test("PATCH /api/keys/[id] updates permissions and rejects invalid payloads", as
assert.equal(missingKeyBody.error, "Key not found");
});

test("PATCH /api/keys/[id] renames a key and rejects invalid names", async () => {
await enableManagementAuth();
await createManagementKey();
const created = await apiKeysDb.createApiKey("Original Name", MACHINE_ID);

// Valid rename
const renameResponse = await keyRoute.PATCH(
await makeManagementSessionRequest(`http://localhost/api/keys/${created.id}`, {
method: "PATCH",
body: { name: "Renamed Key" },
}),
{ params: Promise.resolve({ id: created.id }) }
);
const renameBody = (await renameResponse.json()) as any;
const renamed = await apiKeysDb.getApiKeyById(created.id);

assert.equal(renameResponse.status, 200);
assert.equal(renameBody.name, "Renamed Key");
assert.equal(renamed?.name, "Renamed Key");

// Empty name should be rejected by Zod schema (min(1))
const emptyNameResponse = await keyRoute.PATCH(
await makeManagementSessionRequest(`http://localhost/api/keys/${created.id}`, {
method: "PATCH",
body: { name: " " },
}),
{ params: Promise.resolve({ id: created.id }) }
);
assert.equal(emptyNameResponse.status, 400);

// Name too long should be rejected (max 200)
const longNameResponse = await keyRoute.PATCH(
await makeManagementSessionRequest(`http://localhost/api/keys/${created.id}`, {
method: "PATCH",
body: { name: "x".repeat(201) },
}),
{ params: Promise.resolve({ id: created.id }) }
);
assert.equal(longNameResponse.status, 400);
});

test("DELETE /api/keys/[id] removes keys and reports missing resources", async () => {
await enableManagementAuth();
await createManagementKey();
Expand Down