feat(ui): save team admin editable fields from their own card with a Save button

Ticking a field only changes the draft. The allow-list is written when the proxy admin clicks Save, and the card sits next to UI Settings instead of inside its auto-saving toggles.
This commit is contained in:
ryan-crabbe-berri 2026-09-16 14:31:02 -07:00
parent 6e2ae19670
commit b92bd98df6
5 changed files with 270 additions and 183 deletions

View file

@ -17,6 +17,7 @@ import SCIMConfig from "@/components/SCIM";
import LoggingSettings from "@/components/Settings/AdminSettings/LoggingSettings/LoggingSettings";
import SSOSettings from "@/components/Settings/AdminSettings/SSOSettings/SSOSettings";
import UISettings from "@/components/Settings/AdminSettings/UISettings/UISettings";
import TeamAdminEditableFieldsSettings from "@/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings";
import UserBannerSettings from "@/components/Settings/AdminSettings/UserBannerSettings/UserBannerSettings";
import CyberArk from "@/components/Settings/AdminSettings/CyberArk/CyberArk";
import HashicorpVault from "@/components/Settings/AdminSettings/HashicorpVault/HashicorpVault";
@ -382,6 +383,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
children: (
<div className="flex flex-col gap-4">
<UISettings />
<TeamAdminEditableFieldsSettings />
<UserBannerSettings />
</div>
),

View file

@ -1,91 +1,175 @@
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen } from "@/../tests/test-utils";
import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
import { toast } from "@/lib/toast";
import TeamAdminEditableFieldsSettings from "./TeamAdminEditableFieldsSettings";
const mockUseUISettings = vi.hoisted(() => vi.fn());
const mockUseUpdateUISettings = vi.hoisted(() => vi.fn());
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "test-token" }),
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: mockUseUISettings,
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings", () => ({
useUpdateUISettings: mockUseUpdateUISettings,
}));
const TPM_LABEL = "Tokens per minute Limit (TPM)";
const mockSettings = (supported: readonly string[], enabled: readonly string[]) =>
mockUseUISettings.mockReturnValue({
isLoading: false,
data: {
field_schema: {
properties: {
team_admin_editable_team_fields: {
description: "Fields a team admin may change",
items: { type: "string", enum: supported },
},
},
},
values: { team_admin_editable_team_fields: enabled },
},
});
const mockSave = ({
isPending = false,
outcome = "success",
}: {
isPending?: boolean;
outcome?: "success" | "error";
}) => {
const mutate = vi.fn((_settings: unknown, options: { onSuccess: () => void; onError: (error: Error) => void }) =>
outcome === "success" ? options.onSuccess() : options.onError(new Error("save failed")),
);
mockUseUpdateUISettings.mockReturnValue({ mutate, isPending });
return mutate;
};
const saveButton = () => screen.getByRole("button", { name: "Save" });
describe("TeamAdminEditableFieldsSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("explains that nothing can be enabled when the proxy supports no fields", () => {
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={[]}
supportedFields={[]}
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
mockSettings([], []);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(screen.getByText("Team admins cannot edit team settings")).toBeInTheDocument();
expect(screen.getByText(/does not support enabling any team settings fields/)).toBeInTheDocument();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
});
it("renders one checkbox per supported field, checked for the enabled ones and named by the field's form label when the dashboard has one", () => {
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={["tpm_limit"]}
supportedFields={["max_budget", "tpm_limit"]}
description="Fields a team admin may change"
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
it("renders one checkbox per supported field, checked for the saved ones, with Save disabled until something changes", () => {
mockSettings(["max_budget", "tpm_limit"], ["tpm_limit"]);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(screen.getByText("Team admin editable fields")).toBeInTheDocument();
expect(screen.getByText("1 field enabled")).toBeInTheDocument();
expect(screen.getByText("Fields a team admin may change")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "max_budget" })).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).toBeChecked();
expect(saveButton()).toBeDisabled();
});
it("saves the list with the field added when an unchecked field is ticked", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={["tpm_limit"]}
supportedFields={["max_budget", "tpm_limit"]}
isUpdating={false}
onUpdate={onUpdate}
/>,
it("only saves a ticked field once Save is clicked", async () => {
mockSettings(["max_budget", "tpm_limit"], ["tpm_limit"]);
const mutate = mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: "max_budget" }));
expect(screen.getByRole("checkbox", { name: "max_budget" })).toBeChecked();
expect(mutate).not.toHaveBeenCalled();
fireEvent.click(saveButton());
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Team admin editable fields updated successfully"));
expect(mutate).toHaveBeenCalledWith(
{ team_admin_editable_team_fields: ["max_budget", "tpm_limit"] },
expect.anything(),
);
await user.click(screen.getByRole("checkbox", { name: "max_budget" }));
expect(onUpdate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["tpm_limit", "max_budget"] });
expect(saveButton()).toBeDisabled();
});
it("saves the list with the field removed when a checked field is unticked", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={["max_budget", "tpm_limit"]}
supportedFields={["max_budget", "tpm_limit"]}
isUpdating={false}
onUpdate={onUpdate}
/>,
);
it("saves the list without an unticked field", async () => {
mockSettings(["max_budget", "tpm_limit"], ["max_budget", "tpm_limit"]);
const mutate = mockSave({});
await user.click(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" }));
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
fireEvent.click(saveButton());
expect(onUpdate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["max_budget"] });
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["max_budget"] }, expect.anything());
});
it("blocks toggling while a save is in flight", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={[]}
supportedFields={["tpm_limit"]}
isUpdating={true}
onUpdate={onUpdate}
/>,
);
it("disables Save again when the draft is ticked back to the saved list", () => {
mockSettings(["tpm_limit"], []);
mockSave({});
await user.click(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" }));
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
expect(onUpdate).not.toHaveBeenCalled();
expect(saveButton()).toBeEnabled();
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).not.toBeChecked();
expect(saveButton()).toBeDisabled();
});
it("treats a saved list in another order, or with fields this proxy dropped, as the same selection", () => {
mockSettings(["max_budget", "tpm_limit"], ["tpm_limit", "retired_field", "max_budget"]);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(screen.getByText("2 fields enabled")).toBeInTheDocument();
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
expect(saveButton()).toBeDisabled();
});
it("keeps the draft and shows the error when the save fails", async () => {
mockSettings(["tpm_limit"], []);
const mutate = mockSave({ outcome: "error" });
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
fireEvent.click(saveButton());
await waitFor(() => expect(toast.fromError).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledTimes(1);
expect(toast.success).not.toHaveBeenCalled();
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).toBeChecked();
expect(saveButton()).toBeEnabled();
});
it("blocks ticking and saving while a save is in flight", () => {
mockSettings(["tpm_limit"], []);
const mutate = mockSave({ isPending: true });
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).not.toBeChecked();
expect(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
expect(mutate).not.toHaveBeenCalled();
});
});

View file

@ -1,65 +1,136 @@
"use client";
import { Controller } from "react-hook-form";
import { z } from "zod/v4";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
teamAdminFieldLabel,
} from "@/components/team/teamAdminEditAccess";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { teamAdminFieldLabel } from "@/components/team/teamAdminEditAccess";
import { Skeleton } from "@/components/ui/skeleton";
import { useZodForm } from "@/lib/forms/useZodForm";
import { toast } from "@/lib/toast";
interface TeamAdminEditableFieldsSettingsProps {
editableFields: readonly string[];
supportedFields: readonly string[];
description?: string;
isUpdating: boolean;
onUpdate: (settings: { team_admin_editable_team_fields: string[] }) => void;
}
const editableFieldsSchema = z.object({ team_admin_editable_team_fields: z.array(z.string()) });
export default function TeamAdminEditableFieldsSettings({
editableFields,
supportedFields,
description,
isUpdating,
onUpdate,
}: TeamAdminEditableFieldsSettingsProps) {
const toggleField = (field: string, checked: boolean) => {
const next = checked ? [...editableFields, field] : editableFields.filter((item) => item !== field);
onUpdate({ team_admin_editable_team_fields: next });
};
type SaveEditableFields = ReturnType<typeof useUpdateUISettings>["mutate"];
export default function TeamAdminEditableFieldsSettings() {
const { accessToken } = useAuthorized();
const { data, isLoading } = useUISettings();
const { mutate: saveSettings, isPending } = useUpdateUISettings(accessToken);
const supportedFields = parseSupportedTeamAdminEditableFields(data?.field_schema);
const savedFields = parseTeamAdminEditableFields(data?.values);
const enabledFields = supportedFields.filter((field) => savedFields.includes(field));
return (
<div className="space-y-4">
<div className="space-y-1">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">Team admin editable fields</p>
<Badge variant={editableFields.length > 0 ? "secondary" : "outline"}>
{editableFields.length > 0
? `${editableFields.length} field${editableFields.length !== 1 ? "s" : ""} enabled`
<CardTitle>Team admin editable fields</CardTitle>
<Badge variant={enabledFields.length > 0 ? "secondary" : "outline"}>
{enabledFields.length > 0
? `${enabledFields.length} field${enabledFields.length !== 1 ? "s" : ""} enabled`
: "Team admins cannot edit team settings"}
</Badge>
</div>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{supportedFields.length === 0 ? (
<p className="text-xs italic text-muted-foreground">
This proxy version does not support enabling any team settings fields for team admins yet.
</p>
) : (
<div className="ml-4 space-y-2">
{supportedFields.map((field) => {
const checkboxId = `team-admin-editable-${field}`;
return (
<label key={field} htmlFor={checkboxId} className="flex cursor-pointer items-center gap-2">
<Checkbox
id={checkboxId}
checked={editableFields.includes(field)}
disabled={isUpdating}
onCheckedChange={(checked) => toggleField(field, checked === true)}
/>
<span className="text-sm text-foreground">{teamAdminFieldLabel(field)}</span>
</label>
);
})}
</div>
)}
</div>
<CardDescription>
{data?.field_schema?.properties?.team_admin_editable_team_fields?.description ??
"Team settings fields a team admin may change on the teams they administer."}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-16 w-full" />
) : (
<TeamAdminEditableFieldsForm
key={enabledFields.join(",")}
enabledFields={enabledFields}
supportedFields={supportedFields}
isPending={isPending}
saveSettings={saveSettings}
/>
)}
</CardContent>
</Card>
);
}
interface TeamAdminEditableFieldsFormProps {
enabledFields: readonly string[];
supportedFields: readonly string[];
isPending: boolean;
saveSettings: SaveEditableFields;
}
function TeamAdminEditableFieldsForm({
enabledFields,
supportedFields,
isPending,
saveSettings,
}: TeamAdminEditableFieldsFormProps) {
const form = useZodForm(editableFieldsSchema, {
defaultValues: { team_admin_editable_team_fields: [...enabledFields] },
});
const submit = form.handleSubmit((values) =>
saveSettings(values, {
onSuccess: () => {
form.reset(values);
toast.success("Team admin editable fields updated successfully");
},
onError: (error) => {
toast.fromError(error);
},
}),
);
if (supportedFields.length === 0) {
return (
<p className="text-sm italic text-muted-foreground">
This proxy version does not support enabling any team settings fields for team admins yet.
</p>
);
}
return (
<form onSubmit={(event) => void submit(event)} className="space-y-4">
<Controller
control={form.control}
name="team_admin_editable_team_fields"
render={({ field }) => (
<div className="space-y-2">
{supportedFields.map((name) => {
const checkboxId = `team-admin-editable-${name}`;
return (
<label key={name} htmlFor={checkboxId} className="flex cursor-pointer items-center gap-2">
<Checkbox
id={checkboxId}
checked={field.value.includes(name)}
disabled={isPending}
onCheckedChange={(checked) =>
field.onChange(
supportedFields.filter((item) => (item === name ? checked : field.value.includes(item))),
)
}
/>
<span className="text-sm text-foreground">{teamAdminFieldLabel(name)}</span>
</label>
);
})}
</div>
)}
/>
<Button type="submit" disabled={isPending || !form.formState.isDirty}>
{isPending ? "Saving..." : "Save"}
</Button>
</form>
);
}

View file

@ -155,48 +155,4 @@ describe("UISettings", () => {
);
expect(toast.success).toHaveBeenCalledWith("UI settings updated successfully");
});
it("saves the team admin editable field list when a supported field is ticked", () => {
const mutateMock = vi.fn((_settings, options) => {
options?.onSuccess?.();
});
mockUseUpdateUISettings.mockReturnValue({
mutate: mutateMock,
isPending: false,
error: null,
});
mockUseUISettings.mockReturnValue(
buildSettingsResponse({
data: {
field_schema: {
properties: {
team_admin_editable_team_fields: {
description: "Team settings fields a team admin may change",
type: "array",
items: { type: "string", enum: ["tpm_limit"] },
},
},
},
values: { team_admin_editable_team_fields: [] },
},
}),
);
render(<UISettings />);
expect(screen.getByText("Team settings fields a team admin may change")).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" }));
});
expect(mutateMock).toHaveBeenCalledWith(
{ team_admin_editable_team_fields: ["tpm_limit"] },
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
expect(toast.success).toHaveBeenCalledWith("Team admin editable fields updated successfully");
});
});

View file

@ -9,12 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
} from "@/components/team/teamAdminEditAccess";
import PageVisibilitySettings from "./PageVisibilitySettings";
import TeamAdminEditableFieldsSettings from "./TeamAdminEditableFieldsSettings";
interface SettingRowProps {
ariaLabel: string;
@ -70,7 +65,6 @@ export default function UISettings() {
const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins;
const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org;
const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys;
const teamAdminEditableFieldsProperty = schema?.properties?.team_admin_editable_team_fields;
const values = data?.values ?? {};
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
@ -116,17 +110,6 @@ export default function UISettings() {
});
};
const handleUpdateTeamAdminEditableFields = (settings: { team_admin_editable_team_fields: string[] }) => {
updateSettings(settings, {
onSuccess: () => {
toast.success("Team admin editable fields updated successfully");
},
onError: (error) => {
toast.fromError(error);
},
});
};
const handleToggleForwardClientHeaders = (checked: boolean) => {
updateSettings(
{ forward_client_headers_to_llm_api: checked },
@ -456,15 +439,6 @@ export default function UISettings() {
isUpdating={isUpdating}
onUpdate={handleUpdatePageVisibility}
/>
<Separator />
<TeamAdminEditableFieldsSettings
editableFields={parseTeamAdminEditableFields(values)}
supportedFields={parseSupportedTeamAdminEditableFields(schema)}
description={teamAdminEditableFieldsProperty?.description}
isUpdating={isUpdating}
onUpdate={handleUpdateTeamAdminEditableFields}
/>
</div>
)}
</CardContent>