diff --git a/docs/providers.md b/docs/providers.md index 002eb9111..43155d413 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -12,7 +12,7 @@ Tale connects to AI models through **providers** — OpenAI-compatible API endpo Providers are managed in **Settings > Providers** in the management UI. Admins can: - **Add a provider** with a name, display name, base URL, API key, and one or more models -- **Edit a provider** to update its configuration or add/remove models +- **Edit a provider** to update its display name, description, base URL, and default models. The description is shown in the provider list to help users understand what the provider is for. Default models let you pre-select which model is used for chat, vision, and embedding when users pick this provider. - **Delete a provider** to remove it entirely Each model definition includes an ID (must match the model name expected by the API), a display name, and one or more tags (`chat`, `vision`, `embedding`) that control where the model appears in the platform. diff --git a/docs/settings.md b/docs/settings.md index b41403859..a1fd0f54d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -66,6 +66,30 @@ Customize the look of the platform for your organization. Admin only. Available Available to all users. Change your password here. If you signed up via SSO, you can also set a regular password from this page to enable direct login. +## Governance + +Admin only. Configure organization-wide AI policies and controls. The governance page is organized into three groups accessible from a left-hand navigation: + +### Content & Models + +- **System Prompt**: set a global system prompt prepended to every AI conversation in the organization. +- **Default Models**: choose the default chat, vision, and embedding models used when users don't pick one explicitly. +- **Model Access**: control which models are available to specific teams or users. + +### Policies & Limits + +- **Budgets**: set spending limits per user, team, or the entire organization with configurable periods and thresholds. +- **Upload Policy**: restrict file uploads by type, size, or count. +- **Retention**: configure how long conversations and files are kept before automatic deletion. +- **Feature Controls**: toggle platform features (e.g., file uploads, web search, image generation) on or off organization-wide. + +### Security & Monitoring + +- **PII Detection**: enable automatic detection and masking (or blocking) of personally identifiable information in messages. Supports built-in patterns and custom regex rules. +- **Usage Dashboard**: view token consumption, cost breakdowns, and usage trends across the organization. + ## Audit logs Admin only. A time-ordered record of significant actions taken in the organization. Categories include authentication events, member changes, data operations, integration updates, workflow publications, security events, and admin actions. Useful for compliance and troubleshooting. + +Admins can export audit logs as **CSV** or **JSON** using the export buttons above the log table. Exports respect the currently active category filter. diff --git a/examples/providers/openrouter.secrets.json b/examples/providers/openrouter.secrets.json index e58ac25c9..e7b0ebbdd 100644 --- a/examples/providers/openrouter.secrets.json +++ b/examples/providers/openrouter.secrets.json @@ -1,20 +1,15 @@ { - "apiKey": "ENC[AES256_GCM,data:MJKxyEKDQFk2Y+E3R7eK13LwIxO9FQ6TxAYc93NlRc56As9XMaLTx0CNfM82MyoHyQCduKgCf3a8RK9r2bWVgo8NnVnd2YzGOA==,iv:OqTfslCeZbiYkEQqAI/Jj0NLuJW1h0hdZPus8Sel3cU=,tag:yaBSdC8+gmPdEcfLmECXOg==,type:str]", + "apiKey": "ENC[AES256_GCM,data:Os9IU5ZlUhe7QWMhqrhsVIFNKVD2FzszIkHGOb9l8Oa8z57AZUek+XK7qM//OyY60sMQQjVkbnygU7175lgO05lXlWRLqWiksA==,iv:2rpxkfK022BbwBCDwhu2YvlbF+HXv+KPaLPu6dQWX/k=,tag:gqMQcSCc9Q2wGJaer5V3jw==,type:str]", "sops": { - "kms": null, - "gcp_kms": null, - "azure_kv": null, - "hc_vault": null, "age": [ { - "recipient": "age1xsc5y9x0dref9kd6fwv2356pw2zl5s7gp5v6jam9h4q7mv6fm9aqumvqhj", - "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPYnV2WjVHR3ZFSGRlYmxR\ncnUybXVRcWNrVFkrMVlnTkZ1bFhNRmk3WjBVCmtSZ2RsbFZCdWY5dk94RlhhU1dN\nQnZnejFvYW4vVTBOUWVBUHJRaEovQmcKLS0tIENlVUJmTjFDcXZjWjV4RitnZHlv\nN09UeFo5Y2xnZXRwQ3RabEc2QVgyckUK7u0Avecl9B628T56Np6gGxQ1+yCSRjXa\n8HBlTjMa4g1OR8d4isfZ9VE+lETdNaVlultd9F1GWEvgv/p7WxpeUg==\n-----END AGE ENCRYPTED FILE-----\n" + "recipient": "age18ylfcfvf9we4rc5hpza6n9tvhwuw55jfun9wdpd5uhux5rs3qf4q95y2y4", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByUU1FMndnUDAzeTZySW1x\nMFJwNEhuYXF4UlZZWGRQaS85SjNkbmVpMG44CjVhTEh4c1BNZ1VXZXN3R2cxeFBt\nVjB6Sm5BOXc1NFdCcGMzS1BEWDJHeWMKLS0tIEFUekUrOHFmMSsraFdMOVU4d3FJ\nTzdwWWtiNytmTVNRQW5MTExRVEk3MmcKHmSVxvF3Nz/yMGZ3lFY+oDcryUdoOT7/\nmxnY/U4ZX3sCkLphYdUVuAA6nqstfzVn57X1ND8vqaFXPn2lhVc00Q==\n-----END AGE ENCRYPTED FILE-----\n" } ], - "lastmodified": "2026-04-05T11:39:10Z", - "mac": "ENC[AES256_GCM,data:2OYyLhDFsCM/spi9pp6bUXwuShtuqumEv7brGMrcRMdziaX8tQTnEL8Kcd774ne3BFDg+sfAnX2SH7e66qUxXd1qMXJWNN96lkjStc422rilpXWvq29RDKjatuAf7qSZoF0rWFNK9sAeQ0J+6bHytQ5FT3DXnS0SoXozMxCvz3A=,iv:8fx48QGvx8Nudqo1ByyWnPsebKn6BpAHQzw5rvj7zdg=,tag:21dyPzSJgJNpnStSvxs+yA==,type:str]", - "pgp": null, + "lastmodified": "2026-04-12T18:34:34Z", + "mac": "ENC[AES256_GCM,data:dTQzeY02n+I8wUXpfhlHBAQ8EZgMmPo1lMKZFtvbyEl9VWgYc9Q9+Bde0g6hmpXJNKqNx4VNhiQV6NFl0oyQQNpaN6yAu9Ka1gs64eymLUwuiBigSvSoiBOoICGaHx9rNhdgu55vU3xCGpp1C4lwGdj6lpKYJ7Pefdc0ZS1rnLo=,iv:ZBoTH20q8nPOp5H11WQiiH/VrKMao2RcaBiu9TeSVx4=,tag:HiqFbmIQMpO5Id0S7ls6Gw==,type:str]", "unencrypted_suffix": "_unencrypted", - "version": "3.9.4" + "version": "3.12.2" } -} \ No newline at end of file +} diff --git a/services/platform/app/components/ui/forms/input.test.tsx b/services/platform/app/components/ui/forms/input.test.tsx index bfda670d5..dd1e97d2f 100644 --- a/services/platform/app/components/ui/forms/input.test.tsx +++ b/services/platform/app/components/ui/forms/input.test.tsx @@ -188,7 +188,7 @@ describe('Input', () => { it('sets autocomplete for password', () => { render(); const input = screen.getByLabelText('Password'); - expect(input).toHaveAttribute('autocomplete', 'current-password'); + expect(input).toHaveAttribute('autocomplete', 'off'); }); it('allows custom autocomplete', () => { diff --git a/services/platform/app/components/ui/forms/input.tsx b/services/platform/app/components/ui/forms/input.tsx index ad7a89dac..9b26207dc 100644 --- a/services/platform/app/components/ui/forms/input.tsx +++ b/services/platform/app/components/ui/forms/input.tsx @@ -82,7 +82,7 @@ export const Input = forwardRef( const [showShake, setShowShake] = useState(false); const inputType = isPassword ? (show ? 'text' : 'password') : type; const resolvedAutoComplete = - autoComplete ?? (isPassword ? 'current-password' : undefined); + autoComplete ?? (isPassword ? 'off' : undefined); const hasError = !!errorMessage; const showInvalid = hasError || !!isInvalid; const describedBy = diff --git a/services/platform/app/components/ui/navigation/tabs.tsx b/services/platform/app/components/ui/navigation/tabs.tsx index 825158786..1806dad77 100644 --- a/services/platform/app/components/ui/navigation/tabs.tsx +++ b/services/platform/app/components/ui/navigation/tabs.tsx @@ -19,6 +19,8 @@ interface TabsProps { onValueChange?: (value: string) => void; className?: string; listClassName?: string; + /** Optional actions rendered to the right of the tab list */ + actions?: ReactNode; } export function Tabs({ @@ -28,6 +30,7 @@ export function Tabs({ onValueChange, className, listClassName, + actions, }: TabsProps) { return ( - - {items.map((item) => ( - - {item.label} - - ))} - +
+ + {items.map((item) => ( + + {item.label} + + ))} + + {actions &&
{actions}
} +
{items.map( (item) => item.content && ( {item.content} diff --git a/services/platform/app/features/chat/components/message-bubble.tsx b/services/platform/app/features/chat/components/message-bubble.tsx index 4df3a3446..71a84e579 100644 --- a/services/platform/app/features/chat/components/message-bubble.tsx +++ b/services/platform/app/features/chat/components/message-bubble.tsx @@ -419,7 +419,7 @@ function MessageBubbleComponent({ )} {!isUser && !isAssistantStreaming && !!displayContent && ( -
+
; category?: string; - isAdmin?: boolean; userEmailMap?: Map; } @@ -34,13 +29,11 @@ export function AuditLogTable({ organizationId, paginatedResult, category, - isAdmin: isAdminUser = false, userEmailMap, }: AuditLogTableProps) { const navigate = useNavigate(); const { formatDate } = useFormatDate(); const { t } = useT('settings'); - const { toast } = useToast(); const [selectedLog, setSelectedLog] = useState(null); const resolveEmail = useCallback( @@ -53,35 +46,6 @@ export function AuditLogTable({ resolveEmail, }); - const exportAction = useConvexAction(api.audit_logs.actions.requestExport, { - onSuccess: (data) => { - if (data.url) { - window.open(data.url, '_blank', 'noopener,noreferrer'); - } - toast({ - title: t('logs.audit.export.complete'), - description: data.fileName, - }); - }, - onError: () => { - toast({ - title: t('logs.audit.export.error'), - variant: 'destructive', - }); - }, - }); - - const handleExport = useCallback( - (format: 'csv' | 'json') => { - exportAction.mutate({ - organizationId, - format, - filter: category ? { category } : undefined, - }); - }, - [organizationId, category, exportAction], - ); - const handleCategoryChange = useCallback( (values: string[]) => { void navigate({ @@ -146,33 +110,6 @@ export function AuditLogTable({ return ( <> - {isAdminUser && ( -
- - -
- )} - - - {draft.scopeId - ? (memberOptions.find((o) => o.value === draft.scopeId) - ?.label ?? draft.scopeId) - : t('budgets.selectUser')} - - + {draft.scopeId + ? (memberOptions.find((o) => o.value === draft.scopeId) + ?.label ?? draft.scopeId) + : t('budgets.selectUser')} + } />
@@ -208,20 +205,15 @@ function RuleDialog({ emptyText={t('budgets.noTeamsFound')} aria-label={t('budgets.selectTeamAriaLabel')} trigger={ - + {draft.scopeId + ? (teamOptions.find((o) => o.value === draft.scopeId) + ?.label ?? draft.scopeId) + : t('budgets.selectTeam')} + } />
@@ -506,132 +498,139 @@ export function BudgetEditor({ organizationId }: BudgetEditorProps) { /> } > - - - {t('budgets.overrideHint')} - - - - {rules.length > 0 ? ( -
- - - - - - - - - - - - - - - {rules.map((rule, index) => ( - - - - - - - - +
+ + + {t('budgets.overrideHint')} + + + + {rules.length > 0 ? ( +
+
{t('budgets.title')}
- {t('budgets.scope')} - - {t('budgets.target')} - - {t('budgets.period')} - - {t('budgets.tokenLimit')} - - {t('budgets.maxCost')} - - {t('budgets.maxRequests')} - - {t('budgets.actions')} -
{rule.scope}{resolveTarget(rule)}{rule.period} - {rule.maxTokens != null - ? rule.maxTokens.toLocaleString() - : '\u2014'} - - {rule.maxCostCents != null - ? formatCost(rule.maxCostCents) - : '\u2014'} - - {rule.maxRequests != null - ? rule.maxRequests.toLocaleString() - : '\u2014'} - - - - - -
+ + + + + + + + + + - ))} - -
{t('budgets.title')}
+ {t('budgets.scope')} + + {t('budgets.target')} + + {t('budgets.period')} + + {t('budgets.tokenLimit')} + + {t('budgets.maxCost')} + + {t('budgets.maxRequests')} + + {t('budgets.actions')} +
-
- ) : ( - - {t('budgets.noRules')} - - )} + + + {rules.map((rule, index) => ( + + {rule.scope} + {resolveTarget(rule)} + {rule.period} + + {rule.maxTokens != null + ? rule.maxTokens.toLocaleString() + : '\u2014'} + + + {rule.maxCostCents != null + ? formatCost(rule.maxCostCents) + : '\u2014'} + + + {rule.maxRequests != null + ? rule.maxRequests.toLocaleString() + : '\u2014'} + + + + + + + + + ))} + + + + ) : ( + + {t('budgets.noRules')} + + )} - + +
- + - -
+ +
+
{t('defaultModels.target')} @@ -205,90 +207,70 @@ function RuleDialog({ emptyText={t('defaultModels.noTeamsFound')} aria-label={t('defaultModels.target')} trigger={ - + {draft.scopeId + ? (teamOptions.find((o) => o.value === draft.scopeId) + ?.label ?? draft.scopeId) + : t('defaultModels.selectTeam')} + } />
)} - -
- - {t('defaultModels.provider')} - - updateDraft({ providerName: value })} - options={providerOptions} - searchPlaceholder={t('defaultModels.searchProviders')} - emptyText={t('defaultModels.noProvidersFound')} - aria-label={t('defaultModels.provider')} - trigger={ - - } - /> -
- -
- - {t('defaultModels.model')} - - updateDraft({ modelId: value })} - options={modelOptions} - searchPlaceholder={t('defaultModels.searchModels')} - emptyText={t('defaultModels.noModelsFound')} - aria-label={t('defaultModels.model')} - trigger={ - - } - /> -
-
+
+ + {t('defaultModels.provider')} + + updateDraft({ providerName: value })} + options={providerOptions} + searchPlaceholder={t('defaultModels.searchProviders')} + emptyText={t('defaultModels.noProvidersFound')} + aria-label={t('defaultModels.provider')} + trigger={ + + {draft.providerName + ? (providerOptions.find((o) => o.value === draft.providerName) + ?.label ?? draft.providerName) + : t('defaultModels.selectProvider')} + + } + /> +
+ +
+ + {t('defaultModels.model')} + + updateDraft({ modelId: value })} + options={modelOptions} + searchPlaceholder={t('defaultModels.searchModels')} + emptyText={t('defaultModels.noModelsFound')} + aria-label={t('defaultModels.model')} + trigger={ + + {draft.modelId + ? (modelOptions.find((o) => o.value === draft.modelId) + ?.label ?? draft.modelId) + : t('defaultModels.selectModel')} + + } + /> +
); @@ -415,7 +397,15 @@ export function DefaultModelEditor({ (rule: DefaultModelRule) => { let newRules: DefaultModelRule[]; if (editingIndex === null) { - newRules = [...rules, rule]; + // Replace any existing rule with the same scope+target instead of duplicating + const existingIndex = rules.findIndex( + (r) => r.scope === rule.scope && r.scopeId === rule.scopeId, + ); + if (existingIndex !== -1) { + newRules = rules.map((r, i) => (i === existingIndex ? rule : r)); + } else { + newRules = [...rules, rule]; + } } else { newRules = rules.map((r, i) => (i === editingIndex ? rule : r)); } @@ -436,7 +426,11 @@ export function DefaultModelEditor({ ); } case 'role': - return rule.scopeId ?? '\u2014'; + return ( + ROLE_OPTIONS.find((o) => o.value === rule.scopeId)?.label ?? + rule.scopeId ?? + '\u2014' + ); case 'default': return t('defaultModels.allUsers'); default: @@ -483,104 +477,113 @@ export function DefaultModelEditor({ /> } > - - - {rules.length > 0 ? ( -
- - - - - - - - - - - - - {rules.map((rule, index) => ( - - - - - - +
+ + + {rules.length > 0 ? ( +
+
- {t('defaultModels.title')} -
- {t('defaultModels.scope')} - - {t('defaultModels.target')} - - {t('defaultModels.provider')} - - {t('defaultModels.model')} - - {t('defaultModels.actions')} -
{rule.scope}{resolveTarget(rule)}{resolveProviderName(rule)}{resolveModelName(rule)} - - - - -
+ + + + + + + + - ))} - -
+ {t('defaultModels.title')} +
+ {t('defaultModels.scope')} + + {t('defaultModels.target')} + + {t('defaultModels.provider')} + + {t('defaultModels.model')} + + {t('defaultModels.actions')} +
-
- ) : ( - - {t('defaultModels.noRules')} - - )} + + + {rules.map((rule, index) => ( + + {rule.scope} + {resolveTarget(rule)} + + {resolveProviderName(rule)} + + {resolveModelName(rule)} + + + + + + + + ))} + + +
+ ) : ( + + {t('defaultModels.noRules')} + + )} - + + - +
- - {draft.scopeId - ? (memberOptions.find((o) => o.value === draft.scopeId) - ?.label ?? draft.scopeId) - : t('featureFlags.selectUser')} - - + {draft.scopeId + ? (memberOptions.find((o) => o.value === draft.scopeId) + ?.label ?? draft.scopeId) + : t('featureFlags.selectUser')} + } /> @@ -223,20 +220,15 @@ function RuleDialog({ emptyText={t('featureFlags.noTeamsFound')} aria-label={t('featureFlags.selectTeam')} trigger={ - + {draft.scopeId + ? (teamOptions.find((o) => o.value === draft.scopeId) + ?.label ?? draft.scopeId) + : t('featureFlags.selectTeam')} + } /> @@ -481,125 +473,134 @@ export function FeatureFlagsEditor({ /> } > - - - {rules.length > 0 ? ( -
- - - - - - - - - - - - - - - {rules.map((rule, index) => ( - - - - - - - - +
+ + + {rules.length > 0 ? ( +
+
{t('featureFlags.title')}
- {t('featureFlags.scope')} - - {t('featureFlags.target')} - - {t('featureFlags.webSearch')} - - {t('featureFlags.codeExecution')} - - {t('featureFlags.fileUpload')} - - {t('featureFlags.maxContextTokens')} - - {t('featureFlags.actions')} -
{rule.scope}{resolveTarget(rule)} - {rule.webSearch === false ? '\u2718' : '\u2714'} - - {rule.codeExecution === false ? '\u2718' : '\u2714'} - - {rule.fileUpload === false ? '\u2718' : '\u2714'} - - {rule.maxContextTokens != null - ? formatNumber(rule.maxContextTokens) - : '\u2014'} - - - - - -
+ + + + + + + + + + - ))} - -
+ {t('featureFlags.title')} +
+ {t('featureFlags.scope')} + + {t('featureFlags.target')} + + {t('featureFlags.webSearch')} + + {t('featureFlags.codeExecution')} + + {t('featureFlags.fileUpload')} + + {t('featureFlags.maxContextTokens')} + + {t('featureFlags.actions')} +
-
- ) : ( - - {t('featureFlags.noRules')} - - )} + + + {rules.map((rule, index) => ( + + {rule.scope} + {resolveTarget(rule)} + + {rule.webSearch === false ? '\u2718' : '\u2714'} + + + {rule.codeExecution === false ? '\u2718' : '\u2714'} + + + {rule.fileUpload === false ? '\u2718' : '\u2714'} + + + {rule.maxContextTokens != null + ? formatNumber(rule.maxContextTokens) + : '\u2014'} + + + + + + + + + ))} + + + + ) : ( + + {t('featureFlags.noRules')} + + )} - + +
-
+ - - {draft.scopeId - ? (memberOptions.find((o) => o.value === draft.scopeId) - ?.label ?? draft.scopeId) - : t('modelAccess.selectUser')} - - + {draft.scopeId + ? (memberOptions.find((o) => o.value === draft.scopeId) + ?.label ?? draft.scopeId) + : t('modelAccess.selectUser')} + } /> @@ -212,20 +209,15 @@ function RuleDialog({ emptyText={t('modelAccess.noTeamsFound')} aria-label={t('modelAccess.selectTeam')} trigger={ - + {draft.scopeId + ? (teamOptions.find((o) => o.value === draft.scopeId) + ?.label ?? draft.scopeId) + : t('modelAccess.selectTeam')} + } /> @@ -454,9 +446,22 @@ export function ModelAccessEditor({ organizationId }: ModelAccessEditorProps) { + } > - - +
+ {t('modelAccess.mode')}
@@ -469,106 +474,102 @@ export function ModelAccessEditor({ organizationId }: ModelAccessEditorProps) { />
- - - - {rules.length > 0 ? ( -
- - - - - - - - - - - - {rules.map((rule, index) => ( - - - - + {rules.map((rule, index) => ( + + + + + + + ))} + +
{t('modelAccess.title')}
- {t('modelAccess.scope')} - - {t('modelAccess.target')} - - {mode === 'allowlist' - ? t('modelAccess.allowedModels') - : t('modelAccess.blockedModels')} - - {t('modelAccess.actions')} -
{rule.scope}{resolveTarget(rule)} + + {rules.length > 0 ? ( +
+ + + + + + + + ? t('modelAccess.allowedModels') + : t('modelAccess.blockedModels')} + + - ))} - -
+ {t('modelAccess.title')} +
+ {t('modelAccess.scope')} + + {t('modelAccess.target')} + {mode === 'allowlist' - ? resolveModelNames(rule.allowedModels) - : resolveModelNames(rule.blockedModels ?? [])} - - - - - - - + {t('modelAccess.actions')} +
-
- ) : ( - - {t('modelAccess.noRules')} - - )} + +
{rule.scope}{resolveTarget(rule)} + {mode === 'allowlist' + ? resolveModelNames(rule.allowedModels) + : resolveModelNames(rule.blockedModels ?? [])} + + + + + +
+
+ ) : ( + + {t('modelAccess.noRules')} + + )} - + +
- +
{dialogOpen && ( | null>(null); - const [initialized, setInitialized] = useState(false); + + const cannotManage = ability.cannot('write', 'orgSettings'); + const initialized = useRef(false); // Sync from server data once loaded - if (policy && !initialized) { - setEnabled(policy.enabled ?? false); - setMode(policy.config?.mode ?? 'mask'); - setEnabledPatterns( - new Set(policy.config?.enabledPatterns ?? PATTERN_NAMES), - ); - setCustomPatterns(policy.config?.customPatterns ?? []); - setInitialized(true); - } + useEffect(() => { + if (policy && !initialized.current) { + initialized.current = true; + setEnabled(policy.enabled ?? false); + setMode(policy.config?.mode ?? 'mask'); + setEnabledPatterns( + new Set(policy.config?.enabledPatterns ?? PATTERN_NAMES), + ); + setCustomPatterns(policy.config?.customPatterns ?? []); + } + }, [policy]); const saveConfig = useCallback( async (overrides: { @@ -88,8 +94,10 @@ export function PiiConfig({ organizationId }: PiiConfigProps) { try { await upsertMutation.mutateAsync(resolved); toast({ title: t('pii.saved'), variant: 'success' }); - } catch { - toast({ title: t('pii.saveFailed'), variant: 'destructive' }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : t('pii.saveFailed'); + toast({ title: message, variant: 'destructive' }); } }, [ @@ -197,11 +205,7 @@ export function PiiConfig({ organizationId }: PiiConfigProps) { }, [testText, enabledPatterns, customPatterns]); if (isLoading) { - return ( -
- -
- ); + return null; } const modeOptions = [ @@ -210,24 +214,27 @@ export function PiiConfig({ organizationId }: PiiConfigProps) { ]; return ( -
- + - - + } + > {enabled && ( - <> +
@@ -382,8 +396,8 @@ export function PiiConfig({ organizationId }: PiiConfigProps) { )} - +
)} -
+ ); } diff --git a/services/platform/app/features/settings/governance/components/retention-editor.tsx b/services/platform/app/features/settings/governance/components/retention-editor.tsx index d28dbe459..213677cd1 100644 --- a/services/platform/app/features/settings/governance/components/retention-editor.tsx +++ b/services/platform/app/features/settings/governance/components/retention-editor.tsx @@ -14,6 +14,7 @@ import { retentionPolicyConfigSchema, type RetentionPolicyConfig, } from '@/lib/shared/schemas/governance'; +import { cn } from '@/lib/utils/cn'; import { isRecord } from '@/lib/utils/type-guards'; import { useUpsertGovernancePolicy } from '../hooks/mutations'; @@ -125,23 +126,30 @@ export function RetentionEditor({ organizationId }: RetentionEditorProps) { /> } > -
- - setRetentionDays(e.target.value ? Number(e.target.value) : 0) - } - onBlur={() => void saveConfig({ retentionDays })} - disabled={cannotManage || !enabled} - size="sm" - placeholder="e.g. 90" - min={0} - /> - - Documents older than this will be deleted. - +
+
+ + setRetentionDays(e.target.value ? Number(e.target.value) : 0) + } + onBlur={() => void saveConfig({ retentionDays })} + disabled={cannotManage || !enabled} + size="sm" + placeholder="e.g. 90" + min={0} + /> + + Documents older than this will be deleted. + +
@@ -160,25 +168,32 @@ export function RetentionEditor({ organizationId }: RetentionEditorProps) { /> } > -
- - setUserTempRetentionHours( - e.target.value ? Number(e.target.value) : 0, - ) - } - onBlur={() => void saveConfig({ userTempRetentionHours })} - disabled={cannotManage || !userTempEnabled} - size="sm" - placeholder="e.g. 24" - min={0} - /> - - Temporary files older than this will be deleted. - +
+
+ + setUserTempRetentionHours( + e.target.value ? Number(e.target.value) : 0, + ) + } + onBlur={() => void saveConfig({ userTempRetentionHours })} + disabled={cannotManage || !userTempEnabled} + size="sm" + placeholder="e.g. 24" + min={0} + /> + + Temporary files older than this will be deleted. + +
@@ -197,25 +212,32 @@ export function RetentionEditor({ organizationId }: RetentionEditorProps) { /> } > -
- - setAgentTempRetentionHours( - e.target.value ? Number(e.target.value) : 0, - ) - } - onBlur={() => void saveConfig({ agentTempRetentionHours })} - disabled={cannotManage || !agentTempEnabled} - size="sm" - placeholder="e.g. 24" - min={0} - /> - - Temporary files older than this will be deleted. - +
+
+ + setAgentTempRetentionHours( + e.target.value ? Number(e.target.value) : 0, + ) + } + onBlur={() => void saveConfig({ agentTempRetentionHours })} + disabled={cannotManage || !agentTempEnabled} + size="sm" + placeholder="e.g. 24" + min={0} + /> + + Temporary files older than this will be deleted. + +
diff --git a/services/platform/app/features/settings/governance/components/select-trigger-button.tsx b/services/platform/app/features/settings/governance/components/select-trigger-button.tsx new file mode 100644 index 000000000..a1f411d51 --- /dev/null +++ b/services/platform/app/features/settings/governance/components/select-trigger-button.tsx @@ -0,0 +1,23 @@ +import { type ButtonHTMLAttributes, forwardRef } from 'react'; + +interface SelectTriggerButtonProps extends ButtonHTMLAttributes { + hasValue: boolean; +} + +export const SelectTriggerButton = forwardRef< + HTMLButtonElement, + SelectTriggerButtonProps +>(function SelectTriggerButton({ hasValue, children, ...props }, ref) { + return ( + + ); +}); diff --git a/services/platform/app/features/settings/governance/components/system-prompt-editor.tsx b/services/platform/app/features/settings/governance/components/system-prompt-editor.tsx index a19430290..c75e37c64 100644 --- a/services/platform/app/features/settings/governance/components/system-prompt-editor.tsx +++ b/services/platform/app/features/settings/governance/components/system-prompt-editor.tsx @@ -115,7 +115,7 @@ export function SystemPromptEditor({ value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t('systemPrompt.prefixPlaceholder')} - rows={6} + rows={4} aria-label={t('systemPrompt.prefixLabel')} errorMessage={ prefixOverLimit ? t('systemPrompt.charLimitExceeded') : undefined @@ -137,7 +137,7 @@ export function SystemPromptEditor({ value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t('systemPrompt.suffixPlaceholder')} - rows={6} + rows={4} aria-label={t('systemPrompt.suffixLabel')} errorMessage={ suffixOverLimit ? t('systemPrompt.charLimitExceeded') : undefined diff --git a/services/platform/app/features/settings/governance/components/upload-policy-editor.tsx b/services/platform/app/features/settings/governance/components/upload-policy-editor.tsx index a21b17b29..32983cc69 100644 --- a/services/platform/app/features/settings/governance/components/upload-policy-editor.tsx +++ b/services/platform/app/features/settings/governance/components/upload-policy-editor.tsx @@ -15,6 +15,7 @@ import { uploadPolicyConfigSchema, type UploadPolicyConfig, } from '@/lib/shared/schemas/governance'; +import { cn } from '@/lib/utils/cn'; import { isRecord } from '@/lib/utils/type-guards'; import { useUpsertGovernancePolicy } from '../hooks/mutations'; @@ -164,77 +165,84 @@ export function UploadPolicyEditor({ )} - -
- setAllowedExtensions(e.target.value)} - placeholder={t('uploadPolicy.extensionPlaceholder')} - disabled={cannotManage || !enabled} - size="sm" - /> -
- -
- setBlockedExtensions(e.target.value)} - placeholder={t('uploadPolicy.extensionPlaceholder')} - disabled={cannotManage || !enabled} - size="sm" - /> -
- -
- setAllowedMimeTypes(e.target.value)} - placeholder={t('uploadPolicy.mimeTypePlaceholder')} - disabled={cannotManage || !enabled} - size="sm" - /> -
- -
- setMaxFileSizeMB(e.target.value)} - disabled={cannotManage || !enabled} - size="sm" - min={0} - step={1} - /> -
- -
- setMaxVolumeGB(e.target.value)} - disabled={cannotManage || !enabled} - size="sm" - min={0} - step={0.1} - /> -
-
- - + +
+ setAllowedExtensions(e.target.value)} + placeholder={t('uploadPolicy.extensionPlaceholder')} + disabled={cannotManage || !enabled} + size="sm" + /> +
+ +
+ setBlockedExtensions(e.target.value)} + placeholder={t('uploadPolicy.extensionPlaceholder')} + disabled={cannotManage || !enabled} + size="sm" + /> +
+ +
+ setAllowedMimeTypes(e.target.value)} + placeholder={t('uploadPolicy.mimeTypePlaceholder')} + disabled={cannotManage || !enabled} + size="sm" + /> +
+ +
+ setMaxFileSizeMB(e.target.value)} + disabled={cannotManage || !enabled} + size="sm" + min={0} + step={1} + /> +
+ +
+ setMaxVolumeGB(e.target.value)} + disabled={cannotManage || !enabled} + size="sm" + min={0} + step={0.1} + /> +
+
+ + +
); diff --git a/services/platform/app/features/settings/mcp-servers/components/__tests__/mcp-server-card.test.tsx b/services/platform/app/features/settings/mcp-servers/components/__tests__/mcp-server-card.test.tsx index dd18273a8..c45737ece 100644 --- a/services/platform/app/features/settings/mcp-servers/components/__tests__/mcp-server-card.test.tsx +++ b/services/platform/app/features/settings/mcp-servers/components/__tests__/mcp-server-card.test.tsx @@ -27,17 +27,38 @@ function makeServer( describe('McpServerCard', () => { it('renders server display name', () => { - render(); + render( + , + ); expect(screen.getByText('Test Server')).toBeInTheDocument(); }); it('renders description', () => { - render(); + render( + , + ); expect(screen.getByText('A test MCP server')).toBeInTheDocument(); }); it('renders tool count', () => { - render(); + render( + , + ); expect(screen.getByText('1 tool')).toBeInTheDocument(); }); @@ -51,6 +72,8 @@ describe('McpServerCard', () => { ], })} onClick={vi.fn()} + onEdit={vi.fn()} + onDelete={vi.fn()} />, ); expect(screen.getByText('2 tools')).toBeInTheDocument(); @@ -59,9 +82,14 @@ describe('McpServerCard', () => { it('calls onClick when clicked', async () => { const onClick = vi.fn(); const { user } = render( - , + , ); - await user.click(screen.getByRole('button')); + await user.click(screen.getByText('Test Server')); expect(onClick).toHaveBeenCalledTimes(1); }); @@ -70,6 +98,8 @@ describe('McpServerCard', () => { , ); expect(screen.getByText('Connected')).toBeInTheDocument(); @@ -80,6 +110,8 @@ describe('McpServerCard', () => { , ); expect(screen.getByText('Disconnected')).toBeInTheDocument(); @@ -90,6 +122,8 @@ describe('McpServerCard', () => { , ); expect(screen.getByText('Error')).toBeInTheDocument(); @@ -98,7 +132,12 @@ describe('McpServerCard', () => { describe('accessibility', () => { it('passes axe audit for active server', async () => { const { container } = render( - , + , ); await checkAccessibility(container); }); @@ -108,6 +147,8 @@ describe('McpServerCard', () => { , ); await checkAccessibility(container); @@ -118,6 +159,8 @@ describe('McpServerCard', () => { , ); await checkAccessibility(container); diff --git a/services/platform/app/features/settings/mcp-servers/components/__tests__/mcp-server-panel.test.tsx b/services/platform/app/features/settings/mcp-servers/components/__tests__/mcp-server-panel.test.tsx index 53f6cbec1..14b410e7b 100644 --- a/services/platform/app/features/settings/mcp-servers/components/__tests__/mcp-server-panel.test.tsx +++ b/services/platform/app/features/settings/mcp-servers/components/__tests__/mcp-server-panel.test.tsx @@ -135,7 +135,7 @@ describe('McpServerPanel', () => { expect(screen.getByText('https://example.com/mcp')).toBeInTheDocument(); }); - it('renders delete button', () => { + it('renders actions menu with edit and delete', () => { render( { onUpdated={vi.fn()} />, ); - expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /actions menu/i }), + ).toBeInTheDocument(); }); describe('accessibility', () => { diff --git a/services/platform/app/features/settings/mcp-servers/components/mcp-server-card.tsx b/services/platform/app/features/settings/mcp-servers/components/mcp-server-card.tsx index 076368b5e..805e742ac 100644 --- a/services/platform/app/features/settings/mcp-servers/components/mcp-server-card.tsx +++ b/services/platform/app/features/settings/mcp-servers/components/mcp-server-card.tsx @@ -1,10 +1,16 @@ 'use client'; -import { Server, Wrench } from 'lucide-react'; +import { Ellipsis, Pencil, Server, Trash2, Wrench } from 'lucide-react'; +import { useMemo } from 'react'; import { Badge } from '@/app/components/ui/feedback/badge'; import { Card } from '@/app/components/ui/layout/card'; import { Center, HStack, Stack } from '@/app/components/ui/layout/layout'; +import { + DropdownMenu, + type DropdownMenuGroup, +} from '@/app/components/ui/overlays/dropdown-menu'; +import { IconButton } from '@/app/components/ui/primitives/icon-button'; import { Heading } from '@/app/components/ui/typography/heading'; import { Text } from '@/app/components/ui/typography/text'; import { useT } from '@/lib/i18n/client'; @@ -14,6 +20,8 @@ import type { McpServerListItem } from './types'; interface McpServerCardProps { server: McpServerListItem; onClick: () => void; + onEdit: () => void; + onDelete: () => void; } function StatusBadge({ status }: { status: string }) { @@ -37,62 +45,108 @@ function TransportBadge({ type }: { type: string }) { return {label}; } -export function McpServerCard({ server, onClick }: McpServerCardProps) { +export function McpServerCard({ + server, + onClick, + onEdit, + onDelete, +}: McpServerCardProps) { const { t } = useT('mcpServers'); + const { t: tCommon } = useT('common'); const toolCount = server.discoveredTools?.length ?? 0; + const menuItems = useMemo( + () => [ + [ + { + type: 'item' as const, + label: t('editServer'), + icon: Pencil, + onClick: onEdit, + }, + ], + [ + { + type: 'item' as const, + label: t('deleteServer'), + icon: Trash2, + onClick: onDelete, + destructive: true, + }, + ], + ], + [t, onEdit, onDelete], + ); + return ( - + )} + + + + {server.authType !== 'none' && ( + + {server.authType === 'api_key' + ? t('form.apiKey') + : t('form.oauth2')} + + )} + {toolCount > 0 && ( + + + + {toolCount} {toolCount === 1 ? 'tool' : 'tools'} + + + )} + + + +
); } diff --git a/services/platform/app/features/settings/mcp-servers/components/mcp-server-form.tsx b/services/platform/app/features/settings/mcp-servers/components/mcp-server-form.tsx index a36a56795..b2ba9f049 100644 --- a/services/platform/app/features/settings/mcp-servers/components/mcp-server-form.tsx +++ b/services/platform/app/features/settings/mcp-servers/components/mcp-server-form.tsx @@ -17,6 +17,10 @@ interface McpServerFormProps { isSubmitting?: boolean; onSubmit: (data: McpServerFormData) => void; onCancel?: () => void; + /** HTML id for the form element — allows external submit buttons via form attribute */ + formId?: string; + /** Hide the built-in action buttons (Cancel / Save) when rendering them externally */ + hideActions?: boolean; } export interface McpServerFormData { @@ -94,6 +98,8 @@ export function McpServerForm({ isSubmitting, onSubmit, onCancel, + formId, + hideActions, }: McpServerFormProps) { const { t } = useT('mcpServers'); @@ -258,7 +264,7 @@ export function McpServerForm({ ); return ( -
+ -
- {onCancel && ( - + )} + - )} - -
+
+ )} ); diff --git a/services/platform/app/features/settings/mcp-servers/components/mcp-server-panel.tsx b/services/platform/app/features/settings/mcp-servers/components/mcp-server-panel.tsx index f76235df1..553260a7a 100644 --- a/services/platform/app/features/settings/mcp-servers/components/mcp-server-panel.tsx +++ b/services/platform/app/features/settings/mcp-servers/components/mcp-server-panel.tsx @@ -1,12 +1,25 @@ 'use client'; import { useAction } from 'convex/react'; -import { CheckCircle2, Loader2, ShieldAlert, Wrench, X } from 'lucide-react'; -import { useCallback, useState } from 'react'; +import { + CheckCircle2, + Ellipsis, + Loader2, + Pencil, + ShieldAlert, + Trash2, + Wrench, + X, +} from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; import { DeleteDialog } from '@/app/components/ui/dialog/delete-dialog'; import { Badge } from '@/app/components/ui/feedback/badge'; import { HStack, Stack } from '@/app/components/ui/layout/layout'; +import { + DropdownMenu, + type DropdownMenuGroup, +} from '@/app/components/ui/overlays/dropdown-menu'; import { Sheet } from '@/app/components/ui/overlays/sheet'; import { Button } from '@/app/components/ui/primitives/button'; import { IconButton } from '@/app/components/ui/primitives/icon-button'; @@ -26,6 +39,7 @@ interface McpServerPanelProps { server: McpServerListItem; onDeleted: () => void; onUpdated: () => void; + initialEditing?: boolean; } export function McpServerPanel({ @@ -34,6 +48,7 @@ export function McpServerPanel({ server, onDeleted, onUpdated, + initialEditing = false, }: McpServerPanelProps) { const { t } = useT('mcpServers'); const { t: tCommon } = useT('common'); @@ -48,7 +63,7 @@ export function McpServerPanel({ ); const [isTesting, setIsTesting] = useState(false); - const [isEditing, setIsEditing] = useState(false); + const [isEditing, setIsEditing] = useState(initialEditing); const [isSubmitting, setIsSubmitting] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -147,6 +162,31 @@ export function McpServerPanel({ const discoveredTools = server.discoveredTools ?? []; + const menuItems = useMemo( + () => [ + [ + { + type: 'item' as const, + label: t('editServer'), + icon: Pencil, + onClick: () => setIsEditing(true), + disabled: isTesting || isDeleting, + }, + ], + [ + { + type: 'item' as const, + label: t('deleteServer'), + icon: Trash2, + onClick: () => setConfirmDelete(true), + destructive: true, + disabled: isTesting || isDeleting, + }, + ], + ], + [t, isTesting, isDeleting], + ); + return ( <> {isEditing ? t('editServer') : server.displayName} - onOpenChange(false)} - /> + + {!isEditing && ( + + } + items={menuItems} + align="end" + /> + )} + onOpenChange(false)} + /> +
{isEditing ? ( setIsEditing(false)} /> ) : ( @@ -320,51 +377,50 @@ export function McpServerPanel({ )}
- {!isEditing && ( -
- - - - - - - - - + +
+ ) : ( + + - -
- )} + {server.status === 'active' ? t('deactivate') : t('activate')} + + +
+ )} + (null); + const [openInEditMode, setOpenInEditMode] = useState(false); + const [deleteServer, setDeleteServer] = useState( + null, + ); + const [isDeleting, setIsDeleting] = useState(false); const handleCreate = useCallback( async (data: McpServerFormData) => { @@ -64,9 +74,33 @@ export function McpServers({ organizationId }: McpServersProps) { ); const handleCardClick = useCallback((server: McpServerListItem) => { + setOpenInEditMode(false); + setSelectedServerId(server._id); + }, []); + + const handleCardEdit = useCallback((server: McpServerListItem) => { + setOpenInEditMode(true); setSelectedServerId(server._id); }, []); + const handleDelete = useCallback(async () => { + if (!deleteServer) return; + setIsDeleting(true); + try { + await removeAction({ + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- server._id is a string at runtime; Convex actions require branded Id type + id: deleteServer._id as Id<'mcpServers'>, + }); + toast({ title: t('deleted'), variant: 'success' }); + setDeleteServer(null); + void refetch(); + } catch { + toast({ title: t('error'), variant: 'destructive' }); + } finally { + setIsDeleting(false); + } + }, [removeAction, deleteServer, t, refetch]); + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex query returns loosely typed data; shape matches McpServerListItem from queries.ts const serverList = (servers ?? []) as McpServerListItem[]; @@ -97,6 +131,8 @@ export function McpServers({ organizationId }: McpServersProps) { key={server._id} server={server} onClick={() => handleCardClick(server)} + onEdit={() => handleCardEdit(server)} + onDelete={() => setDeleteServer(server)} /> ))} @@ -113,13 +149,45 @@ export function McpServers({ organizationId }: McpServersProps) { onOpenChange={setAddDialogOpen} title={t('addServer')} size="md" - className="p-6" + hideClose + className="flex flex-col gap-0 p-0" > - setAddDialogOpen(false)} - /> + + + {t('addServer')} + + setAddDialogOpen(false)} + /> + +
+ +
+
+ + +
{selectedServer && ( @@ -129,6 +197,7 @@ export function McpServers({ organizationId }: McpServersProps) { if (!open) setSelectedServerId(null); }} server={selectedServer} + initialEditing={openInEditMode} onDeleted={() => { setSelectedServerId(null); void refetch(); @@ -136,6 +205,17 @@ export function McpServers({ organizationId }: McpServersProps) { onUpdated={() => void refetch()} /> )} + + { + if (!open) setDeleteServer(null); + }} + title={t('deleteServer')} + description={t('deleteConfirmation')} + isDeleting={isDeleting} + onDelete={handleDelete} + />
); } diff --git a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx index 947985a29..4d9c2bc82 100644 --- a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx +++ b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx @@ -182,6 +182,7 @@ export function AddMemberDialog({ void; @@ -34,7 +38,9 @@ export function ProviderEditPanel({ const [form, setForm] = useState({ name: '', displayName: '', + description: '', baseUrl: '', + defaults: {} as Record, }); useEffect(() => { @@ -42,7 +48,9 @@ export function ProviderEditPanel({ setForm({ name: providerName, displayName: data.config.displayName, + description: data.config.description ?? '', baseUrl: data.config.baseUrl, + defaults: { ...data.config.defaults }, }); } }, [data, providerName]); @@ -51,6 +59,9 @@ export function ProviderEditPanel({ async (e: React.FormEvent) => { e.preventDefault(); if (!data?.ok) return; + const cleanedDefaults = Object.fromEntries( + Object.entries(form.defaults).filter(([, v]) => v && v !== NONE_VALUE), + ); try { await saveProvider({ orgSlug: 'default', @@ -58,7 +69,12 @@ export function ProviderEditPanel({ config: { ...data.config, displayName: form.displayName, + description: form.description || undefined, baseUrl: form.baseUrl, + defaults: + Object.keys(cleanedDefaults).length > 0 + ? cleanedDefaults + : undefined, }, }); toast({ title: t('providers.saved'), variant: 'success' }); @@ -78,7 +94,14 @@ export function ProviderEditPanel({ const isDirty = data?.ok && (form.displayName !== data.config.displayName || - form.baseUrl !== data.config.baseUrl); + form.description !== (data.config.description ?? '') || + form.baseUrl !== data.config.baseUrl || + form.defaults.chat !== (data.config.defaults?.chat ?? NONE_VALUE) || + form.defaults.vision !== (data.config.defaults?.vision ?? NONE_VALUE) || + form.defaults.embedding !== + (data.config.defaults?.embedding ?? NONE_VALUE)); + + const models = data?.ok ? data.config.models : []; return ( +