mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): hide guardrail review buttons from non-admin users (#27535)
Some checks failed
CodSpeed Benchmarks / benchmarks (push) Has been cancelled
Code Quality Checks / code-quality (push) Has been cancelled
UI Unit Tests / ui-unit-tests (push) Has been cancelled
Unit Tests: Core Utilities / core-utils (push) Has been cancelled
Unit Tests: Documentation Validation / documentation (push) Has been cancelled
Unit Tests: Enterprise, Google GenAI & Routing / enterprise-routing (push) Has been cancelled
Unit Tests: Integrations (Callbacks & Logging) / integrations (push) Has been cancelled
Unit Tests: LLM Provider Transformations / Vertex AI (push) Has been cancelled
Unit Tests: LLM Provider Transformations / All Other Providers (push) Has been cancelled
Unit Tests: MCP, Secrets, Containers & Misc / misc (push) Has been cancelled
Unit Tests: Proxy Auth & Key Management / proxy-auth (push) Has been cancelled
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Proxy API Endpoints / proxy-endpoints (push) Has been cancelled
Unit Tests: Proxy API Endpoints / proxy-server (push) Has been cancelled
Unit Tests: Proxy Infrastructure / proxy-infra (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / auth-and-jwt (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / key-generation (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-config (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-response-and-misc (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-server (push) Has been cancelled
Unit Tests: Responses, Caching & Types / responses-caching-types (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-server-extras (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-token-counter (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-user-auth-and-spend (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-utils (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Some checks failed
CodSpeed Benchmarks / benchmarks (push) Has been cancelled
Code Quality Checks / code-quality (push) Has been cancelled
UI Unit Tests / ui-unit-tests (push) Has been cancelled
Unit Tests: Core Utilities / core-utils (push) Has been cancelled
Unit Tests: Documentation Validation / documentation (push) Has been cancelled
Unit Tests: Enterprise, Google GenAI & Routing / enterprise-routing (push) Has been cancelled
Unit Tests: Integrations (Callbacks & Logging) / integrations (push) Has been cancelled
Unit Tests: LLM Provider Transformations / Vertex AI (push) Has been cancelled
Unit Tests: LLM Provider Transformations / All Other Providers (push) Has been cancelled
Unit Tests: MCP, Secrets, Containers & Misc / misc (push) Has been cancelled
Unit Tests: Proxy Auth & Key Management / proxy-auth (push) Has been cancelled
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Proxy API Endpoints / proxy-endpoints (push) Has been cancelled
Unit Tests: Proxy API Endpoints / proxy-server (push) Has been cancelled
Unit Tests: Proxy Infrastructure / proxy-infra (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / auth-and-jwt (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / key-generation (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-config (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-response-and-misc (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-server (push) Has been cancelled
Unit Tests: Responses, Caching & Types / responses-caching-types (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-server-extras (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-token-counter (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-user-auth-and-spend (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-utils (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
* fix(ui): hide guardrail review buttons from non-admin users The team guardrail submissions list rendered Approve/Reject buttons for non-admin users even though the backend correctly rejected the calls. Thread userRole from the page through GuardrailsPanel into TeamGuardrailsTab and gate the row-card and detail-panel review buttons on isAdmin so the UI matches the backend authorization. Defense in depth only — the backend remains the source of truth and is double-gated at both the route admin check and the explicit endpoint role check. Refs LIT-2494 * refactor(ui): read userRole from useAuthorized hook instead of prop drilling Drop the userRole prop chain through GuardrailsPage → GuardrailsPanel → TeamGuardrailsTab. Each component reads userRole directly from the useAuthorized hook, matching the pattern used elsewhere in the dashboard. Tests now mock useAuthorized per case (the same pattern as top_key_view.test.tsx) instead of passing userRole as a prop. Refs LIT-2494 * fix(ui): drop userRole prop on GuardrailsPanel call site in src/app/page.tsx Missed in the earlier refactor — GuardrailsPanel no longer accepts userRole as a prop (reads from useAuthorized hook), so callers must not pass it. The build was failing in production type-check. Refs LIT-2494 * fix(ui): gate guardrail forward-key toggle and header editors on proxy admin * refactor(ui): remove dead app_admin case from user role formatting
This commit is contained in:
parent
c6a796a84b
commit
d4d0bf0acc
5 changed files with 269 additions and 104 deletions
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { TeamGuardrailsTab } from "./TeamGuardrailsTab";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
listGuardrailSubmissions: vi.fn(),
|
||||
approveGuardrailSubmission: vi.fn(),
|
||||
rejectGuardrailSubmission: vi.fn(),
|
||||
updateGuardrailCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail", () => ({
|
||||
useRegisterGuardrail: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/team_dropdown", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listGuardrailSubmissions } from "@/components/networking";
|
||||
|
||||
const pendingSubmission = {
|
||||
guardrail_id: "guard-1",
|
||||
guardrail_name: "test-pending-guardrail",
|
||||
status: "pending_review",
|
||||
team_id: "team-1",
|
||||
team_guardrail: true,
|
||||
litellm_params: {
|
||||
guardrail: "generic_guardrail_api",
|
||||
mode: "pre_call",
|
||||
api_base: "https://example.com/guard",
|
||||
headers: { "X-API-Key": "secret" },
|
||||
extra_headers: ["x-request-id"],
|
||||
},
|
||||
guardrail_info: {},
|
||||
submitted_at: "2026-05-09T00:00:00Z",
|
||||
};
|
||||
|
||||
const baseAuth = {
|
||||
token: "test-token",
|
||||
accessToken: "test-token",
|
||||
userId: "user-1",
|
||||
userEmail: "user@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
};
|
||||
|
||||
describe("TeamGuardrailsTab — approve/reject role gate", () => {
|
||||
const mockUseAuthorized = vi.mocked(useAuthorized);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listGuardrailSubmissions).mockResolvedValue({
|
||||
submissions: [pendingSubmission],
|
||||
summary: { total: 1, pending_review: 1, active: 0, rejected: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides Approve and Reject buttons for an internal user on a pending submission", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Internal User" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Approve and Reject buttons for an Admin Viewer, whom the backend rejects with 403", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Admin Viewer" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Approve and Reject buttons for an admin on a pending submission", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Admin" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.getByRole("button", { name: /approve/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Approve and Reject buttons when userRole is undefined (defaults to non-admin)", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: undefined });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables all admin-only write controls for a non-admin, including the detail panel", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Internal User" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
expect(screen.getByRole("switch")).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByText("Forward LiteLLM API Key");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
screen.getAllByRole("switch").forEach((toggle) => expect(toggle).toBeDisabled());
|
||||
expect(screen.queryByRole("button", { name: "Add" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^Remove/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("e.g. x-request-id")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps all write controls enabled for an admin in the detail panel", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Admin" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByText("Forward LiteLLM API Key");
|
||||
|
||||
expect(screen.getAllByRole("button", { name: /approve/i }).length).toBeGreaterThanOrEqual(2);
|
||||
screen.getAllByRole("switch").forEach((toggle) => expect(toggle).toBeEnabled());
|
||||
expect(screen.getAllByRole("button", { name: "Add" })).toHaveLength(2);
|
||||
expect(screen.getByLabelText("Remove X-API-Key")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Remove x-request-id")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,8 @@ import {
|
|||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import TeamDropdown from "@/components/common_components/team_dropdown";
|
||||
import { useRegisterGuardrail } from "@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
|
||||
type GuardrailStatus = "active" | "pending" | "rejected";
|
||||
|
||||
|
|
@ -188,16 +190,25 @@ function StatCard({ label, value, color }: { label: string; value: number; color
|
|||
);
|
||||
}
|
||||
|
||||
function Toggle({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) {
|
||||
function Toggle({
|
||||
enabled,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${
|
||||
enabled ? "bg-blue-500" : "bg-gray-200"
|
||||
}`}
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
|
|
@ -212,6 +223,7 @@ type GuardrailCardProps = {
|
|||
guardrail: TeamGuardrail;
|
||||
isSelected: boolean;
|
||||
isHeadersExpanded: boolean;
|
||||
isAdmin: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleForwardKey: () => void;
|
||||
onToggleHeaders: () => void;
|
||||
|
|
@ -223,6 +235,7 @@ function GuardrailCard({
|
|||
guardrail: g,
|
||||
isSelected,
|
||||
isHeadersExpanded,
|
||||
isAdmin,
|
||||
onSelect,
|
||||
onToggleForwardKey,
|
||||
onToggleHeaders,
|
||||
|
|
@ -266,7 +279,7 @@ function GuardrailCard({
|
|||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">Forward API Key</span>
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} />
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} disabled={!isAdmin} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
|
|
@ -276,7 +289,7 @@ function GuardrailCard({
|
|||
>
|
||||
{isSelected ? "Close" : "Review"}
|
||||
</button>
|
||||
{g.status === "pending" && (
|
||||
{isAdmin && g.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -348,6 +361,7 @@ function ConfigRow({ label, children }: { label: string; children: React.ReactNo
|
|||
|
||||
type DetailPanelProps = {
|
||||
guardrail: TeamGuardrail;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
|
|
@ -358,6 +372,7 @@ type DetailPanelProps = {
|
|||
|
||||
function DetailPanel({
|
||||
guardrail: g,
|
||||
isAdmin,
|
||||
onClose,
|
||||
onApprove,
|
||||
onReject,
|
||||
|
|
@ -425,7 +440,7 @@ function DetailPanel({
|
|||
<KeyIcon className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span className="text-xs font-semibold text-blue-800">Forward LiteLLM API Key</span>
|
||||
</div>
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} />
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} disabled={!isAdmin} />
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 leading-relaxed">
|
||||
When enabled, the caller's LiteLLM API key is forwarded as an{" "}
|
||||
|
|
@ -456,28 +471,63 @@ function DetailPanel({
|
|||
<span className="text-gray-700 truncate">
|
||||
{h.key}: {h.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateCustomHeaders(g.customHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${h.key}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateCustomHeaders(g.customHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${h.key}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<input
|
||||
type="text"
|
||||
value={newStaticHeaderKey}
|
||||
onChange={(e) => setNewStaticHeaderKey(e.target.value)}
|
||||
placeholder="Header name (e.g. X-API-Key)"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
{isAdmin && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<input
|
||||
type="text"
|
||||
value={newStaticHeaderKey}
|
||||
onChange={(e) => setNewStaticHeaderKey(e.target.value)}
|
||||
placeholder="Header name (e.g. X-API-Key)"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
onUpdateCustomHeaders([...g.customHeaders, { key, value }]);
|
||||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newStaticHeaderValue}
|
||||
onChange={(e) => setNewStaticHeaderValue(e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
onUpdateCustomHeaders([...g.customHeaders, { key, value }]);
|
||||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
|
|
@ -485,44 +535,13 @@ function DetailPanel({
|
|||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newStaticHeaderValue}
|
||||
onChange={(e) => setNewStaticHeaderValue(e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
onUpdateCustomHeaders([...g.customHeaders, { key, value }]);
|
||||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
onUpdateCustomHeaders([...g.customHeaders, { key, value }]);
|
||||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors shrink-0"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors shrink-0"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
|
|
@ -546,50 +565,54 @@ function DetailPanel({
|
|||
className="flex items-center justify-between gap-2 text-xs font-mono bg-gray-50 border border-gray-200 rounded-sm px-2 py-1.5"
|
||||
>
|
||||
<span className="text-gray-700 truncate">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateExtraHeaders(g.extraHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${name}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateExtraHeaders(g.extraHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${name}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newExtraHeader}
|
||||
onChange={(e) => setNewExtraHeader(e.target.value)}
|
||||
placeholder="e.g. x-request-id"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newExtraHeader}
|
||||
onChange={(e) => setNewExtraHeader(e.target.value)}
|
||||
placeholder="e.g. x-request-id"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const name = newExtraHeader.trim().toLowerCase();
|
||||
if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) {
|
||||
onUpdateExtraHeaders([...g.extraHeaders, name]);
|
||||
setNewExtraHeader("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const name = newExtraHeader.trim().toLowerCase();
|
||||
if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) {
|
||||
onUpdateExtraHeaders([...g.extraHeaders, name]);
|
||||
setNewExtraHeader("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const name = newExtraHeader.trim().toLowerCase();
|
||||
if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) {
|
||||
onUpdateExtraHeaders([...g.extraHeaders, name]);
|
||||
setNewExtraHeader("");
|
||||
}
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
|
|
@ -635,7 +658,7 @@ function DetailPanel({
|
|||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
Test Endpoint
|
||||
</button>
|
||||
{g.status === "pending" && (
|
||||
{isAdmin && g.status === "pending" && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -722,6 +745,8 @@ interface TeamGuardrailsTabProps {
|
|||
}
|
||||
|
||||
export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
||||
const { userRole } = useAuthorized();
|
||||
const isAdmin = userRole ? isProxyAdminRole(userRole) : false;
|
||||
const [guardrails, setGuardrails] = useState<TeamGuardrail[]>([]);
|
||||
const [summary, setSummary] = useState({
|
||||
total: 0,
|
||||
|
|
@ -922,6 +947,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
guardrail={g}
|
||||
isSelected={selectedId === g.id}
|
||||
isHeadersExpanded={expandedHeaders.has(g.id)}
|
||||
isAdmin={isAdmin}
|
||||
onSelect={() => setSelectedId(selectedId === g.id ? null : g.id)}
|
||||
onToggleForwardKey={() => toggleForwardKey(g.id)}
|
||||
onToggleHeaders={() => toggleHeaders(g.id)}
|
||||
|
|
@ -934,6 +960,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
{selected && (
|
||||
<DetailPanel
|
||||
guardrail={selected}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onApprove={() => setConfirmAction({ id: selected.id, action: "approve" })}
|
||||
onReject={() => setConfirmAction({ id: selected.id, action: "reject" })}
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ describe("useAuthorized", () => {
|
|||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
user_role: "proxy_admin",
|
||||
premium_user: true,
|
||||
disabled_non_admin_personal_key_creation: false,
|
||||
login_method: "username_password",
|
||||
|
|
@ -197,7 +197,7 @@ describe("useAuthorized", () => {
|
|||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
user_role: "proxy_admin",
|
||||
premium_user: true,
|
||||
disabled_non_admin_personal_key_creation: false,
|
||||
login_method: "username_password",
|
||||
|
|
@ -256,7 +256,7 @@ describe("useAuthorized", () => {
|
|||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
user_role: "proxy_admin",
|
||||
};
|
||||
|
||||
decodeTokenMock.mockReturnValue(decodedPayload);
|
||||
|
|
|
|||
|
|
@ -106,8 +106,6 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
|||
return "App Owner";
|
||||
case "demo_app_owner":
|
||||
return "App Owner";
|
||||
case "app_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin_viewer":
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ export const formatUserRole = (userRole: string): string => {
|
|||
return "App Owner";
|
||||
case "demo_app_owner":
|
||||
return "App Owner";
|
||||
case "app_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin_viewer":
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue