mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
fix(ui): gate team guardrail admin actions behind proxy admin role
Internal (non-admin) users could see Approve/Reject buttons and the forward-key / header edit controls on the Submitted Guardrails tab even though the backend approve, reject and update endpoints are PROXY_ADMIN only and reject those calls. The controls now render only for a true proxy admin, gated on isProxyAdminRole so Admin Viewer (which the backend also rejects) no longer sees them either. Non-admin team members keep full read visibility: submission status, team, configured headers and the forward-key state (shown as a read-only label instead of a toggle), so they can still see the status of a guardrail they submitted and the guardrails attached to their team. userRole is threaded from GuardrailsPanel into TeamGuardrailsTab to drive the gating
This commit is contained in:
parent
1b2a7ce518
commit
6a248be76f
3 changed files with 238 additions and 100 deletions
|
|
@ -233,7 +233,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
{
|
||||
key: "submitted",
|
||||
label: "Submitted Guardrails",
|
||||
children: <TeamGuardrailsTab accessToken={accessToken} />,
|
||||
children: <TeamGuardrailsTab accessToken={accessToken} userRole={userRole} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TeamGuardrailsTab } from "./TeamGuardrailsTab";
|
||||
import { listGuardrailSubmissions, type GuardrailSubmissionItem } from "@/components/networking";
|
||||
|
||||
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 }),
|
||||
}));
|
||||
|
||||
const pendingSubmission: GuardrailSubmissionItem = {
|
||||
guardrail_id: "gd-1",
|
||||
guardrail_name: "PII Scanner",
|
||||
status: "pending_review",
|
||||
team_id: "team-alpha",
|
||||
team_guardrail: true,
|
||||
litellm_params: {
|
||||
api_base: "https://guard.example.com/scan",
|
||||
forward_api_key: true,
|
||||
headers: [{ key: "X-Api-Key", value: "secret" }],
|
||||
extra_headers: ["x-request-id"],
|
||||
method: "POST",
|
||||
},
|
||||
guardrail_info: { description: "Scans for PII", model: "gpt-4o" },
|
||||
submitted_by_email: "member@example.com",
|
||||
submitted_at: "2026-05-01T00:00:00Z",
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listGuardrailSubmissions).mockResolvedValue({
|
||||
submissions: [pendingSubmission],
|
||||
summary: { total: 1, pending_review: 1, active: 0, rejected: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
async function renderTab(userRole?: string) {
|
||||
render(<TeamGuardrailsTab accessToken="sk-test" userRole={userRole} />);
|
||||
return screen.findByText("PII Scanner");
|
||||
}
|
||||
|
||||
describe("TeamGuardrailsTab role gating", () => {
|
||||
it("shows submission status and team to a non-admin submitter", async () => {
|
||||
const heading = await renderTab("Internal User");
|
||||
const card = heading.closest(".bg-white") as HTMLElement;
|
||||
// Point 1: the submitter can see the status after submitting
|
||||
expect(within(card).getByText("Pending Review")).toBeInTheDocument();
|
||||
// Point 2: the team member can see which team the guardrail is attached to
|
||||
expect(within(card).getByText("Team: team-alpha")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Approve/Reject and the forward-key toggle from a non-admin", async () => {
|
||||
await renderTab("Internal User");
|
||||
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Forward API Key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Approve/Reject from an Admin Viewer (backend rejects them too)", async () => {
|
||||
await renderTab("Admin Viewer");
|
||||
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Approve/Reject and the forward-key toggle to a proxy admin", async () => {
|
||||
await renderTab("Admin");
|
||||
expect(screen.getByRole("button", { name: "Approve" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Forward API Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows header editors in the detail panel only to a proxy admin", async () => {
|
||||
await renderTab("Admin");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
const panel = screen.getByText("Forward LiteLLM API Key").closest("div");
|
||||
expect(panel).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("Header name (e.g. X-API-Key)")).toBeInTheDocument();
|
||||
// Forward-key is an interactive toggle for admins, not a static label
|
||||
expect(screen.queryByText(/^(Enabled|Disabled)$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the detail panel read-only for a non-admin", async () => {
|
||||
await renderTab("Internal User");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
// Configured headers are still visible to read
|
||||
expect(screen.getByText("X-Api-Key: secret")).toBeInTheDocument();
|
||||
expect(screen.getByText("x-request-id")).toBeInTheDocument();
|
||||
// But no editing affordances
|
||||
expect(screen.queryByPlaceholderText("Header name (e.g. X-API-Key)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("e.g. x-request-id")).not.toBeInTheDocument();
|
||||
// Forward-key renders as a read-only state, not a toggle
|
||||
expect(screen.getByText("Enabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,7 @@ 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 { isProxyAdminRole } from "@/utils/roles";
|
||||
|
||||
type GuardrailStatus = "active" | "pending" | "rejected";
|
||||
|
||||
|
|
@ -212,6 +213,7 @@ type GuardrailCardProps = {
|
|||
guardrail: TeamGuardrail;
|
||||
isSelected: boolean;
|
||||
isHeadersExpanded: boolean;
|
||||
isAdmin: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleForwardKey: () => void;
|
||||
onToggleHeaders: () => void;
|
||||
|
|
@ -223,6 +225,7 @@ function GuardrailCard({
|
|||
guardrail: g,
|
||||
isSelected,
|
||||
isHeadersExpanded,
|
||||
isAdmin,
|
||||
onSelect,
|
||||
onToggleForwardKey,
|
||||
onToggleHeaders,
|
||||
|
|
@ -264,10 +267,12 @@ function GuardrailCard({
|
|||
</div>
|
||||
</div>
|
||||
<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} />
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -276,7 +281,7 @@ function GuardrailCard({
|
|||
>
|
||||
{isSelected ? "Close" : "Review"}
|
||||
</button>
|
||||
{g.status === "pending" && (
|
||||
{isAdmin && g.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -348,6 +353,7 @@ function ConfigRow({ label, children }: { label: string; children: React.ReactNo
|
|||
|
||||
type DetailPanelProps = {
|
||||
guardrail: TeamGuardrail;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
|
|
@ -358,6 +364,7 @@ type DetailPanelProps = {
|
|||
|
||||
function DetailPanel({
|
||||
guardrail: g,
|
||||
isAdmin,
|
||||
onClose,
|
||||
onApprove,
|
||||
onReject,
|
||||
|
|
@ -425,7 +432,11 @@ 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} />
|
||||
{isAdmin ? (
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} />
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-blue-800">{g.forwardKey ? "Enabled" : "Disabled"}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 leading-relaxed">
|
||||
When enabled, the caller's LiteLLM API key is forwarded as an{" "}
|
||||
|
|
@ -456,28 +467,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 +531,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 +561,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 +654,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"
|
||||
|
|
@ -719,9 +738,11 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi
|
|||
|
||||
interface TeamGuardrailsTabProps {
|
||||
accessToken: string | null;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
||||
export function TeamGuardrailsTab({ accessToken, userRole }: TeamGuardrailsTabProps) {
|
||||
const isAdmin = userRole ? isProxyAdminRole(userRole) : false;
|
||||
const [guardrails, setGuardrails] = useState<TeamGuardrail[]>([]);
|
||||
const [summary, setSummary] = useState({
|
||||
total: 0,
|
||||
|
|
@ -922,6 +943,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 +956,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" })}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue