Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_mcp_server_env_vars

# Conflicts:
#	ui/litellm-dashboard/e2e_tests/tests/mcp/mcpServers.spec.ts
#	ui/litellm-dashboard/src/components/networking.tsx
This commit is contained in:
mateo-berri 2026-06-04 18:44:16 +00:00
commit 113f6d8e24
No known key found for this signature in database
601 changed files with 13521 additions and 16093 deletions

View file

@ -14,10 +14,9 @@ async function globalSetup() {
await page.getByPlaceholder("Enter your username").fill(email);
await page.getByPlaceholder("Enter your password").fill(password);
await page.getByRole("button", { name: "Login", exact: true }).click();
await page.waitForURL(
(url) => url.pathname.startsWith("/ui") && !url.pathname.includes("/login"),
{ timeout: 30_000 },
);
await page.waitForURL((url) => url.pathname.startsWith("/ui") && !url.pathname.includes("/login"), {
timeout: 30_000,
});
await expect(page.locator("a", { hasText: "Virtual Keys" })).toBeVisible({ timeout: 30_000 });
// Dismiss feedback popup if present
const dismiss = page.getByText("Don't ask me again");

View file

@ -20,7 +20,9 @@ export async function dismissFeedbackPopup(page: PlaywrightPage): Promise<void>
if (await dismissButton.isVisible({ timeout: 1_500 }).catch(() => false)) {
await dismissButton.click();
// Wait for the popup to disappear
await expect(dismissButton).not.toBeVisible({ timeout: 2_000 }).catch(() => {});
await expect(dismissButton)
.not.toBeVisible({ timeout: 2_000 })
.catch(() => {});
}
}

View file

@ -31,7 +31,7 @@ export default defineConfig({
/* Slow down actions when SLOWMO=<ms> is set, useful for headed local debugging */
launchOptions: {
slowMo: process.env.SLOWMO ? (parseInt(process.env.SLOWMO, 10) || 0) : 0,
slowMo: process.env.SLOWMO ? parseInt(process.env.SLOWMO, 10) || 0 : 0,
},
},

View file

@ -13,9 +13,12 @@ test.describe("Logout", () => {
// is declared with trigger={["click"]}, so a plain click opens the popup.
await page.getByRole("button", { name: /Account menu/i }).click();
const popup = page.locator(".ant-dropdown:visible").filter({
has: page.locator(".bg-white.rounded-lg.shadow-lg"),
}).first();
const popup = page
.locator(".ant-dropdown:visible")
.filter({
has: page.locator(".bg-white.rounded-lg.shadow-lg"),
})
.first();
await expect(popup).toBeVisible({ timeout: 5_000 });
// Click Logout — the handler clears the auth cookie and navigates via

View file

@ -38,10 +38,9 @@ test.describe("PROXY_LOGOUT_URL redirect", () => {
// fetch (/sso/get/ui_settings) resolves. Clicking Logout before that lands
// runs `window.location.href = ""` — a same-origin reload, not a redirect —
// so gate the click on the settings response, not just on first paint.
const settingsLoaded = page.waitForResponse(
(r) => r.url().includes("/sso/get/ui_settings") && r.ok(),
{ timeout: 30_000 },
);
const settingsLoaded = page.waitForResponse((r) => r.url().includes("/sso/get/ui_settings") && r.ok(), {
timeout: 30_000,
});
await page.goto("/ui");
await expect(page.getByText("Virtual Keys")).toBeVisible({ timeout: 15_000 });
await settingsLoaded;
@ -59,10 +58,7 @@ test.describe("PROXY_LOGOUT_URL redirect", () => {
// handleLogout clears cookies/local storage, then assigns window.location.href.
// Arm the navigation wait before the click so we never miss the redirect.
await Promise.all([
page.waitForURL((url) => url.origin === target.origin, { timeout: 15_000 }),
logout.click(),
]);
await Promise.all([page.waitForURL((url) => url.origin === target.origin, { timeout: 15_000 }), logout.click()]);
// The browser landed on exactly the configured logout URL. Compare normalized
// hrefs (both sides through URL()) so trailing-slash / default-port rewrites the
@ -74,9 +70,7 @@ test.describe("PROXY_LOGOUT_URL redirect", () => {
// ...and the client-side session cookie is gone (clearTokenCookies ran before
// the redirect). HttpOnly cookies set server-side can't be cleared from JS,
// so scope the check to the JS-managed token the UI is responsible for.
const clientTokensAfter = (await page.context().cookies()).filter(
(c) => c.name === "token" && !c.httpOnly,
);
const clientTokensAfter = (await page.context().cookies()).filter((c) => c.name === "token" && !c.httpOnly);
expect(clientTokensAfter, "client token cookie should be cleared on logout").toHaveLength(0);
});
});

View file

@ -22,9 +22,9 @@ test.describe("Internal User", () => {
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
await teamSelect.click();
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
await expect(
page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first(),
).toBeVisible({ timeout: 5_000 });
await expect(page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({
timeout: 5_000,
});
});
test("Team info page omits the Settings tab for non-admin members", async ({ page }) => {
@ -44,9 +44,9 @@ test.describe("Internal User", () => {
// Anchor on the user's own seeded key so the absence check below cannot
// pass vacuously against an empty table.
await expect(
page.locator("table tbody").getByText(E2E_INTERNAL_USER_KEY_ALIAS).first(),
).toBeVisible({ timeout: 10_000 });
await expect(page.locator("table tbody").getByText(E2E_INTERNAL_USER_KEY_ALIAS).first()).toBeVisible({
timeout: 10_000,
});
// The litellm-dashboard team is the proxy's internal bookkeeping team —
// its keys must never leak into an internal user's Virtual Keys table.

View file

@ -1,9 +1,5 @@
import { test, expect } from "@playwright/test";
import {
INTERNAL_USER_STORAGE_PATH,
E2E_TEAM_CRUD_ALIAS,
E2E_TEAM_ORG_ALIAS,
} from "../../constants";
import { INTERNAL_USER_STORAGE_PATH, E2E_TEAM_CRUD_ALIAS, E2E_TEAM_ORG_ALIAS } from "../../constants";
import { Page } from "../../fixtures/pages";
import { navigateToPage } from "../../helpers/navigation";

View file

@ -1,9 +1,5 @@
import { test, expect } from "@playwright/test";
import {
E2E_TEAM_CRUD_ID,
E2E_VIEWER_KEY_ALIAS,
INTERNAL_VIEWER_STORAGE_PATH,
} from "../../constants";
import { E2E_TEAM_CRUD_ID, E2E_VIEWER_KEY_ALIAS, INTERNAL_VIEWER_STORAGE_PATH } from "../../constants";
import { Page } from "../../fixtures/pages";
import { navigateToPage } from "../../helpers/navigation";

View file

@ -22,9 +22,7 @@ test.describe("Navbar identity scoping", () => {
await expect(accountButton).toHaveAttribute("aria-label", /Internal User/, { timeout: 5_000 });
await expect(accountButton).toHaveAttribute(
"aria-label",
new RegExp(
`signed in as (${escapeRegExp(E2E_INTERNAL_USER_EMAIL)}|${escapeRegExp(E2E_INTERNAL_USER_ID)})`,
),
new RegExp(`signed in as (${escapeRegExp(E2E_INTERNAL_USER_EMAIL)}|${escapeRegExp(E2E_INTERNAL_USER_ID)})`),
{ timeout: 5_000 },
);

View file

@ -21,9 +21,12 @@ test("user can log in", async ({ page }) => {
// Filter by the popupRender wrapper class to disambiguate from other
// ant-dropdown popups.
const popup = page.locator(".ant-dropdown:visible").filter({
has: page.locator(".bg-white.rounded-lg.shadow-lg"),
}).first();
const popup = page
.locator(".ant-dropdown:visible")
.filter({
has: page.locator(".bg-white.rounded-lg.shadow-lg"),
})
.first();
await expect(popup).toBeVisible({ timeout: 5_000 });
await expect(popup.getByText("Admin", { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(popup.getByText("default_user_id", { exact: true })).toBeVisible({ timeout: 5_000 });

View file

@ -31,8 +31,9 @@ test.describe("AI Hub (internal admin view)", () => {
// Submit
await modal.getByRole("button", { name: "Make Public" }).click();
await expect(page.getByText(/Successfully made .* model group\(s\) public/i).first())
.toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/Successfully made .* model group\(s\) public/i).first()).toBeVisible({
timeout: 15_000,
});
});
test("AI Hub tab list renders Model Hub, Agent Hub, MCP Hub and Skill Hub", async ({ page }) => {

View file

@ -154,10 +154,7 @@ test.describe("Add Model", () => {
// The Team-BYOK switch is gated on `premiumUser` — without a license set
// for the proxy under test, the toggle is disabled and this manual-QA
// step cannot be exercised.
test.skip(
!process.env.LITELLM_LICENSE,
"LITELLM_LICENSE not set in test env — Team-BYOK switch is disabled",
);
test.skip(!process.env.LITELLM_LICENSE, "LITELLM_LICENSE not set in test env — Team-BYOK switch is disabled");
// Make the test idempotent across retries and local reruns: delete any
// Cohere model already scoped to the e2e team before we start, and again
@ -170,10 +167,11 @@ test.describe("Add Model", () => {
const res = await request.get("/v2/model/info", { headers: auth });
if (!res.ok()) return;
const body = await res.json();
const matches: Array<{ id: string }> = (body?.data ?? []).filter((m: any) =>
typeof m?.model_name === "string" &&
m.model_name.startsWith("cohere") &&
m?.model_info?.team_id === E2E_TEAM_CRUD_ID,
const matches: Array<{ id: string }> = (body?.data ?? []).filter(
(m: any) =>
typeof m?.model_name === "string" &&
m.model_name.startsWith("cohere") &&
m?.model_info?.team_id === E2E_TEAM_CRUD_ID,
);
for (const m of matches) {
await request.post("/model/delete", { headers: auth, data: { id: m.id } });
@ -208,9 +206,7 @@ test.describe("Add Model", () => {
const teamDropdown = page.getByTestId("team-dropdown");
await expect(teamDropdown).toBeVisible({ timeout: 5_000 });
await teamDropdown.click();
const teamOption = page.locator(".ant-select-dropdown:visible")
.getByText(E2E_TEAM_CRUD_ID)
.first();
const teamOption = page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ID).first();
await expect(teamOption).toBeVisible({ timeout: 5_000 });
await teamOption.click();
@ -219,8 +215,9 @@ test.describe("Add Model", () => {
// Scope the success toast to antd's notification container so a stale
// success message from an earlier test in the same context can't satisfy
// the assertion.
await expect(page.locator(".ant-notification").getByText("created successfully").last())
.toBeVisible({ timeout: 15_000 });
await expect(page.locator(".ant-notification").getByText("created successfully").last()).toBeVisible({
timeout: 15_000,
});
// Verify the model is now in All Models with the team_id attached. The
// Models table renders team-scoped models with the team id in the row.
@ -237,16 +234,16 @@ test.describe("Add Model", () => {
// Confirm the search returned at least one result — gives a clear
// failure message when the table is empty instead of timing out on a
// row assertion.
await expect(page.getByTestId("models-results-count")).toHaveText(
/Showing \d+ - \d+ of \d+ results/,
{ timeout: 15_000 },
);
await expect(page.getByTestId("models-results-count")).toHaveText(/Showing \d+ - \d+ of \d+ results/, {
timeout: 15_000,
});
// Stronger than "alias appears somewhere in tbody" — pin the assertion
// to a single row that has BOTH the cohere model_name AND the seeded
// team alias, so a stale cohere row from "Add wildcard route" (no team)
// can't satisfy the check.
const teamCohereRow = page.locator("table tbody tr")
const teamCohereRow = page
.locator("table tbody tr")
.filter({ hasText: "cohere/" })
.filter({ hasText: E2E_TEAM_CRUD_ALIAS });
await expect(teamCohereRow).toHaveCount(1, { timeout: 15_000 });

View file

@ -62,9 +62,7 @@ test.describe("Clear custom pricing on a deployment", () => {
}
});
test("UI sends null for cleared pricing and backend removes the override", async ({
page,
}) => {
test("UI sends null for cleared pricing and backend removes the override", async ({ page }) => {
// Navigate to the model detail view.
await page.goto("/ui");
await page.getByText("Models + Endpoints").click();
@ -97,34 +95,24 @@ test.describe("Clear custom pricing on a deployment", () => {
// Capture the outgoing PATCH so we can assert the UI sends explicit nulls.
const patchPromise = page.waitForRequest(
(req) =>
req.method() === "PATCH" &&
req.url().includes(`/model/${createdModelId}/update`)
(req) => req.method() === "PATCH" && req.url().includes(`/model/${createdModelId}/update`),
);
await page.getByRole("button", { name: "Save Changes" }).click();
const patchReq = await patchPromise;
const patchBody = JSON.parse(patchReq.postData() ?? "{}");
expect(
patchBody.litellm_params.input_cost_per_token,
"UI sends explicit null for cleared input cost"
).toBeNull();
expect(
patchBody.litellm_params.output_cost_per_token,
"UI sends explicit null for cleared output cost"
).toBeNull();
expect(patchBody.litellm_params.input_cost_per_token, "UI sends explicit null for cleared input cost").toBeNull();
expect(patchBody.litellm_params.output_cost_per_token, "UI sends explicit null for cleared output cost").toBeNull();
expect(
patchBody.litellm_params.cache_read_input_token_cost,
"UI sends explicit null for cleared cache_read cost"
"UI sends explicit null for cleared cache_read cost",
).toBeNull();
expect(
patchBody.litellm_params.cache_creation_input_token_cost,
"UI sends explicit null for cleared cache_write cost"
"UI sends explicit null for cleared cache_write cost",
).toBeNull();
// Success toast confirms the save was accepted.
await expect(
page.getByText("Model settings updated successfully")
).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Model settings updated successfully")).toBeVisible({ timeout: 10_000 });
// Verify via the management API: the user-set rate is gone from both blobs.
// The cost-map may synthesize a default for known providers in the response,
@ -132,46 +120,40 @@ test.describe("Clear custom pricing on a deployment", () => {
// undefined.
const infoRes = await page.request.get(
`/v2/model/info?include_team_models=true&page=1&size=100&modelId=${createdModelId}`,
{ headers: { Authorization: `Bearer ${masterKey}` } }
{ headers: { Authorization: `Bearer ${masterKey}` } },
);
expect(infoRes.ok()).toBe(true);
const infoBody = await infoRes.json();
const row = (infoBody.data ?? infoBody).find?.(
(m: any) => m?.model_info?.id === createdModelId
);
const row = (infoBody.data ?? infoBody).find?.((m: any) => m?.model_info?.id === createdModelId);
expect(row, "model info row").toBeTruthy();
expect(
"input_cost_per_token" in row.litellm_params,
"litellm_params.input_cost_per_token key removed"
).toBe(false);
expect(
"output_cost_per_token" in row.litellm_params,
"litellm_params.output_cost_per_token key removed"
).toBe(false);
expect("input_cost_per_token" in row.litellm_params, "litellm_params.input_cost_per_token key removed").toBe(false);
expect("output_cost_per_token" in row.litellm_params, "litellm_params.output_cost_per_token key removed").toBe(
false,
);
expect(
"cache_read_input_token_cost" in row.litellm_params,
"litellm_params.cache_read_input_token_cost key removed"
"litellm_params.cache_read_input_token_cost key removed",
).toBe(false);
expect(
"cache_creation_input_token_cost" in row.litellm_params,
"litellm_params.cache_creation_input_token_cost key removed"
"litellm_params.cache_creation_input_token_cost key removed",
).toBe(false);
expect(
row.model_info.input_cost_per_token,
"model_info.input_cost_per_token no longer the seeded override"
"model_info.input_cost_per_token no longer the seeded override",
).not.toBe(SEED_INPUT_PER_TOKEN);
expect(
row.model_info.output_cost_per_token,
"model_info.output_cost_per_token no longer the seeded override"
"model_info.output_cost_per_token no longer the seeded override",
).not.toBe(SEED_OUTPUT_PER_TOKEN);
expect(
row.model_info.cache_read_input_token_cost,
"model_info.cache_read_input_token_cost no longer the seeded override"
"model_info.cache_read_input_token_cost no longer the seeded override",
).not.toBe(SEED_CACHE_READ_PER_TOKEN);
expect(
row.model_info.cache_creation_input_token_cost,
"model_info.cache_creation_input_token_cost no longer the seeded override"
"model_info.cache_creation_input_token_cost no longer the seeded override",
).not.toBe(SEED_CACHE_WRITE_PER_TOKEN);
});
});

View file

@ -6,15 +6,7 @@ import { menuLabelToPage } from "../../fixtures/menuMappings";
import { navigateToPage } from "../../helpers/navigation";
const sidebarButtons = {
[Role.ProxyAdmin]: [
"Virtual Keys",
"Playground",
"Models",
"Usage",
"Teams",
"Internal Users",
"AI Hub",
],
[Role.ProxyAdmin]: ["Virtual Keys", "Playground", "Models", "Usage", "Teams", "Internal Users", "AI Hub"],
};
const roles = [{ role: Role.ProxyAdmin, storage: ADMIN_STORAGE_PATH }];

View file

@ -89,12 +89,8 @@ test.describe("Proxy Admin - Keys", () => {
await page.getByRole("spinbutton", { name: "RPM Limit" }).fill("456");
await page.getByRole("button", { name: "Save Changes" }).click();
await expect(
page.getByRole("paragraph").filter({ hasText: "TPM: 123" })
).toBeVisible({ timeout: 10_000 });
await expect(
page.getByRole("paragraph").filter({ hasText: "RPM: 456" })
).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("paragraph").filter({ hasText: "TPM: 123" })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("paragraph").filter({ hasText: "RPM: 456" })).toBeVisible({ timeout: 10_000 });
});
test("Delete key", async ({ page }) => {

View file

@ -14,10 +14,7 @@ import { ADMIN_STORAGE_PATH } from "../../constants";
*/
test.describe("Premium license wiring", () => {
test("admin session JWT carries premium_user=true when LITELLM_LICENSE is set", () => {
test.skip(
!process.env.LITELLM_LICENSE,
"LITELLM_LICENSE not set in test env — proxy is running unlicensed",
);
test.skip(!process.env.LITELLM_LICENSE, "LITELLM_LICENSE not set in test env — proxy is running unlicensed");
const storage = JSON.parse(fs.readFileSync(ADMIN_STORAGE_PATH, "utf-8"));
const tokenCookie = storage.cookies?.find((c: { name: string }) => c.name === "token");
@ -28,9 +25,7 @@ test.describe("Premium license wiring", () => {
const jwtParts = tokenCookie.value.split(".");
expect(jwtParts.length, "token cookie is not a 3-part JWT").toBe(3);
const [, payloadB64] = jwtParts;
const payload = JSON.parse(
Buffer.from(payloadB64, "base64url").toString("utf-8"),
);
const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf-8"));
expect(payload.premium_user).toBe(true);
});

View file

@ -19,7 +19,10 @@ test.describe("Proxy Admin - Teams", () => {
const uniqueAlias = `e2e-created-team-${Date.now()}`;
// Click the Create Team button — accessible name includes "Create Team"
await page.getByRole("button", { name: /Create Team/i }).first().click();
await page
.getByRole("button", { name: /Create Team/i })
.first()
.click();
// Wait for the Create Team modal
const dialog = page.locator(".ant-modal:visible");
@ -157,8 +160,9 @@ test.describe("Proxy Admin - Teams", () => {
await page.getByRole("button", { name: "Save Changes" }).click();
await expect(page.getByText(/Team settings updated|updated successfully/i).first())
.toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/Team settings updated|updated successfully/i).first()).toBeVisible({
timeout: 10_000,
});
} finally {
// Leave the team in its seeded state for any subsequent test or rerun.
await restore();

View file

@ -86,17 +86,16 @@ test.describe("Router Settings - Fallbacks", () => {
await modal.getByRole("button", { name: /Save All Configurations/i }).click();
// Success toast
await expect(page.getByText(/fallback configuration\(s\) added successfully/i).first())
.toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/fallback configuration\(s\) added successfully/i).first()).toBeVisible({
timeout: 10_000,
});
// Modal closes, and a single row contains BOTH the primary and the fallback
// model — stronger than asserting each name appears somewhere in tbody,
// which could be satisfied by leftover rows from prior runs.
await expect(modal).not.toBeVisible({ timeout: 5_000 });
const newRow = page.locator("table tbody tr")
.filter({ hasText: PRIMARY })
.filter({ hasText: FALLBACK });
const newRow = page.locator("table tbody tr").filter({ hasText: PRIMARY }).filter({ hasText: FALLBACK });
await expect(newRow).toHaveCount(1, { timeout: 10_000 });
});
});

View file

@ -28,13 +28,11 @@ test.describe("Team Admin", () => {
await clickTeamId(page, E2E_TEAM_CRUD_ID);
await page.getByRole("tab", { name: "Virtual Keys" }).click();
await expect(page.getByText(E2E_INTERNAL_USER_KEY_ALIAS).first())
.toBeVisible({ timeout: 10_000 });
await expect(page.getByText(E2E_INTERNAL_USER_KEY_ALIAS).first()).toBeVisible({ timeout: 10_000 });
// And from the global Virtual Keys page, the same key should be visible.
await navigateToPage(page, Page.ApiKeys);
await expect(page.getByText(E2E_INTERNAL_USER_KEY_ALIAS).first())
.toBeVisible({ timeout: 10_000 });
await expect(page.getByText(E2E_INTERNAL_USER_KEY_ALIAS).first()).toBeVisible({ timeout: 10_000 });
});
test("Team admin can add a member to their team", async ({ page }) => {
@ -60,8 +58,7 @@ test.describe("Team Admin", () => {
await modal.getByRole("button", { name: /Add Member/i }).click();
await expect(page.getByText("Team member added successfully").first())
.toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Team member added successfully").first()).toBeVisible({ timeout: 10_000 });
});
test("Team admin can remove a member from their team", async ({ page }) => {
@ -82,8 +79,7 @@ test.describe("Team Admin", () => {
await expect(modal).toBeVisible({ timeout: 5_000 });
await modal.getByRole("button", { name: /^Delete$/ }).click();
await expect(page.getByText("Team member removed successfully").first())
.toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Team member removed successfully").first()).toBeVisible({ timeout: 10_000 });
});
test("Team admin can create a team key with All Team Models", async ({ page }) => {

View file

@ -1,18 +1,9 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["scripts/**/*.ts"],
"project": [
"src/**/*.{ts,tsx}",
"tests/**/*.{ts,tsx}",
"scripts/**/*.ts",
"e2e_tests/**/*.ts"
],
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}", "scripts/**/*.ts", "e2e_tests/**/*.ts"],
"playwright": {
"config": "e2e_tests/playwright.config.ts",
"entry": [
"e2e_tests/**/*.spec.ts",
"e2e_tests/**/*.setup.ts",
"e2e_tests/globalSetup.ts"
]
"entry": ["e2e_tests/**/*.spec.ts", "e2e_tests/**/*.setup.ts", "e2e_tests/globalSetup.ts"]
}
}

View file

@ -2,7 +2,7 @@
The LiteLLM UI is currently being refactored/rewritten to reduce development friction. Please read this document to understand what's expected for new contributions.
The project follows strict NextJS file structure. All pages on the site (determined by the sidebar) are contained in their own folder, and routing is automatically handled by NextJS based on the file structure.
The project follows strict NextJS file structure. All pages on the site (determined by the sidebar) are contained in their own folder, and routing is automatically handled by NextJS based on the file structure.
For example, NextJS will automatically render the admin settings page when the user visits `/settings/admin-settings`
@ -16,7 +16,9 @@ For example, NextJS will automatically render the admin settings page when the u
You can use parenthesis around directory names to hide them from the user route, for example `(dashboard)`, while still getting the benefits of `layout` and file structure.
### File Structure
Every page must follow the following file structure pattern.
```
├── teams
│   ├── TeamsView.tsx
@ -34,11 +36,11 @@ Every page must follow the following file structure pattern.
│   └── page.tsx
```
### Component Files
### Component Files
All component files should ideally be as dumb as possible. Their only job should be to take the data they need from hooks or props and render them to the UI. If a component file becomes too large (over `300` lines or so), **please break it down** into smaller components.
A component should only be placed where it will be used. For example, if a component will only be used by the `teams` page, it should belong in the `teams/components` folder.
A component should only be placed where it will be used. For example, if a component will only be used by the `teams` page, it should belong in the `teams/components` folder.
**Common components should be moved to the lowest common ancestor components folder.**

View file

@ -464,7 +464,6 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
/>
</ConfigProvider>
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
</Sider>
</Layout>
);

View file

@ -31,13 +31,15 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side
console.log("[SidebarProvider] Fetching UI settings from /get/ui_settings");
const settings = await getUISettings(accessToken);
console.log("[SidebarProvider] UI settings response:", settings);
// API returns 'values' not 'settings'
if (settings?.values?.enabled_ui_pages_internal_users !== undefined) {
console.log("[SidebarProvider] Setting enabled pages:", settings.values.enabled_ui_pages_internal_users);
setEnabledPagesInternalUsers(settings.values.enabled_ui_pages_internal_users);
} else {
console.log("[SidebarProvider] No enabled_ui_pages_internal_users in response (all pages visible by default)");
console.log(
"[SidebarProvider] No enabled_ui_pages_internal_users in response (all pages visible by default)",
);
}
if (settings?.values?.enable_projects_ui !== undefined) {

View file

@ -1,20 +1,12 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups";
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchAccessGroupDetails = async (
accessToken: string,
accessGroupId: string,
): Promise<AccessGroupResponse> => {
const fetchAccessGroupDetails = async (accessToken: string, accessGroupId: string): Promise<AccessGroupResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`;
@ -45,17 +37,13 @@ export const useAccessGroupDetails = (accessGroupId?: string) => {
return useQuery<AccessGroupResponse>({
queryKey: accessGroupKeys.detail(accessGroupId!),
queryFn: async () => fetchAccessGroupDetails(accessToken!, accessGroupId!),
enabled:
Boolean(accessToken && accessGroupId) &&
all_admin_roles.includes(userRole || ""),
enabled: Boolean(accessToken && accessGroupId) && all_admin_roles.includes(userRole || ""),
// Seed from the list cache when available
initialData: () => {
if (!accessGroupId) return undefined;
const groups = queryClient.getQueryData<AccessGroupResponse[]>(
accessGroupKeys.list({}),
);
const groups = queryClient.getQueryData<AccessGroupResponse[]>(accessGroupKeys.list({}));
return groups?.find((g) => g.access_group_id === accessGroupId);
},

View file

@ -1,11 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -32,9 +27,7 @@ export const accessGroupKeys = createQueryKeys("accessGroups");
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchAccessGroups = async (
accessToken: string,
): Promise<AccessGroupResponse[]> => {
const fetchAccessGroups = async (accessToken: string): Promise<AccessGroupResponse[]> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group`;
@ -64,7 +57,6 @@ export const useAccessGroups = () => {
return useQuery<AccessGroupResponse[]>({
queryKey: accessGroupKeys.list({}),
queryFn: async () => fetchAccessGroups(accessToken!),
enabled:
Boolean(accessToken) && all_admin_roles.includes(userRole || ""),
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole || ""),
});
};

View file

@ -1,10 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups";

View file

@ -1,19 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { accessGroupKeys } from "./useAccessGroups";
// ── Fetch function ───────────────────────────────────────────────────────────
const deleteAccessGroup = async (
accessToken: string,
accessGroupId: string,
): Promise<void> => {
const deleteAccessGroup = async (accessToken: string, accessGroupId: string): Promise<void> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`;

View file

@ -1,10 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups";

View file

@ -4,27 +4,22 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useCloudZeroCreate } from "./useCloudZeroCreate";
const {
mockProxyBaseUrl,
mockAccessToken,
mockHeaderName,
mockGetProxyBaseUrl,
mockGetGlobalLitellmHeaderName,
} = vi.hoisted(() => {
const mockProxyBaseUrl = "https://proxy.example.com";
const mockAccessToken = "test-access-token";
const mockHeaderName = "X-LiteLLM-API-Key";
const mockGetProxyBaseUrl = vi.fn(() => mockProxyBaseUrl);
const mockGetGlobalLitellmHeaderName = vi.fn(() => mockHeaderName);
const { mockProxyBaseUrl, mockAccessToken, mockHeaderName, mockGetProxyBaseUrl, mockGetGlobalLitellmHeaderName } =
vi.hoisted(() => {
const mockProxyBaseUrl = "https://proxy.example.com";
const mockAccessToken = "test-access-token";
const mockHeaderName = "X-LiteLLM-API-Key";
const mockGetProxyBaseUrl = vi.fn(() => mockProxyBaseUrl);
const mockGetGlobalLitellmHeaderName = vi.fn(() => mockHeaderName);
return {
mockProxyBaseUrl,
mockAccessToken,
mockHeaderName,
mockGetProxyBaseUrl,
mockGetGlobalLitellmHeaderName,
};
});
return {
mockProxyBaseUrl,
mockAccessToken,
mockHeaderName,
mockGetProxyBaseUrl,
mockGetGlobalLitellmHeaderName,
};
});
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: mockGetProxyBaseUrl,

View file

@ -4,27 +4,22 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useCloudZeroDryRun } from "./useCloudZeroDryRun";
const {
mockProxyBaseUrl,
mockAccessToken,
mockHeaderName,
mockGetProxyBaseUrl,
mockGetGlobalLitellmHeaderName,
} = vi.hoisted(() => {
const mockProxyBaseUrl = "https://proxy.example.com";
const mockAccessToken = "test-access-token";
const mockHeaderName = "X-LiteLLM-API-Key";
const mockGetProxyBaseUrl = vi.fn(() => mockProxyBaseUrl);
const mockGetGlobalLitellmHeaderName = vi.fn(() => mockHeaderName);
const { mockProxyBaseUrl, mockAccessToken, mockHeaderName, mockGetProxyBaseUrl, mockGetGlobalLitellmHeaderName } =
vi.hoisted(() => {
const mockProxyBaseUrl = "https://proxy.example.com";
const mockAccessToken = "test-access-token";
const mockHeaderName = "X-LiteLLM-API-Key";
const mockGetProxyBaseUrl = vi.fn(() => mockProxyBaseUrl);
const mockGetGlobalLitellmHeaderName = vi.fn(() => mockHeaderName);
return {
mockProxyBaseUrl,
mockAccessToken,
mockHeaderName,
mockGetProxyBaseUrl,
mockGetGlobalLitellmHeaderName,
};
});
return {
mockProxyBaseUrl,
mockAccessToken,
mockHeaderName,
mockGetProxyBaseUrl,
mockGetGlobalLitellmHeaderName,
};
});
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: mockGetProxyBaseUrl,

View file

@ -4,27 +4,22 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useCloudZeroExport } from "./useCloudZeroExport";
const {
mockProxyBaseUrl,
mockAccessToken,
mockHeaderName,
mockGetProxyBaseUrl,
mockGetGlobalLitellmHeaderName,
} = vi.hoisted(() => {
const mockProxyBaseUrl = "https://proxy.example.com";
const mockAccessToken = "test-access-token";
const mockHeaderName = "X-LiteLLM-API-Key";
const mockGetProxyBaseUrl = vi.fn(() => mockProxyBaseUrl);
const mockGetGlobalLitellmHeaderName = vi.fn(() => mockHeaderName);
const { mockProxyBaseUrl, mockAccessToken, mockHeaderName, mockGetProxyBaseUrl, mockGetGlobalLitellmHeaderName } =
vi.hoisted(() => {
const mockProxyBaseUrl = "https://proxy.example.com";
const mockAccessToken = "test-access-token";
const mockHeaderName = "X-LiteLLM-API-Key";
const mockGetProxyBaseUrl = vi.fn(() => mockProxyBaseUrl);
const mockGetGlobalLitellmHeaderName = vi.fn(() => mockHeaderName);
return {
mockProxyBaseUrl,
mockAccessToken,
mockHeaderName,
mockGetProxyBaseUrl,
mockGetGlobalLitellmHeaderName,
};
});
return {
mockProxyBaseUrl,
mockAccessToken,
mockHeaderName,
mockGetProxyBaseUrl,
mockGetGlobalLitellmHeaderName,
};
});
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: mockGetProxyBaseUrl,

View file

@ -13,11 +13,7 @@ describe("createQueryKeys", () => {
});
it("should generate a list key with params", () => {
expect(keys.list({ page: 1, limit: 10 })).toEqual([
"books",
"list",
{ params: { page: 1, limit: 10 } },
]);
expect(keys.list({ page: 1, limit: 10 })).toEqual(["books", "list", { params: { page: 1, limit: 10 } }]);
});
it("should generate a list key with undefined params when none provided", () => {

View file

@ -2,9 +2,7 @@ import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage } from
export const getHashicorpVaultConfig = async (accessToken: string) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault`
: `/config_overrides/hashicorp_vault`;
const url = proxyBaseUrl ? `${proxyBaseUrl}/config_overrides/hashicorp_vault` : `/config_overrides/hashicorp_vault`;
const response = await fetch(url, {
method: "GET",
headers: {
@ -20,14 +18,9 @@ export const getHashicorpVaultConfig = async (accessToken: string) => {
return data;
};
export const updateHashicorpVaultConfig = async (
accessToken: string,
config: Record<string, any>,
) => {
export const updateHashicorpVaultConfig = async (accessToken: string, config: Record<string, any>) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault`
: `/config_overrides/hashicorp_vault`;
const url = proxyBaseUrl ? `${proxyBaseUrl}/config_overrides/hashicorp_vault` : `/config_overrides/hashicorp_vault`;
const response = await fetch(url, {
method: "POST",
headers: {
@ -47,9 +40,7 @@ export const updateHashicorpVaultConfig = async (
export const deleteHashicorpVaultConfig = async (accessToken: string) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault`
: `/config_overrides/hashicorp_vault`;
const url = proxyBaseUrl ? `${proxyBaseUrl}/config_overrides/hashicorp_vault` : `/config_overrides/hashicorp_vault`;
const response = await fetch(url, {
method: "DELETE",
headers: {

View file

@ -289,11 +289,7 @@ describe("useGuardrails", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.globalGuardrailNames).toEqual(
new Set(["global-guard-a", "global-guard-b"]),
);
expect(result.current.data?.optionalGuardrailNames).toEqual(
new Set(["optional-guard-a", "optional-guard-b"]),
);
expect(result.current.data?.globalGuardrailNames).toEqual(new Set(["global-guard-a", "global-guard-b"]));
expect(result.current.data?.optionalGuardrailNames).toEqual(new Set(["optional-guard-a", "optional-guard-b"]));
});
});

View file

@ -1,10 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { createQueryKeys } from "../common/queryKeysFactory";

View file

@ -1,8 +1,5 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
getGlobalLitellmHeaderName,
getProxyBaseUrl,
} from "@/components/networking";
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
import { createQueryKeys } from "../common/queryKeysFactory";
const healthReadinessDetailsKeys = createQueryKeys("healthReadinessDetails");
@ -18,9 +15,7 @@ export interface HealthReadinessDetailsResponse {
is_detailed_debug?: boolean;
}
const fetchHealthReadinessDetails = async (
accessToken: string,
): Promise<HealthReadinessDetailsResponse> => {
const fetchHealthReadinessDetails = async (accessToken: string): Promise<HealthReadinessDetailsResponse> => {
const baseUrl = getProxyBaseUrl();
const response = await fetch(`${baseUrl}/health/readiness/details`, {
method: "GET",
@ -30,9 +25,7 @@ const fetchHealthReadinessDetails = async (
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch health readiness details: ${response.statusText}`,
);
throw new Error(`Failed to fetch health readiness details: ${response.statusText}`);
}
return response.json();
};

View file

@ -128,9 +128,7 @@ describe("useInfiniteKeyAliases", () => {
});
it("should fetch the next page when fetchNextPage is called", async () => {
mockKeyAliasesCall
.mockResolvedValueOnce(mockPage1)
.mockResolvedValueOnce(mockPage2);
mockKeyAliasesCall.mockResolvedValueOnce(mockPage1).mockResolvedValueOnce(mockPage2);
const wrapper = createWrapper();
const { result } = renderHook(() => useInfiniteKeyAliases(2), { wrapper });
@ -151,10 +149,10 @@ describe("useInfiniteKeyAliases", () => {
it("should include search in query key so search changes refetch from page 1", async () => {
const wrapper = createWrapper();
const { result, rerender } = renderHook(
({ search }: { search?: string }) => useInfiniteKeyAliases(50, search),
{ wrapper, initialProps: { search: undefined } },
);
const { result, rerender } = renderHook(({ search }: { search?: string }) => useInfiniteKeyAliases(50, search), {
wrapper,
initialProps: { search: undefined },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);

View file

@ -5,11 +5,7 @@ import useAuthorized from "../useAuthorized";
const infiniteKeyAliasKeys = createQueryKeys("infiniteKeyAliases");
export const useInfiniteKeyAliases = (
size: number = 50,
search?: string,
team_id?: string,
) => {
export const useInfiniteKeyAliases = (size: number = 50, search?: string, team_id?: string) => {
const { accessToken } = useAuthorized();
return useInfiniteQuery<PaginatedKeyAliasResponse>({
queryKey: infiniteKeyAliasKeys.list({
@ -20,13 +16,7 @@ export const useInfiniteKeyAliases = (
},
}),
queryFn: async ({ pageParam }) => {
return await keyAliasesCall(
accessToken!,
pageParam as number,
size,
search,
team_id,
);
return await keyAliasesCall(accessToken!, pageParam as number, size, search, team_id);
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {

View file

@ -410,10 +410,7 @@ describe("useKeys", () => {
}),
});
const { result } = renderHook(
() => useKeys(1, 10, { projectID: "project-1" }),
{ wrapper },
);
const { result } = renderHook(() => useKeys(1, 10, { projectID: "project-1" }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
@ -436,10 +433,7 @@ describe("useKeys", () => {
}),
});
const { result } = renderHook(
() => useKeys(1, 10, { projectID: "project-1", teamID: "team-1" }),
{ wrapper },
);
const { result } = renderHook(() => useKeys(1, 10, { projectID: "project-1", teamID: "team-1" }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
@ -456,10 +450,7 @@ describe("useKeys", () => {
json: async () => mockKeysResponse,
});
const { result } = renderHook(
() => useKeys(1, 10, { projectID: null }),
{ wrapper },
);
const { result } = renderHook(() => useKeys(1, 10, { projectID: null }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);

View file

@ -1,11 +1,6 @@
import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -43,18 +38,13 @@ export interface KeyListCallOptions {
status?: string | null;
}
const keyListCall = async (
accessToken: string,
page: number,
pageSize: number,
options: KeyListCallOptions = {},
) => {
const keyListCall = async (accessToken: string, page: number, pageSize: number, options: KeyListCallOptions = {}) => {
/**
* Get all available keys on proxy
*/
try {
const baseUrl = getProxyBaseUrl();
const params = new URLSearchParams(
Object.entries({
team_id: options.teamID,
@ -134,4 +124,4 @@ export const useDeletedKeys = (
staleTime: 30000, // 30 seconds
placeholderData: keepPreviousData,
});
};
};

View file

@ -1,10 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { keyKeys } from "./useKeys";
@ -20,10 +15,7 @@ export interface ResetKeySpendResponse {
// ── Fetch function ────────────────────────────────────────────────────────────
export const resetKeySpend = async (
accessToken: string,
keyToken: string,
): Promise<ResetKeySpendResponse> => {
export const resetKeySpend = async (accessToken: string, keyToken: string): Promise<ResetKeySpendResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl ? `${baseUrl}/key/${keyToken}/reset_spend` : `/key/${keyToken}/reset_spend`}`;

View file

@ -10,11 +10,7 @@ import { uiSpendLogDetailsCall } from "@/components/networking";
* @param startTime - The formatted start time for the query
* @param enabled - Whether the query should be enabled (e.g., drawer is open)
*/
export const useLogDetails = (
requestId: string | undefined,
startTime: string | undefined,
enabled: boolean,
) => {
export const useLogDetails = (requestId: string | undefined, startTime: string | undefined, enabled: boolean) => {
const { accessToken } = useAuthorized();
return useQuery({

View file

@ -3,9 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import useAuthorized from "../useAuthorized";
const mcpSemanticFilterSettingsKeys = createQueryKeys(
"mcpSemanticFilterSettings"
);
const mcpSemanticFilterSettingsKeys = createQueryKeys("mcpSemanticFilterSettings");
export const useMCPSemanticFilterSettings = () => {
const { accessToken } = useAuthorized();

View file

@ -2,9 +2,7 @@ import { updateMCPSemanticFilterSettings } from "@/components/networking";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
const mcpSemanticFilterSettingsKeys = createQueryKeys(
"mcpSemanticFilterSettings"
);
const mcpSemanticFilterSettingsKeys = createQueryKeys("mcpSemanticFilterSettings");
export const useUpdateMCPSemanticFilterSettings = (accessToken: string) => {
const queryClient = useQueryClient();

View file

@ -121,4 +121,4 @@ describe("useMCPAccessGroups", () => {
expect(result.current.data).toEqual([]);
});
});
});

View file

@ -24,32 +24,32 @@ export const useMCPServerHealth = () => {
refetchInterval: 30000,
});
const recheckServerHealth = useCallback(async (serverId: string) => {
if (!accessToken) return;
const recheckServerHealth = useCallback(
async (serverId: string) => {
if (!accessToken) return;
setRecheckingServerIds((prev) => new Set(prev).add(serverId));
setRecheckingServerIds((prev) => new Set(prev).add(serverId));
try {
const result: MCPServerHealth[] = await fetchMCPServerHealth(accessToken, [serverId]);
try {
const result: MCPServerHealth[] = await fetchMCPServerHealth(accessToken, [serverId]);
queryClient.setQueriesData<MCPServerHealth[]>(
{ queryKey: mcpServerHealthKeys.lists() },
(oldData) => {
queryClient.setQueriesData<MCPServerHealth[]>({ queryKey: mcpServerHealthKeys.lists() }, (oldData) => {
if (!oldData) return result;
return oldData.map((h) => {
const updated = result.find((r) => r.server_id === h.server_id);
return updated ?? h;
});
},
);
} finally {
setRecheckingServerIds((prev) => {
const next = new Set(prev);
next.delete(serverId);
return next;
});
}
}, [accessToken, queryClient]);
});
} finally {
setRecheckingServerIds((prev) => {
const next = new Set(prev);
next.delete(serverId);
return next;
});
}
},
[accessToken, queryClient],
);
return {
...query,

View file

@ -131,4 +131,4 @@ describe("useMCPServers", () => {
expect(result.current.data).toEqual([]);
});
});
});

View file

@ -28,7 +28,15 @@ const allProxyModelsKeys = createQueryKeys("allProxyModels");
const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels");
const infiniteModelKeys = createQueryKeys("infiniteModels");
export const useModelsInfo = (page: number = 1, size: number = 50, search?: string, modelId?: string, teamId?: string, sortBy?: string, sortOrder?: string) => {
export const useModelsInfo = (
page: number = 1,
size: number = 50,
search?: string,
modelId?: string,
teamId?: string,
sortBy?: string,
sortOrder?: string,
) => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<PaginatedModelInfoResponse>({
queryKey: modelKeys.list({
@ -44,7 +52,8 @@ export const useModelsInfo = (page: number = 1, size: number = 50, search?: stri
...(sortOrder && { sortOrder }),
},
}),
queryFn: async () => await modelInfoCall(accessToken!, userId!, userRole!, page, size, search, modelId, teamId, sortBy, sortOrder),
queryFn: async () =>
await modelInfoCall(accessToken!, userId!, userRole!, page, size, search, modelId, teamId, sortBy, sortOrder),
enabled: Boolean(accessToken && userId && userRole),
});
};
@ -76,10 +85,7 @@ export const useSelectedTeamModels = (teamID: string | null) => {
});
};
export const useInfiniteModelInfo = (
size: number = 50,
search?: string,
) => {
export const useInfiniteModelInfo = (size: number = 50, search?: string) => {
const { accessToken, userId, userRole } = useAuthorized();
return useInfiniteQuery<PaginatedModelInfoResponse>({
queryKey: infiniteModelKeys.list({
@ -91,14 +97,7 @@ export const useInfiniteModelInfo = (
},
}),
queryFn: async ({ pageParam }) => {
return await modelInfoCall(
accessToken!,
userId!,
userRole!,
pageParam as number,
size,
search,
);
return await modelInfoCall(accessToken!, userId!, userRole!, pageParam as number, size, search);
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {

View file

@ -103,9 +103,7 @@ describe("useCreateProject", () => {
const { result } = renderHook(() => useCreateProject(), {
wrapper: makeWrapper(queryClient),
});
await expect(result.current.mutateAsync({ team_id: "team-1" })).rejects.toThrow(
"Access token is required"
);
await expect(result.current.mutateAsync({ team_id: "team-1" })).rejects.toThrow("Access token is required");
expect(global.fetch).not.toHaveBeenCalled();
});
});

View file

@ -1,10 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ProjectResponse, projectKeys } from "./useProjects";
@ -25,10 +20,7 @@ export interface ProjectCreateParams {
// ── Fetch function ───────────────────────────────────────────────────────────
const createProject = async (
accessToken: string,
params: ProjectCreateParams,
): Promise<ProjectResponse> => {
const createProject = async (accessToken: string, params: ProjectCreateParams): Promise<ProjectResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/new`;

View file

@ -80,9 +80,7 @@ describe("useDeleteProject", () => {
const { result } = renderHook(() => useDeleteProject(), {
wrapper: makeWrapper(queryClient),
});
await expect(result.current.mutateAsync(["proj-1"])).rejects.toThrow(
"Access token is required"
);
await expect(result.current.mutateAsync(["proj-1"])).rejects.toThrow("Access token is required");
expect(global.fetch).not.toHaveBeenCalled();
});
});

View file

@ -1,19 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { projectKeys } from "./useProjects";
// ── Fetch function ───────────────────────────────────────────────────────────
const deleteProjects = async (
accessToken: string,
projectIds: string[],
): Promise<void> => {
const deleteProjects = async (accessToken: string, projectIds: string[]): Promise<void> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/delete`;

View file

@ -1,20 +1,12 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ProjectResponse, projectKeys } from "./useProjects";
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchProjectDetails = async (
accessToken: string,
projectId: string,
): Promise<ProjectResponse> => {
const fetchProjectDetails = async (accessToken: string, projectId: string): Promise<ProjectResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/info?project_id=${encodeURIComponent(projectId)}`;
@ -45,17 +37,13 @@ export const useProjectDetails = (projectId?: string) => {
return useQuery<ProjectResponse>({
queryKey: projectKeys.detail(projectId!),
queryFn: async () => fetchProjectDetails(accessToken!, projectId!),
enabled:
Boolean(accessToken && projectId) &&
all_admin_roles.includes(userRole || ""),
enabled: Boolean(accessToken && projectId) && all_admin_roles.includes(userRole || ""),
// Seed from the list cache when available
initialData: () => {
if (!projectId) return undefined;
const projects = queryClient.getQueryData<ProjectResponse[]>(
projectKeys.list({}),
);
const projects = queryClient.getQueryData<ProjectResponse[]>(projectKeys.list({}));
return projects?.find((p) => p.project_id === projectId);
},

View file

@ -1,11 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { all_admin_roles } from "@/utils/roles";
@ -49,9 +44,7 @@ export const projectKeys = createQueryKeys("projects");
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchProjects = async (
accessToken: string,
): Promise<ProjectResponse[]> => {
const fetchProjects = async (accessToken: string): Promise<ProjectResponse[]> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/list`;

View file

@ -108,9 +108,9 @@ describe("useUpdateProject", () => {
const { result } = renderHook(() => useUpdateProject(), {
wrapper: makeWrapper(queryClient),
});
await expect(
result.current.mutateAsync({ projectId: "proj-1", params: {} })
).rejects.toThrow("Access token is required");
await expect(result.current.mutateAsync({ projectId: "proj-1", params: {} })).rejects.toThrow(
"Access token is required",
);
expect(global.fetch).not.toHaveBeenCalled();
});
});

View file

@ -1,10 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ProjectResponse, projectKeys } from "./useProjects";
@ -58,11 +53,7 @@ export const useUpdateProject = () => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<
ProjectResponse,
Error,
{ projectId: string; params: ProjectUpdateParams }
>({
return useMutation<ProjectResponse, Error, { projectId: string; params: ProjectUpdateParams }>({
mutationFn: async ({ projectId, params }) => {
if (!accessToken) {
throw new Error("Access token is required");

View file

@ -54,7 +54,7 @@ describe("useStoreModelInDB", () => {
field_value: true,
config_type: "general_settings",
}),
})
}),
);
});
@ -80,15 +80,12 @@ describe("useStoreModelInDB", () => {
field_value: false,
config_type: "general_settings",
}),
})
}),
);
});
it("should throw error when access token is missing", async () => {
vi.spyOn(
await import("../useAuthorized"),
"default"
).mockReturnValue({
vi.spyOn(await import("../useAuthorized"), "default").mockReturnValue({
accessToken: null,
userRole: null,
userId: null,

View file

@ -12,7 +12,7 @@ export interface StoreModelInDBResponse {
const performStoreModelInDB = async (
accessToken: string,
params: StoreModelInDBParams
params: StoreModelInDBParams,
): Promise<StoreModelInDBResponse> => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl ? `${proxyBaseUrl}/config/field/update` : `/config/field/update`;
@ -41,11 +41,7 @@ const performStoreModelInDB = async (
return data;
};
export const useStoreModelInDB = (): UseMutationResult<
StoreModelInDBResponse,
Error,
StoreModelInDBParams
> => {
export const useStoreModelInDB = (): UseMutationResult<StoreModelInDBResponse, Error, StoreModelInDBParams> => {
const { accessToken } = useAuthorized();
return useMutation<StoreModelInDBResponse, Error, StoreModelInDBParams>({

View file

@ -14,7 +14,7 @@ export interface StoreRequestInSpendLogsResponse {
const performStoreRequestInSpendLogs = async (
accessToken: string,
params: StoreRequestInSpendLogsParams
params: StoreRequestInSpendLogsParams,
): Promise<StoreRequestInSpendLogsResponse> => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl ? `${proxyBaseUrl}/config/update` : `/config/update`;

View file

@ -423,7 +423,7 @@ describe("useTeam", () => {
// This tests the defensive error path in queryFn (lines 111-112)
// The enabled check prevents queryFn from running, but we can test the defensive code
// by manually constructing and calling the queryFn logic
// Set up mocks
mockUseAuthorized.mockReturnValue({
accessToken: null, // Missing accessToken
@ -438,24 +438,24 @@ describe("useTeam", () => {
// Import useQueryClient to get access to query client
const { useQueryClient } = await import("@tanstack/react-query");
// Manually test the queryFn logic by calling it directly
// This simulates what would happen if enabled check was bypassed
const testQueryFn = async () => {
const { accessToken } = mockUseAuthorized();
const teamId = "team-1";
// This is the defensive check from lines 111-112
if (!accessToken || !teamId) {
throw new Error("Missing auth or teamId");
}
return teamInfoCall(accessToken, teamId);
};
// Test that the error is thrown
await expect(testQueryFn()).rejects.toThrow("Missing auth or teamId");
// Also test with missing teamId
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
@ -471,11 +471,11 @@ describe("useTeam", () => {
const testQueryFnMissingTeamId = async () => {
const { accessToken } = mockUseAuthorized();
const teamId = undefined; // Missing teamId
if (!accessToken || !teamId) {
throw new Error("Missing auth or teamId");
}
return teamInfoCall(accessToken, teamId);
};
@ -736,13 +736,10 @@ describe("useDeletedTeams", () => {
json: async () => ({ teams: mockDeletedTeams }),
});
const { result, rerender } = renderHook(
({ page }) => useDeletedTeams(page, 10, {}),
{
wrapper,
initialProps: { page: 1 },
},
);
const { result, rerender } = renderHook(({ page }) => useDeletedTeams(page, 10, {}), {
wrapper,
initialProps: { page: 1 },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);

View file

@ -4,12 +4,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchTeams } from "@/app/(dashboard)/networking";
import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory";
import { teamInfoCall } from "@/components/networking";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
export interface TeamsResponse {
teams: Team[];
@ -24,7 +19,6 @@ export interface DeletedTeam extends Team {
deleted_by: string;
}
export interface TeamListCallOptions {
organizationID?: string | null;
teamID?: string | null;
@ -47,7 +41,7 @@ export const teamListCall = async (
*/
try {
const baseUrl = getProxyBaseUrl();
const params = new URLSearchParams(
Object.entries({
team_id: options.teamID,
@ -128,11 +122,7 @@ export const useTeam = (teamId?: string) => {
const infiniteTeamKeys = createQueryKeys("infiniteTeams");
export const useInfiniteTeams = (
pageSize: number = 50,
search?: string,
organizationId?: string | null,
) => {
export const useInfiniteTeams = (pageSize: number = 50, search?: string, organizationId?: string | null) => {
const { accessToken, userId, userRole } = useAuthorized();
const isAdmin = userRole === "Admin" || userRole === "Admin Viewer";
@ -174,7 +164,7 @@ const deletedTeamListCall = async (
*/
try {
const baseUrl = getProxyBaseUrl();
const params = new URLSearchParams(
Object.entries({
team_id: options.teamID,
@ -211,10 +201,10 @@ const deletedTeamListCall = async (
const data = await response.json();
console.log("/team/list?status=deleted API Response:", data);
// Extract teams array from response if it's wrapped in a response object
// Otherwise return the data directly if it's already an array
if (data && typeof data === 'object' && 'teams' in data) {
if (data && typeof data === "object" && "teams" in data) {
return data.teams as DeletedTeam[];
}
return data as DeletedTeam[];
@ -239,4 +229,4 @@ export const useDeletedTeams = (
staleTime: 30000, // 30 seconds
placeholderData: keepPreviousData,
});
};
};

View file

@ -8,7 +8,15 @@ import useAuthorized from "./useAuthorized";
// Unmock useAuthorized to test the actual implementation
vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
const { replaceMock, clearTokenCookiesMock, getProxyBaseUrlMock, getUiConfigMock, decodeTokenMock, checkTokenValidityMock, buildLoginUrlWithReturnMock } = vi.hoisted(() => ({
const {
replaceMock,
clearTokenCookiesMock,
getProxyBaseUrlMock,
getUiConfigMock,
decodeTokenMock,
checkTokenValidityMock,
buildLoginUrlWithReturnMock,
} = vi.hoisted(() => ({
replaceMock: vi.fn(),
clearTokenCookiesMock: vi.fn(),
getProxyBaseUrlMock: vi.fn(() => "http://proxy.example"),
@ -102,7 +110,7 @@ describe("useAuthorized", () => {
admin_ui_disabled: false,
sso_configured: false,
});
const decodedPayload = {
key: "api-key-123",
user_id: "user-1",
@ -112,7 +120,7 @@ describe("useAuthorized", () => {
disabled_non_admin_personal_key_creation: false,
login_method: "username_password",
};
decodeTokenMock.mockReturnValue(decodedPayload);
checkTokenValidityMock.mockReturnValue(true);

View file

@ -36,11 +36,7 @@ const DEFAULT_AUTH = {
showSSOBanner: false,
};
const buildUserListResponse = (
page: number,
totalPages: number,
userCount = 2,
): UserListResponse => ({
const buildUserListResponse = (page: number, totalPages: number, userCount = 2): UserListResponse => ({
page,
page_size: 50,
total: totalPages * userCount,
@ -90,13 +86,7 @@ describe("useInfiniteUsers", () => {
expect(result.current.data?.pages).toHaveLength(1);
expect(result.current.data?.pages[0]).toEqual(mockResponse);
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null);
});
it("should use the default page size of 50", async () => {
@ -109,13 +99,7 @@ describe("useInfiniteUsers", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null);
});
it("should use a custom page size when provided", async () => {
@ -131,13 +115,7 @@ describe("useInfiniteUsers", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
customPageSize,
null,
);
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, customPageSize, null);
});
it("should pass searchEmail to userListCall when provided", async () => {
@ -153,13 +131,7 @@ describe("useInfiniteUsers", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
searchEmail,
);
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, searchEmail);
});
it("should pass null for searchEmail when not provided", async () => {
@ -174,13 +146,7 @@ describe("useInfiniteUsers", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null);
});
it("should fetch the next page when more pages are available", async () => {
@ -209,13 +175,7 @@ describe("useInfiniteUsers", () => {
expect(result.current.data?.pages[1]).toEqual(page2);
expect(userListCall).toHaveBeenCalledTimes(2);
expect(userListCall).toHaveBeenLastCalledWith(
"test-access-token",
null,
2,
50,
null,
);
expect(userListCall).toHaveBeenLastCalledWith("test-access-token", null, 2, 50, null);
});
it("should not have a next page when on the last page", async () => {
@ -275,13 +235,7 @@ describe("useInfiniteUsers", () => {
});
it("should execute query for each admin role", async () => {
const adminRoles = [
"Admin",
"Admin Viewer",
"proxy_admin",
"proxy_admin_viewer",
"org_admin",
];
const adminRoles = ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"];
for (const role of adminRoles) {
vi.clearAllMocks();
@ -328,12 +282,6 @@ describe("useInfiniteUsers", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null);
});
});

View file

@ -8,10 +8,7 @@ const infiniteUsersKeys = createQueryKeys("infiniteUsers");
const DEFAULT_PAGE_SIZE = 50;
export const useInfiniteUsers = (
pageSize: number = DEFAULT_PAGE_SIZE,
searchEmail?: string,
) => {
export const useInfiniteUsers = (pageSize: number = DEFAULT_PAGE_SIZE, searchEmail?: string) => {
const { accessToken, userRole } = useAuthorized();
return useInfiniteQuery<UserListResponse>({
queryKey: infiniteUsersKeys.list({
@ -23,10 +20,10 @@ export const useInfiniteUsers = (
queryFn: async ({ pageParam }) => {
return await userListCall(
accessToken!,
null, // userIDs
pageParam as number, // page
pageSize, // page_size
searchEmail || null, // userEmail
null, // userIDs
pageParam as number, // page
pageSize, // page_size
searchEmail || null, // userEmail
);
},
initialPageParam: 1,

View file

@ -69,17 +69,13 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
proxySettings={undefined}
setProxySettings={() => { }}
setProxySettings={() => {}}
accessToken={accessToken}
/>
<DebugWarningBanner accessToken={accessToken} />
<div className="flex flex-1 overflow-auto">
<div className="mt-2">
<SidebarProvider
setPage={handleSetPage}
defaultSelectedKey={page}
sidebarCollapsed={sidebarCollapsed}
/>
<SidebarProvider setPage={handleSetPage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
</div>
<main className="flex-1">{children}</main>
</div>

View file

@ -518,7 +518,11 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
);
}
return (
<TabGroup index={selectedTabIndex} onIndexChange={setSelectedTabIndex} className="gap-2 h-[75vh] w-full ">
<TabGroup
index={selectedTabIndex}
onIndexChange={setSelectedTabIndex}
className="gap-2 h-[75vh] w-full "
>
<TabList className="flex justify-between mt-2 w-full items-center">
<div className="flex">{visibleTabs.map((t) => t.tab)}</div>

View file

@ -21,7 +21,7 @@ vi.mock("@/components/molecules/notifications_manager", () => ({
// Mock react-query
const mockInvalidateQueries = vi.fn();
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal() as any;
const actual = (await importOriginal()) as any;
return {
...actual,
useQueryClient: () => ({
@ -178,24 +178,30 @@ describe("AllModelsTab", () => {
}),
);
const modelData = createPaginatedModelData([
{
model_name: "gpt-4-accessible",
model_info: {
id: "model-1",
access_via_team_ids: ["team-456"],
access_groups: [],
const modelData = createPaginatedModelData(
[
{
model_name: "gpt-4-accessible",
model_info: {
id: "model-1",
access_via_team_ids: ["team-456"],
access_groups: [],
},
},
},
{
model_name: "gpt-3.5-turbo-blocked",
model_info: {
id: "model-2",
access_via_team_ids: ["team-789"],
access_groups: [],
{
model_name: "gpt-3.5-turbo-blocked",
model_info: {
id: "model-2",
access_via_team_ids: ["team-789"],
access_groups: [],
},
},
},
], 2, 1, 1, 50);
],
2,
1,
1,
50,
);
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
@ -239,24 +245,30 @@ describe("AllModelsTab", () => {
}),
);
const modelData = createPaginatedModelData([
{
model_name: "gpt-4-sales",
model_info: {
id: "model-sales-1",
access_via_team_ids: [],
access_groups: ["sales-model-group"],
const modelData = createPaginatedModelData(
[
{
model_name: "gpt-4-sales",
model_info: {
id: "model-sales-1",
access_via_team_ids: [],
access_groups: ["sales-model-group"],
},
},
},
{
model_name: "gpt-4-engineering",
model_info: {
id: "model-eng-1",
access_via_team_ids: [],
access_groups: ["engineering-model-group"],
{
model_name: "gpt-4-engineering",
model_info: {
id: "model-eng-1",
access_via_team_ids: [],
access_groups: ["engineering-model-group"],
},
},
},
], 2, 1, 1, 50);
],
2,
1,
1,
50,
);
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
@ -284,26 +296,32 @@ describe("AllModelsTab", () => {
}),
);
const modelData = createPaginatedModelData([
{
model_name: "gpt-4-personal",
model_info: {
id: "model-personal-1",
direct_access: true,
access_via_team_ids: [],
access_groups: [],
const modelData = createPaginatedModelData(
[
{
model_name: "gpt-4-personal",
model_info: {
id: "model-personal-1",
direct_access: true,
access_via_team_ids: [],
access_groups: [],
},
},
},
{
model_name: "gpt-4-team-only",
model_info: {
id: "model-team-1",
direct_access: false,
access_via_team_ids: ["team-123"],
access_groups: [],
{
model_name: "gpt-4-team-only",
model_info: {
id: "model-team-1",
direct_access: false,
access_via_team_ids: ["team-123"],
access_groups: [],
},
},
},
], 2, 1, 1, 50);
],
2,
1,
1,
50,
);
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
@ -330,38 +348,44 @@ describe("AllModelsTab", () => {
}),
);
const modelData = createPaginatedModelData([
{
model_name: "gpt-4-config",
litellm_model_name: "gpt-4-config",
provider: "openai",
model_info: {
id: "model-config-1",
db_model: false,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
const modelData = createPaginatedModelData(
[
{
model_name: "gpt-4-config",
litellm_model_name: "gpt-4-config",
provider: "openai",
model_info: {
id: "model-config-1",
db_model: false,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
},
},
{
model_name: "gpt-4-db",
litellm_model_name: "gpt-4-db",
provider: "openai",
model_info: {
id: "model-db-1",
db_model: true,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
{
model_name: "gpt-4-db",
litellm_model_name: "gpt-4-db",
provider: "openai",
model_info: {
id: "model-db-1",
db_model: true,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
},
},
], 2, 1, 1, 50);
],
2,
1,
1,
50,
);
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
@ -387,23 +411,29 @@ describe("AllModelsTab", () => {
}),
);
const modelData = createPaginatedModelData([
{
model_name: "gpt-4-config",
litellm_model_name: "gpt-4-config",
provider: "openai",
model_info: {
id: "model-config-1",
db_model: false,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
const modelData = createPaginatedModelData(
[
{
model_name: "gpt-4-config",
litellm_model_name: "gpt-4-config",
provider: "openai",
model_info: {
id: "model-config-1",
db_model: false,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
},
},
], 1, 1, 1, 50);
],
1,
1,
1,
50,
);
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
@ -537,23 +567,29 @@ describe("AllModelsTab", () => {
}),
);
const modelData = createPaginatedModelData([
{
model_name: "gpt-4-delete-test",
litellm_model_name: "gpt-4-delete-test",
provider: "openai",
model_info: {
id: "model-to-delete",
db_model: true,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
const modelData = createPaginatedModelData(
[
{
model_name: "gpt-4-delete-test",
litellm_model_name: "gpt-4-delete-test",
provider: "openai",
model_info: {
id: "model-to-delete",
db_model: true,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
},
},
], 1, 1, 1, 50);
],
1,
1,
1,
50,
);
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null, refetch: vi.fn() });
@ -581,23 +617,29 @@ describe("AllModelsTab", () => {
}),
);
const modelData = createPaginatedModelData([
{
model_name: "gpt-4-clickable",
litellm_model_name: "gpt-4-clickable",
provider: "openai",
model_info: {
id: "clickable-model-id",
db_model: true,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
const modelData = createPaginatedModelData(
[
{
model_name: "gpt-4-clickable",
litellm_model_name: "gpt-4-clickable",
provider: "openai",
model_info: {
id: "clickable-model-id",
db_model: true,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
},
},
], 1, 1, 1, 50);
],
1,
1,
1,
50,
);
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null, refetch: vi.fn() });

View file

@ -68,7 +68,7 @@ const AllModelsTab = ({
setCurrentPage(1);
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
}, 200),
[]
[],
);
useEffect(() => {
@ -100,15 +100,11 @@ const AllModelsTab = ({
return sort.desc ? "desc" : "asc";
}, [sorting]);
const { data: rawModelData, isLoading: isLoadingModelsInfo, refetch: refetchModels } = useModelsInfo(
currentPage,
pageSize,
debouncedSearch || undefined,
undefined,
teamIdForQuery,
sortBy,
sortOrder
);
const {
data: rawModelData,
isLoading: isLoadingModelsInfo,
refetch: refetchModels,
} = useModelsInfo(currentPage, pageSize, debouncedSearch || undefined, undefined, teamIdForQuery, sortBy, sortOrder);
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;
const getProviderFromModel = (model: string) => {
@ -494,7 +490,7 @@ const AllModelsTab = ({
) : (
<span data-testid="models-results-count" className="text-sm text-gray-700">
{paginationMeta.total_count > 0
? `Showing ${((currentPage - 1) * pageSize) + 1} - ${Math.min(currentPage * pageSize, paginationMeta.total_count)} of ${paginationMeta.total_count} results`
? `Showing ${(currentPage - 1) * pageSize + 1} - ${Math.min(currentPage * pageSize, paginationMeta.total_count)} of ${paginationMeta.total_count} results`
: "Showing 0 results"}
</span>
)}
@ -510,10 +506,9 @@ const AllModelsTab = ({
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
}}
disabled={currentPage === 1}
className={`px-3 py-1 text-sm border rounded-md ${currentPage === 1
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}`}
className={`px-3 py-1 text-sm border rounded-md ${
currentPage === 1 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
}`}
>
Previous
</button>
@ -529,10 +524,11 @@ const AllModelsTab = ({
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
}}
disabled={currentPage >= paginationMeta.total_pages}
className={`px-3 py-1 text-sm border rounded-md ${currentPage >= paginationMeta.total_pages
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}`}
className={`px-3 py-1 text-sm border rounded-md ${
currentPage >= paginationMeta.total_pages
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}`}
>
Next
</button>
@ -550,8 +546,8 @@ const AllModelsTab = ({
setSelectedModelId,
setSelectedTeamId,
getDisplayModelName,
() => { },
() => { },
() => {},
() => {},
expandedRows,
setExpandedRows,
setDeleteModalModelId,
@ -577,24 +573,28 @@ const AllModelsTab = ({
alertMessage="This action cannot be undone."
message="Are you sure you want to delete this model?"
resourceInformationTitle="Model Information"
resourceInformation={modelToDelete ? [
{
label: "Model Name",
value: modelToDelete.model_name || "Not Set",
},
{
label: "LiteLLM Model Name",
value: modelToDelete.litellm_model_name || "Not Set",
},
{
label: "Provider",
value: modelToDelete.provider || "Not Set",
},
{
label: "Created By",
value: modelToDelete.model_info?.created_by || "Not Set",
},
] : []}
resourceInformation={
modelToDelete
? [
{
label: "Model Name",
value: modelToDelete.model_name || "Not Set",
},
{
label: "LiteLLM Model Name",
value: modelToDelete.litellm_model_name || "Not Set",
},
{
label: "Provider",
value: modelToDelete.provider || "Not Set",
},
{
label: "Created By",
value: modelToDelete.model_info?.created_by || "Not Set",
},
]
: []
}
onCancel={() => setDeleteModalModelId(null)}
onOk={handleDeleteModel}
confirmLoading={deleteLoading}

View file

@ -36,43 +36,43 @@ export default function PlaygroundPage() {
return (
<div className="h-full w-full flex flex-col">
<TabGroup className="w-full" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<TabList className="mb-0">
<Tab>Chat</Tab>
<Tab>Compare</Tab>
<Tab>Compliance</Tab>
<Tab>Agent Builder (Experimental)</Tab>
</TabList>
<TabPanels className="h-full">
<TabPanel className="h-full">
<ChatUI
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
/>
</TabPanel>
<TabPanel className="h-full">
<CompareUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabPanel>
<TabPanel className="h-full">
<ComplianceUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabPanel>
<TabPanel className="h-full">
<AgentBuilderView
accessToken={accessToken}
token={token}
userID={userId}
userRole={userRole}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
customProxyBaseUrl={proxySettings?.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings?.PROXY_BASE_URL}
/>
</TabPanel>
</TabPanels>
</TabGroup>
<TabGroup className="w-full" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<TabList className="mb-0">
<Tab>Chat</Tab>
<Tab>Compare</Tab>
<Tab>Compliance</Tab>
<Tab>Agent Builder (Experimental)</Tab>
</TabList>
<TabPanels className="h-full">
<TabPanel className="h-full">
<ChatUI
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
/>
</TabPanel>
<TabPanel className="h-full">
<CompareUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabPanel>
<TabPanel className="h-full">
<ComplianceUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabPanel>
<TabPanel className="h-full">
<AgentBuilderView
accessToken={accessToken}
token={token}
userID={userId}
userRole={userRole}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
customProxyBaseUrl={proxySettings?.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings?.PROXY_BASE_URL}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
);
}

View file

@ -304,8 +304,7 @@ describe("LoginPage", () => {
},
writable: true,
});
document.cookie =
"token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax";
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax";
});
afterEach(() => {

View file

@ -50,13 +50,11 @@ function LoginPageContent() {
// Validate the SSO code is a plausible OAuth authorization code (alphanumeric
// plus common URL-safe chars) so that arbitrary user input cannot trigger the
// exchange endpoint.
const ssoCode =
rawSsoCode && /^[a-zA-Z0-9._~+/=-]+$/.test(rawSsoCode) ? rawSsoCode : null;
const ssoCode = rawSsoCode && /^[a-zA-Z0-9._~+/=-]+$/.test(rawSsoCode) ? rawSsoCode : null;
if (ssoCode) {
const rawWorkerUrl = localStorage.getItem("litellm_worker_url");
// Validate the stored worker URL: only allow http(s) URLs.
const workerUrl =
rawWorkerUrl && /^https?:\/\/.+/.test(rawWorkerUrl) ? rawWorkerUrl : null;
const workerUrl = rawWorkerUrl && /^https?:\/\/.+/.test(rawWorkerUrl) ? rawWorkerUrl : null;
exchangeLoginCode(ssoCode, workerUrl).then(() => {
params.delete("code");
const cleanSearch = params.toString();
@ -277,10 +275,7 @@ function LoginPageContent() {
</Form.Item>
<Form.Item>
{!uiConfig?.sso_configured ? (
<Popover
content="Please configure SSO to log in with SSO."
trigger="hover"
>
<Popover content="Please configure SSO to log in with SSO." trigger="hover">
<Button disabled block size="large">
Login with SSO
</Button>
@ -315,7 +310,13 @@ function LoginPageContent() {
type="info"
showIcon
closable
message={<Text>Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon loading this page. To re-enable auto-redirect-to-SSO, set <Text code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</Text> in your environment configuration.</Text>}
message={
<Text>
Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon
loading this page. To re-enable auto-redirect-to-SSO, set{" "}
<Text code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</Text> in your environment configuration.
</Text>
}
/>
)}
</Card>

View file

@ -80,12 +80,12 @@ const McpOAuthCallbackContent = () => {
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-6">
<div className="max-w-lg w-full rounded-lg bg-white shadow-md p-8 text-center space-y-4">
<h1 className="text-xl font-semibold text-slate-900">LiteLLM MCP OAuth</h1>
<p className="text-sm text-slate-700">
Authorization complete. You may close this window and return to the LiteLLM dashboard.
</p>
<p className="text-xs text-slate-500">
If the window does not close automatically, everything is still saved—you can close it manually.
</p>
<p className="text-sm text-slate-700">
Authorization complete. You may close this window and return to the LiteLLM dashboard.
</p>
<p className="text-xs text-slate-500">
If the window does not close automatically, everything is still saved—you can close it manually.
</p>
</div>
</div>
);

View file

@ -16,9 +16,7 @@ function PublicModelHubTableContent() {
setAccessToken(key);
}, [key]);
return (
<ModelHubTable accessToken={accessToken} publicPage={true} premiumUser={false} userRole={null} />
);
return <ModelHubTable accessToken={accessToken} publicPage={true} premiumUser={false} userRole={null} />;
}
export default function PublicModelHubTable() {

View file

@ -11,9 +11,7 @@ describe("OnboardingErrorView", () => {
it("should show the expiry description", () => {
render(<OnboardingErrorView />);
expect(
screen.getByText("The invitation link may be invalid or expired.")
).toBeInTheDocument();
expect(screen.getByText("The invitation link may be invalid or expired.")).toBeInTheDocument();
});
it("should render a Back to Login link pointing to /ui/login", () => {

View file

@ -26,9 +26,7 @@ export function OnboardingForm({ variant }: OnboardingFormProps) {
const { mutate: claimToken, isPending } = useClaimOnboardingToken();
const decoded = credentialsData?.token
? (jwtDecode(credentialsData.token) as { [key: string]: any })
: null;
const decoded = credentialsData?.token ? (jwtDecode(credentialsData.token) as { [key: string]: any }) : null;
const userEmail: string = decoded?.user_email ?? "";
const userId: string | null = decoded?.user_id ?? null;
const accessToken: string | null = decoded?.key ?? null;
@ -53,14 +51,12 @@ export function OnboardingForm({ variant }: OnboardingFormProps) {
clearTokenCookies();
storeLoginToken(data.token);
const proxyBaseUrl = getProxyBaseUrl();
window.location.href = proxyBaseUrl
? `${proxyBaseUrl}/ui/?login=success`
: "/ui/?login=success";
window.location.href = proxyBaseUrl ? `${proxyBaseUrl}/ui/?login=success` : "/ui/?login=success";
},
onError: (error: Error) => {
setClaimError(error.message || "Failed to submit. Please try again.");
},
}
},
);
};

View file

@ -74,16 +74,12 @@ describe("OnboardingFormBody", () => {
await user.click(screen.getByRole("button", { name: /sign up/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ password: "mypassword" })
);
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ password: "mypassword" }));
});
});
it("should show 'Reset Password' on the submit button for reset_password variant", () => {
render(<OnboardingFormBody {...defaultProps} variant="reset_password" />);
expect(
screen.getByRole("button", { name: /reset password/i })
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reset password/i })).toBeInTheDocument();
});
});

View file

@ -9,13 +9,7 @@ type OnboardingFormBodyProps = {
onSubmit: (values: { password: string }) => void;
};
export function OnboardingFormBody({
variant,
userEmail,
isPending,
claimError,
onSubmit,
}: OnboardingFormBodyProps) {
export function OnboardingFormBody({ variant, userEmail, isPending, claimError, onSubmit }: OnboardingFormBodyProps) {
const [form] = Form.useForm();
React.useEffect(() => {
@ -28,9 +22,7 @@ export function OnboardingFormBody({
<Typography.Title level={5} className="text-center mb-5">
🚅 LiteLLM
</Typography.Title>
<Typography.Title level={3}>
{variant === "reset_password" ? "Reset Password" : "Sign Up"}
</Typography.Title>
<Typography.Title level={3}>{variant === "reset_password" ? "Reset Password" : "Sign Up"}</Typography.Title>
<Typography.Text>
{variant === "reset_password"
? "Reset your password to access Admin UI."
@ -45,12 +37,7 @@ export function OnboardingFormBody({
description={
<div className="flex justify-between items-center">
<span>SSO is under the Enterprise Tier.</span>
<Button
type="primary"
size="small"
href="https://forms.gle/W3U4PZpJGFHWtHyA9"
target="_blank"
>
<Button type="primary" size="small" href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
Get Free Trial
</Button>
</div>
@ -59,7 +46,12 @@ export function OnboardingFormBody({
/>
)}
<Form className="mt-10 mb-5" layout="vertical" form={form} onFinish={(values) => onSubmit({ password: values.password })}>
<Form
className="mt-10 mb-5"
layout="vertical"
form={form}
onFinish={(values) => onSubmit({ password: values.password })}
>
<Form.Item label="Email Address" name="user_email">
<Input type="email" disabled />
</Form.Item>
@ -68,18 +60,12 @@ export function OnboardingFormBody({
label="Password"
name="password"
rules={[{ required: true, message: "password required to sign up" }]}
help={
variant === "reset_password"
? "Enter your new password"
: "Create a password for your account"
}
help={variant === "reset_password" ? "Enter your new password" : "Create a password for your account"}
>
<Input.Password />
</Form.Item>
{claimError && (
<Alert type="error" message={claimError} showIcon className="mb-4" />
)}
{claimError && <Alert type="error" message={claimError} showIcon className="mb-4" />}
<div className="mt-10">
<Button htmlType="submit" loading={isPending}>

View file

@ -12,11 +12,7 @@ function OnboardingContent() {
export default function Onboarding() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen">Loading...</div>
}
>
<Suspense fallback={<div className="flex items-center justify-center min-h-screen">Loading...</div>}>
<OnboardingContent />
</Suspense>
);

View file

@ -46,7 +46,13 @@ import SpendLogsTable from "@/components/view_logs";
import ViewUserDashboard from "@/components/view_users";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { useAuth } from "@/contexts/AuthContext";
import { buildLoginUrlWithReturn, consumeReturnUrl, isValidReturnUrl, normalizeUrlForCompare, storeReturnUrl } from "@/utils/returnUrlUtils";
import {
buildLoginUrlWithReturn,
consumeReturnUrl,
isValidReturnUrl,
normalizeUrlForCompare,
storeReturnUrl,
} from "@/utils/returnUrlUtils";
import { isAdminRole } from "@/utils/roles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
@ -67,17 +73,8 @@ interface ProxySettings {
const LEGACY_REDIRECTS: Record<string, string> = {};
function CreateKeyPageContent() {
const {
authLoading,
token,
userID,
userRole,
userEmail,
accessToken,
premiumUser,
setUserRole,
setUserEmail,
} = useAuth();
const { authLoading, token, userID, userRole, userEmail, accessToken, premiumUser, setUserRole, setUserEmail } =
useAuth();
const [teams, setTeams] = useState<Team[] | null>(null);
const [keys, setKeys] = useState<null | any[]>([]);
@ -129,15 +126,13 @@ function CreateKeyPageContent() {
// Validate owned_by against allowed values
const validOwnedByValues = ["you", "service_account", "another_user"];
const validatedOwnedBy = ownedBy && validOwnedByValues.includes(ownedBy)
? (ownedBy as CreateKeyPrefillData["owned_by"])
: undefined;
const validatedOwnedBy =
ownedBy && validOwnedByValues.includes(ownedBy) ? (ownedBy as CreateKeyPrefillData["owned_by"]) : undefined;
// Validate key_type against allowed values
const validKeyTypes = ["default", "llm_api", "management"];
const validatedKeyType = keyType && validKeyTypes.includes(keyType)
? (keyType as CreateKeyPrefillData["key_type"])
: undefined;
const validatedKeyType =
keyType && validKeyTypes.includes(keyType) ? (keyType as CreateKeyPrefillData["key_type"]) : undefined;
// Sanitize key_alias (limit length, trim whitespace)
const sanitizedKeyAlias = keyAlias
@ -149,8 +144,8 @@ function CreateKeyPageContent() {
? modelsParam
.split(",")
.slice(0, 100) // Limit number of models to prevent DoS
.map(m => m.trim().slice(0, 256)) // Limit individual model name length
.filter(m => m.length > 0) // Remove empty strings
.map((m) => m.trim().slice(0, 256)) // Limit individual model name length
.filter((m) => m.length > 0) // Remove empty strings
: undefined;
return {
@ -259,7 +254,9 @@ function CreateKeyPageContent() {
if (accessToken && userID && userRole) {
v2TeamListCall(accessToken, 1, 100, {
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
}).then((response) => setTeams(response.teams ?? [])).catch(console.error);
})
.then((response) => setTeams(response.teams ?? []))
.catch(console.error);
}
if (accessToken) {
fetchOrganizations(accessToken, setOrganizations);
@ -353,235 +350,231 @@ function CreateKeyPageContent() {
return (
<Suspense fallback={<LoadingScreen />}>
<ConfigProvider theme={{
<ConfigProvider
theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}>
<ThemeProvider accessToken={accessToken}>
{invitation_id ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
}}
>
<ThemeProvider accessToken={accessToken}>
{invitation_id ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
/>
) : (
<div className="flex flex-col min-h-screen">
<Navbar
setProxySettings={setProxySettings}
proxySettings={proxySettings}
accessToken={accessToken}
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
/>
) : (
<div className="flex flex-col min-h-screen">
<Navbar
setProxySettings={setProxySettings}
proxySettings={proxySettings}
accessToken={accessToken}
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
/>
<div className="flex flex-1">
<div className="mt-2">
<div className="flex flex-1">
<div className="mt-2">
<SidebarProvider setPage={updatePage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
</div>
{page == "api-keys" ? (
<UserDashboard
userID={userID}
userRole={userRole}
{page == "api-keys" ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : page == "models" ? (
<OldModelDashboard
token={token}
keys={keys}
modelData={modelData}
setModelData={setModelData}
premiumUser={premiumUser}
teams={teams}
/>
) : page == "llm-playground" ? (
<PlaygroundPage />
) : page == "users" ? (
<ViewUserDashboard
userID={userID}
userRole={userRole}
token={token}
keys={keys}
teams={teams}
accessToken={accessToken}
setKeys={setKeys}
/>
) : page == "teams" ? (
<OldTeams
teams={teams}
setTeams={setTeams}
accessToken={accessToken}
userID={userID}
userRole={userRole}
organizations={organizations}
premiumUser={premiumUser}
searchParams={searchParams}
/>
) : page == "organizations" ? (
<Organizations
organizations={organizations}
setOrganizations={setOrganizations}
userModels={userModels}
accessToken={accessToken}
userRole={userRole}
premiumUser={premiumUser}
/>
) : page == "admin-panel" ? (
<AdminPanel proxySettings={proxySettings} />
) : page == "api_ref" || page == "api-reference" ? (
<APIReferenceView proxySettings={proxySettings} />
) : page == "logging-and-alerts" ? (
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
) : page == "budgets" ? (
<BudgetPanel accessToken={accessToken} />
) : page == "guardrails" ? (
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
) : page == "policies" ? (
<PoliciesPanel accessToken={accessToken} userRole={userRole} />
) : page == "agents" ? (
<AgentsPanel accessToken={accessToken} userRole={userRole} teams={teams} />
) : page == "prompts" ? (
<PromptsPanel accessToken={accessToken} userRole={userRole} />
) : page == "transform-request" ? (
<TransformRequestPanel accessToken={accessToken} />
) : page == "router-settings" ? (
<GeneralSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
/>
) : page == "ui-theme" ? (
<UIThemeSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "cost-tracking" ? (
<CostTrackingSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "model-hub-table" ? (
isAdminRole(userRole) ? (
<ModelHubTable
accessToken={accessToken}
publicPage={false}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : page == "models" ? (
<OldModelDashboard
token={token}
keys={keys}
modelData={modelData}
setModelData={setModelData}
premiumUser={premiumUser}
teams={teams}
/>
) : page == "llm-playground" ? (
<PlaygroundPage />
) : page == "users" ? (
<ViewUserDashboard
userID={userID}
userRole={userRole}
token={token}
keys={keys}
teams={teams}
accessToken={accessToken}
setKeys={setKeys}
/>
) : page == "teams" ? (
<OldTeams
teams={teams}
setTeams={setTeams}
accessToken={accessToken}
userID={userID}
userRole={userRole}
organizations={organizations}
premiumUser={premiumUser}
searchParams={searchParams}
/>
) : page == "organizations" ? (
<Organizations
organizations={organizations}
setOrganizations={setOrganizations}
userModels={userModels}
accessToken={accessToken}
userRole={userRole}
premiumUser={premiumUser}
/>
) : page == "admin-panel" ? (
<AdminPanel
proxySettings={proxySettings}
/>
) : page == "api_ref" || page == "api-reference" ? (
<APIReferenceView proxySettings={proxySettings} />
) : page == "logging-and-alerts" ? (
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
) : page == "budgets" ? (
<BudgetPanel accessToken={accessToken} />
) : page == "guardrails" ? (
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
) : page == "policies" ? (
<PoliciesPanel accessToken={accessToken} userRole={userRole} />
) : page == "agents" ? (
<AgentsPanel accessToken={accessToken} userRole={userRole} teams={teams} />
) : page == "prompts" ? (
<PromptsPanel accessToken={accessToken} userRole={userRole} />
) : page == "transform-request" ? (
<TransformRequestPanel accessToken={accessToken} />
) : page == "router-settings" ? (
<GeneralSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
/>
) : page == "ui-theme" ? (
<UIThemeSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "cost-tracking" ? (
<CostTrackingSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "model-hub-table" ? (
isAdminRole(userRole) ? (
<ModelHubTable
accessToken={accessToken}
publicPage={false}
premiumUser={premiumUser}
userRole={userRole}
/>
) : (
<PublicModelHub accessToken={accessToken} isEmbedded={true} />
)
) : page == "caching" ? (
<CacheDashboard
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "pass-through-settings" ? (
<PassThroughSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
premiumUser={premiumUser}
/>
) : page == "logs" ? (
<SpendLogsTable
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "mcp-servers" ? (
<MCPServers accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "search-tools" ? (
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "skills" || page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "access-groups" ? (
<AccessGroupsPage />
) : page == "projects" ? (
<ProjectsPage />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tool-policies" ? (
<ToolPoliciesView accessToken={accessToken} userRole={userRole} />
) : page == "workflows" ? (
<WorkflowRuns accessToken={accessToken} />
) : page == "memory" ? (
<MemoryView
accessToken={accessToken}
userID={userID}
userRole={userRole}
/>
) : page == "guardrails-monitor" ? (
<GuardrailsMonitorView accessToken={accessToken} />
) : page == "new_usage" ? (
<NewUsagePage
teams={(teams as Team[]) ?? []}
organizations={(organizations as Organization[]) ?? []}
/>
) : (
<Usage
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
keys={keys}
premiumUser={premiumUser}
/>
)}
</div>
{/* Survey Components */}
<SurveyPrompt
isVisible={showSurveyPrompt}
onOpen={handleOpenSurvey}
onDismiss={handleDismissSurveyPrompt}
/>
<SurveyModal
isOpen={showSurveyModal}
onClose={handleSurveyModalClose}
onComplete={handleSurveyComplete}
/>
{/* Claude Code Components */}
<ClaudeCodePrompt
isVisible={showClaudeCodePrompt}
onOpen={handleOpenClaudeCode}
onDismiss={handleDismissClaudeCodePrompt}
/>
<ClaudeCodeModal
isOpen={showClaudeCodeModal}
onClose={handleClaudeCodeModalClose}
onComplete={handleClaudeCodeComplete}
/>
<PublicModelHub accessToken={accessToken} isEmbedded={true} />
)
) : page == "caching" ? (
<CacheDashboard
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "pass-through-settings" ? (
<PassThroughSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
premiumUser={premiumUser}
/>
) : page == "logs" ? (
<SpendLogsTable
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "mcp-servers" ? (
<MCPServers accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "search-tools" ? (
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "skills" || page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "access-groups" ? (
<AccessGroupsPage />
) : page == "projects" ? (
<ProjectsPage />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tool-policies" ? (
<ToolPoliciesView accessToken={accessToken} userRole={userRole} />
) : page == "workflows" ? (
<WorkflowRuns accessToken={accessToken} />
) : page == "memory" ? (
<MemoryView accessToken={accessToken} userID={userID} userRole={userRole} />
) : page == "guardrails-monitor" ? (
<GuardrailsMonitorView accessToken={accessToken} />
) : page == "new_usage" ? (
<NewUsagePage
teams={(teams as Team[]) ?? []}
organizations={(organizations as Organization[]) ?? []}
/>
) : (
<Usage
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
keys={keys}
premiumUser={premiumUser}
/>
)}
</div>
)}
</ThemeProvider>
</ConfigProvider>
{/* Survey Components */}
<SurveyPrompt
isVisible={showSurveyPrompt}
onOpen={handleOpenSurvey}
onDismiss={handleDismissSurveyPrompt}
/>
<SurveyModal
isOpen={showSurveyModal}
onClose={handleSurveyModalClose}
onComplete={handleSurveyComplete}
/>
{/* Claude Code Components */}
<ClaudeCodePrompt
isVisible={showClaudeCodePrompt}
onOpen={handleOpenClaudeCode}
onDismiss={handleDismissClaudeCodePrompt}
/>
<ClaudeCodeModal
isOpen={showClaudeCodeModal}
onClose={handleClaudeCodeModalClose}
onComplete={handleClaudeCodeComplete}
/>
</div>
)}
</ThemeProvider>
</ConfigProvider>
</Suspense>
);
}

View file

@ -104,12 +104,10 @@ describe("AgentHubTableColumns", () => {
render(<TestTable data={[mockAgent]} />);
// "In:" and "Out:" are in <span> children; getByText with exact:false
// matches against the element's full textContent across child nodes
expect(screen.getByText((_, el) =>
el?.tagName === "P" && el.textContent === "In: text"
)).toBeInTheDocument();
expect(screen.getByText((_, el) =>
el?.tagName === "P" && el.textContent === "Out: text, image"
)).toBeInTheDocument();
expect(screen.getByText((_, el) => el?.tagName === "P" && el.textContent === "In: text")).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.tagName === "P" && el.textContent === "Out: text, image"),
).toBeInTheDocument();
});
it("should display 'Yes' badge for public agents", () => {

View file

@ -2,14 +2,8 @@ import { SearchOutlined } from "@ant-design/icons";
import { Card, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react";
import { Input } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import {
extractCategories,
filterPluginsByCategory,
filterPluginsBySearch,
} from "../claude_code_plugins/helpers";
import {
MarketplaceResponse
} from "../claude_code_plugins/types";
import { extractCategories, filterPluginsByCategory, filterPluginsBySearch } from "../claude_code_plugins/helpers";
import { MarketplaceResponse } from "../claude_code_plugins/types";
import { ModelDataTable } from "../model_dashboard/table";
import NotificationsManager from "../molecules/notifications_manager";
import { getClaudeCodeMarketplace } from "../networking";
@ -19,11 +13,8 @@ interface ClaudeCodeMarketplaceTabProps {
publicPage?: boolean;
}
const ClaudeCodeMarketplaceTab: React.FC<ClaudeCodeMarketplaceTabProps> = ({
publicPage = false,
}) => {
const [marketplaceData, setMarketplaceData] =
useState<MarketplaceResponse | null>(null);
const ClaudeCodeMarketplaceTab: React.FC<ClaudeCodeMarketplaceTabProps> = ({ publicPage = false }) => {
const [marketplaceData, setMarketplaceData] = useState<MarketplaceResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0);
@ -74,18 +65,13 @@ const ClaudeCodeMarketplaceTab: React.FC<ClaudeCodeMarketplaceTabProps> = ({
return plugins;
}, [marketplaceData, selectedCategory, searchTerm]);
const columns = useMemo(
() => getMarketplaceTableColumns(copyToClipboard, publicPage),
[publicPage]
);
const columns = useMemo(() => getMarketplaceTableColumns(copyToClipboard, publicPage), [publicPage]);
if (!marketplaceData && !isLoading) {
return (
<Card>
<div className="text-center p-12">
<Text className="text-gray-500">
Failed to load marketplace. Please try again later.
</Text>
<Text className="text-gray-500">Failed to load marketplace. Please try again later.</Text>
</div>
</Card>
);
@ -110,14 +96,8 @@ const ClaudeCodeMarketplaceTab: React.FC<ClaudeCodeMarketplaceTabProps> = ({
<TabList className="mb-4">
{categories.map((category) => {
// Count plugins in this category
const categoryPlugins = filterPluginsByCategory(
marketplaceData?.plugins || [],
category
);
const count = filterPluginsBySearch(
categoryPlugins,
searchTerm
).length;
const categoryPlugins = filterPluginsByCategory(marketplaceData?.plugins || [], category);
const count = filterPluginsBySearch(categoryPlugins, searchTerm).length;
return (
<Tab key={category}>
@ -143,8 +123,7 @@ const ClaudeCodeMarketplaceTab: React.FC<ClaudeCodeMarketplaceTabProps> = ({
{/* Footer Info */}
<div className="mt-4 text-center space-y-2">
<Text className="text-sm text-gray-600">
Showing {filteredPlugins.length} of{" "}
{marketplaceData?.plugins.length || 0} plugin
Showing {filteredPlugins.length} of {marketplaceData?.plugins.length || 0} plugin
{marketplaceData?.plugins.length !== 1 ? "s" : ""}
{searchTerm && ` matching "${searchTerm}"`}
{selectedCategory !== "All" && ` in ${selectedCategory}`}

View file

@ -48,11 +48,7 @@ describe("ModelHubTable", () => {
});
// Reusable helper function to setup mocks for auth redirect tests
const setupAuthRedirectTest = (
requireAuth: boolean,
tokenValue: string | null,
isTokenValid: boolean
) => {
const setupAuthRedirectTest = (requireAuth: boolean, tokenValue: string | null, isTokenValid: boolean) => {
mockUseUISettings.mockReturnValue({
data: {
values: {
@ -87,14 +83,12 @@ describe("ModelHubTable", () => {
tokenValue: string | null,
isTokenValid: boolean,
shouldRedirect: boolean,
description: string
description: string,
) => {
it(description, async () => {
setupAuthRedirectTest(requireAuth, tokenValue, isTokenValid);
renderWithProviders(
<ModelHubTable accessToken={null} publicPage={true} premiumUser={false} userRole={null} />
);
renderWithProviders(<ModelHubTable accessToken={null} publicPage={true} premiumUser={false} userRole={null} />);
await waitFor(() => {
if (shouldRedirect) {
@ -125,7 +119,9 @@ describe("ModelHubTable", () => {
isLoading: false,
});
renderWithProviders(<ModelHubTable accessToken="test-token" publicPage={false} premiumUser={false} userRole={null} />);
renderWithProviders(
<ModelHubTable accessToken="test-token" publicPage={false} premiumUser={false} userRole={null} />,
);
await waitFor(() => {
expect(screen.getByText("AI Hub")).toBeInTheDocument();
@ -172,7 +168,7 @@ describe("ModelHubTable", () => {
null,
false,
true,
"should redirect to login when requireAuth is true and there is no token"
"should redirect to login when requireAuth is true and there is no token",
);
testAuthRedirect(
@ -180,7 +176,7 @@ describe("ModelHubTable", () => {
"expired-token",
false,
true,
"should redirect to login when requireAuth is true and token is expired"
"should redirect to login when requireAuth is true and token is expired",
);
testAuthRedirect(
@ -188,24 +184,18 @@ describe("ModelHubTable", () => {
"malformed-token",
false,
true,
"should redirect to login when requireAuth is true and token is malformed"
"should redirect to login when requireAuth is true and token is malformed",
);
// Test cases where requireAuth is false - should NOT redirect regardless of token state
testAuthRedirect(
false,
null,
false,
false,
"should not redirect when requireAuth is false and there is no token"
);
testAuthRedirect(false, null, false, false, "should not redirect when requireAuth is false and there is no token");
testAuthRedirect(
false,
"expired-token",
false,
false,
"should not redirect when requireAuth is false and token is expired"
"should not redirect when requireAuth is false and token is expired",
);
testAuthRedirect(
@ -213,7 +203,7 @@ describe("ModelHubTable", () => {
"malformed-token",
false,
false,
"should not redirect when requireAuth is false and token is malformed"
"should not redirect when requireAuth is false and token is malformed",
);
});
});

View file

@ -526,9 +526,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
<TabPanel>
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
<Button onClick={() => setIsMakeSkillPublicModalVisible(true)}>
Select Skills to Make Public
</Button>
<Button onClick={() => setIsMakeSkillPublicModalVisible(true)}>Select Skills to Make Public</Button>
</div>
)}
<SkillHubDashboard

View file

@ -51,7 +51,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
s.description?.toLowerCase().includes(q) ||
s.domain?.toLowerCase().includes(q) ||
s.namespace?.toLowerCase().includes(q) ||
s.keywords?.some((k) => k.toLowerCase().includes(q))
s.keywords?.some((k) => k.toLowerCase().includes(q)),
);
}
return result;
@ -94,9 +94,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
{/* Search + filters + table */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">
All {publicPage ? "Public " : ""}Skills
</h3>
<h3 className="text-sm font-semibold text-gray-700">All {publicPage ? "Public " : ""}Skills</h3>
<div className="flex items-center gap-2">
<Select
placeholder="All Domains"
@ -117,11 +115,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
</div>
</div>
<ModelDataTable
columns={skillHubColumns(
(skill) => setSelectedSkill(skill),
copyToClipboard,
publicPage
)}
columns={skillHubColumns((skill) => setSelectedSkill(skill), copyToClipboard, publicPage)}
data={filteredSkills}
isLoading={false}
defaultSorting={[{ id: "name", desc: false }]}

View file

@ -36,9 +36,7 @@ export const getMarketplaceTableColumns = (
</div>
{/* Show description on mobile */}
<div className="md:hidden">
<Text className="text-xs text-gray-600">
{plugin.description || "No description"}
</Text>
<Text className="text-xs text-gray-600">{plugin.description || "No description"}</Text>
</div>
</div>
);
@ -52,11 +50,7 @@ export const getMarketplaceTableColumns = (
cell: ({ row }) => {
const plugin = row.original;
return (
<Text className="text-xs line-clamp-2">
{plugin.description || "-"}
</Text>
);
return <Text className="text-xs line-clamp-2">{plugin.description || "-"}</Text>;
},
meta: {
className: "hidden md:table-cell",

View file

@ -8,13 +8,7 @@ import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails");
vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({
AccessGroupEditModal: ({
visible,
onCancel,
}: {
visible: boolean;
onCancel: () => void;
}) =>
AccessGroupEditModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) =>
visible ? (
<div role="dialog" aria-label="Edit Access Group">
<button onClick={onCancel}>Close Modal</button>
@ -50,9 +44,7 @@ const baseMockReturnValue = {
refetch: vi.fn(),
} as unknown as ReturnType<typeof useAccessGroupDetails>;
const createMockAccessGroup = (
overrides: Partial<AccessGroupResponse> = {}
): AccessGroupResponse => ({
const createMockAccessGroup = (overrides: Partial<AccessGroupResponse> = {}): AccessGroupResponse => ({
access_group_id: "ag-1",
access_group_name: "Test Group",
description: "A test access group",
@ -81,9 +73,7 @@ describe("AccessGroupDetail", () => {
});
it("should render the component", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("heading", { name: "Test Group" })).toBeInTheDocument();
});
@ -94,9 +84,7 @@ describe("AccessGroupDetail", () => {
isLoading: true,
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.queryByRole("heading", { name: "Test Group" })).not.toBeInTheDocument();
});
@ -108,9 +96,7 @@ describe("AccessGroupDetail", () => {
isLoading: false,
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("Access group not found")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
@ -118,9 +104,7 @@ describe("AccessGroupDetail", () => {
it("should call onBack when back button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
const buttons = screen.getAllByRole("button");
const backButton = buttons.find((btn) => !btn.textContent?.includes("Edit"));
@ -130,18 +114,14 @@ describe("AccessGroupDetail", () => {
});
it("should display access group name and ID", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("heading", { name: "Test Group" })).toBeInTheDocument();
expect(screen.getByText(/ID:/)).toBeInTheDocument();
});
it("should display description in Group Details", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("Group Details")).toBeInTheDocument();
expect(screen.getByText("A test access group")).toBeInTheDocument();
@ -153,18 +133,14 @@ describe("AccessGroupDetail", () => {
data: createMockAccessGroup({ description: null }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("—")).toBeInTheDocument();
});
it("should open edit modal when Edit Access Group button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument();
@ -176,9 +152,7 @@ describe("AccessGroupDetail", () => {
it("should close edit modal when Close Modal is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.click(screen.getByRole("button", { name: /Edit Access Group/i }));
expect(screen.getByRole("dialog", { name: "Edit Access Group" })).toBeInTheDocument();
@ -188,9 +162,7 @@ describe("AccessGroupDetail", () => {
});
it("should display attached keys", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("Attached Keys")).toBeInTheDocument();
expect(screen.getByText("key-1")).toBeInTheDocument();
@ -198,9 +170,7 @@ describe("AccessGroupDetail", () => {
});
it("should display attached teams", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("Attached Teams")).toBeInTheDocument();
expect(screen.getByText("team-1")).toBeInTheDocument();
@ -214,9 +184,7 @@ describe("AccessGroupDetail", () => {
}),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
@ -230,9 +198,7 @@ describe("AccessGroupDetail", () => {
}),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.click(screen.getByRole("button", { name: "View All (6)" }));
expect(screen.getByRole("button", { name: "Show Less" })).toBeInTheDocument();
@ -249,9 +215,7 @@ describe("AccessGroupDetail", () => {
}),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
@ -262,9 +226,7 @@ describe("AccessGroupDetail", () => {
data: createMockAccessGroup({ assigned_key_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("No keys attached")).toBeInTheDocument();
});
@ -275,17 +237,13 @@ describe("AccessGroupDetail", () => {
data: createMockAccessGroup({ assigned_team_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("No teams attached")).toBeInTheDocument();
});
it("should display Models tab with model IDs", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("tab", { name: /Models/i })).toBeInTheDocument();
expect(screen.getByText("model-1")).toBeInTheDocument();
@ -294,9 +252,7 @@ describe("AccessGroupDetail", () => {
it("should display MCP Servers tab with server IDs", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
const mcpTab = screen.getByRole("tab", { name: /MCP Servers/i });
expect(mcpTab).toBeInTheDocument();
@ -306,9 +262,7 @@ describe("AccessGroupDetail", () => {
it("should display Agents tab with agent IDs", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
const agentsTab = screen.getByRole("tab", { name: /Agents/i });
expect(agentsTab).toBeInTheDocument();
@ -322,9 +276,7 @@ describe("AccessGroupDetail", () => {
data: createMockAccessGroup({ access_model_names: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("No models assigned to this group")).toBeInTheDocument();
});
@ -336,9 +288,7 @@ describe("AccessGroupDetail", () => {
data: createMockAccessGroup({ access_mcp_server_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.click(screen.getByRole("tab", { name: /MCP Servers/i }));
expect(screen.getByText("No MCP servers assigned to this group")).toBeInTheDocument();
@ -351,9 +301,7 @@ describe("AccessGroupDetail", () => {
data: createMockAccessGroup({ access_agent_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.click(screen.getByRole("tab", { name: /Agents/i }));
expect(screen.getByText("No agents assigned to this group")).toBeInTheDocument();
@ -366,17 +314,13 @@ describe("AccessGroupDetail", () => {
data: createMockAccessGroup({ assigned_key_ids: [longKeyId] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText(/a{10}\.\.\.a{6}/)).toBeInTheDocument();
});
it("should display created and last updated timestamps", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("Created")).toBeInTheDocument();
expect(screen.getByText("Last Updated")).toBeInTheDocument();

View file

@ -13,17 +13,9 @@ import {
Tabs,
Tag,
theme,
Typography
Typography,
} from "antd";
import {
ArrowLeftIcon,
BotIcon,
EditIcon,
KeyIcon,
LayersIcon,
ServerIcon,
UsersIcon,
} from "lucide-react";
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
import { useState } from "react";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
@ -36,12 +28,8 @@ interface AccessGroupDetailProps {
onBack: () => void;
}
export function AccessGroupDetail({
accessGroupId,
onBack,
}: AccessGroupDetailProps) {
const { data: accessGroup, isLoading } =
useAccessGroupDetails(accessGroupId);
export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) {
const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId);
const { token } = theme.useToken();
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [showAllKeys, setShowAllKeys] = useState(false);
@ -72,12 +60,7 @@ export function AccessGroupDetail({
paddingInline: token.paddingLG * 2,
}}
>
<Button
icon={<ArrowLeftIcon size={16} />}
onClick={onBack}
type="text"
style={{ marginBottom: 16 }}
/>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" style={{ marginBottom: 16 }} />
<Empty description="Access group not found" />
</Content>
);
@ -90,9 +73,7 @@ export function AccessGroupDetail({
const teamIds = accessGroup.assigned_team_ids ?? [];
const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW);
const displayedTeams = showAllTeams
? teamIds
: teamIds.slice(0, MAX_PREVIEW);
const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW);
const handleEdit = () => {
setIsEditModalVisible(true);
@ -180,9 +161,7 @@ export function AccessGroupDetail({
];
return (
<Content
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
>
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
{/* Header */}
<div
style={{
@ -193,11 +172,7 @@ export function AccessGroupDetail({
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Button
icon={<ArrowLeftIcon size={16} />}
onClick={onBack}
type="text"
/>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" />
<div>
<Title level={2} style={{ margin: 0 }}>
{accessGroup.access_group_name}
@ -207,11 +182,7 @@ export function AccessGroupDetail({
</Text>
</div>
</div>
<Button
type="primary"
icon={<EditIcon size={16} />}
onClick={handleEdit}
>
<Button type="primary" icon={<EditIcon size={16} />} onClick={handleEdit}>
Edit Access Group
</Button>
</div>
@ -220,9 +191,7 @@ export function AccessGroupDetail({
<Row style={{ marginBottom: 24 }}>
<Card>
<Descriptions title="Group Details" column={1}>
<Descriptions.Item label="Description">
{accessGroup.description || "—"}
</Descriptions.Item>
<Descriptions.Item label="Description">{accessGroup.description || "—"}</Descriptions.Item>
<Descriptions.Item label="Created">
{new Date(accessGroup.created_at).toLocaleString()}
{accessGroup.created_by && (
@ -258,10 +227,7 @@ export function AccessGroupDetail({
}
extra={
keyIds?.length > MAX_PREVIEW ? (
<Button
type="link"
onClick={() => setShowAllKeys(!showAllKeys)}
>
<Button type="link" onClick={() => setShowAllKeys(!showAllKeys)}>
{showAllKeys ? "Show Less" : `View All (${keyIds?.length})`}
</Button>
) : null
@ -272,18 +238,13 @@ export function AccessGroupDetail({
{displayedKeys.map((id) => (
<Tag key={id}>
<Text code style={{ fontSize: 12 }}>
{id.length > 20
? `${id.slice(0, 10)}...${id.slice(-6)}`
: id}
{id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id}
</Text>
</Tag>
))}
</Flex>
) : (
<Empty
description="No keys attached"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
<Empty description="No keys attached" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
@ -298,13 +259,8 @@ export function AccessGroupDetail({
}
extra={
teamIds?.length > MAX_PREVIEW ? (
<Button
type="link"
onClick={() => setShowAllTeams(!showAllTeams)}
>
{showAllTeams
? "Show Less"
: `View All (${teamIds?.length})`}
<Button type="link" onClick={() => setShowAllTeams(!showAllTeams)}>
{showAllTeams ? "Show Less" : `View All (${teamIds?.length})`}
</Button>
) : null
}
@ -320,10 +276,7 @@ export function AccessGroupDetail({
))}
</Flex>
) : (
<Empty
description="No teams attached"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
<Empty description="No teams attached" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>

View file

@ -20,10 +20,7 @@ interface AccessGroupBaseFormProps {
isNameDisabled?: boolean;
}
export function AccessGroupBaseForm({
form,
isNameDisabled = false,
}: AccessGroupBaseFormProps) {
export function AccessGroupBaseForm({ form, isNameDisabled = false }: AccessGroupBaseFormProps) {
const { data: agentsData } = useAgents();
const { data: mcpServersData } = useMCPServers();
@ -50,19 +47,10 @@ export function AccessGroupBaseForm({
},
]}
>
<Input
placeholder="e.g. Engineering Team"
disabled={isNameDisabled}
/>
<Input placeholder="e.g. Engineering Team" disabled={isNameDisabled} />
</Form.Item>
<Form.Item
name="description"
label="Description"
>
<TextArea
rows={4}
placeholder="Describe the purpose of this access group..."
/>
<Form.Item name="description" label="Description">
<TextArea rows={4} placeholder="Describe the purpose of this access group..." />
</Form.Item>
</div>
),

View file

@ -1,10 +1,7 @@
import React from "react";
import { Modal, Form } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import {
AccessGroupBaseForm,
AccessGroupFormValues,
} from "./AccessGroupBaseForm";
import { AccessGroupBaseForm, AccessGroupFormValues } from "./AccessGroupBaseForm";
import {
useCreateAccessGroup,
AccessGroupCreateParams,
@ -16,11 +13,7 @@ interface AccessGroupCreateModalProps {
onSuccess?: () => void;
}
export function AccessGroupCreateModal({
visible,
onCancel,
onSuccess,
}: AccessGroupCreateModalProps) {
export function AccessGroupCreateModal({ visible, onCancel, onSuccess }: AccessGroupCreateModalProps) {
const [form] = Form.useForm<AccessGroupFormValues>();
const createMutation = useCreateAccessGroup();

View file

@ -1,14 +1,8 @@
import React, { useEffect } from "react";
import { Modal, Form } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import {
AccessGroupBaseForm,
AccessGroupFormValues,
} from "./AccessGroupBaseForm";
import {
useEditAccessGroup,
AccessGroupUpdateParams,
} from "@/app/(dashboard)/hooks/accessGroups/useEditAccessGroup";
import { AccessGroupBaseForm, AccessGroupFormValues } from "./AccessGroupBaseForm";
import { useEditAccessGroup, AccessGroupUpdateParams } from "@/app/(dashboard)/hooks/accessGroups/useEditAccessGroup";
import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
interface AccessGroupEditModalProps {
@ -18,12 +12,7 @@ interface AccessGroupEditModalProps {
onSuccess?: () => void;
}
export function AccessGroupEditModal({
visible,
accessGroup,
onCancel,
onSuccess,
}: AccessGroupEditModalProps) {
export function AccessGroupEditModal({ visible, accessGroup, onCancel, onSuccess }: AccessGroupEditModalProps) {
const [form] = Form.useForm<AccessGroupFormValues>();
const editMutation = useEditAccessGroup();

View file

@ -48,13 +48,7 @@ vi.mock("@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup", () => ({
}));
vi.mock("./AccessGroupsDetailsPage", () => ({
AccessGroupDetail: ({
accessGroupId,
onBack,
}: {
accessGroupId: string;
onBack: () => void;
}) => (
AccessGroupDetail: ({ accessGroupId, onBack }: { accessGroupId: string; onBack: () => void }) => (
<div data-testid="access-group-detail">
<span>Detail for {accessGroupId}</span>
<button onClick={onBack}>Back</button>
@ -63,13 +57,7 @@ vi.mock("./AccessGroupsDetailsPage", () => ({
}));
vi.mock("./AccessGroupsModal/AccessGroupCreateModal", () => ({
AccessGroupCreateModal: ({
visible,
onCancel,
}: {
visible: boolean;
onCancel: () => void;
}) =>
AccessGroupCreateModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) =>
visible ? (
<div data-testid="create-access-group-modal">
<button onClick={onCancel}>Cancel</button>
@ -78,20 +66,8 @@ vi.mock("./AccessGroupsModal/AccessGroupCreateModal", () => ({
}));
vi.mock("../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton", () => ({
default: ({
variant,
tooltipText,
onClick,
}: {
variant: string;
tooltipText: string;
onClick: () => void;
}) => (
<button
data-testid={`action-button-${variant.toLowerCase()}`}
aria-label={tooltipText}
onClick={onClick}
>
default: ({ variant, tooltipText, onClick }: { variant: string; tooltipText: string; onClick: () => void }) => (
<button data-testid={`action-button-${variant.toLowerCase()}`} aria-label={tooltipText} onClick={onClick}>
{variant}
</button>
),
@ -118,23 +94,17 @@ describe("AccessGroupsPage", () => {
it("should display page title and subtitle", () => {
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByRole("heading", { name: "Access Groups" })).toBeInTheDocument();
expect(
screen.getByText("Manage resource permissions for your organization"),
).toBeInTheDocument();
expect(screen.getByText("Manage resource permissions for your organization")).toBeInTheDocument();
});
it("should display Create Access Group button", () => {
renderWithProviders(<AccessGroupsPage />);
expect(
screen.getByRole("button", { name: /create access group/i }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /create access group/i })).toBeInTheDocument();
});
it("should display search input with placeholder", () => {
renderWithProviders(<AccessGroupsPage />);
expect(
screen.getByPlaceholderText("Search groups by name, ID, or description..."),
).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search groups by name, ID, or description...")).toBeInTheDocument();
});
it("should display access groups in table", () => {
@ -155,9 +125,7 @@ describe("AccessGroupsPage", () => {
it("should filter groups by search text matching name", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
const searchInput = screen.getByPlaceholderText("Search groups by name, ID, or description...");
await user.type(searchInput, "Admin");
expect(screen.getByText("Admin Group")).toBeInTheDocument();
expect(screen.queryByText("Read Only")).not.toBeInTheDocument();
@ -166,9 +134,7 @@ describe("AccessGroupsPage", () => {
it("should filter groups by search text matching ID", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
const searchInput = screen.getByPlaceholderText("Search groups by name, ID, or description...");
await user.type(searchInput, "ag-2");
expect(screen.getByText("Read Only")).toBeInTheDocument();
expect(screen.queryByText("Admin Group")).not.toBeInTheDocument();
@ -177,9 +143,7 @@ describe("AccessGroupsPage", () => {
it("should filter groups by search text matching description", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
const searchInput = screen.getByPlaceholderText("Search groups by name, ID, or description...");
await user.type(searchInput, "read-only");
expect(screen.getByText("Read Only")).toBeInTheDocument();
expect(screen.queryByText("Admin Group")).not.toBeInTheDocument();
@ -188,9 +152,7 @@ describe("AccessGroupsPage", () => {
it("should reset to first page when search text changes", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
const searchInput = screen.getByPlaceholderText("Search groups by name, ID, or description...");
await user.type(searchInput, "Admin");
const pagination = screen.getByText(/groups/);
expect(pagination).toHaveTextContent("1 groups");
@ -240,9 +202,7 @@ describe("AccessGroupsPage", () => {
const dialog = screen.getByRole("dialog", { name: "Delete Access Group" });
expect(dialog).toBeInTheDocument();
expect(
within(dialog).getByText(
"Are you sure you want to delete this access group? This action cannot be undone.",
),
within(dialog).getByText("Are you sure you want to delete this access group? This action cannot be undone."),
).toBeInTheDocument();
expect(within(dialog).getByText("Access Group Information")).toBeInTheDocument();
expect(within(dialog).getByText("ag-1")).toBeInTheDocument();
@ -303,9 +263,7 @@ describe("AccessGroupsPage", () => {
it("should display empty state when no groups match search", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
const searchInput = screen.getByPlaceholderText("Search groups by name, ID, or description...");
await user.type(searchInput, "nonexistent-group-xyz");
expect(screen.getByRole("table")).toBeInTheDocument();
});

View file

@ -1,7 +1,4 @@
import {
AccessGroupResponse,
useAccessGroups,
} from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup";
import { PlusOutlined } from "@ant-design/icons";
import {
@ -13,26 +10,8 @@ import {
SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
Button,
Card,
Flex,
Input,
Layout,
Pagination,
Space,
Table,
Tag,
theme,
Tooltip,
Typography,
} from "antd";
import {
BotIcon,
LayersIcon,
SearchIcon,
ServerIcon
} from "lucide-react";
import { Button, Card, Flex, Input, Layout, Pagination, Space, Table, Tag, theme, Tooltip, Typography } from "antd";
import { BotIcon, LayersIcon, SearchIcon, ServerIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
@ -82,16 +61,12 @@ function buildAntdColumns(
return headers.map((header) => {
const canSort = header.column.getCanSort();
const isSorted = header.column.getIsSorted();
const meta = header.column.columnDef.meta as
| { responsive?: string[] }
| undefined;
const meta = header.column.columnDef.meta as { responsive?: string[] } | undefined;
const col: Record<string, unknown> = {
title: (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{canSort && (
<TableHeaderSortDropdown
sortState={isSorted === false ? false : (isSorted as SortState)}
@ -99,9 +74,7 @@ function buildAntdColumns(
if (newState === false) {
onSortingChange([]);
} else {
onSortingChange([
{ id: header.column.id, desc: newState === "desc" },
]);
onSortingChange([{ id: header.column.id, desc: newState === "desc" }]);
}
}}
columnId={header.column.id}
@ -114,9 +87,7 @@ function buildAntdColumns(
render: (_: unknown, record: AccessGroup) => {
const row = rowLookup.get(record.id);
if (!row) return null;
const cell = row
.getVisibleCells()
.find((c) => c.column.id === header.id);
const cell = row.getVisibleCells().find((c) => c.column.id === header.id);
if (!cell) return null;
return flexRender(cell.column.columnDef.cell, cell.getContext());
},
@ -136,10 +107,7 @@ export function AccessGroupsPage() {
// Admin Viewer follows the read-parity rule: see access groups, no writes.
const canModify = isProxyAdminRole(userRole ?? "");
const { data: groupsData, isLoading } = useAccessGroups();
const groups = useMemo(
() => (groupsData ?? []).map(mapResponseToAccessGroup),
[groupsData],
);
const groups = useMemo(() => (groupsData ?? []).map(mapResponseToAccessGroup), [groupsData]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
@ -243,8 +211,7 @@ export function AccessGroupsPage() {
header: () => <span>Created</span>,
enableSorting: true,
sortingFn: "datetime",
cell: ({ getValue }) =>
new Date(getValue() as string).toLocaleDateString(),
cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(),
meta: { responsive: ["lg"] },
},
{
@ -252,8 +219,7 @@ export function AccessGroupsPage() {
accessorKey: "updatedAt",
header: () => <span>Updated</span>,
enableSorting: false,
cell: ({ getValue }) =>
new Date(getValue() as string).toLocaleDateString(),
cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(),
meta: { responsive: ["xl"] },
},
...(canModify
@ -295,16 +261,10 @@ export function AccessGroupsPage() {
const sortedRows = table.getRowModel().rows;
// Paginated slice
const paginatedRows = sortedRows.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const paginatedRows = sortedRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// Map for O(1) lookup by record id in antd render()
const rowLookup = useMemo(
() => new Map(paginatedRows.map((row) => [row.original.id, row])),
[paginatedRows],
);
const rowLookup = useMemo(() => new Map(paginatedRows.map((row) => [row.original.id, row])), [paginatedRows]);
// Convert TanStack headers → antd columns
const antdColumns = buildAntdColumns(table, rowLookup, setSorting);
@ -313,37 +273,20 @@ export function AccessGroupsPage() {
const dataSource = paginatedRows.map((row) => row.original);
if (selectedGroupId) {
return (
<AccessGroupDetail
accessGroupId={selectedGroupId}
onBack={() => setSelectedGroupId(null)}
/>
);
return <AccessGroupDetail accessGroupId={selectedGroupId} onBack={() => setSelectedGroupId(null)} />;
}
return (
<Content
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
>
<Flex
justify="space-between"
align="center"
style={{ marginBottom: 16 }}
>
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={0}>
<Title level={2} style={{ margin: 0 }}>
Access Groups
</Title>
<Text type="secondary">
Manage resource permissions for your organization
</Text>
<Text type="secondary">Manage resource permissions for your organization</Text>
</Space>
{canModify && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalVisible(true)}
>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsCreateModalVisible(true)}>
Create Access Group
</Button>
)}
@ -375,19 +318,10 @@ export function AccessGroupsPage() {
showSizeChanger={false}
/>
</Flex>
<Table
columns={antdColumns}
dataSource={dataSource}
rowKey="id"
loading={isLoading}
pagination={false}
/>
<Table columns={antdColumns} dataSource={dataSource} rowKey="id" loading={isLoading} pagination={false} />
</Card>
<AccessGroupCreateModal
visible={isCreateModalVisible}
onCancel={() => setIsCreateModalVisible(false)}
/>
<AccessGroupCreateModal visible={isCreateModalVisible} onCancel={() => setIsCreateModalVisible(false)} />
<DeleteResourceModal
isOpen={!!groupToDelete}

View file

@ -1,46 +1,46 @@
export interface AccessGroup {
id: string
name: string
description: string
modelIds: string[]
mcpServerIds: string[]
agentIds: string[]
keyIds: string[]
teamIds: string[]
createdAt: string
createdBy: string
updatedAt: string
updatedBy: string
id: string;
name: string;
description: string;
modelIds: string[];
mcpServerIds: string[];
agentIds: string[];
keyIds: string[];
teamIds: string[];
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}
export interface Model {
id: string
name: string
provider: string
id: string;
name: string;
provider: string;
}
export interface McpServer {
id: string
name: string
endpoint: string
id: string;
name: string;
endpoint: string;
}
export interface Agent {
id: string
name: string
type: string
id: string;
name: string;
type: string;
}
export interface AccessGroupKey {
id: string
alias: string
status: string
createdAt: string
id: string;
alias: string;
status: string;
createdAt: string;
}
export interface AccessGroupTeam {
id: string
name: string
members: number
role: string
id: string;
name: string;
members: number;
role: string;
}

View file

@ -106,7 +106,6 @@ describe("BulkEditUserModal", () => {
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("should show update all users checkbox when allowAllUsers is true", () => {
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
@ -198,11 +197,10 @@ describe("BulkEditUserModal", () => {
await user.click(submitButton);
await waitFor(() => {
expect(mockUserBulkUpdateUserCall).toHaveBeenCalledWith(
"test-token",
{ user_role: "admin", max_budget: 100 },
["user1", "user2"],
);
expect(mockUserBulkUpdateUserCall).toHaveBeenCalledWith("test-token", { user_role: "admin", max_budget: 100 }, [
"user1",
"user2",
]);
});
});
@ -226,7 +224,6 @@ describe("BulkEditUserModal", () => {
});
});
it("should show success message after successful user update", async () => {
const user = userEvent.setup();
mockUserBulkUpdateUserCall.mockResolvedValue({
@ -268,7 +265,6 @@ describe("BulkEditUserModal", () => {
});
});
it("should show error message when bulk update fails", async () => {
const user = userEvent.setup();
mockUserBulkUpdateUserCall.mockRejectedValueOnce(new Error("Update failed"));

View file

@ -1,15 +1,5 @@
import React, { useState } from "react";
import {
Modal,
Typography,
Divider,
Table,
Select,
InputNumber,
Card,
Space,
Checkbox,
} from "antd";
import { Modal, Typography, Divider, Table, Select, InputNumber, Card, Space, Checkbox } from "antd";
import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "./networking";
import { UserEditView } from "./user_edit_view";
import NotificationsManager from "./molecules/notifications_manager";

View file

@ -75,46 +75,30 @@ describe("AddMarginForm", () => {
});
it("should disable the submit button when no provider is selected (percentage mode)", () => {
renderWithProviders(
<AddMarginForm {...DEFAULT_PROPS} selectedProvider={undefined} percentageValue="10" />
);
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} selectedProvider={undefined} percentageValue="10" />);
expect(screen.getByRole("button", { name: /add provider margin/i })).toBeDisabled();
});
it("should disable the submit button when provider is selected but no percentage value (percentage mode)", () => {
renderWithProviders(
<AddMarginForm {...DEFAULT_PROPS} selectedProvider="OpenAI" percentageValue="" />
);
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} selectedProvider="OpenAI" percentageValue="" />);
expect(screen.getByRole("button", { name: /add provider margin/i })).toBeDisabled();
});
it("should enable the submit button when provider and percentage value are both provided", () => {
renderWithProviders(
<AddMarginForm {...DEFAULT_PROPS} selectedProvider="OpenAI" percentageValue="10" />
);
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} selectedProvider="OpenAI" percentageValue="10" />);
expect(screen.getByRole("button", { name: /add provider margin/i })).not.toBeDisabled();
});
it("should disable the submit button in fixed mode when no fixed amount is provided", () => {
renderWithProviders(
<AddMarginForm
{...DEFAULT_PROPS}
selectedProvider="OpenAI"
marginType="fixed"
fixedAmountValue=""
/>
<AddMarginForm {...DEFAULT_PROPS} selectedProvider="OpenAI" marginType="fixed" fixedAmountValue="" />,
);
expect(screen.getByRole("button", { name: /add provider margin/i })).toBeDisabled();
});
it("should enable the submit button in fixed mode when provider and fixed amount are provided", () => {
renderWithProviders(
<AddMarginForm
{...DEFAULT_PROPS}
selectedProvider="OpenAI"
marginType="fixed"
fixedAmountValue="0.001"
/>
<AddMarginForm {...DEFAULT_PROPS} selectedProvider="OpenAI" marginType="fixed" fixedAmountValue="0.001" />,
);
expect(screen.getByRole("button", { name: /add provider margin/i })).not.toBeDisabled();
});
@ -123,12 +107,7 @@ describe("AddMarginForm", () => {
const onAddProvider = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<AddMarginForm
{...DEFAULT_PROPS}
selectedProvider="OpenAI"
percentageValue="10"
onAddProvider={onAddProvider}
/>
<AddMarginForm {...DEFAULT_PROPS} selectedProvider="OpenAI" percentageValue="10" onAddProvider={onAddProvider} />,
);
await user.click(screen.getByRole("button", { name: /add provider margin/i }));
@ -138,9 +117,7 @@ describe("AddMarginForm", () => {
it("should call onMarginTypeChange when the Fixed Amount radio is clicked", async () => {
const onMarginTypeChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<AddMarginForm {...DEFAULT_PROPS} onMarginTypeChange={onMarginTypeChange} />
);
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} onMarginTypeChange={onMarginTypeChange} />);
await user.click(screen.getByText("Fixed Amount"));
expect(onMarginTypeChange).toHaveBeenCalledWith("fixed");

View file

@ -53,7 +53,9 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
size="large"
optionFilterProp="children"
filterOption={(input, option) =>
String(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
String(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
>
<AntdSelect.Option key="global" value="global" label="Global (All Providers)">
@ -95,11 +97,7 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
}
rules={[{ required: true, message: "Please select a margin type" }]}
>
<Radio.Group
value={marginType}
onChange={(e) => onMarginTypeChange(e.target.value)}
className="w-full"
>
<Radio.Group value={marginType} onChange={(e) => onMarginTypeChange(e.target.value)} className="w-full">
<Radio value="percentage">Percentage-based</Radio>
<Radio value="fixed">Fixed Amount</Radio>
</Radio.Group>
@ -182,11 +180,11 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
)}
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
<Button
<Button
variant="primary"
onClick={onAddProvider}
onClick={onAddProvider}
disabled={
!selectedProvider ||
!selectedProvider ||
(marginType === "percentage" && !percentageValue) ||
(marginType === "fixed" && !fixedAmountValue)
}
@ -199,4 +197,3 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
};
export default AddMarginForm;

View file

@ -55,23 +55,17 @@ describe("AddProviderForm", () => {
});
it("should disable the submit button when a provider is selected but no discount is entered", () => {
renderWithProviders(
<AddProviderForm {...DEFAULT_PROPS} selectedProvider="OpenAI" newDiscount="" />
);
renderWithProviders(<AddProviderForm {...DEFAULT_PROPS} selectedProvider="OpenAI" newDiscount="" />);
expect(screen.getByRole("button", { name: /add provider discount/i })).toBeDisabled();
});
it("should disable the submit button when a discount is entered but no provider is selected", () => {
renderWithProviders(
<AddProviderForm {...DEFAULT_PROPS} selectedProvider={undefined} newDiscount="5" />
);
renderWithProviders(<AddProviderForm {...DEFAULT_PROPS} selectedProvider={undefined} newDiscount="5" />);
expect(screen.getByRole("button", { name: /add provider discount/i })).toBeDisabled();
});
it("should enable the submit button when both a provider and a discount value are provided", () => {
renderWithProviders(
<AddProviderForm {...DEFAULT_PROPS} selectedProvider="OpenAI" newDiscount="5" />
);
renderWithProviders(<AddProviderForm {...DEFAULT_PROPS} selectedProvider="OpenAI" newDiscount="5" />);
expect(screen.getByRole("button", { name: /add provider discount/i })).not.toBeDisabled();
});
@ -79,12 +73,7 @@ describe("AddProviderForm", () => {
const onAddProvider = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<AddProviderForm
{...DEFAULT_PROPS}
selectedProvider="OpenAI"
newDiscount="5"
onAddProvider={onAddProvider}
/>
<AddProviderForm {...DEFAULT_PROPS} selectedProvider="OpenAI" newDiscount="5" onAddProvider={onAddProvider} />,
);
await user.click(screen.getByRole("button", { name: /add provider discount/i }));

View file

@ -45,7 +45,9 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
size="large"
optionFilterProp="children"
filterOption={(input, option) =>
String(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
String(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
>
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => {
@ -94,11 +96,7 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
</Form.Item>
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
<Button
variant="primary"
onClick={onAddProvider}
disabled={!selectedProvider || !newDiscount}
>
<Button variant="primary" onClick={onAddProvider} disabled={!selectedProvider || !newDiscount}>
Add Provider Discount
</Button>
</div>
@ -107,4 +105,3 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
};
export default AddProviderForm;

View file

@ -71,7 +71,7 @@ describe("CostTrackingSettings", () => {
it("should return nothing when accessToken is null", () => {
const { container } = renderWithProviders(
<CostTrackingSettings userID="user-1" userRole="proxy_admin" accessToken={null} />
<CostTrackingSettings userID="user-1" userRole="proxy_admin" accessToken={null} />,
);
expect(container.firstChild).toBeNull();
});
@ -103,31 +103,23 @@ describe("CostTrackingSettings", () => {
});
it("should not show Provider Discounts section for a non-admin role", () => {
renderWithProviders(
<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />
);
renderWithProviders(<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />);
expect(screen.queryByText("Provider Discounts")).not.toBeInTheDocument();
});
it("should not show Fee/Price Margin section for a non-admin role", () => {
renderWithProviders(
<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />
);
renderWithProviders(<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />);
expect(screen.queryByText("Fee/Price Margin")).not.toBeInTheDocument();
});
it("should show Provider Discounts for the 'Admin' role as well", () => {
renderWithProviders(
<CostTrackingSettings userID="user-1" userRole="Admin" accessToken="test-token" />
);
renderWithProviders(<CostTrackingSettings userID="user-1" userRole="Admin" accessToken="test-token" />);
expect(screen.getByText("Provider Discounts")).toBeInTheDocument();
});
it("should show the subtitle describing discount/margin configuration", () => {
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
expect(
screen.getByText(/configure cost discounts and margins/i)
).toBeInTheDocument();
expect(screen.getByText(/configure cost discounts and margins/i)).toBeInTheDocument();
});
describe("Add Provider Discount modal", () => {
@ -144,9 +136,7 @@ describe("CostTrackingSettings", () => {
const addButton = await screen.findByRole("button", { name: /add provider discount/i });
await user.click(addButton);
expect(
await screen.findByText("Add Provider Discount", { selector: "h2" })
).toBeInTheDocument();
expect(await screen.findByText("Add Provider Discount", { selector: "h2" })).toBeInTheDocument();
});
});
@ -163,9 +153,7 @@ describe("CostTrackingSettings", () => {
const addButton = await screen.findByRole("button", { name: /add provider margin/i });
await user.click(addButton);
expect(
await screen.findByText("Add Provider Margin", { selector: "h2" })
).toBeInTheDocument();
expect(await screen.findByText("Add Provider Margin", { selector: "h2" })).toBeInTheDocument();
});
});
@ -179,9 +167,7 @@ describe("CostTrackingSettings", () => {
await userEvent.setup().click(accordionHeader);
}
expect(
await screen.findByText(/no provider discounts configured/i)
).toBeInTheDocument();
expect(await screen.findByText(/no provider discounts configured/i)).toBeInTheDocument();
});
it("should show the empty state message when no margin config is loaded", async () => {
@ -193,9 +179,7 @@ describe("CostTrackingSettings", () => {
await userEvent.setup().click(accordionHeader);
}
expect(
await screen.findByText(/no provider margins configured/i)
).toBeInTheDocument();
expect(await screen.findByText(/no provider margins configured/i)).toBeInTheDocument();
});
});
});

Some files were not shown because too many files have changed in this diff Show more