mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
b930e2fc2b
commit
a2be3986b2
14 changed files with 871 additions and 831 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue