feat(ui): rebuild Default Team Settings on react-hook-form + zod

Replaces the hand-rolled TeamSSOSettings component (useState mirroring,
no validation, no tests) with a shadcn + react-hook-form + zod form
colocated at teams/_components/default-team-settings, following the
default-user-settings pattern.

The permission checklist is now derived from the generated
KeyManagementRoutes union via an exhaustive record, so enum drift is a
compile error; the old hardcoded list had drifted (missing
/team/key/bulk_update, /key/access_group_assignment, /spend/logs,
/spend/logs/v2). Budget duration options move to a shared module reused
by the default-user-settings form. Dead networking wrappers removed.
This commit is contained in:
ryan-crabbe-berri 2026-07-28 12:44:50 -07:00
parent b930e2fc2b
commit a2be3986b2
14 changed files with 871 additions and 831 deletions

View file

@ -0,0 +1,243 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/ModelSelect/ModelSelect", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/ModelSelect/ModelSelect")>();
return {
MODEL_SENTINEL_OPTIONS: actual.MODEL_SENTINEL_OPTIONS,
ModelSelect: ({ onChange }: { onChange: (values: string[]) => void }) => (
<button type="button" onClick={() => onChange(["all-proxy-models"])}>
set-models
</button>
),
};
});
import NotificationsManager from "@/components/molecules/notifications_manager";
import { DefaultTeamSettingsForm } from "./DefaultTeamSettingsForm";
import type { DefaultTeamSettings } from "./mapper";
const SETTINGS: DefaultTeamSettings = {
values: {
max_budget: 100,
budget_duration: "30d",
tpm_limit: 1000,
rpm_limit: 50,
models: ["gpt-5.2"],
team_member_permissions: ["/key/generate"],
},
field_schema: {},
};
const SAVED_BODY = {
max_budget: 100,
budget_duration: "30d",
tpm_limit: 1000,
rpm_limit: 50,
models: ["gpt-5.2"],
team_member_permissions: ["/key/generate"],
};
const renderForm = (overrides?: {
fetchSettings?: ReturnType<typeof vi.fn>;
updateSettings?: ReturnType<typeof vi.fn>;
}) => {
const fetchSettings = overrides?.fetchSettings ?? vi.fn().mockResolvedValue(SETTINGS);
const updateSettings = overrides?.updateSettings ?? vi.fn().mockResolvedValue(undefined);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<DefaultTeamSettingsForm fetchSettings={fetchSettings} updateSettings={updateSettings} />
</QueryClientProvider>,
);
return { fetchSettings, updateSettings };
};
const saveButton = async () => await screen.findByRole("button", { name: "Save Changes" });
const enterEditMode = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(await screen.findByRole("button", { name: "Edit Settings" }));
};
describe("DefaultTeamSettingsForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a read-only summary until Edit Settings is clicked", async () => {
renderForm();
expect(await screen.findByText("100")).toBeInTheDocument();
expect(screen.getByText("monthly")).toBeInTheDocument();
expect(screen.getByText("1000")).toBeInTheDocument();
expect(screen.getByText("50")).toBeInTheDocument();
expect(screen.getByText("gpt-5.2")).toBeInTheDocument();
expect(screen.getByText("/key/generate")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Max Budget (USD)")).not.toBeInTheDocument();
});
it("labels model sentinels in the read-only summary", async () => {
renderForm({
fetchSettings: vi
.fn()
.mockResolvedValue({ ...SETTINGS, values: { ...SETTINGS.values, models: ["all-proxy-models"] } }),
});
expect(await screen.findByText("All Proxy Models")).toBeInTheDocument();
});
it("disables Save until the loaded settings are edited", async () => {
const user = userEvent.setup();
renderForm();
await enterEditMode(user);
expect(await saveButton()).toBeDisabled();
});
it("shows an error instead of the form when the settings cannot be loaded", async () => {
renderForm({ fetchSettings: vi.fn().mockRejectedValue(new Error("nope")) });
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load the default team settings.");
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
});
it("sends every field on save, not only the edited one", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.type(screen.getByLabelText("Max Budget (USD)"), "250");
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, max_budget: 250 });
});
it("clears an emptied budget with null", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, max_budget: null });
});
it("rejects a fractional TPM limit instead of saving it", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
fireEvent.change(await screen.findByLabelText("TPM Limit"), { target: { value: "12.5" } });
await user.click(await saveButton());
expect(await screen.findByText("Must be a non-negative whole number")).toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
it("sends the models selection through unchanged, sentinel values included", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.click(await screen.findByRole("button", { name: "set-models" }));
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, models: ["all-proxy-models"] });
});
it("saves a newly granted permission", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.click(await screen.findByRole("checkbox", { name: "/key/delete" }));
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({
...SAVED_BODY,
team_member_permissions: ["/key/generate", "/key/delete"],
});
});
it("clears the permissions with null when the last one is revoked", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.click(await screen.findByRole("checkbox", { name: "/key/generate" }));
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, team_member_permissions: null });
});
it("returns to the read-only view showing the new values after a successful save", async () => {
const user = userEvent.setup();
const updated = { ...SETTINGS, values: { ...SETTINGS.values, max_budget: 250 } };
const { updateSettings } = renderForm({
fetchSettings: vi.fn().mockResolvedValueOnce(SETTINGS).mockResolvedValue(updated),
});
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.type(screen.getByLabelText("Max Budget (USD)"), "250");
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(await screen.findByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(await screen.findByText("250")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
expect(NotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully");
});
it("keeps the edit and surfaces the backend error when the save fails", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm({
updateSettings: vi.fn().mockRejectedValue(new Error("Set `'STORE_MODEL_IN_DB='True'` in your env.")),
});
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.type(screen.getByLabelText("Max Budget (USD)"), "250");
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Set `'STORE_MODEL_IN_DB='True'` in your env."),
);
expect(await saveButton()).toBeEnabled();
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(250);
});
it("discards edits and returns to the read-only view when Cancel is pressed", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.type(screen.getByLabelText("Max Budget (USD)"), "250");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(screen.getByText("100")).toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
await enterEditMode(user);
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(100);
expect(await saveButton()).toBeDisabled();
});
});

View file

@ -0,0 +1,325 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { type Control } from "react-hook-form";
import { ModelSelect, MODEL_SENTINEL_OPTIONS } from "@/components/ModelSelect/ModelSelect";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { BUDGET_DURATION_OPTIONS, NO_RESET } from "@/components/shared/form/budgetDuration";
import { getPermissionInfo } from "@/components/team/permission_definitions";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useZodForm } from "@/lib/forms/useZodForm";
import { fetchClient } from "@/lib/http/api";
import { buildBody, settingsToForm, type DefaultTeamParams, type DefaultTeamSettings } from "./mapper";
import { SELECTABLE_PERMISSIONS, type KeyManagementRoute } from "./permissions";
import { defaultTeamSettingsSchema, type DefaultTeamSettingsFormValues } from "./schema";
const MODEL_SENTINEL_LABELS: ReadonlyMap<string, string> = new Map(
MODEL_SENTINEL_OPTIONS.map(({ value, label }) => [value, label]),
);
const SETTINGS_QUERY_KEY = ["defaultTeamSettings"] as const;
const PERMISSIONS_DESCRIPTION =
"Default permissions granted to members of newly created teams. /key/info and /key/health are always included.";
const defaultFetchSettings = async (): Promise<DefaultTeamSettings> => {
const { data } = await fetchClient.GET("/get/default_team_settings");
if (data === undefined) {
throw new Error("Failed to load default team settings");
}
return data;
};
const defaultUpdateSettings = async (body: DefaultTeamParams): Promise<void> => {
await fetchClient.PATCH("/update/default_team_settings", { body });
};
type SettingsControl = Control<DefaultTeamSettingsFormValues, unknown, DefaultTeamSettingsFormValues>;
const PermissionRow = ({
route,
granted,
onToggle,
}: {
route: KeyManagementRoute;
granted: boolean;
onToggle: (checked: boolean) => void;
}) => {
const reactId = React.useId();
const labelId = `${reactId}-label`;
const descriptionId = `${reactId}-description`;
return (
<label className="flex cursor-pointer items-start gap-3">
<Checkbox
aria-labelledby={labelId}
aria-describedby={descriptionId}
className="mt-0.5"
checked={granted}
onCheckedChange={onToggle}
/>
<span className="flex flex-col">
<span id={labelId} className="font-mono text-sm">
{route}
</span>
<span id={descriptionId} className="text-xs text-muted-foreground">
{getPermissionInfo(route).description}
</span>
</span>
</label>
);
};
const PermissionsField = ({ control }: { control: SettingsControl }) => (
<FormField
control={control}
name="team_member_permissions"
label="Team Member Permissions"
description={PERMISSIONS_DESCRIPTION}
>
{({ id, value, onChange }) => (
<div id={id} className="flex flex-col gap-3">
{SELECTABLE_PERMISSIONS.map((route) => (
<PermissionRow
key={route}
route={route}
granted={value.includes(route)}
onToggle={(checked) =>
onChange(checked ? [...value, route] : value.filter((existing) => existing !== route))
}
/>
))}
</div>
)}
</FormField>
);
const ViewRow = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground">{children}</p>
</div>
);
const SettingsView = ({ values }: { values: DefaultTeamSettingsFormValues }) => {
const durationValue = values.budget_duration === "" ? NO_RESET : values.budget_duration;
const durationLabel =
BUDGET_DURATION_OPTIONS.find((option) => option.value === durationValue)?.label ?? values.budget_duration;
return (
<div className="flex flex-col gap-4">
<ViewRow label="Max Budget (USD)">{values.max_budget === "" ? "Not set" : values.max_budget}</ViewRow>
<ViewRow label="Reset Budget">{durationLabel}</ViewRow>
<ViewRow label="TPM Limit">{values.tpm_limit === "" ? "Not set" : values.tpm_limit}</ViewRow>
<ViewRow label="RPM Limit">{values.rpm_limit === "" ? "Not set" : values.rpm_limit}</ViewRow>
<ViewRow label="Default Models">
{values.models.length === 0
? "Not set"
: values.models.map((model) => MODEL_SENTINEL_LABELS.get(model) ?? model).join(", ")}
</ViewRow>
<ViewRow label="Team Member Permissions">
{values.team_member_permissions.length === 0 ? "Not set" : values.team_member_permissions.join(", ")}
</ViewRow>
</div>
);
};
interface SettingsFormProps {
initialValues: DefaultTeamSettingsFormValues;
updateSettings: (body: DefaultTeamParams) => Promise<void>;
onCancel: () => void;
onSaved: () => void;
}
const SettingsForm = ({ initialValues, updateSettings, onCancel, onSaved }: SettingsFormProps) => {
const queryClient = useQueryClient();
const form = useZodForm(defaultTeamSettingsSchema, { defaultValues: initialValues });
const { isDirty } = form.formState;
const mutation = useMutation({
mutationFn: (values: DefaultTeamSettingsFormValues) => updateSettings(buildBody(values)),
onSuccess: (_result, values) => {
NotificationsManager.success("Default team settings updated successfully");
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY });
form.reset(values);
onSaved();
},
onError: (error: unknown) =>
NotificationsManager.fromBackend(
error instanceof Error ? error.message : "Failed to update default team settings",
),
});
const onSubmit = form.handleSubmit((values) => mutation.mutate(values));
return (
<form onSubmit={onSubmit} noValidate>
<FieldGroup>
<FormField
control={form.control}
name="max_budget"
label="Max Budget (USD)"
description="Default maximum budget for new teams"
>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={0.01} min={0} />}
</FormField>
<FormField
control={form.control}
name="budget_duration"
label="Reset Budget"
description="How often the default budget resets"
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={BUDGET_DURATION_OPTIONS}
value={value === "" ? NO_RESET : value}
onValueChange={(selected) => onChange(selected === null || selected === NO_RESET ? "" : selected)}
>
<SelectTrigger id={id} className="w-full" aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BUDGET_DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
<FormField
control={form.control}
name="tpm_limit"
label="TPM Limit"
description="Default tokens-per-minute limit for new teams"
>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
</FormField>
<FormField
control={form.control}
name="rpm_limit"
label="RPM Limit"
description="Default requests-per-minute limit for new teams"
>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
</FormField>
<FormField
control={form.control}
name="models"
label="Default Models"
description="Models new teams can access"
>
{(field) => (
<ModelSelect
value={field.value}
onChange={field.onChange}
context="global"
options={{ includeSpecialOptions: true }}
/>
)}
</FormField>
<PermissionsField control={form.control} />
</FieldGroup>
<div className="mt-6 flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset(initialValues);
onCancel();
}}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={!isDirty || mutation.isPending}>
{mutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
);
};
const SettingsCard = ({ action, children }: { action?: React.ReactNode; children: React.ReactNode }) => (
<Card>
<CardHeader>
<CardTitle>Default Team Settings</CardTitle>
<CardDescription>
Applied to every new team automatically created by LiteLLM, such as teams created from SSO groups.
</CardDescription>
{action !== undefined && <CardAction>{action}</CardAction>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
export interface DefaultTeamSettingsFormProps {
fetchSettings?: () => Promise<DefaultTeamSettings>;
updateSettings?: (body: DefaultTeamParams) => Promise<void>;
}
export const DefaultTeamSettingsForm = ({
fetchSettings = defaultFetchSettings,
updateSettings = defaultUpdateSettings,
}: DefaultTeamSettingsFormProps) => {
const [isEditing, setIsEditing] = React.useState(false);
const { data, isPending, isError } = useQuery({ queryKey: SETTINGS_QUERY_KEY, queryFn: fetchSettings });
const initialValues = React.useMemo(() => (data === undefined ? undefined : settingsToForm(data.values)), [data]);
if (isPending) {
return (
<SettingsCard>
<Skeleton className="h-64 w-full" />
</SettingsCard>
);
}
if (isError || initialValues === undefined) {
return (
<SettingsCard>
<p role="alert">Could not load the default team settings.</p>
</SettingsCard>
);
}
return (
<SettingsCard
action={
isEditing ? undefined : (
<Button type="button" onClick={() => setIsEditing(true)}>
Edit Settings
</Button>
)
}
>
{isEditing ? (
<SettingsForm
initialValues={initialValues}
updateSettings={updateSettings}
onCancel={() => setIsEditing(false)}
onSaved={() => setIsEditing(false)}
/>
) : (
<SettingsView values={initialValues} />
)}
</SettingsCard>
);
};

View file

@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { buildBody, settingsToForm } from "./mapper";
import type { DefaultTeamSettingsFormValues } from "./schema";
const CONFIGURED_SETTINGS = {
max_budget: 100.5,
budget_duration: "30d",
tpm_limit: 1000,
rpm_limit: 50,
models: ["gpt-5.2"],
team_member_permissions: ["/key/generate", "/key/update"],
};
const UNCONFIGURED_SETTINGS = {
max_budget: null,
budget_duration: null,
tpm_limit: null,
rpm_limit: null,
models: null,
team_member_permissions: null,
};
const CONFIGURED_FORM = {
max_budget: "100.5",
budget_duration: "30d",
tpm_limit: "1000",
rpm_limit: "50",
models: ["gpt-5.2"],
team_member_permissions: ["/key/generate", "/key/update"],
};
const UNCONFIGURED_FORM = {
max_budget: "",
budget_duration: "",
tpm_limit: "",
rpm_limit: "",
models: [],
team_member_permissions: [],
};
describe("settingsToForm", () => {
it("maps a fully populated settings blob onto widget-space strings", () => {
expect(settingsToForm(CONFIGURED_SETTINGS)).toStrictEqual(CONFIGURED_FORM);
});
it("maps an unconfigured settings blob onto empty widget state", () => {
expect(settingsToForm(UNCONFIGURED_SETTINGS)).toStrictEqual(UNCONFIGURED_FORM);
});
it("maps an empty settings blob onto empty widget state", () => {
expect(settingsToForm({})).toStrictEqual(UNCONFIGURED_FORM);
});
it("drops stored permissions the form does not offer instead of breaking the save", () => {
const hydrated = settingsToForm({
team_member_permissions: ["/key/generate", "/key/health", "/made/up/route"],
});
expect(hydrated.team_member_permissions).toStrictEqual(["/key/generate"]);
});
it("degrades unrecognisable value types to empty widget state instead of throwing", () => {
expect(
settingsToForm({ max_budget: "lots", tpm_limit: [], models: "gpt-5.2", team_member_permissions: 7 }),
).toStrictEqual(UNCONFIGURED_FORM);
});
});
const formValues = (overrides: Partial<DefaultTeamSettingsFormValues> = {}): DefaultTeamSettingsFormValues => ({
max_budget: "100",
budget_duration: "30d",
tpm_limit: "1000",
rpm_limit: "50",
models: ["gpt-5.2"],
team_member_permissions: ["/key/generate"],
...overrides,
});
const SAVED_BODY = {
max_budget: 100,
budget_duration: "30d",
tpm_limit: 1000,
rpm_limit: 50,
models: ["gpt-5.2"],
team_member_permissions: ["/key/generate"],
};
describe("buildBody", () => {
it("sends every field, because the endpoint replaces the whole settings object", () => {
expect(buildBody(formValues())).toStrictEqual(SAVED_BODY);
});
it("clears emptied fields with null but keeps models a list, because that backend field is non-nullable", () => {
expect(
buildBody(
formValues({
max_budget: "",
budget_duration: "",
tpm_limit: "",
rpm_limit: "",
models: [],
team_member_permissions: [],
}),
),
).toStrictEqual({
max_budget: null,
budget_duration: null,
tpm_limit: null,
rpm_limit: null,
models: [],
team_member_permissions: null,
});
});
});

View file

@ -0,0 +1,48 @@
import { z } from "zod/v4";
import type { components } from "@/lib/http/schema";
import { isSelectablePermission } from "./permissions";
import type { DefaultTeamSettingsFormValues } from "./schema";
export type DefaultTeamSettings = components["schemas"]["DefaultTeamSettingsResponse"];
export type DefaultTeamParams = components["schemas"]["DefaultTeamSSOParams"];
const serverValuesShape = {
max_budget: z.number().nullish().catch(null),
budget_duration: z.string().nullish().catch(null),
tpm_limit: z.number().nullish().catch(null),
rpm_limit: z.number().nullish().catch(null),
models: z.array(z.string()).nullish().catch(null),
team_member_permissions: z.array(z.string()).nullish().catch(null),
};
const serverValuesSchema = z.object(serverValuesShape);
export const settingsToForm = (values: DefaultTeamSettings["values"]): DefaultTeamSettingsFormValues => {
const parsed = serverValuesSchema.parse(values);
return {
max_budget: parsed.max_budget?.toString() ?? "",
budget_duration: parsed.budget_duration ?? "",
tpm_limit: parsed.tpm_limit?.toString() ?? "",
rpm_limit: parsed.rpm_limit?.toString() ?? "",
models: parsed.models ?? [],
team_member_permissions: (parsed.team_member_permissions ?? []).filter(isSelectablePermission),
};
};
const numberOrNull = (raw: string): number | null => (raw.trim() === "" ? null : Number(raw));
const textOrNull = (raw: string): string | null => (raw.trim() === "" ? null : raw);
const listOrNull = <T>(items: readonly T[]): T[] | null => (items.length === 0 ? null : [...items]);
export const buildBody = (values: DefaultTeamSettingsFormValues): DefaultTeamParams => ({
max_budget: numberOrNull(values.max_budget),
budget_duration: textOrNull(values.budget_duration),
tpm_limit: numberOrNull(values.tpm_limit),
rpm_limit: numberOrNull(values.rpm_limit),
models: [...values.models],
team_member_permissions: listOrNull(values.team_member_permissions),
});

View file

@ -0,0 +1,32 @@
import type { components } from "@/lib/http/schema";
export type KeyManagementRoute = components["schemas"]["KeyManagementRoutes"];
const PERMISSION_IS_SELECTABLE: Record<KeyManagementRoute, boolean> = {
"/key/generate": true,
"/key/update": true,
"/key/delete": true,
"/key/regenerate": true,
"/key/{key_id}/regenerate": true,
"/key/service-account/generate": true,
"/key/block": true,
"/key/unblock": true,
"/key/bulk_update": true,
"/team/key/bulk_update": true,
"/key/{key_id}/reset_spend": true,
"/key/access_group_assignment": true,
"/key/info": true,
"/key/list": true,
"/key/aliases": true,
"/team/daily/activity": true,
"/spend/logs": true,
"/spend/logs/v2": true,
"/key/health": false,
};
export const SELECTABLE_PERMISSIONS: readonly KeyManagementRoute[] = (
Object.keys(PERMISSION_IS_SELECTABLE) as KeyManagementRoute[]
).filter((route) => PERMISSION_IS_SELECTABLE[route]);
export const isSelectablePermission = (value: string): value is KeyManagementRoute =>
(SELECTABLE_PERMISSIONS as readonly string[]).includes(value);

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { defaultTeamSettingsSchema, type DefaultTeamSettingsFormValues } from "./schema";
const values = (overrides: Partial<DefaultTeamSettingsFormValues> = {}): DefaultTeamSettingsFormValues => ({
max_budget: "",
budget_duration: "",
tpm_limit: "",
rpm_limit: "",
models: [],
team_member_permissions: [],
...overrides,
});
const issuesFor = (input: DefaultTeamSettingsFormValues) => {
const result = defaultTeamSettingsSchema.safeParse(input);
return result.success ? [] : result.error.issues.map((issue) => ({ path: issue.path, message: issue.message }));
};
describe("defaultTeamSettingsSchema", () => {
it("accepts fully blank settings", () => {
expect(defaultTeamSettingsSchema.safeParse(values()).success).toBe(true);
});
it("accepts a decimal budget and whole-number rate limits", () => {
const input = values({
max_budget: "100.5",
tpm_limit: "1000",
rpm_limit: "50",
models: ["gpt-5.2"],
team_member_permissions: ["/key/generate"],
});
expect(defaultTeamSettingsSchema.safeParse(input).success).toBe(true);
});
it("rejects a non-numeric or negative budget", () => {
expect(issuesFor(values({ max_budget: "lots" }))).toStrictEqual([
{ path: ["max_budget"], message: "Must be a non-negative number" },
]);
expect(issuesFor(values({ max_budget: "-5" }))).toStrictEqual([
{ path: ["max_budget"], message: "Must be a non-negative number" },
]);
});
it("rejects fractional rate limits", () => {
expect(issuesFor(values({ tpm_limit: "12.5" }))).toStrictEqual([
{ path: ["tpm_limit"], message: "Must be a non-negative whole number" },
]);
expect(issuesFor(values({ rpm_limit: "0.5" }))).toStrictEqual([
{ path: ["rpm_limit"], message: "Must be a non-negative whole number" },
]);
});
it("rejects a permission that is not offered to teams, even when the backend enum knows it", () => {
expect(issuesFor(values({ team_member_permissions: ["/key/health"] }))).toStrictEqual([
{ path: ["team_member_permissions", 0], message: "Unknown permission" },
]);
});
});

View file

@ -0,0 +1,35 @@
import { z } from "zod/v4";
import { isSelectablePermission, type KeyManagementRoute } from "./permissions";
const isBlank = (value: string): boolean => value.trim() === "";
const amountOrEmpty = z
.string()
.refine(
(value) => isBlank(value) || (Number.isFinite(Number(value)) && Number(value) >= 0),
"Must be a non-negative number",
);
const wholeNumberOrEmpty = z
.string()
.refine(
(value) => isBlank(value) || (Number.isInteger(Number(value)) && Number(value) >= 0),
"Must be a non-negative whole number",
);
const selectablePermission = z.custom<KeyManagementRoute>(
(value) => typeof value === "string" && isSelectablePermission(value),
"Unknown permission",
);
export const defaultTeamSettingsSchema = z.object({
max_budget: amountOrEmpty,
budget_duration: z.string(),
tpm_limit: wholeNumberOrEmpty,
rpm_limit: wholeNumberOrEmpty,
models: z.array(z.string()),
team_member_permissions: z.array(selectablePermission),
});
export type DefaultTeamSettingsFormValues = z.output<typeof defaultTeamSettingsSchema>;

View file

@ -8,6 +8,7 @@ import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { ModelSelect, MODEL_SENTINEL_OPTIONS } from "@/components/ModelSelect/ModelSelect";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { BUDGET_DURATION_OPTIONS, NO_RESET } from "@/components/shared/form/budgetDuration";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
@ -22,16 +23,6 @@ import { fetchClient } from "@/lib/http/api";
import { buildBody, settingsToForm, type DefaultInternalUserParams, type InternalUserSettings } from "./mapper";
import { defaultUserSettingsSchema, EMPTY_TEAM_ROW, type DefaultUserSettingsFormValues } from "./schema";
const NO_RESET = "never";
const BUDGET_DURATION_OPTIONS = [
{ value: NO_RESET, label: "No reset" },
{ value: "1h", label: "hourly" },
{ value: "24h", label: "daily" },
{ value: "7d", label: "weekly" },
{ value: "30d", label: "monthly" },
] as const;
const TEAM_ROLE_OPTIONS = [
{ value: "user", label: "User" },
{ value: "admin", label: "Admin" },

View file

@ -1,474 +0,0 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../tests/test-utils";
import TeamSSOSettings from "./TeamSSOSettings";
import * as networking from "./networking";
import NotificationsManager from "./molecules/notifications_manager";
vi.mock("./networking");
vi.mock("./common_components/budget_duration_dropdown", () => {
const BudgetDurationDropdown = ({ value, onChange }: { value: string | null; onChange: (value: string) => void }) => (
<select
data-testid="budget-duration-dropdown"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
aria-label="Budget duration"
>
<option value="">Select duration</option>
<option value="24h">Daily</option>
<option value="7d">Weekly</option>
<option value="30d">Monthly</option>
</select>
);
BudgetDurationDropdown.displayName = "BudgetDurationDropdown";
return {
default: BudgetDurationDropdown,
getBudgetDurationLabel: vi.fn((value: string) => {
const map: Record<string, string> = { "24h": "daily", "7d": "weekly", "30d": "monthly" };
return map[value] || value;
}),
};
});
vi.mock("./key_team_helpers/fetch_available_models_team_key", () => ({
getModelDisplayName: vi.fn((model: string) => model),
}));
vi.mock("./ModelSelect/ModelSelect", () => {
const ModelSelect = ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => (
<select
data-testid="model-select"
multiple
value={value || []}
onChange={(e) => {
const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value);
onChange(selectedValues);
}}
aria-label="Models"
>
<option value="gpt-4">gpt-4</option>
<option value="claude-3">claude-3</option>
</select>
);
ModelSelect.displayName = "ModelSelect";
return { ModelSelect };
});
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
const React = await import("react");
const SelectComponent = ({
value,
onChange,
mode,
children,
className,
}: {
value: any;
onChange: (value: any) => void;
mode?: string;
children: React.ReactNode;
className?: string;
}) => {
const isMultiple = mode === "multiple";
const selectValue = isMultiple ? (Array.isArray(value) ? value : []) : value || "";
return React.createElement(
"select",
{
multiple: isMultiple,
value: selectValue,
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value);
onChange(isMultiple ? selectedValues : selectedValues[0] || undefined);
},
className,
"aria-label": "Select",
role: "listbox",
},
children,
);
};
SelectComponent.displayName = "Select";
const SelectOption = ({
value: optionValue,
children: optionChildren,
}: {
value: string;
children: React.ReactNode;
}) => React.createElement("option", { value: optionValue }, optionChildren);
SelectOption.displayName = "SelectOption";
SelectComponent.Option = SelectOption;
const Spin = ({ size }: { size?: string }) =>
React.createElement("div", { "data-testid": "spinner", "data-size": size });
Spin.displayName = "Spin";
const InputNumber = ({
value,
onChange,
placeholder,
prefix,
}: {
value: number | null;
onChange: (value: number | null) => void;
placeholder?: string;
prefix?: string;
min?: number;
className?: string;
style?: React.CSSProperties;
}) =>
React.createElement("input", {
type: "number",
value: value ?? "",
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value === "" ? null : Number(e.target.value);
onChange(v);
},
placeholder,
"data-prefix": prefix,
"aria-label": "number input",
});
InputNumber.displayName = "InputNumber";
return {
...actual,
Spin,
Select: SelectComponent,
InputNumber,
};
});
const mockGetDefaultTeamSettings = vi.mocked(networking.getDefaultTeamSettings);
const mockUpdateDefaultTeamSettings = vi.mocked(networking.updateDefaultTeamSettings);
const mockNotificationsManager = vi.mocked(NotificationsManager);
describe("TeamSSOSettings", () => {
const defaultProps = {
accessToken: "test-token",
userID: "test-user",
userRole: "admin",
};
const mockSettingsResponse = {
values: {
max_budget: 1000,
budget_duration: "30d",
tpm_limit: 500,
rpm_limit: 100,
models: ["gpt-4"],
team_member_permissions: ["/key/generate", "/key/update"],
},
};
beforeEach(() => {
vi.clearAllMocks();
});
// --- Loading & Error States ---
it("should show loading spinner while fetching settings", () => {
mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => {}));
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
expect(screen.getByTestId("spinner")).toBeInTheDocument();
});
it("should display error message when fetch fails", async () => {
mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed"));
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByText("No team settings available or you do not have permission to view them."),
).toBeInTheDocument();
});
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings");
});
it("should not fetch settings when access token is null", async () => {
renderWithProviders(<TeamSSOSettings accessToken={null} userID="test-user" userRole="admin" />);
await waitFor(() => {
expect(mockGetDefaultTeamSettings).not.toHaveBeenCalled();
});
});
// --- View Mode ---
it("should render title and subtitle", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Default Team Settings")).toBeInTheDocument();
expect(
screen.getByText("These settings will be applied by default when creating new teams."),
).toBeInTheDocument();
});
});
it("should render section headers", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Budget & Rate Limits")).toBeInTheDocument();
expect(screen.getByText("Access & Permissions")).toBeInTheDocument();
});
});
it("should display all field labels and descriptions", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Max Budget")).toBeInTheDocument();
expect(screen.getByText("Budget Duration")).toBeInTheDocument();
expect(screen.getByText("TPM Limit")).toBeInTheDocument();
expect(screen.getByText("RPM Limit")).toBeInTheDocument();
expect(screen.getByText("Models")).toBeInTheDocument();
expect(screen.getByText("Team Member Permissions")).toBeInTheDocument();
});
// Descriptions
expect(screen.getByText("Maximum budget (in USD) for new automatically created teams.")).toBeInTheDocument();
expect(screen.getByText("How frequently the team's budget resets.")).toBeInTheDocument();
});
it("should display formatted values in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
// max_budget displayed with $
expect(screen.getByText("$1,000")).toBeInTheDocument();
// budget_duration through getBudgetDurationLabel
expect(screen.getByText("monthly")).toBeInTheDocument();
// tpm_limit formatted
expect(screen.getByText("500")).toBeInTheDocument();
// rpm_limit formatted
expect(screen.getByText("100")).toBeInTheDocument();
});
});
it("should display models as tags in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
});
});
it("should display permissions as tags in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("/key/generate")).toBeInTheDocument();
expect(screen.getByText("/key/update")).toBeInTheDocument();
});
});
it("should display 'Not set' for null values", async () => {
mockGetDefaultTeamSettings.mockResolvedValue({
values: {
max_budget: null,
budget_duration: null,
tpm_limit: null,
rpm_limit: null,
models: [],
team_member_permissions: [],
},
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
const notSetElements = screen.getAllByText("Not set");
// max_budget, budget_duration, tpm_limit, rpm_limit, models (empty), permissions (empty)
expect(notSetElements.length).toBeGreaterThanOrEqual(4);
});
});
// --- Edit Mode Toggle ---
it("should toggle to edit mode when Edit Settings is clicked", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Save Changes/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Edit Settings/i })).not.toBeInTheDocument();
});
it("should cancel edit mode and reset values", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await userEvent.click(screen.getByRole("button", { name: /Cancel/i }));
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Cancel/i })).not.toBeInTheDocument();
});
// --- Edit Mode Fields ---
it("should show budget duration dropdown in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
expect(screen.getByTestId("budget-duration-dropdown")).toBeInTheDocument();
});
});
it("should show ModelSelect in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
});
});
it("should show number inputs for budget and rate limits in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
const numberInputs = screen.getAllByLabelText("number input");
// max_budget, tpm_limit, rpm_limit
expect(numberInputs.length).toBe(3);
});
});
it("should show permissions multi-select in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
const listboxes = screen.getAllByRole("listbox");
expect(listboxes.length).toBeGreaterThan(0);
});
});
// --- Save ---
it("should save settings and show success notification", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockResolvedValue({
settings: mockSettingsResponse.values,
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => {
expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", expect.any(Object));
});
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully");
// Should exit edit mode after save
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
});
it("should show error notification when save fails", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed"));
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => {
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings");
});
});
it("should disable cancel button while saving", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettingsResponse.values }), 100)),
);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
expect(screen.getByRole("button", { name: /Cancel/i })).toBeDisabled();
});
});

View file

@ -1,324 +0,0 @@
import React, { useState, useEffect } from "react";
import { Card, Button, InputNumber, Typography, Spin, Select, Tag, Row, Col } from "antd";
import { EditOutlined, SaveOutlined } from "@ant-design/icons";
import { getDefaultTeamSettings, updateDefaultTeamSettings } from "./networking";
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import NotificationsManager from "./molecules/notifications_manager";
import { ModelSelect } from "./ModelSelect/ModelSelect";
const { Title, Text } = Typography;
interface TeamSSOSettingsProps {
accessToken: string | null;
userID: string;
userRole: string;
}
const PERMISSION_OPTIONS = [
"/key/generate",
"/key/update",
"/key/delete",
"/key/regenerate",
"/key/service-account/generate",
"/key/{key_id}/regenerate",
"/key/block",
"/key/unblock",
"/key/bulk_update",
"/key/{key_id}/reset_spend",
"/key/info",
"/key/list",
"/key/aliases",
"/team/daily/activity",
];
interface SettingRowProps {
label: string;
description: string;
isEditing: boolean;
viewContent: React.ReactNode;
editContent: React.ReactNode;
}
const SettingRow: React.FC<SettingRowProps> = ({ label, description, isEditing, viewContent, editContent }) => (
<Row className="py-5 border-b border-gray-100 last:border-0">
<Col span={8} className="pr-6">
<div className="text-sm font-semibold text-gray-900">{label}</div>
<div className="text-xs text-gray-500 mt-1 leading-relaxed">{description}</div>
</Col>
<Col span={16} className="flex items-center">
<div className="w-full">{isEditing ? editContent : viewContent}</div>
</Col>
</Row>
);
const NotSet = () => <Text className="text-gray-400 italic">Not set</Text>;
const renderTags = (values: string[], displayFn?: (v: string) => string) => {
if (!values || values.length === 0) return <NotSet />;
return (
<div className="flex flex-wrap gap-2">
{values.map((v) => (
<Tag key={v} color="blue">
{displayFn ? displayFn(v) : v}
</Tag>
))}
</div>
);
};
interface SettingsValues {
max_budget: number | null;
budget_duration: string | null;
tpm_limit: number | null;
rpm_limit: number | null;
models: string[];
team_member_permissions: string[];
}
const DEFAULT_VALUES: SettingsValues = {
max_budget: null,
budget_duration: null,
tpm_limit: null,
rpm_limit: null,
models: [],
team_member_permissions: [],
};
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
const [loading, setLoading] = useState<boolean>(true);
const [values, setValues] = useState<SettingsValues>(DEFAULT_VALUES);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [editedValues, setEditedValues] = useState<SettingsValues>(DEFAULT_VALUES);
const [saving, setSaving] = useState<boolean>(false);
const [fetchError, setFetchError] = useState<boolean>(false);
useEffect(() => {
const fetchSettings = async () => {
if (!accessToken) {
setLoading(false);
return;
}
try {
const data = await getDefaultTeamSettings(accessToken);
const fetched = { ...DEFAULT_VALUES, ...(data.values || {}) };
setValues(fetched);
setEditedValues(fetched);
} catch (error) {
console.error("Error fetching team SSO settings:", error);
setFetchError(true);
NotificationsManager.fromBackend("Failed to fetch team settings");
} finally {
setLoading(false);
}
};
fetchSettings();
}, [accessToken]);
const handleSave = async () => {
if (!accessToken) return;
setSaving(true);
try {
const updatedSettings = await updateDefaultTeamSettings(accessToken, editedValues);
const newValues = { ...DEFAULT_VALUES, ...(updatedSettings.settings || {}) };
setValues(newValues);
setEditedValues(newValues);
setIsEditing(false);
NotificationsManager.success("Default team settings updated successfully");
} catch (error) {
console.error("Error updating team settings:", error);
NotificationsManager.fromBackend("Failed to update team settings");
} finally {
setSaving(false);
}
};
const handleCancel = () => {
setIsEditing(false);
setEditedValues(values);
};
const update = <K extends keyof SettingsValues>(key: K, value: SettingsValues[K]) => {
setEditedValues((prev) => ({ ...prev, [key]: value }));
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<Spin size="large" />
</div>
);
}
if (fetchError) {
return (
<Card>
<Text>No team settings available or you do not have permission to view them.</Text>
</Card>
);
}
return (
<Card styles={{ body: { padding: 32 } }}>
{/* Header */}
<div className="flex justify-between items-start mb-2">
<div>
<Title level={3} className="m-0 text-gray-900">
Default Team Settings
</Title>
<Text className="text-gray-500 mt-1 block">
These settings will be applied by default when creating new teams.
</Text>
</div>
<div>
{isEditing ? (
<div className="flex gap-3">
<Button onClick={handleCancel} disabled={saving}>
Cancel
</Button>
<Button type="primary" onClick={handleSave} loading={saving} icon={<SaveOutlined />}>
Save Changes
</Button>
</div>
) : (
<Button onClick={() => setIsEditing(true)} icon={<EditOutlined />}>
Edit Settings
</Button>
)}
</div>
</div>
<div className="mt-8">
{/* Budget & Rate Limits */}
<div className="mb-8">
<div className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Budget & Rate Limits</div>
<div className="border-t border-gray-100">
<SettingRow
label="Max Budget"
description="Maximum budget (in USD) for new automatically created teams."
isEditing={isEditing}
viewContent={
values.max_budget != null ? <Text>${Number(values.max_budget).toLocaleString()}</Text> : <NotSet />
}
editContent={
<InputNumber
className="w-full"
style={{ maxWidth: 320 }}
value={editedValues.max_budget}
onChange={(v) => update("max_budget", v)}
placeholder="Not set"
prefix="$"
min={0}
/>
}
/>
<SettingRow
label="Budget Duration"
description="How frequently the team's budget resets."
isEditing={isEditing}
viewContent={
values.budget_duration ? <Text>{getBudgetDurationLabel(values.budget_duration)}</Text> : <NotSet />
}
editContent={
<BudgetDurationDropdown
value={editedValues.budget_duration || null}
onChange={(v) => update("budget_duration", v)}
style={{ maxWidth: 320 }}
/>
}
/>
<SettingRow
label="TPM Limit"
description="Maximum tokens per minute allowed across all models."
isEditing={isEditing}
viewContent={values.tpm_limit != null ? <Text>{values.tpm_limit.toLocaleString()}</Text> : <NotSet />}
editContent={
<InputNumber
className="w-full"
style={{ maxWidth: 320 }}
value={editedValues.tpm_limit}
onChange={(v) => update("tpm_limit", v)}
placeholder="Not set"
min={0}
/>
}
/>
<SettingRow
label="RPM Limit"
description="Maximum requests per minute allowed across all models."
isEditing={isEditing}
viewContent={values.rpm_limit != null ? <Text>{values.rpm_limit.toLocaleString()}</Text> : <NotSet />}
editContent={
<InputNumber
className="w-full"
style={{ maxWidth: 320 }}
value={editedValues.rpm_limit}
onChange={(v) => update("rpm_limit", v)}
placeholder="Not set"
min={0}
/>
}
/>
</div>
</div>
{/* Access & Permissions */}
<div className="mb-8">
<div className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Access & Permissions</div>
<div className="border-t border-gray-100">
<SettingRow
label="Models"
description="Default list of models that new teams can access."
isEditing={isEditing}
viewContent={renderTags(values.models, getModelDisplayName)}
editContent={
<ModelSelect
value={editedValues.models || []}
onChange={(v) => update("models", v)}
context="global"
style={{ width: "100%" }}
options={{ includeSpecialOptions: true }}
/>
}
/>
<SettingRow
label="Team Member Permissions"
description="Default permissions granted to members of newly created teams. /key/info and /key/health are always included."
isEditing={isEditing}
viewContent={renderTags(values.team_member_permissions)}
editContent={
<Select
mode="multiple"
style={{ width: "100%" }}
value={editedValues.team_member_permissions || []}
onChange={(v) => update("team_member_permissions", v)}
placeholder="Select permissions"
tagRender={({ label, closable, onClose }) => (
<Tag color="blue" closable={closable} onClose={onClose} className="mr-1 mt-1 mb-1">
{label}
</Tag>
)}
>
{PERMISSION_OPTIONS.map((option) => (
<Select.Option key={option} value={option}>
{option}
</Select.Option>
))}
</Select>
}
/>
</div>
</div>
</div>
</Card>
);
};
export default TeamSSOSettings;

View file

@ -1,7 +1,7 @@
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import AvailableTeamsPanel from "@/components/team/AvailableTeamsPanel";
import TeamInfoView from "@/components/team/TeamInfo";
import TeamSSOSettings from "@/components/TeamSSOSettings";
import { DefaultTeamSettingsForm } from "@/app/(dashboard)/teams/_components/default-team-settings/DefaultTeamSettingsForm";
import { isProxyAdminRole } from "@/utils/roles";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Accordion, AccordionBody, AccordionHeader, TextInput } from "@tremor/react";
@ -531,7 +531,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
{
key: "default-settings",
label: "Default Team Settings",
children: <TeamSSOSettings accessToken={accessToken} userID={userID || ""} userRole={userRole || ""} />,
children: <DefaultTeamSettingsForm />,
},
]
: []),

View file

@ -5393,26 +5393,6 @@ export const tagDeleteCall = async (accessToken: string, tagName: string): Promi
}
};
export const getDefaultTeamSettings = async (accessToken: string) => {
try {
const data = await apiClient.get(`/get/default_team_settings`, { accessToken });
return data;
} catch (error) {
console.error("Failed to fetch default team settings:", error);
throw error;
}
};
export const updateDefaultTeamSettings = async (accessToken: string, settings: Record<string, any>) => {
try {
const data = await apiClient.patch(`/update/default_team_settings`, { accessToken, body: settings });
return data;
} catch (error) {
console.error("Failed to update default team settings:", error);
throw error;
}
};
export const getTeamPermissionsCall = async (accessToken: string, teamId: string) => {
try {
let url = proxyBaseUrl

View file

@ -0,0 +1,9 @@
export const NO_RESET = "never";
export const BUDGET_DURATION_OPTIONS = [
{ value: NO_RESET, label: "No reset" },
{ value: "1h", label: "hourly" },
{ value: "24h", label: "daily" },
{ value: "7d", label: "weekly" },
{ value: "30d", label: "monthly" },
] as const;

File diff suppressed because one or more lines are too long