From 5736c3cf4f3b4a523dd12f55a9e702d7344e1ace Mon Sep 17 00:00:00 2001 From: israel Date: Mon, 13 Apr 2026 01:02:41 +0100 Subject: [PATCH 1/4] refactor(platform): overhaul settings UI with grouped layouts and enhanced panels Restructure governance settings from flat tabs to grouped sections (content & models, policies & limits, security & monitoring). Add provider edit panel with description and default model selection. Enhance MCP server cards with inline edit/delete actions and detail panel menu. Add team detail dialog with member list. Add audit log export functionality. Extract reusable SelectTriggerButton component. Update Tabs component with actions slot and flex layout. Fix password input autocomplete attribute. --- examples/providers/openrouter.secrets.json | 19 +- .../app/components/ui/forms/input.test.tsx | 2 +- .../app/components/ui/forms/input.tsx | 2 +- .../app/components/ui/navigation/tabs.tsx | 42 +- .../chat/components/message-bubble.tsx | 2 +- .../account/components/account-form.tsx | 5 + .../audit-logs/components/audit-log-table.tsx | 63 - .../governance/components/budget-editor.tsx | 293 +- .../components/default-model-editor.tsx | 357 +- .../components/feature-flags-editor.tsx | 281 +- .../components/model-access-editor.tsx | 245 +- .../governance/components/pii-config.tsx | 68 +- .../components/retention-editor.tsx | 132 +- .../components/select-trigger-button.tsx | 23 + .../components/system-prompt-editor.tsx | 4 +- .../components/upload-policy-editor.tsx | 148 +- .../__tests__/mcp-server-card.test.tsx | 53 +- .../__tests__/mcp-server-panel.test.tsx | 6 +- .../components/mcp-server-card.tsx | 150 +- .../components/mcp-server-form.tsx | 38 +- .../components/mcp-server-panel.tsx | 160 +- .../mcp-servers/components/mcp-servers.tsx | 94 +- .../components/member-add-dialog.tsx | 1 + .../components/member-edit-dialog.tsx | 1 + .../components/provider-edit-panel.tsx | 77 +- .../teams/components/team-detail-dialog.tsx | 148 + .../teams/components/team-row-actions.tsx | 22 +- .../settings/teams/components/teams-table.tsx | 23 +- .../teams/hooks/use-teams-table-config.tsx | 13 +- .../dashboard/$id/settings/governance.tsx | 150 +- .../routes/dashboard/$id/settings/logs.tsx | 143 +- .../$id/settings/providers/$providerName.tsx | 741 +- services/platform/convex/_generated/api.d.ts | 9138 ++++++++++++++++- services/platform/messages/de.json | 13 +- services/platform/messages/en.json | 14 +- 35 files changed, 11186 insertions(+), 1485 deletions(-) create mode 100644 services/platform/app/features/settings/governance/components/select-trigger-button.tsx create mode 100644 services/platform/app/features/settings/teams/components/team-detail-dialog.tsx diff --git a/examples/providers/openrouter.secrets.json b/examples/providers/openrouter.secrets.json index e58ac25c9c..e7b0ebbddc 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 bfda670d55..dd1e97d2fa 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 ad7a89dac1..9b26207dc6 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 8251587864..1806dad77e 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 4df3a3446b..71a84e5797 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); - // 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); - } + const cannotManage = ability.cannot('write', 'orgSettings'); + + // Sync from server data + useEffect(() => { + if (policy) { + 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 +92,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 +203,7 @@ export function PiiConfig({ organizationId }: PiiConfigProps) { }, [testText, enabledPatterns, customPatterns]); if (isLoading) { - return ( -
- -
- ); + return null; } const modeOptions = [ @@ -210,24 +212,27 @@ export function PiiConfig({ organizationId }: PiiConfigProps) { ]; return ( -
- + - - + } + > {enabled && ( - <> +
@@ -382,8 +394,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 d28dbe459d..213677cd13 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 0000000000..bfa9a7a1e2 --- /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 a19430290a..c75e37c649 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 a21b17b29f..32983cc695 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 dd18273a89..856c8cef14 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,7 +82,12 @@ describe('McpServerCard', () => { it('calls onClick when clicked', async () => { const onClick = vi.fn(); const { user } = render( - , + , ); await user.click(screen.getByRole('button')); 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 53f6cbec11..14b410e7b3 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 076368b5e5..3c92d98292 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 a36a56795d..b2ba9f049e 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 f76235df12..553260a7a0 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 947985a291..4d9c2bc823 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 ( +