mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
commit
113f6d8e24
601 changed files with 13521 additions and 16093 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
||||
|
|
|
|||
|
|
@ -464,7 +464,6 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
|
|||
/>
|
||||
</ConfigProvider>
|
||||
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
|
||||
|
||||
</Sider>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 || ""),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"]));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -121,4 +121,4 @@ describe("useMCPAccessGroups", () => {
|
|||
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -131,4 +131,4 @@ describe("useMCPServers", () => {
|
|||
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }]}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue