Connect more of the Default Parameters and Model/Provider whitelist screen (#28)

This commit is contained in:
John Richmond 2025-05-18 08:44:33 -07:00 committed by GitHub
parent a933a42de5
commit 01e6eada6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 621 additions and 268 deletions

View file

@ -2,34 +2,33 @@
import { z } from 'zod';
import { AuditLogTargetType } from '@/db/schema';
import { createAuditLog } from '@/lib/server/auditLogs';
import { db } from '@/db';
import { AuditLogTargetType, orgSettings } from '@/db/schema';
import { insertAuditLog } from '@/lib/server/auditLogs';
import {
handleError,
isAuthSuccess,
validateAuth,
type ApiResponse,
} from './apiUtils';
import { sql } from 'drizzle-orm';
import { ORGANIZATION_ALLOW_ALL } from '@/schemas';
const defaultParametersSchema = z.object({
experimentalPowerSteering: z.boolean().optional(),
terminalOutputLimit: z.number().int().nonnegative().optional(),
compressProgressBar: z.boolean().optional(),
terminalOutputLineLimit: z.number().int().nonnegative().optional(),
terminalCompressProgressBar: z.boolean().optional(),
inheritEnvVars: z.boolean().optional(),
disableShellIntegration: z.boolean().optional(),
shellIntegrationTimeout: z.number().int().nonnegative().optional(),
commandDelay: z.number().int().nonnegative().optional(),
enablePowerShellCounter: z.boolean().optional(),
clearZshEol: z.boolean().optional(),
enableOhMyZsh: z.boolean().optional(),
terminalShellIntegrationDisabled: z.boolean().optional(),
terminalShellIntegrationTimeout: z.number().int().nonnegative().optional(),
terminalCommandDelay: z.number().int().nonnegative().optional(),
terminalZshClearEolMark: z.boolean().optional(),
enablePowerlevel10k: z.boolean().optional(),
openTabsLimit: z.number().int().nonnegative().optional(),
workspaceFilesLimit: z.number().int().nonnegative().optional(),
showRooignoreFiles: z.boolean().optional(),
fileReadThreshold: z.number().int().optional(),
alwaysReadEntireFile: z.boolean().optional(),
enableAutoCheckpoints: z.boolean().optional(),
maxOpenTabsContext: z.number().int().nonnegative().optional(),
maxWorkspaceFiles: z.number().int().nonnegative().optional(),
showRooIgnoredFiles: z.boolean().optional(),
maxReadFileLine: z.number().int().gte(-1).optional(),
enableCheckpoints: z.boolean().optional(),
useCustomTemperature: z.boolean().optional(),
temperature: z.number().nonnegative().optional(),
rateLimit: z.number().nonnegative().optional(),
@ -62,15 +61,33 @@ export async function updateDefaultParameters(
const validatedData = result.data;
// TODO: Audit log description should contain more granular information on
// what changed.
await createAuditLog({
userId: authResult.userId,
orgId: authResult.orgId,
targetType: AuditLogTargetType.DEFAULT_PARAMETERS,
targetId: 'default-parameters',
newValue: validatedData,
description: 'Updated default parameters',
await db.transaction(async (tx) => {
await tx
.insert(orgSettings)
.values({
orgId: authResult.orgId,
version: 1,
defaultSettings: validatedData,
allowList: ORGANIZATION_ALLOW_ALL,
})
.onConflictDoUpdate({
target: orgSettings.orgId,
set: {
defaultSettings: validatedData,
version: sql`${orgSettings.version} + 1`,
},
});
// TODO: consider trying to capture the changes more granularly,
// although that would prevent upsert
await insertAuditLog(tx, {
userId: authResult.userId,
orgId: authResult.orgId,
targetType: AuditLogTargetType.DEFAULT_PARAMETERS,
targetId: 'default-parameters',
newValue: validatedData,
description: 'Updated default parameters',
});
});
return {

View file

@ -1,13 +1,27 @@
'use server';
import { eq } from 'drizzle-orm';
import { auth } from '@clerk/nextjs/server';
import { db } from '@/db';
import { orgSettings, type OrgSettings } from '@/db/schema';
import { AuditLogTargetType, orgSettings } from '@/db/schema';
import { eq, sql } from 'drizzle-orm';
import {
ORGANIZATION_ALLOW_ALL,
ORGANIZATION_DEFAULT,
type OrganizationSettings,
organizationAllowListSchema,
organizationDefaultSettingsSchema,
} from '@/schemas';
import { z } from 'zod';
import {
handleError,
isAuthSuccess,
validateAuth,
type ApiResponse,
} from './apiUtils';
import { insertAuditLog } from '@/lib/server/auditLogs';
export async function getOrganizationSettings(): Promise<
OrgSettings | undefined
OrganizationSettings | undefined
> {
const { userId, orgId } = await auth();
@ -25,5 +39,111 @@ export async function getOrganizationSettings(): Promise<
.where(eq(orgSettings.orgId, orgId))
.limit(1);
return settings.length === 0 ? undefined : settings[0];
return settings.length === 0 ? ORGANIZATION_DEFAULT : settings[0];
}
/**
* Schema for updating organization settings
*/
const updateOrganizationSchema = z
.object({
defaultSettings: organizationDefaultSettingsSchema.optional(),
allowList: organizationAllowListSchema.optional(),
})
.refine(
(data) =>
data.defaultSettings !== undefined || data.allowList !== undefined,
{
message: 'At least one of defaultSettings or allowList must be provided',
},
);
type UpdateOrganizationRequest = z.infer<typeof updateOrganizationSchema>;
export async function updateOrganization(
data: UpdateOrganizationRequest,
): Promise<ApiResponse> {
try {
const authResult = await validateAuth();
if (!isAuthSuccess(authResult)) return authResult;
const { userId, orgId } = authResult;
const result = updateOrganizationSchema.safeParse(data);
if (!result.success) {
return {
success: false,
error: 'Invalid request data',
};
}
const validatedData = result.data;
// Perform database update in a transaction
await db.transaction(async (tx) => {
// Get current settings or prepare for insert
const currentSettings = await tx
.select()
.from(orgSettings)
.where(eq(orgSettings.orgId, orgId))
.limit(1);
const isNewRecord = currentSettings.length === 0;
const updateData: Partial<typeof orgSettings.$inferInsert> = {};
if (validatedData.defaultSettings) {
updateData.defaultSettings = validatedData.defaultSettings;
}
if (validatedData.allowList) {
updateData.allowList = validatedData.allowList;
}
if (isNewRecord) {
await tx.insert(orgSettings).values({
orgId,
version: 1,
defaultSettings: validatedData.defaultSettings || {},
allowList: validatedData.allowList || ORGANIZATION_ALLOW_ALL,
});
} else {
await tx
.update(orgSettings)
.set({
...updateData,
version: sql`${orgSettings.version} + 1`,
updatedAt: new Date(),
})
.where(eq(orgSettings.orgId, orgId));
}
if (validatedData.defaultSettings) {
await insertAuditLog(tx, {
userId,
orgId,
targetType: AuditLogTargetType.DEFAULT_PARAMETERS,
targetId: 'organization-default-settings',
newValue: validatedData.defaultSettings,
description: 'Updated organization default settings',
});
}
if (validatedData.allowList) {
await insertAuditLog(tx, {
userId,
orgId,
targetType: AuditLogTargetType.PROVIDER_WHITELIST,
targetId: 'organization-allow-list',
newValue: validatedData.allowList,
description: 'Updated organization allow list',
});
}
});
return {
success: true,
message: 'Organization settings updated successfully',
};
} catch (error) {
return handleError(error, 'organization_settings');
}
}

View file

@ -3,11 +3,18 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useState, useMemo, useRef, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import {
useQuery,
useQueryClient,
type QueryClient,
} from '@tanstack/react-query';
import { updateDefaultParameters } from '@/actions/defaultParameters';
import { getOrganizationSettings } from '@/actions/organizationSettings';
import { type OrganizationSettings } from '@/schemas';
import {
Button,
Checkbox,
@ -24,22 +31,19 @@ import { CheckCheck, FlaskConical, SlidersHorizontal } from 'lucide-react';
type DefaultParamsFormValues = {
experimentalPowerSteering: boolean;
terminalOutputLimit: number;
compressProgressBar: boolean;
terminalOutputLineLimit: number;
terminalCompressProgressBar: boolean;
inheritEnvVars: boolean;
disableShellIntegration: boolean;
shellIntegrationTimeout: number;
commandDelay: number;
enablePowerShellCounter: boolean;
clearZshEol: boolean;
enableOhMyZsh: boolean;
terminalShellIntegrationDisabled: boolean;
terminalShellIntegrationTimeout: number;
terminalCommandDelay: number;
terminalZshClearEolMark: boolean;
enablePowerlevel10k: boolean;
openTabsLimit: number;
workspaceFilesLimit: number;
showRooignoreFiles: boolean;
fileReadThreshold: number;
alwaysReadEntireFile: boolean;
enableAutoCheckpoints: boolean;
maxOpenTabsContext: number;
maxWorkspaceFiles: number;
showRooIgnoredFiles: boolean;
maxReadFileLine: number;
enableCheckpoints: boolean;
useCustomTemperature: boolean;
temperature: number;
rateLimit: number;
@ -47,44 +51,102 @@ type DefaultParamsFormValues = {
matchPrecision: number;
};
export const DefaultParameters = () => {
const mergeWithDefaultValues = (
defaultValues: DefaultParamsFormValues,
orgSettings?: OrganizationSettings,
): DefaultParamsFormValues => {
if (!orgSettings || !orgSettings.defaultSettings) {
return defaultValues;
}
return {
...defaultValues,
...orgSettings.defaultSettings,
};
};
const defaultFormValues: DefaultParamsFormValues = {
experimentalPowerSteering: true,
terminalOutputLineLimit: 500,
terminalCompressProgressBar: true,
inheritEnvVars: true,
terminalShellIntegrationDisabled: false,
terminalShellIntegrationTimeout: 5,
terminalCommandDelay: 0,
terminalZshClearEolMark: true,
enablePowerlevel10k: false,
maxOpenTabsContext: 20,
maxWorkspaceFiles: 200,
showRooIgnoredFiles: true,
maxReadFileLine: 500,
enableCheckpoints: true,
useCustomTemperature: true,
temperature: 0,
rateLimit: 0,
enableEditingThroughDiffs: true,
matchPrecision: 100,
} as const;
type ParametersFormProps = {
orgSettings: OrganizationSettings;
queryClient: QueryClient;
};
const ParametersForm = ({ orgSettings, queryClient }: ParametersFormProps) => {
const t = useTranslations('ProviderWhitelist');
const [isSaving, setIsSaving] = useState(false);
const [readEntireFile, setReadEntireFile] = useState(false);
const mergedValues = useMemo(
() => mergeWithDefaultValues(defaultFormValues, orgSettings),
[orgSettings],
);
const previousMaxReadFileLine = useRef<number>(
mergedValues.maxReadFileLine === -1 ? 500 : mergedValues.maxReadFileLine,
);
const form = useForm<DefaultParamsFormValues>({
defaultValues: {
experimentalPowerSteering: true,
terminalOutputLimit: 500,
compressProgressBar: true,
inheritEnvVars: true,
disableShellIntegration: false,
shellIntegrationTimeout: 5,
commandDelay: 0,
enablePowerShellCounter: false,
clearZshEol: true,
enableOhMyZsh: false,
enablePowerlevel10k: false,
openTabsLimit: 20,
workspaceFilesLimit: 200,
showRooignoreFiles: true,
fileReadThreshold: 500,
alwaysReadEntireFile: false,
enableAutoCheckpoints: true,
useCustomTemperature: true,
temperature: 0,
rateLimit: 0,
enableEditingThroughDiffs: true,
matchPrecision: 100,
},
defaultValues: mergedValues,
});
const maxReadFileLineValue = form.watch('maxReadFileLine');
useEffect(() => {
previousMaxReadFileLine.current =
mergedValues.maxReadFileLine === -1 ? 500 : mergedValues.maxReadFileLine;
}, [mergedValues]);
useEffect(() => {
setReadEntireFile(maxReadFileLineValue === -1);
if (maxReadFileLineValue !== -1) {
previousMaxReadFileLine.current = maxReadFileLineValue;
}
}, [maxReadFileLineValue, form]);
const handleReadEntireFileChange = (checked: boolean) => {
if (checked) {
const currentValue = form.getValues('maxReadFileLine');
if (currentValue !== -1) {
previousMaxReadFileLine.current = currentValue;
}
form.setValue('maxReadFileLine', -1);
} else {
form.setValue('maxReadFileLine', previousMaxReadFileLine.current);
}
setReadEntireFile(checked);
};
const onSubmit = async (data: DefaultParamsFormValues) => {
setIsSaving(true);
try {
const result = await updateDefaultParameters(data);
if (result.success) {
queryClient.invalidateQueries({ queryKey: ['organizationSettings'] });
toast('Settings saved', {
description: 'Default parameters have been updated successfully.',
});
@ -124,7 +186,7 @@ export const DefaultParameters = () => {
<div className="mt-4 space-y-4">
<FormField
control={form.control}
name="enableAutoCheckpoints"
name="enableCheckpoints"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
@ -172,7 +234,7 @@ export const DefaultParameters = () => {
<div className="mt-4 space-y-6">
<FormField
control={form.control}
name="openTabsLimit"
name="maxOpenTabsContext"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
@ -202,7 +264,7 @@ export const DefaultParameters = () => {
<FormField
control={form.control}
name="workspaceFilesLimit"
name="maxWorkspaceFiles"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
@ -232,7 +294,7 @@ export const DefaultParameters = () => {
<FormField
control={form.control}
name="showRooignoreFiles"
name="showRooIgnoredFiles"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
@ -261,7 +323,7 @@ export const DefaultParameters = () => {
<div className="flex items-center gap-2">
<FormField
control={form.control}
name="fileReadThreshold"
name="maxReadFileLine"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
@ -274,27 +336,23 @@ export const DefaultParameters = () => {
field.onChange(Number(e.target.value))
}
className="w-32"
disabled={readEntireFile}
value={readEntireFile ? '' : field.value}
/>
</FormControl>
</FormItem>
)}
/>
<span>lines</span>
<FormField
control={form.control}
name="alwaysReadEntireFile"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>Always read entire file</FormLabel>
</FormItem>
)}
/>
<FormItem className="flex items-center space-x-2">
<FormControl>
<Checkbox
checked={readEntireFile}
onCheckedChange={handleReadEntireFileChange}
/>
</FormControl>
<FormLabel>Always read entire file</FormLabel>
</FormItem>
</div>
<FormDescription>
Roo reads this number of lines when the model omits
@ -482,7 +540,7 @@ export const DefaultParameters = () => {
<FormField
control={form.control}
name="terminalOutputLimit"
name="terminalOutputLineLimit"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
@ -512,7 +570,7 @@ export const DefaultParameters = () => {
<FormField
control={form.control}
name="compressProgressBar"
name="terminalCompressProgressBar"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
@ -570,7 +628,7 @@ export const DefaultParameters = () => {
<FormField
control={form.control}
name="disableShellIntegration"
name="terminalShellIntegrationDisabled"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
@ -596,7 +654,7 @@ export const DefaultParameters = () => {
<FormField
control={form.control}
name="shellIntegrationTimeout"
name="terminalShellIntegrationTimeout"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
@ -630,7 +688,7 @@ export const DefaultParameters = () => {
<FormField
control={form.control}
name="commandDelay"
name="terminalCommandDelay"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
@ -668,7 +726,7 @@ export const DefaultParameters = () => {
<FormField
control={form.control}
name="clearZshEol"
name="terminalZshClearEolMark"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
@ -746,3 +804,23 @@ export const DefaultParameters = () => {
</>
);
};
export const DefaultParameters = () => {
const queryClient = useQueryClient();
const { data: orgSettings } = useQuery({
queryKey: ['organizationSettings'],
queryFn: getOrganizationSettings,
});
if (!orgSettings) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin text-2xl"></div>
<span className="ml-2">Loading settings...</span>
</div>
);
}
return <ParametersForm orgSettings={orgSettings} queryClient={queryClient} />;
};

View file

@ -2,211 +2,274 @@
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
updateAllowAllProviders,
updateModelStatus,
updateProviderStatus,
} from '@/actions/providerWhitelist';
import { Badge, Checkbox, Label } from '@/components/ui';
getOrganizationSettings,
updateOrganization,
} from '@/actions/organizationSettings';
import { Badge, Button, Checkbox, Label } from '@/components/ui';
import { toast } from 'sonner';
import {
ORGANIZATION_ALLOW_ALL,
type OrganizationAllowList,
type OrganizationSettings,
} from '@/schemas';
import { type ProviderName } from '@roo-code/types';
const initialProviders = [
type ProviderSetting = {
allowAll: boolean;
models?: string[];
};
// Define a type for providers record
type ProvidersRecord = Record<ProviderName, ProviderSetting>;
// Provider metadata without state information
const providerMetadata: {
id: ProviderName;
name: string;
models: {
id: string;
name: string;
}[];
}[] = [
{
id: 'openai',
id: 'openai-native',
name: 'OpenAI',
enabled: true,
models: [
{ id: 'gpt-4', name: 'GPT-4', enabled: true },
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', enabled: true },
{ id: 'gpt-4o', name: 'GPT-4o', enabled: false },
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo' },
{ id: 'gpt-4o', name: 'GPT-4o' },
],
},
{
id: 'anthropic',
name: 'Anthropic',
enabled: true,
models: [
{ id: 'claude-3-opus', name: 'Claude 3 Opus', enabled: true },
{ id: 'claude-3-sonnet', name: 'Claude 3 Sonnet', enabled: true },
{ id: 'claude-3-haiku', name: 'Claude 3 Haiku', enabled: false },
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' },
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3 Sonnet' },
{
id: 'claude-3-7-sonnet-20250219:thinking',
name: 'Claude 3 Sonnet Thinking',
},
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' },
],
},
{
id: 'openrouter',
name: 'OpenRouter',
models: [
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus' },
{ id: 'anthropic/claude-3.7-sonnet', name: 'Claude 3.7 Sonnet' },
{
id: 'anthropic/claude-3.7-sonnet:thinking',
name: 'Claude 3.7 Sonnet Thinking',
},
{ id: 'anthropic/claude-3.5-haiku', name: 'Claude 3.5 Haiku' },
{ id: 'openai/gpt-4', name: 'GPT-4' },
{ id: 'openai/gpt-3.5-turbo', name: 'GPT-3.5 Turbo' },
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
{
id: 'google/gemini-2.5-flash-preview',
name: 'Gemini 2.5 Flash Preview',
},
],
},
{
id: 'mistral',
name: 'Mistral AI',
enabled: false,
models: [
{ id: 'mistral-large', name: 'Mistral Large', enabled: false },
{ id: 'mistral-medium', name: 'Mistral Medium', enabled: false },
{ id: 'mistral-small', name: 'Mistral Small', enabled: false },
],
},
{
id: 'cohere',
name: 'Cohere',
enabled: false,
models: [
{ id: 'command-r', name: 'Command R', enabled: false },
{ id: 'command-r-plus', name: 'Command R+', enabled: false },
{ id: 'mistral-large-latest', name: 'Mistral Large' },
{ id: 'mistral-small-latest', name: 'Mistral Small' },
],
},
];
export const ProviderWhitelist = () => {
type ProviderWhitelistFormProps = {
orgSettings: OrganizationSettings;
queryClient: ReturnType<typeof useQueryClient>;
};
const ProviderWhitelistForm = ({
orgSettings,
queryClient,
}: ProviderWhitelistFormProps) => {
const t = useTranslations('ProviderWhitelist');
const [providers, setProviders] = useState(initialProviders);
const [policyVersion] = useState(1);
const [allowAllProviders, setAllowAllProviders] = useState(true);
const [isUpdating, setIsUpdating] = useState(false);
const [allowList, setAllowList] = useState<OrganizationAllowList>(
orgSettings?.allowList || ORGANIZATION_ALLOW_ALL,
);
// Common error handling function for API calls.
const handleApiError = (error: unknown, errorMessage: string) => {
console.error(errorMessage, error);
toast.error(errorMessage);
setIsUpdating(false);
const [hasChanges, setHasChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const isAllowAllChecked = () => allowList.allowAll;
const isProviderChecked = (providerId: ProviderName) => {
if (allowList.allowAll) return true;
return allowList.providers[providerId]?.allowAll || false;
};
const toggleAllowAllProviders = async () => {
const previousAllowAllProviders = allowAllProviders;
const previousProviders = [...providers];
const newAllowAllProviders = !allowAllProviders;
setAllowAllProviders(newAllowAllProviders);
const isModelChecked = (providerId: ProviderName, modelId: string) => {
if (allowList.allowAll) return true;
const provider = allowList.providers[providerId];
if (!provider) return false;
if (provider.allowAll) return true;
return provider.models?.includes(modelId) || false;
};
let updatedProviders = providers;
const toggleAllowAll = () => {
const newAllowAll = !allowList.allowAll;
if (newAllowAllProviders) {
// Enable all providers and their models/
updatedProviders = providers.map((provider) => ({
...provider,
enabled: true,
models: provider.models.map((model) => ({ ...model, enabled: true })),
}));
if (newAllowAll) {
setAllowList({ allowAll: true, providers: {} });
} else {
const newProviders: Partial<ProvidersRecord> = {};
setProviders(updatedProviders);
}
// Call API to update backend with the changed allow all providers setting.
setIsUpdating(true);
try {
const result = await updateAllowAllProviders({
allowAllProviders: newAllowAllProviders,
policyVersion,
providerMetadata.forEach((provider) => {
newProviders[provider.id] = { allowAll: true };
});
if (!result.success) {
throw new Error(result.error || 'Failed to update setting');
}
} catch (error) {
setAllowAllProviders(previousAllowAllProviders);
setProviders(previousProviders);
handleApiError(
error,
'Failed to update allow all providers setting. Please try again.',
);
} finally {
setIsUpdating(false);
setAllowList({
allowAll: false,
providers: newProviders,
});
}
setHasChanges(true);
};
const toggleProvider = async (providerId: string) => {
if (allowAllProviders) {
return;
const toggleProvider = (providerId: ProviderName) => {
if (allowList.allowAll) {
const newProviders: Partial<ProvidersRecord> = {};
providerMetadata.forEach((provider) => {
if (provider.id !== providerId) {
newProviders[provider.id] = { allowAll: true };
}
});
setAllowList({
allowAll: false,
providers: newProviders,
});
} else {
const newProviders = { ...allowList.providers };
const providersRecord = newProviders;
const isCurrentlyEnabled = isProviderChecked(providerId);
if (isCurrentlyEnabled) {
if (providersRecord[providerId]) {
providersRecord[providerId] = {
...providersRecord[providerId],
allowAll: false,
models: [],
};
}
} else {
providersRecord[providerId] = { allowAll: true };
}
setAllowList({ ...allowList, providers: newProviders });
}
// Find the provider to get its current state.
const provider = providers.find((p) => p.id === providerId);
if (!provider) return;
setHasChanges(true);
};
const previousProviders = [...providers];
const newEnabled = !provider.enabled;
const toggleModel = (providerId: ProviderName, modelId: string) => {
if (allowList.allowAll || isProviderChecked(providerId)) {
const newProviders = { ...allowList.providers };
const providersRecord = newProviders;
const updatedProviders = providers.map((provider) => {
if (provider.id === providerId) {
return {
if (allowList.allowAll) {
providerMetadata.forEach((provider) => {
providersRecord[provider.id] = { allowAll: true };
});
const provider = providerMetadata.find((p) => p.id === providerId);
if (provider) {
const models = provider.models
.filter((m) => m.id !== modelId)
.map((m) => m.id);
providersRecord[providerId] = { allowAll: false, models };
}
setAllowList({ allowAll: false, providers: newProviders });
} else {
const provider = providerMetadata.find((p) => p.id === providerId);
if (provider) {
const models = provider.models
.filter((m) => m.id !== modelId)
.map((m) => m.id);
providersRecord[providerId] = { allowAll: false, models };
setAllowList({ ...allowList, providers: newProviders });
}
}
} else {
const newProviders = { ...allowList.providers };
const providersRecord = newProviders;
const provider = providersRecord[providerId] || {
allowAll: false,
models: [],
};
const models = provider.models || [];
const isCurrentlyEnabled = models.includes(modelId);
if (isCurrentlyEnabled) {
providersRecord[providerId] = {
...provider,
enabled: newEnabled,
// If provider is disabled, disable all its models
models: provider.models.map((model) => ({
...model,
enabled: newEnabled ? model.enabled : false,
})),
models: models.filter((m: string) => m !== modelId),
};
} else {
providersRecord[providerId] = {
...provider,
models: [...models, modelId],
};
}
return provider;
});
setProviders(updatedProviders);
const allModels =
providerMetadata
.find((p) => p.id === providerId)
?.models.map((m) => m.id) || [];
const enabledModels = providersRecord[providerId].models || [];
// Call API to update backend with the changed provider status
setIsUpdating(true);
try {
const result = await updateProviderStatus({
providerId,
enabled: newEnabled,
policyVersion,
});
if (!result.success) {
throw new Error(result.error || 'Failed to update provider status');
if (
allModels.length === enabledModels.length &&
allModels.every((m) => enabledModels.includes(m))
) {
providersRecord[providerId] = { allowAll: true };
}
}
} catch (error) {
setProviders(previousProviders);
handleApiError(
error,
'Failed to update provider status. Please try again.',
);
} finally {
setIsUpdating(false);
setAllowList({ ...allowList, providers: newProviders });
}
setHasChanges(true);
};
const toggleModel = async (providerId: string, modelId: string) => {
if (allowAllProviders) {
return;
}
// Find the model to get its current state.
const provider = providers.find((p) => p.id === providerId);
if (!provider) return;
const model = provider.models.find((m) => m.id === modelId);
if (!model) return;
const previousProviders = [...providers];
const newEnabled = !model.enabled;
const updatedProviders = providers.map((provider) => {
return provider.id === providerId
? {
...provider,
models: provider.models.map((model) =>
model.id === modelId ? { ...model, enabled: newEnabled } : model,
),
}
: provider;
});
setProviders(updatedProviders);
setIsUpdating(true);
const saveChanges = async () => {
setIsSaving(true);
try {
const result = await updateModelStatus({
providerId,
modelId,
enabled: newEnabled,
policyVersion,
const result = await updateOrganization({
allowList: allowList,
});
if (!result.success) {
throw new Error(result.error || 'Failed to update model status');
throw new Error(result.error || 'Failed to update settings');
}
queryClient.invalidateQueries({ queryKey: ['organizationSettings'] });
toast.success('Provider settings saved successfully');
setHasChanges(false);
} catch (error) {
setProviders(previousProviders);
handleApiError(error, 'Failed to update model status. Please try again.');
console.error('Failed to save provider settings', error);
toast.error('Failed to save provider settings');
} finally {
setIsUpdating(false);
setIsSaving(false);
}
};
@ -227,8 +290,9 @@ export const ProviderWhitelist = () => {
<div className="mb-4 flex items-center space-x-2">
<Checkbox
id="allow-all-providers"
checked={allowAllProviders}
onCheckedChange={toggleAllowAllProviders}
checked={isAllowAllChecked()}
onCheckedChange={toggleAllowAll}
disabled={isSaving}
/>
<Label
htmlFor="allow-all-providers"
@ -237,14 +301,14 @@ export const ProviderWhitelist = () => {
Allow All Providers
</Label>
</div>
<div className={allowAllProviders ? 'opacity-50' : ''}>
{providers.map((provider) => (
<div className={isAllowAllChecked() ? 'opacity-50' : ''}>
{providerMetadata.map((provider) => (
<div key={provider.id} className="mb-4">
<div className="mb-2 flex items-center space-x-2">
<Checkbox
id={`provider-${provider.id}`}
checked={provider.enabled}
disabled={allowAllProviders}
checked={isProviderChecked(provider.id)}
disabled={isAllowAllChecked() || isSaving}
onCheckedChange={() => toggleProvider(provider.id)}
/>
<Label
@ -259,15 +323,19 @@ export const ProviderWhitelist = () => {
<div key={model.id} className="flex items-center space-x-2">
<Checkbox
id={`model-${model.id}`}
checked={allowAllProviders || model.enabled}
disabled={allowAllProviders || !provider.enabled}
checked={isModelChecked(provider.id, model.id)}
disabled={
isAllowAllChecked() ||
isProviderChecked(provider.id) ||
isSaving
}
onCheckedChange={() =>
toggleModel(provider.id, model.id)
}
/>
<Label
htmlFor={`model-${model.id}`}
className={`text-xs ${allowAllProviders || !provider.enabled ? 'text-muted-foreground' : ''}`}
className={`text-xs ${isAllowAllChecked() || !isProviderChecked(provider.id) ? 'text-muted-foreground' : ''}`}
>
{model.name}
</Label>
@ -279,16 +347,58 @@ export const ProviderWhitelist = () => {
</div>
<div className="flex items-center space-x-2 pt-2">
<Badge variant="outline" className="text-xs">
{`Policy v${policyVersion}`}
{`Policy v${orgSettings?.version || 1}`}
</Badge>
<span className="text-xs text-muted-foreground">
{isUpdating
{isSaving
? 'Updating provider whitelist...'
: 'Changes will be pushed to SSE stream within 30 seconds'}
: hasChanges
? 'You have unsaved changes'
: 'Changes will be pushed to SSE stream within 30 seconds'}
</span>
</div>
<Button
onClick={saveChanges}
disabled={!hasChanges || isSaving}
className="mt-4"
>
{isSaving ? (
<>
<span className="mr-2">Saving...</span>
<span className="animate-spin"></span>
</>
) : (
'Save Changes'
)}
</Button>
</div>
</div>
</>
);
};
export const ProviderWhitelist = () => {
const queryClient = useQueryClient();
const { data: orgSettings } = useQuery({
queryKey: ['organizationSettings'],
queryFn: getOrganizationSettings,
});
if (!orgSettings) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin text-2xl"></div>
<span className="ml-2">Loading settings...</span>
</div>
);
}
return (
<ProviderWhitelistForm
orgSettings={orgSettings}
queryClient={queryClient}
/>
);
};

View file

@ -26,4 +26,8 @@ const disconnect = async () => {
await pool.end();
};
export type DB_OR_TX =
| typeof db
| Parameters<Parameters<typeof db.transaction>[0]>[0];
export { db, testDb, disconnect };

View file

@ -1,12 +1,22 @@
import { db } from '@/db';
import { db, type DB_OR_TX } from '@/db';
import { auditLogs, type CreateAuditLog } from '@/db/schema';
import { logger } from '@/lib/server/logger';
export async function createAuditLog(values: CreateAuditLog) {
export async function insertAuditLog(
db: DB_OR_TX,
values: CreateAuditLog,
): Promise<void> {
await db.insert(auditLogs).values(values);
const { userId, orgId, targetType } = values;
logger.info({ userId, orgId, targetType });
}
export async function createAuditLog(values: CreateAuditLog): Promise<{
success: boolean;
error?: string | Record<string, unknown>;
}> {
try {
await db.insert(auditLogs).values(values);
const { userId, orgId, targetType } = values;
logger.info({ userId, orgId, targetType });
await insertAuditLog(db, values);
return { success: true };
} catch (e) {
const error =

View file

@ -2,6 +2,7 @@
* TimePeriod
*/
import { providerNames } from '@roo-code/types';
import { z } from 'zod';
export const timePeriods = [7, 30, 90] as const;
@ -11,6 +12,7 @@ export type TimePeriod = (typeof timePeriods)[number];
export const organizationAllowListSchema = z.object({
allowAll: z.boolean(),
providers: z.record(
z.enum(providerNames),
z.object({
allowAll: z.boolean(),
models: z.array(z.string()).optional(),
@ -29,11 +31,17 @@ export const ORGANIZATION_ALLOW_ALL: OrganizationAllowList = {
export const organizationDefaultSettingsSchema = z.object({
enableCheckpoints: z.boolean().optional(),
maxOpenTabsContext: z.number().optional(),
maxWorkspaceFiles: z.number().optional(),
showRooIgnoredFiles: z.boolean().optional(),
maxReadFileLine: z.number().optional(),
fuzzyMatchThreshold: z.number().optional(),
maxOpenTabsContext: z.number().int().nonnegative().optional(),
maxReadFileLine: z.number().int().gte(-1).optional(),
maxWorkspaceFiles: z.number().int().nonnegative().optional(),
showRooIgnoredFiles: z.boolean().optional(),
terminalCommandDelay: z.number().int().nonnegative().optional(),
terminalCompressProgressBar: z.boolean().optional(),
terminalOutputLineLimit: z.number().int().nonnegative().optional(),
terminalShellIntegrationDisabled: z.boolean().optional(),
terminalShellIntegrationTimeout: z.number().int().nonnegative().optional(),
terminalZshClearEolMark: z.boolean().optional(),
});
export type OrganizationDefaultSettings = z.infer<
@ -47,3 +55,9 @@ export const organizationSettingsSchema = z.object({
});
export type OrganizationSettings = z.infer<typeof organizationSettingsSchema>;
export const ORGANIZATION_DEFAULT: OrganizationSettings = {
version: 0,
defaultSettings: {},
allowList: ORGANIZATION_ALLOW_ALL,
} as const;