diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md new file mode 100644 index 00000000000..671d76f4c57 --- /dev/null +++ b/.claude/rules/sim-settings-pages.md @@ -0,0 +1,123 @@ +--- +paths: + - "apps/sim/app/workspace/*/settings/**" + - "apps/sim/ee/**/components/**" +--- + +# Settings Pages + +Every settings page renders through the shared **`SettingsPanel`** primitive +(`@/app/workspace/[workspaceId]/settings/components/settings-panel`). It owns the +page chrome so pages never hand-roll it: a fixed header bar (right-aligned +actions), a scroll region, and a centered `max-w-[48rem]` content column led by a +**title + description that come from navigation metadata**. Pages render only +their body. + +Do NOT hand-roll any of these in a settings page — they are the panel's job: + +- `
` shell +- the header bar (`flex flex-shrink-0 … px-[16px] pt-[8.5px] pb-[8.5px]`) +- the scroll container (`min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]`) +- the content column (`mx-auto … max-w-[48rem] … gap-7`) +- a title block (`

` + `

`) +- the page-level search input + +## Canonical page shape + +```tsx +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' + +return ( + + Create + + } + search={{ value: searchTerm, onChange: setSearchTerm, placeholder: 'Search …' }} + > + {/* body only — sections, lists, forms */} + +) +``` + +When the page has modal/dialog siblings, wrap them with the panel in a fragment: + +```tsx +return ( + <> + {body} + + +) +``` + +## `SettingsPanel` props + +- `actions?: ReactNode` — right-aligned header chips. Wrap multiple in a fragment; + the slot reserves the 30px chip height even when empty, so vertical rhythm is + identical across pages. Conditional actions are fine: `actions={canManage && }`. +- `search?: { value; onChange: (value: string) => void; placeholder?; disabled? }` — + renders the canonical search field directly below the title. Pass `setSearchTerm` + straight to `onChange`. Use this for a standalone search; if search shares a row + with other controls (sort, filters, a date picker), render that whole row in + `children` instead and omit the prop. +- `title?` / `description?` — overrides for the nav-driven defaults. **Only** for a + detail sub-view that needs a different heading; normal pages never pass these. +- `scrollContainerRef?: React.Ref` — forwards a ref to the scroll + region (e.g. programmatic scroll-to-bottom). +- `contentClassName?` — layout/spacing only; reach for it rarely. Prefer the + default `gap-7`. + +## Title + description live in navigation metadata + +`apps/sim/app/workspace/[workspaceId]/settings/navigation.ts` is the single source +of truth. Every `NavigationItem` carries a one-line `description`; `SettingsPanel` +resolves both via `getSettingsSectionMeta(section)` and the +`SettingsSectionProvider` the settings shell wraps around the active section. + +Adding a new settings page: + +1. Add the `SettingsSection` id + a `NavigationItem` (with `label` **and** + `description`) in `navigation.ts`. Keep descriptions verb-first, one line, + ~40–55 chars, in the product voice (see `.claude/rules/constitution.md`). +2. Render the component inside the shell's `effectiveSection` switch in + `settings/[section]/settings.tsx`. +3. Build the component body inside `` — no shell, no title block. + +## Other shared settings primitives (do not re-roll these) + +- **`SettingsEmptyState`** (`…/components/settings-empty-state`) — the canonical + muted status message. `variant='fill'` (default) centers in the available + height (empty list, or a not-entitled/loading gate); `variant='inline'` sits in + flow (a search "no results"). Never hand-roll + `

` + or `
`. It owns the `--text-muted` + `text-sm` + tokens, so it also keeps these messages consistent across pages. +- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...` + actions menu for a list row. Pass `label` (aria-label) and + `actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the + component renders the canonical flush `...` trigger + `DropdownMenuContent`. + Conditional items become array spreads: `...(canManage ? [{…}] : [])`. Never + hand-roll the `` + `` trigger per page. + +## Detail sub-views (the one exception) + +A drill-down view reached from a list row (selected MCP server, workflow MCP +server, credential set, permission group) keeps its **own** chrome because it +needs a left-aligned back button (``), which the panel +header (right-actions only) does not model. Leave those returns as hand-rolled +shells; only the list/main view uses `SettingsPanel`. Gate/early-return states +(not-entitled, loading, upgrade prompts) also stay as-is. + +## Audit checklist + +A settings page is design-system-clean when: + +- [ ] Its main return is a `` (or `<>……` with modal siblings) — no hand-rolled shell/header/scroll/column. +- [ ] It renders **no** hand-rolled `

`/description title block — the title comes from nav metadata. +- [ ] Header chips are in `actions`; a standalone search is in the `search` prop. +- [ ] Its `NavigationItem` has an accurate, consistent-length `description`. +- [ ] Detail sub-views and entitlement/loading gates keep their own chrome (intentional). +- [ ] No business logic, handlers, or conditional rendering changed by the migration. +- [ ] `tsc`, `biome`, and the page's tests pass. diff --git a/.claude/skills/add-settings-page/SKILL.md b/.claude/skills/add-settings-page/SKILL.md new file mode 100644 index 00000000000..9e5d7b5671c --- /dev/null +++ b/.claude/skills/add-settings-page/SKILL.md @@ -0,0 +1,57 @@ +--- +name: add-settings-page +description: Add a new Sim settings page, or audit existing settings pages for design-system compliance with the shared SettingsPanel layout. Use when creating a settings tab, or when asked to check/clean up settings pages so they match the design system (consistent title, header, search, spacing). +--- + +# Settings Page (add / audit) + +Sim settings pages all render through the shared **`SettingsPanel`** primitive, +which owns the page chrome and renders a nav-driven title + description. The full +convention lives in `.claude/rules/sim-settings-pages.md` — read it first; this +skill is the procedure. + +Key paths: +- Layout primitive: `apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx` +- Nav metadata (titles + descriptions): `apps/sim/app/workspace/[workspaceId]/settings/navigation.ts` +- Section switch + provider: `apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx` +- Pages: `apps/sim/app/workspace/[workspaceId]/settings/components//.tsx` and EE pages under `apps/sim/ee//components/` + +## Mode A — Add a new settings page + +1. **Navigation.** In `navigation.ts`: add the id to the `SettingsSection` union, + then a `NavigationItem` with `label` AND a one-line `description` (verb-first, + ~40–55 chars, product voice per `.claude/rules/constitution.md`). Place it in + the right `section` group and set any gating flags (`requiresHosted`, + `requiresEnterprise`, etc.). +2. **Wire the switch.** Add the component to the `effectiveSection` render switch + in `settings/[section]/settings.tsx` (lazy `dynamic(...)` like its siblings). +3. **Build the body inside `SettingsPanel`.** Never hand-roll the shell, header + bar, scroll region, content column, or title block. Put header buttons in + `actions`, a standalone search in `search={{ value, onChange, placeholder }}`, + and the page content as `children`. Modals go beside the panel inside a `<>`. +4. **Verify:** `cd apps/sim && bunx tsc --noEmit`; `bunx biome check --write `. + +## Mode B — Audit existing settings pages + +For each page component, confirm the checklist in `.claude/rules/sim-settings-pages.md`: + +1. Find hand-rolled shells that should be `SettingsPanel`: + `git grep -n "flex h-full flex-col bg-\[var(--bg)\]" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` + — every match should be either `settings-panel.tsx`, a **detail sub-view** + (has a `` back button), or an entitlement/loading + **gate** early-return. Anything else is a page that still needs migrating. +2. Find hand-rolled title blocks (should be 0 outside detail views): + `git grep -n "text-\[var(--text-body)\] text-lg" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` +3. Confirm each page imports `SettingsPanel` and that its `NavigationItem` has an + accurate `description` of consistent length with its peers. +4. When migrating a page, change ONLY the structural shell→`SettingsPanel` swap: + move header chips to `actions`, the standalone search to `search`, delete the + `

` title block, replace the three closing `

` (column/scroll/shell) + with ``, and keep modal siblings in a `<>` fragment. Do NOT + touch handlers, state, queries, conditional rendering, or detail/gate returns. + Drop per-page `gap-*`/`pt-*` on the content column in favor of the panel default. +5. Remove now-unused imports (`ChipInput`/`Search`) ONLY after grepping that + they are not still used elsewhere in the file (e.g. by a detail view). +6. **Verify the whole sweep:** `tsc --noEmit`, `biome check` on every touched + file, and run the affected pages' tests. Diff each file against the base and + confirm the change is purely structural before shipping. diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index ec84c16702e..109c9b2cf45 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2760,10 +2760,10 @@ export function TinybirdIcon(props: SVGProps) { export function ThriveIcon(props: SVGProps) { return ( - + ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index b194b2bf359..288c0d146a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -6,6 +6,7 @@ import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' import { captureEvent } from '@/lib/posthog/client' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' +import { SettingsSectionProvider } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { isBillingEnabled, @@ -105,7 +106,6 @@ export function SettingsPage({ section }: SettingsPageProps) { const posthog = usePostHog() const isAdminRole = session?.user?.role === 'admin' - // The Subscription tab was replaced by Billing; redirect legacy links there. const normalizedSection: SettingsSection = (section as string) === 'subscription' ? 'billing' : section const effectiveSection = @@ -125,29 +125,31 @@ export function SettingsPage({ section }: SettingsPageProps) { }, [effectiveSection, sessionLoading, posthog]) return ( -
- {effectiveSection === 'general' && } - {effectiveSection === 'secrets' && } - {effectiveSection === 'credential-sets' && } - {effectiveSection === 'access-control' && } - {effectiveSection === 'audit-logs' && } - {effectiveSection === 'apikeys' && } - {isBillingEnabled && effectiveSection === 'billing' && } - {effectiveSection === 'teammates' && } - {isBillingEnabled && effectiveSection === 'organization' && } - {effectiveSection === 'sso' && } - {effectiveSection === 'data-retention' && } - {effectiveSection === 'data-drains' && } - {effectiveSection === 'whitelabeling' && } - {effectiveSection === 'byok' && } - {effectiveSection === 'copilot' && } - {effectiveSection === 'mcp' && } - {effectiveSection === 'custom-tools' && } - {effectiveSection === 'workflow-mcp-servers' && } - {effectiveSection === 'inbox' && } - {effectiveSection === 'recently-deleted' && } - {effectiveSection === 'admin' && } - {effectiveSection === 'mothership' && } -
+ +
+ {effectiveSection === 'general' && } + {effectiveSection === 'secrets' && } + {effectiveSection === 'credential-sets' && } + {effectiveSection === 'access-control' && } + {effectiveSection === 'audit-logs' && } + {effectiveSection === 'apikeys' && } + {isBillingEnabled && effectiveSection === 'billing' && } + {effectiveSection === 'teammates' && } + {isBillingEnabled && effectiveSection === 'organization' && } + {effectiveSection === 'sso' && } + {effectiveSection === 'data-retention' && } + {effectiveSection === 'data-drains' && } + {effectiveSection === 'whitelabeling' && } + {effectiveSection === 'byok' && } + {effectiveSection === 'copilot' && } + {effectiveSection === 'mcp' && } + {effectiveSection === 'custom-tools' && } + {effectiveSection === 'workflow-mcp-servers' && } + {effectiveSection === 'inbox' && } + {effectiveSection === 'recently-deleted' && } + {effectiveSection === 'admin' && } + {effectiveSection === 'mothership' && } +
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 444d6f573bf..bca74138132 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -12,6 +12,8 @@ import { adminParsers, adminUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/admin/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { useAdminUsers, useBanUser, @@ -155,306 +157,292 @@ export function Admin() { impersonatingUserId, ]) return ( -
-
-
-
-
- - +
+
+ + +
+ + {settings?.superUserModeEnabled && ( + <> +
+
+ +

+ Default uses the configured Sim agent URL. +

+
+ + handleMothershipEnvironmentChange(value as MothershipEnvironment) + } + placeholder='Select environment' disabled={updateSetting.isPending} - onCheckedChange={handleSuperUserModeToggle} + options={MOTHERSHIP_ENV_OPTIONS} />
+ + )} +
- {settings?.superUserModeEnabled && ( - <> -
-
- -

- Default uses the configured Sim agent URL. -

-
- - handleMothershipEnvironmentChange(value as MothershipEnvironment) - } - placeholder='Select environment' - disabled={updateSetting.isPending} - options={MOTHERSHIP_ENV_OPTIONS} - /> -
- - )} -
+
-
+
+

+ Import a workflow by ID along with its associated copilot chats. +

+
+ { + setWorkflowId(e.target.value) + importWorkflow.reset() + }} + placeholder='Enter workflow ID' + disabled={importWorkflow.isPending} + /> + +
+ {importWorkflow.error && ( +

{importWorkflow.error.message}

+ )} + {importWorkflow.isSuccess && ( +

+ Workflow imported successfully (new ID: {importWorkflow.data.newWorkflowId},{' '} + {importWorkflow.data.copilotChatsImported ?? 0} copilot chats imported) +

+ )} +
-
-

- Import a workflow by ID along with its associated copilot chats. -

-
- { - setWorkflowId(e.target.value) - importWorkflow.reset() - }} - placeholder='Enter workflow ID' - disabled={importWorkflow.isPending} - /> - -
- {importWorkflow.error && ( -

{importWorkflow.error.message}

- )} - {importWorkflow.isSuccess && ( -

- Workflow imported successfully (new ID: {importWorkflow.data.newWorkflowId},{' '} - {importWorkflow.data.copilotChatsImported ?? 0} copilot chats imported) -

- )} -
+
-
+
+

User Management

+
+ setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder='Search by email or paste a user ID...' + className='min-w-0 flex-1' + /> + +
-
-

User Management

-
- setSearchInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - placeholder='Search by email or paste a user ID...' - className='min-w-0 flex-1' - /> - -
+ {usersError && ( +

+ {getErrorMessage(usersError, 'Failed to fetch users')} +

+ )} - {usersError && ( -

- {getErrorMessage(usersError, 'Failed to fetch users')} -

- )} + {(setUserRole.error || + banUser.error || + unbanUser.error || + impersonateUser.error || + impersonationGuardError) && ( +

+ {impersonationGuardError || + (setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) + ?.message || + 'Action failed. Please try again.'} +

+ )} - {(setUserRole.error || - banUser.error || - unbanUser.error || - impersonateUser.error || - impersonationGuardError) && ( -

- {impersonationGuardError || - (setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) - ?.message || - 'Action failed. Please try again.'} -

- )} + {searchQuery.length > 0 && usersData && ( + <> +
+
+ Name + Email + Role + Status + Actions +
- {searchQuery.length > 0 && usersData && ( - <> -
-
- Name - Email - Role - Status - Actions -
+ {usersData.users.length === 0 && ( + No users found. + )} - {usersData.users.length === 0 && ( -
- No users found. -
+ {usersData.users.map((u) => ( +
( -
+
+ + {u.name || '—'} + + {u.email} + + + {u.role || 'user'} + + + + {u.banned ? ( + Banned + ) : ( + Active )} - > -
- - {u.name || '—'} - - - {u.email} - - - - {u.role || 'user'} - - - - {u.banned ? ( - Banned - ) : ( - Active - )} - - - {u.id !== session?.user?.id && ( - <> - - - {u.banned ? ( - - ) : ( - - )} - - )} - -
- {banUserId === u.id && !u.banned && ( -
- setBanReason(e.target.value)} - placeholder='Reason (optional)' - className='flex-1' - /> + + + {u.id !== session?.user?.id && ( + <> + -
+ {u.banned ? ( + + ) : ( + + )} + )} -
- ))} -
- - {totalPages > 1 && ( -
- - Page {currentPage} of {totalPages} ({usersData.total} users) -
- +
+ {banUserId === u.id && !u.banned && ( +
+ setBanReason(e.target.value)} + placeholder='Reason (optional)' + className='flex-1' + />
-
- )} - + )} +
+ ))} +
+ + {totalPages > 1 && ( +
+ + Page {currentPage} of {totalPages} ({usersData.total} users) + +
+ + +
+
)} -
-
+ + )}
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index abbc6927713..8c388d89a48 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -5,23 +5,12 @@ import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Info, Plus } from 'lucide-react' import { useParams } from 'next/navigation' -import { - Chip, - ChipConfirmModal, - ChipInput, - chipVariants, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - MoreHorizontal, - Search, - Switch, - Tooltip, - toast, -} from '@/components/emcn' +import { Chip, ChipConfirmModal, Switch, Tooltip, toast } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { type ApiKey, @@ -54,27 +43,13 @@ interface ApiKeyRowMenuProps { function ApiKeyRowMenu({ keyName, onDelete, canDelete = true }: ApiKeyRowMenuProps) { return (
- - - - - - copyKeyName(keyName)}>Copy name - - Delete - - - + copyKeyName(keyName) }, + { label: 'Delete', destructive: true, disabled: !canDelete, onSelect: onDelete }, + ]} + />
) } @@ -87,7 +62,6 @@ export function ApiKeys() { const userPermissions = useUserPermissionsContext() const canManageWorkspaceKeys = userPermissions.canAdmin - // React Query hooks const { data: apiKeysData, isLoading: isLoadingKeys, @@ -98,7 +72,6 @@ export function ApiKeys() { const deleteApiKeyMutation = useDeleteApiKey() const updateSettingsMutation = useUpdateWorkspaceApiKeySettings() - // Extract data from queries const workspaceKeys = apiKeysData?.workspaceKeys || [] const personalKeys = apiKeysData?.personalKeys || [] const conflicts = apiKeysData?.conflicts || [] @@ -107,7 +80,6 @@ export function ApiKeys() { const allowPersonalApiKeys = workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true - // Local UI state const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) @@ -151,7 +123,6 @@ export function ApiKeys() { }) } catch (error) { logger.error('Error deleting API key:', { error }) - // Refetch to restore correct state in case of error refetchApiKeys() } } @@ -162,11 +133,14 @@ export function ApiKeys() { } return ( -
- {/* Fixed header bar */} -
-
-
+ <> + Create API Key -
-
- - {/* Scrollable content */} -
-
- {/* Search Input */} - setSearchTerm(e.target.value)} - /> - - {/* Key list */} - {isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? ( -
- Click "Create API Key" above to get started -
- ) : ( -
- {/* Workspace section */} - {!searchTerm.trim() ? ( - - {workspaceKeys.length === 0 ? ( -
- No workspace API keys yet -
- ) : ( -
- {workspaceKeys.map((key) => ( -
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey || key.key} -

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - canDelete={canManageWorkspaceKeys} - /> -
- ))} -
- )} -
- ) : filteredWorkspaceKeys.length > 0 ? ( - + } + > + {isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? ( + Click "Create API Key" above to get started + ) : ( +
+ {!searchTerm.trim() ? ( + + {workspaceKeys.length === 0 ? ( +
No workspace API keys yet
+ ) : (
- {filteredWorkspaceKeys.map(({ key }) => ( + {workspaceKeys.map((key) => (
@@ -265,112 +190,138 @@ export function ApiKeys() {
))}
- - ) : null} + )} + + ) : filteredWorkspaceKeys.length > 0 ? ( + +
+ {filteredWorkspaceKeys.map(({ key }) => ( +
+
+
+ + {key.name} + + + (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) + +
+

+ {key.displayKey || key.key} +

+
+ { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + canDelete={canManageWorkspaceKeys} + /> +
+ ))} +
+
+ ) : null} - {/* Personal section */} - {(!searchTerm.trim() || filteredPersonalKeys.length > 0) && ( - -
- {filteredPersonalKeys.map(({ key }) => { - const isConflict = conflicts.includes(key.name) - return ( -
-
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey || key.key} -

+ {(!searchTerm.trim() || filteredPersonalKeys.length > 0) && ( + +
+ {filteredPersonalKeys.map(({ key }) => { + const isConflict = conflicts.includes(key.name) + return ( +
+
+
+
+ + {key.name} + + + (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) +
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - /> +

+ {key.displayKey || key.key} +

- {isConflict && ( -
- Workspace API key with the same name overrides this. Rename your - personal key to use it. -
- )} + { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + />
- ) - })} -
- - )} + {isConflict && ( +
+ Workspace API key with the same name overrides this. Rename your + personal key to use it. +
+ )} +
+ ) + })} +
+ + )} - {/* Show message when search has no results across both sections */} - {searchTerm.trim() && - filteredPersonalKeys.length === 0 && - filteredWorkspaceKeys.length === 0 && - (personalKeys.length > 0 || workspaceKeys.length > 0) && ( -
- No API keys found matching "{searchTerm}" -
- )} -
- )} + {searchTerm.trim() && + filteredPersonalKeys.length === 0 && + filteredWorkspaceKeys.length === 0 && + (personalKeys.length > 0 || workspaceKeys.length > 0) && ( + + No API keys found matching "{searchTerm}" + + )} +
+ )} - {/* Allow Personal API Keys Toggle */} - {!isLoading && canManageWorkspaceKeys && ( - - -
-
- - Allow personal API keys - - - - - - - Allow collaborators to create and use their own keys with billing charged to - them. - - -
- {isLoadingSettings ? null : ( - { - try { - await updateSettingsMutation.mutateAsync({ - workspaceId, - allowPersonalApiKeys: checked, - }) - } catch (error) { - logger.error('Error updating workspace settings:', { error }) - } - }} - /> - )} + {!isLoading && canManageWorkspaceKeys && ( + + +
+
+ + Allow personal API keys + + + + + + + Allow collaborators to create and use their own keys with billing charged to + them. + +
- - - )} -
-
+ {isLoadingSettings ? null : ( + { + try { + await updateSettingsMutation.mutateAsync({ + workspaceId, + allowPersonalApiKeys: checked, + }) + } catch (error) { + logger.error('Error updating workspace settings:', { error }) + } + }} + /> + )} +
+
+ + )} + - {/* Create API Key Modal */} - {/* Delete Confirmation Dialog */} { @@ -406,6 +356,6 @@ export function ApiKeys() { pendingLabel: 'Deleting...', }} /> -
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 3b800edaba3..36dde10900e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -38,6 +38,7 @@ import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field' import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useBillingUsageNotifications, @@ -388,8 +389,6 @@ export function Billing() { url: invoice.hostedInvoiceUrl ?? invoice.invoicePdf, })) - // Org admins (and solo users managing their own billing) can edit; everyone - // else sees the same controls rendered read-only / disabled rather than hidden. const canManageBilling = permissions.canEditUsageLimit const showUsageLimit = !subscription.isFree && !subscription.isEnterprise const showOnDemand = hasUsablePaidAccess && !subscription.isEnterprise @@ -405,215 +404,196 @@ export function Billing() { : usageLimitData.minimumLimit return ( -
-
-
-
-
-
-
-
-

Billing

-

- Manage your plan, pricing, and invoices. -

-
- -
-
-
-
- -
-
-
- - {planName} plan - - {priceText} -
+ +
+
+
+
+
- {!subscription.isEnterprise && - (canManageBilling ? ( - - Explore plans - - ) : ( - - Explore plans - - ))}
+
+ {planName} plan + {priceText} +
+
+ {!subscription.isEnterprise && + (canManageBilling ? ( + + Explore plans + + ) : ( + + Explore plans + + ))} +
- {showUsageLimit && ( - + )} + + {showOnDemand && ( + +
+ + Allow usage to go past included usage + + +
+
+ )} + + {!subscription.isFree && !subscription.isEnterprise && ( + +
+ + Email me when I reach 80% usage + + { + if (value !== billingUsageNotificationsEnabled) { + updateGeneralSetting.mutate({ + key: 'billingUsageNotificationsEnabled', + value, + }) + } + }} /> - )} +
+
+ )} - {showOnDemand && ( - + {(subscription.isPaid || subscription.isEnterprise) && ( + +
+ {periodEnd && (
- Allow usage to go past included usage + {isCancelledAtPeriodEnd ? 'Access until' : 'Next billing date'} + + + {new Date(periodEnd).toLocaleDateString()} -
- - )} + )} + +
+ Payment method + + Manage in Stripe + +
- {!subscription.isFree && !subscription.isEnterprise && ( - + {!subscription.isEnterprise && (
- Email me when I reach 80% usage + {isCancelledAtPeriodEnd ? 'Subscription canceled' : 'Cancel subscription'} - { - if (value !== billingUsageNotificationsEnabled) { - updateGeneralSetting.mutate({ - key: 'billingUsageNotificationsEnabled', - value, - }) - } - }} - /> -
-
- )} - - {(subscription.isPaid || subscription.isEnterprise) && ( - -
- {periodEnd && ( -
- - {isCancelledAtPeriodEnd ? 'Access until' : 'Next billing date'} - - - {new Date(periodEnd).toLocaleDateString()} - -
- )} - -
- Payment method + {isCancelledAtPeriodEnd ? ( - Manage in Stripe + Restore -
- - {!subscription.isEnterprise && ( -
- - {isCancelledAtPeriodEnd ? 'Subscription canceled' : 'Cancel subscription'} - - {isCancelledAtPeriodEnd ? ( - - Restore - - ) : ( - - Cancel - - )} -
- )} -
-
- )} - - {!subscription.isFree && invoices.length > 0 && ( - -
- {invoices.map((invoice) => { - const rowClassName = - 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors' - const rowContent = ( - <> - - {invoice.date} - - - {invoice.badge.label} - - - {invoice.amount} - - - - ) - - return invoice.url ? ( - - {rowContent} - - ) : ( -
- {rowContent} -
- ) - })} - - {invoicesData?.hasMore && ( - + Cancel + )}
-
- )} -
-
-
+ )} +
+ + )} + + {!subscription.isFree && invoices.length > 0 && ( + +
+ {invoices.map((invoice) => { + const rowClassName = + 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors' + const rowContent = ( + <> + + {invoice.date} + + + {invoice.badge.label} + + + {invoice.amount} + + + + ) + + return invoice.url ? ( + + {rowContent} + + ) : ( +
+ {rowContent} +
+ ) + })} + + {invoicesData?.hasMore && ( + + )} +
+
+ )} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index 10b8902f805..23e8837e451 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -22,6 +22,7 @@ import { } from '@/app/workspace/[workspaceId]/components/credential-detail/components/chip-field' import { BYOKProviderKeysModal } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal' import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' const logger = createLogger('BYOKKeyManager') @@ -323,9 +324,9 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { ))}
) : showNoResults ? ( -
+ No providers found matching "{searchTerm}" -
+ ) : sections ? (
{sections.map((section) => { @@ -382,7 +383,6 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { : `This key will be used for all ${editingMeta?.name} requests in this workspace. Your key is encrypted and stored securely.`}

- {/* Hidden decoy fields to prevent browser autofill */} -
-
- { - await upsertKey.mutateAsync({ - workspaceId, - providerId: providerId as BYOKProviderId, - apiKey, - keyId, - name, - }) - }} - onDeleteKey={async (providerId, keyId) => { - await deleteKey.mutateAsync({ - workspaceId, - providerId: providerId as BYOKProviderId, - keyId, - }) - }} - /> -
-
-
+ + { + await upsertKey.mutateAsync({ + workspaceId, + providerId: providerId as BYOKProviderId, + apiKey, + keyId, + name, + }) + }} + onDeleteKey={async (providerId, keyId) => { + await deleteKey.mutateAsync({ + workspaceId, + providerId: providerId as BYOKProviderId, + keyId, + }) + }} + /> + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index 00366d171ca..7082bbcba29 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -1,25 +1,22 @@ 'use client' import { useMemo, useState } from 'react' -// import { useParams } from 'next/navigation' import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Plus } from 'lucide-react' import { Chip, ChipConfirmModal, - ChipInput, ChipModal, ChipModalBody, ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, - Search, SecretReveal, - // Switch, } from '@/components/emcn' -// import { useMcpServers, useUpdateMcpServer } from '@/hooks/queries/mcp' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { type CopilotKey, useCopilotKeys, @@ -33,29 +30,11 @@ const logger = createLogger('CopilotSettings') * Copilot Keys management component for handling API keys used with the Copilot feature. * Provides functionality to create, view, and delete copilot API keys. */ -// function McpServerSkeleton() { -// return ( -//
-//
-// -// -//
-// -//
-// ) -// } - export function Copilot() { - // const params = useParams() - // const workspaceId = params.workspaceId as string - const { data: keys = [], isLoading } = useCopilotKeys() const generateKey = useGenerateCopilotKey() const deleteKeyMutation = useDeleteCopilotKey() - // const { data: mcpServers = [], isLoading: mcpLoading } = useMcpServers(workspaceId) - // const updateServer = useUpdateMcpServer() - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [newKeyName, setNewKeyName] = useState('') const [newKey, setNewKey] = useState(null) @@ -65,20 +44,6 @@ export function Copilot() { const [searchTerm, setSearchTerm] = useState('') const [createError, setCreateError] = useState(null) - // const enabledServers = mcpServers.filter((s) => s.enabled) - - // const handleToggleCopilot = async (serverId: string, enabled: boolean) => { - // try { - // await updateServer.mutateAsync({ - // workspaceId, - // serverId, - // updates: { copilotEnabled: enabled }, - // }) - // } catch (error) { - // logger.error('Failed to toggle MCP server for Mothership', { error }) - // } - // } - const filteredKeys = useMemo(() => { if (!searchTerm.trim()) return keys const term = searchTerm.toLowerCase() @@ -140,116 +105,63 @@ export function Copilot() { return ( <> -
- {/* Fixed header bar */} -
-
-
- { - setIsCreateDialogOpen(true) - setCreateError(null) - }} - disabled={isLoading} - > - Create API Key - -
-
- - {/* Scrollable Content */} -
-
- {/* MCP Tools Section — uncomment when ready to allow users to toggle MCP servers for Mothership -
-
- MCP Tools -
- {mcpLoading ? ( -
- - -
- ) : enabledServers.length === 0 ? ( -
- No MCP servers configured. Add servers in the MCP Tools tab. -
- ) : ( -
- {enabledServers.map((server) => ( -
-
- {server.name} -

- {server.toolCount ?? 0} tool{server.toolCount === 1 ? '' : 's'} -

-
- handleToggleCopilot(server.id, checked)} - /> -
- ))} -
- )} -
- */} - - {/* Search Input */} - setSearchTerm(e.target.value)} - /> - - {/* Keys List */} - {isLoading ? null : showEmptyState ? ( -
- Click "Create API Key" above to get started -
- ) : ( -
- {filteredKeys.map((key) => ( -
-
-
- - {key.name || 'Unnamed Key'} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - > - Delete - -
- ))} - {showNoResults && ( -
- No API keys found matching "{searchTerm}" + { + setIsCreateDialogOpen(true) + setCreateError(null) + }} + disabled={isLoading} + > + Create API Key + + } + > + {isLoading ? null : showEmptyState ? ( + Click "Create API Key" above to get started + ) : ( +
+ {filteredKeys.map((key) => ( +
+
+
+ + {key.name || 'Unnamed Key'} + + + (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) +
- )} +

{key.displayKey}

+
+ { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + > + Delete +
+ ))} + {showNoResults && ( + + No API keys found matching "{searchTerm}" + )}
-
-
+ )} + - {/* Create API Key Dialog */} - {/* New API Key Dialog */} { @@ -329,7 +240,6 @@ export function Copilot() { /> - {/* Delete Confirmation Dialog */} { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx index 6f5f05477d3..8664283de10 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx @@ -13,21 +13,13 @@ import { ButtonGroupItem, Chip, ChipConfirmModal, - ChipInput, ChipModal, ChipModalBody, ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, - chipVariants, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, type FileInputOptions, - MoreHorizontal, - Search, TagInput, type TagItem, } from '@/components/emcn' @@ -39,6 +31,9 @@ import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-s import { quickValidateEmail } from '@/lib/messaging/email/validation' import { getUserColor } from '@/lib/workspaces/colors' import { getUserRole } from '@/lib/workspaces/organization' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { type CredentialSet, @@ -522,26 +517,17 @@ export function CredentialSets() {
- - - - - - handleRemoveMember(member.id)} - disabled={removeMember.isPending} - > - Remove - - - + handleRemoveMember(member.id), + }, + ]} + />
) @@ -580,41 +566,30 @@ export function CredentialSets() {
- - - - - - handleResendInvitation(invitation.id, email)} - disabled={ - resendingInvitations.has(invitation.id) || - (resendCooldowns[invitation.id] ?? 0) > 0 - } - > - {resendingInvitations.has(invitation.id) + - handleCancelInvitation(invitation.id)} - disabled={cancellingInvitations.has(invitation.id)} - > - {cancellingInvitations.has(invitation.id) + : 'Resend', + disabled: + resendingInvitations.has(invitation.id) || + (resendCooldowns[invitation.id] ?? 0) > 0, + onSelect: () => handleResendInvitation(invitation.id, email), + }, + { + label: cancellingInvitations.has(invitation.id) ? 'Cancelling...' - : 'Cancel'} - - - + : 'Cancel', + destructive: true, + disabled: cancellingInvitations.has(invitation.id), + onSelect: () => handleCancelInvitation(invitation.id), + }, + ]} + />
) @@ -630,177 +605,154 @@ export function CredentialSets() { return ( <> -
-
-
-
- {canManageCredentialSets && ( - setShowCreateModal(true)}> - Create Group - - )} -
-
+ setShowCreateModal(true)}> + Create Group + + ) + } + > +
+ {hasNoContent && !canManageCredentialSets ? ( + + You're not a member of any polling groups yet. When someone invites you, it will + appear here. + + ) : hasNoResults ? ( + + No results found matching "{searchTerm}" + + ) : ( +
+ {filteredInvitations.length > 0 && ( + +
+ {filteredInvitations.map((invitation) => ( +
+
+
+ {getProviderIcon(invitation.providerId)} +
+
+ + {invitation.credentialSetName} + + + {invitation.organizationName} + +
+
+ handleAcceptInvitation(invitation.token)} + disabled={acceptInvitation.isPending} + > + {acceptInvitation.isPending ? 'Accepting...' : 'Accept'} + +
+ ))} +
+
+ )} -
-
- setSearchTerm(e.target.value)} - /> - -
- {hasNoContent && !canManageCredentialSets ? ( -
- You're not a member of any polling groups yet. When someone invites you, it will - appear here. -
- ) : hasNoResults ? ( -
- No results found matching "{searchTerm}" -
- ) : ( -
- {filteredInvitations.length > 0 && ( - -
- {filteredInvitations.map((invitation) => ( -
-
-
- {getProviderIcon(invitation.providerId)} -
-
- - {invitation.credentialSetName} - - - {invitation.organizationName} - -
-
- handleAcceptInvitation(invitation.token)} - disabled={acceptInvitation.isPending} - > - {acceptInvitation.isPending ? 'Accepting...' : 'Accept'} - + {filteredMemberships.length > 0 && ( + +
+ {filteredMemberships.map((membership) => ( +
+
+
+ {getProviderIcon(membership.providerId)}
- ))} +
+ + {membership.credentialSetName} + + + {membership.organizationName} + +
+
+ + handleLeave(membership.credentialSetId, membership.credentialSetName) + } + disabled={leaveCredentialSet.isPending} + > + Leave +
- - )} + ))} +
+
+ )} - {filteredMemberships.length > 0 && ( - + {canManageCredentialSets && + (filteredOwnedSets.length > 0 || + ownedSetsLoading || + (!searchTerm.trim() && ownedSets.length === 0)) && ( + + {ownedSetsLoading ? null : !searchTerm.trim() && ownedSets.length === 0 ? ( +
+ No polling groups created yet +
+ ) : (
- {filteredMemberships.map((membership) => ( + {filteredOwnedSets.map((set) => (
- {getProviderIcon(membership.providerId)} + {getProviderIcon(set.providerId)}
- {membership.credentialSetName} + {set.name} - {membership.organizationName} + {set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
- - handleLeave( - membership.credentialSetId, - membership.credentialSetName - ) - } - disabled={leaveCredentialSet.isPending} - > - Leave - +
+ setViewingSet(set) }, + { + label: 'Delete', + destructive: true, + disabled: deletingSetIds.has(set.id), + onSelect: () => handleDeleteClick(set), + }, + ]} + /> +
))}
-
- )} - - {canManageCredentialSets && - (filteredOwnedSets.length > 0 || - ownedSetsLoading || - (!searchTerm.trim() && ownedSets.length === 0)) && ( - - {ownedSetsLoading ? null : !searchTerm.trim() && ownedSets.length === 0 ? ( -
- No polling groups created yet -
- ) : ( -
- {filteredOwnedSets.map((set) => ( -
-
-
- {getProviderIcon(set.providerId)} -
-
- - {set.name} - - - {set.memberCount} member{set.memberCount !== 1 ? 's' : ''} - -
-
-
- - - - - - setViewingSet(set)}> - Details - - handleDeleteClick(set)} - disabled={deletingSetIds.has(set.id)} - > - Delete - - - -
-
- ))} -
- )} -
)} -
- )} + + )}
-
+ )}
-
+ -
-
-
-
- setShowAddForm(true)} - disabled={isLoading} - > - Add Tool - + setShowAddForm(true)} + disabled={isLoading} + > + Add Tool + + } + > + {error ? ( +
+

+ {getErrorMessage(error, 'Failed to load tools')} +

-
- -
-
- setSearchTerm(e.target.value)} - disabled={isLoading} - /> - - {error ? ( -
-

- {getErrorMessage(error, 'Failed to load tools')} -

-
- ) : isLoading ? null : showEmptyState ? ( -
- Click "Add Tool" above to get started -
- ) : ( -
- {filteredTools.map((tool) => ( -
-
- - {tool.title || 'Unnamed Tool'} - - {tool.schema?.function?.description && ( -

- {tool.schema.function.description} -

- )} -
-
- - - - - - setEditingTool(tool.id)}> - Edit - - handleDeleteClick(tool.id)} - disabled={deletingTools.has(tool.id)} - > - Delete - - - -
-
- ))} - {showNoResults && ( -
- No tools found matching "{searchTerm}" -
- )} + ) : isLoading ? null : showEmptyState ? ( + Click "Add Tool" above to get started + ) : ( +
+ {filteredTools.map((tool) => ( +
+
+ + {tool.title || 'Unnamed Tool'} + + {tool.schema?.function?.description && ( +

+ {tool.schema.function.description} +

+ )} +
+
+ setEditingTool(tool.id) }, + { + label: 'Delete', + destructive: true, + disabled: deletingTools.has(tool.id), + onSelect: () => handleDeleteClick(tool.id), + }, + ]} + /> +
+ ))} + {showNoResults && ( + + No tools found matching "{searchTerm}" + )}
-
-
+ )} + - {/* Fixed header bar */} -
-
- {isHosted && ( - window.open('/?home', '_blank', 'noopener,noreferrer')}> - Home Page - - )} -
- {!isAuthDisabled && ( -
- Sign out - setShowResetPasswordModal(true)}>Reset password -
- )} -
- - {/* Scrollable content */} -
-
- -
-
-
-
- handleKeyboardActivation(event, handleProfilePictureClick) - } - > - {(() => { - if (imageUrl) { - return ( - {profile?.name - ) - } + <> + + {isHosted && ( + window.open('/?home', '_blank', 'noopener,noreferrer')}> + Home Page + + )} + {!isAuthDisabled && ( + <> + Sign out + setShowResetPasswordModal(true)}>Reset password + + )} + + } + > + +
+
+
+
handleKeyboardActivation(event, handleProfilePictureClick)} + > + {(() => { + if (imageUrl) { return ( - - {getInitials(profile?.name) || ''} - + {profile?.name ) - })()} -
- {isUploadingProfilePicture ? ( -
- ) : ( - - )} -
-
- -
-
-
- {isEditingName ? ( - <> -
- - setName(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={handleInputBlur} - className='absolute top-0 left-0 h-full w-full border-0 bg-transparent p-0 font-medium text-base outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' - maxLength={100} - disabled={updateProfile.isPending} - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - spellCheck='false' - /> -
- - + } + return ( + + {getInitials(profile?.name) || ''} + + ) + })()} +
+ {isUploadingProfilePicture ? ( +
) : ( - <> -

{profile?.name || ''}

- - + )}
-

{profile?.email || ''}

+
- {uploadError &&

{uploadError}

} -
- - - -
-
- -
- +
+
+ {isEditingName ? ( + <> +
+ + setName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleInputBlur} + className='absolute top-0 left-0 h-full w-full border-0 bg-transparent p-0 font-medium text-base outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' + maxLength={100} + disabled={updateProfile.isPending} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> +
+ + + ) : ( + <> +

{profile?.name || ''}

+ + + )}
+

{profile?.email || ''}

- -
- -
- -
+
+ {uploadError &&

{uploadError}

} +
+ + + +
+
+ +
+
+
-
-
- - - - - - -

Automatically connect blocks when dropped near each other

- -
-
-
- + +
+
+
-
-
- - - - - - -

Show error popups on blocks when a workflow run fails

- -
-
-
- +
+
+ + + + + + +

Automatically connect blocks when dropped near each other

+ +
+
+ +
-
- -
- -
+
+
+ + + + + + +

Show error popups on blocks when a workflow run fails

+ +
+
+ +
-
- - + +
+
+
- {isTrainingEnabled && ( -
- - -
- )} +
+ +
- - -
+ {isTrainingEnabled && (
- +
-

- We use OpenTelemetry to collect anonymous usage data to improve Sim. You can opt-out - at any time. -

+ )} +
+
+ + +
+
+ +
- -
-
+

+ We use OpenTelemetry to collect anonymous usage data to improve Sim. You can opt-out + at any time. +

+
+ + - {/* Password Reset Confirmation Modal */} -
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index 7f87e271258..f734ffd5a72 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -5,6 +5,7 @@ import { formatRelativeTime } from '@sim/utils/formatting' import { ArrowRight, Paperclip } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { Badge, ChipInput, ChipSelect, Search } from '@/components/emcn' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { InboxTaskItem } from '@/hooks/queries/inbox' import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox' @@ -89,15 +90,15 @@ export function InboxTaskList() {
{isLoading ? null : filteredTasks.length === 0 ? ( searchTerm.trim() ? ( -
+ {`No tasks matching "${searchTerm}"`} -
+ ) : ( -
+ {config?.address ? `No email tasks yet. Send an email to ${config.address} to get started.` : 'No email tasks yet.'} -
+ ) ) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx index 83cffbf0979..728c01c7017 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx @@ -9,6 +9,7 @@ import { InboxSettingsTab, InboxTaskList, } from '@/app/workspace/[workspaceId]/settings/components/inbox/components' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { useInboxConfig } from '@/hooks/queries/inbox' @@ -59,25 +60,21 @@ export function Inbox() { } return ( -
-
-
- + + - {config?.enabled && ( - <> - + {config?.enabled && ( + <> + - -

- Email tasks received by this workspace. -

- -
- - )} -
-
-
+ +

+ Email tasks received by this workspace. +

+ +
+ + )} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index c468f1e8e4e..a1f60a9e2fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -6,21 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { ChevronDown, Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' -import { - Badge, - Button, - Chip, - ChipConfirmModal, - ChipInput, - chipVariants, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - MoreHorizontal, - Search, - Tooltip, -} from '@/components/emcn' +import { Badge, Button, Chip, ChipConfirmModal, Tooltip } from '@/components/emcn' import { ArrowLeft } from '@/components/emcn/icons' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' @@ -36,6 +22,9 @@ import { mcpServerIdParam, mcpServerIdUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' import { @@ -126,27 +115,13 @@ function ServerListItem({

- - - - - - Details - - Delete - - - +
) @@ -632,70 +607,59 @@ export function MCP() { return ( <> -
-
-
-
- setShowAddModal(true)} - disabled={serversLoading} - > - Add Server - + setShowAddModal(true)} + disabled={serversLoading} + > + Add Server + + } + > + {error ? ( +
+

+ {getErrorMessage(error, 'Failed to load MCP servers')} +

-
- -
-
- setSearchTerm(e.target.value)} - /> - - {error ? ( -
-

- {getErrorMessage(error, 'Failed to load MCP servers')} -

-
- ) : serversLoading ? null : !hasServers ? ( -
- Click "Add Server" above to get started -
- ) : ( -
- {filteredServers.map((server) => { - if (!server?.id) return null - const tools = toolsByServer[server.id] || [] - const isLoadingTools = toolsLoading || toolsFetching - - return ( - handleRemoveServer(server.id)} - onViewDetails={() => handleViewDetails(server.id)} - /> - ) - })} - {showNoResults && ( -
- No servers found matching "{searchTerm}" -
- )} -
+ ) : serversLoading ? null : !hasServers ? ( + Click "Add Server" above to get started + ) : ( +
+ {filteredServers.map((server) => { + if (!server?.id) return null + const tools = toolsByServer[server.id] || [] + const isLoadingTools = toolsLoading || toolsFetching + + return ( + handleRemoveServer(server.id)} + onViewDetails={() => handleViewDetails(server.id)} + /> + ) + })} + {showNoResults && ( + + No servers found matching "{searchTerm}" + )}
-
-
+ )} + {isEmpty ? ( -
{emptyText}
+ {emptyText} ) : (
{children}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx index 4cdf68f9e54..a2eab8cfb6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx @@ -15,6 +15,8 @@ import { mothershipParsers, mothershipUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/mothership/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { type MothershipByokKey, type MothershipEnv, @@ -98,79 +100,68 @@ export function Mothership() { const [end, setEnd] = useState(defaults.end) return ( -
-
-
- {/* Environment selector */} + +
+
+ + setMothershipParams({ env: value as MothershipEnv })} + placeholder='Select environment' + options={ENV_OPTIONS} + /> +
+ +
+ {TABS.map((tab) => ( + + ))} +
+ +
- - setMothershipParams({ env: value as MothershipEnv })} - placeholder='Select environment' - options={ENV_OPTIONS} + + setStart(e.target.value)} />
- - {/* Tab bar */} -
- {TABS.map((tab) => ( - - ))} -
- - {/* Time range (shared across tabs) */} -
-
- - setStart(e.target.value)} - /> -
-
- - setEnd(e.target.value)} - /> -
+
+ + setEnd(e.target.value)} />
+
- + - {activeTab === 'overview' && ( - - )} - {activeTab === 'licenses' && } - {activeTab === 'byok' && } -
+ {activeTab === 'overview' && ( + + )} + {activeTab === 'licenses' && } + {activeTab === 'byok' && }
-
+ ) } -/* ─── BYOK Tab ─── */ - function ByokTab() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' @@ -203,8 +194,6 @@ function ByokTab() { ) } -/* ─── Overview Tab ─── */ - function OverviewTab({ environment, start, @@ -227,7 +216,6 @@ function OverviewTab({ return (
- {/* Summary cards */}
- {/* User breakdown */} User Breakdown {breakdownLoading && (
@@ -311,7 +298,6 @@ function OverviewTab({
)} - {/* Recent requests */} Recent Requests ({requests?.count ?? '…'}) {requestsLoading && ( @@ -387,8 +373,6 @@ function OverviewTab({ ) } -/* ─── Licenses Tab ─── */ - function LicensesTab({ environment }: { environment: MothershipEnv }) { const { data, isLoading, refetch } = useMothershipLicenses(environment) const generateLicense = useGenerateLicense(environment) @@ -484,9 +468,7 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) { Created
{data.licenses.length === 0 && ( -
- No licenses found. -
+ No licenses found. )} {data.licenses.map( (lic: { @@ -519,8 +501,6 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) { ) } -/* ─── Shared components ─── */ - function StatCard({ label, value, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index a9dfb740da6..8dc3b308813 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -19,6 +19,8 @@ import { recentlyDeletedParsers, recentlyDeletedUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { useFolders, useRestoreFolder } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useRestoreTable, useTablesList } from '@/hooks/queries/tables' @@ -406,111 +408,105 @@ export function RecentlyDeleted() { } return ( -
-
-
-
- setSearchTerm(e.target.value)} - disabled={isLoading} - className='min-w-0 flex-1' - /> - { - const sort = (RECENTLY_DELETED_SORT_COLUMNS as readonly string[]).includes(column) - ? (column as RecentlyDeletedSortColumn) - : DEFAULT_RECENTLY_DELETED_SORT_COLUMN - setRecentlyDeletedFilters({ sort, dir: direction }) - }, - onClear: () => - setRecentlyDeletedFilters({ - sort: DEFAULT_RECENTLY_DELETED_SORT_COLUMN, - dir: DEFAULT_RECENTLY_DELETED_SORT_DIRECTION, - }), - }} - /> -
- - ({ value: tab.id, label: tab.label }))} - value={activeTab} - onChange={(v) => setRecentlyDeletedFilters({ tab: v as RecentlyDeletedTab })} - /> - - {error ? ( -
-

- {toError(error).message || 'Failed to load deleted items'} -

-
- ) : isLoading ? null : filtered.length === 0 ? ( - showNoResults ? ( -
- {`No items found matching \u201c${urlSearchTerm}\u201d`} -
- ) : ( -
- No deleted items + +
+ setSearchTerm(e.target.value)} + disabled={isLoading} + className='min-w-0 flex-1' + /> + { + const sort = (RECENTLY_DELETED_SORT_COLUMNS as readonly string[]).includes(column) + ? (column as RecentlyDeletedSortColumn) + : DEFAULT_RECENTLY_DELETED_SORT_COLUMN + setRecentlyDeletedFilters({ sort, dir: direction }) + }, + onClear: () => + setRecentlyDeletedFilters({ + sort: DEFAULT_RECENTLY_DELETED_SORT_COLUMN, + dir: DEFAULT_RECENTLY_DELETED_SORT_DIRECTION, + }), + }} + /> +
+ + ({ value: tab.id, label: tab.label }))} + value={activeTab} + onChange={(v) => setRecentlyDeletedFilters({ tab: v as RecentlyDeletedTab })} + /> + + {error ? ( +
+

+ {toError(error).message || 'Failed to load deleted items'} +

+
+ ) : isLoading ? null : filtered.length === 0 ? ( + showNoResults ? ( + + {`No items found matching \u201c${urlSearchTerm}\u201d`} + + ) : ( + No deleted items + ) + ) : ( +
+ {filtered.map((resource) => { + const isRestoring = restoringIds.has(resource.id) + const isRestored = restoredItems.has(resource.id) + + return ( +
+ + +
+ + {resource.name} + + + {TYPE_LABEL[resource.type]} + {' \u00b7 '} + Deleted {formatDate(resource.deletedAt)} + +
+ + {isRestoring ? ( + + ) : isRestored ? ( +
+ Restored + +
+ ) : ( + + )}
) - ) : ( -
- {filtered.map((resource) => { - const isRestoring = restoringIds.has(resource.id) - const isRestored = restoredItems.has(resource.id) - - return ( -
- - -
- - {resource.name} - - - {TYPE_LABEL[resource.type]} - {' \u00b7 '} - Deleted {formatDate(resource.deletedAt)} - -
- - {isRestoring ? ( - - ) : isRestored ? ( -
- Restored - -
- ) : ( - - )} -
- ) - })} -
- )} + })}
-
-
+ )} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/index.ts new file mode 100644 index 00000000000..750a8f684de --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/index.ts @@ -0,0 +1 @@ +export { type RowAction, RowActionsMenu } from './row-actions-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx new file mode 100644 index 00000000000..288ccd1109f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx @@ -0,0 +1,57 @@ +import { + chipVariants, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + MoreHorizontal, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' + +export interface RowAction { + label: string + onSelect: () => void + /** Renders in the error color (e.g. Delete). */ + destructive?: boolean + disabled?: boolean +} + +interface RowActionsMenuProps { + /** Accessible label for the trigger, e.g. `API key actions`. */ + label: string + actions: RowAction[] + /** Layout-only classes for the trigger button (e.g. a left margin). */ + triggerClassName?: string +} + +/** + * Canonical trailing `...` actions menu for a settings list row. Mirrors the + * Teammates / Secrets / API-key row menus so every list row behaves identically. + */ +export function RowActionsMenu({ label, actions, triggerClassName }: RowActionsMenuProps) { + return ( + + + + + + {actions.map((action) => ( + + {action.label} + + ))} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index d1c25230b8f..bbec6f9f8ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -5,19 +5,7 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' -import { - Chip, - ChipInput, - chipVariants, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - MoreHorizontal, - Search, - Tooltip, - toast, -} from '@/components/emcn' +import { Chip, ChipInput, Tooltip, toast } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { clearPendingCredentialCreateRequest, @@ -27,7 +15,10 @@ import { } from '@/lib/credentials/client-state' import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { isValidEnvVarName } from '@/executor/constants' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { @@ -67,28 +58,15 @@ interface SecretRowMenuProps { */ function SecretRowMenu({ onCopyName, onViewDetails, onDelete }: SecretRowMenuProps) { return ( - - - - - - {onViewDetails && ( - View details - )} - Copy name - {onDelete && ( - - Delete - - )} - - + ) } @@ -944,36 +922,39 @@ export function SecretsManager() { return ( <> -
- {/* Hidden honeypot inputs to prevent browser autofill */} -
- - - -
+
+ + + +
- {/* Fixed header bar */} -
-
-
+ {hasChanges && ( Discard @@ -997,110 +978,87 @@ export function SecretsManager() { {isListSaving ? 'Saving...' : 'Save'} )} -
-
- - {/* Scrollable content */} -
-
- {/* Search */} - setSearchTerm(e.target.value)} - name='env_search_field' - autoComplete='off' - autoCapitalize='off' - spellCheck='false' - readOnly - onFocus={(e) => e.target.removeAttribute('readOnly')} - /> - - {/* Secrets grid */} - {!isLoading && ( -
- {(!searchTerm.trim() || - filteredWorkspaceEntries.length > 0 || - filteredNewWorkspaceRows.length > 0) && ( -
- Workspace -
-
- {(searchTerm.trim() - ? filteredWorkspaceEntries - : Object.entries(workspaceVars) - ).map(([key, value]) => { - const cred = workspaceEnvKeyToCredential.get(key) - const canEditRow = cred?.role === 'admin' - return ( - - ) - })} - {canCreateWorkspaceSecret && - (searchTerm.trim() - ? filteredNewWorkspaceRows - : newWorkspaceRows.map((row, index) => ({ row, originalIndex: index })) - ).map(({ row, originalIndex }) => ( - - ))} -
-
- )} + + } + > + {!isLoading && ( +
+ {(!searchTerm.trim() || + filteredWorkspaceEntries.length > 0 || + filteredNewWorkspaceRows.length > 0) && ( +
+ Workspace +
+
+ {(searchTerm.trim() + ? filteredWorkspaceEntries + : Object.entries(workspaceVars) + ).map(([key, value]) => { + const cred = workspaceEnvKeyToCredential.get(key) + const canEditRow = cred?.role === 'admin' + return ( + + ) + })} + {canCreateWorkspaceSecret && + (searchTerm.trim() + ? filteredNewWorkspaceRows + : newWorkspaceRows.map((row, index) => ({ row, originalIndex: index })) + ).map(({ row, originalIndex }) => ( + + ))} +
+
+ )} - {(!searchTerm.trim() || filteredEnvVars.length > 0) && ( -
- Personal -
-
- {filteredEnvVars.map(({ envVar, originalIndex }) => ( -
- {renderEnvVarRow(envVar, originalIndex)} -
- ))} -
-
- )} - {searchTerm.trim() && - filteredEnvVars.length === 0 && - filteredWorkspaceEntries.length === 0 && - filteredNewWorkspaceRows.length === 0 && - (envVars.length > 0 || - Object.keys(workspaceVars).length > 0 || - newWorkspaceRows.length > 0) && ( -
- No secrets found matching “{searchTerm}” + {(!searchTerm.trim() || filteredEnvVars.length > 0) && ( +
+ Personal +
+
+ {filteredEnvVars.map(({ envVar, originalIndex }) => ( +
+ {renderEnvVarRow(envVar, originalIndex)}
- )} -
+ ))} +
+
)} + {searchTerm.trim() && + filteredEnvVars.length === 0 && + filteredWorkspaceEntries.length === 0 && + filteredNewWorkspaceRows.length === 0 && + (envVars.length > 0 || + Object.keys(workspaceVars).length > 0 || + newWorkspaceRows.length > 0) && ( + + No secrets found matching “{searchTerm}” + + )}
-
-
+ )} + + {children} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/index.ts new file mode 100644 index 00000000000..4535b2c8949 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/index.ts @@ -0,0 +1 @@ +export { SettingsPanel, SettingsSectionProvider } from './settings-panel' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx new file mode 100644 index 00000000000..0b38d3646db --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx @@ -0,0 +1,124 @@ +'use client' + +import { createContext, type ReactNode, useContext } from 'react' +import { ChipInput, Search } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { + getSettingsSectionMeta, + type SettingsSection, +} from '@/app/workspace/[workspaceId]/settings/navigation' + +const SettingsSectionContext = createContext(null) + +/** + * Provides the active settings section to descendants so `SettingsPanel` can + * resolve its title/description from navigation metadata. Set once by the + * settings shell with the resolved (post-redirect) section. + */ +export function SettingsSectionProvider({ + section, + children, +}: { + section: SettingsSection + children: ReactNode +}) { + return ( + {children} + ) +} + +function useSettingsSection(): SettingsSection | null { + return useContext(SettingsSectionContext) +} + +interface SettingsPanelSearch { + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean +} + +interface SettingsPanelProps { + /** Body content rendered below the header in the centered content column. */ + children: ReactNode + /** Right-aligned controls in the fixed header bar (e.g. a Create/Invite chip). */ + actions?: ReactNode + /** Overrides the nav-driven title (e.g. for a detail sub-view). */ + title?: string + /** Overrides the nav-driven description. */ + description?: string + /** Extra classes for the content column (layout/spacing only, e.g. a tighter `gap-*`). */ + contentClassName?: string + /** Ref forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */ + scrollContainerRef?: React.Ref + /** + * Renders the canonical search field directly below the title. Omit on pages + * with no search, or that pair search with extra controls (render that row in + * `children` instead). + */ + search?: SettingsPanelSearch +} + +/** + * Standard chrome for a settings page: a fixed header bar (right-aligned + * `actions`), a scroll region, and a centered content column led by the page + * title + description. The title/description come from the active section's + * navigation metadata by default, and can be overridden for sub-views. + * + * Pages render only their body as `children`; they no longer hand-roll the + * shell, header bar, or title block. + */ +export function SettingsPanel({ + children, + actions, + title, + description, + contentClassName, + scrollContainerRef, + search, +}: SettingsPanelProps) { + const section = useSettingsSection() + const meta = section ? getSettingsSectionMeta(section) : null + const resolvedTitle = title ?? meta?.label + const resolvedDescription = description ?? meta?.description + + return ( +
+
+
+
{actions}
+
+
+
+ {(resolvedTitle || resolvedDescription) && ( +
+ {resolvedTitle && ( +

{resolvedTitle}

+ )} + {resolvedDescription && ( +

{resolvedDescription}

+ )} +
+ )} + {search && ( + search.onChange(event.target.value)} + disabled={search.disabled} + autoComplete='off' + className='w-full' + /> + )} + {children} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index ca12bbced71..33d2cf2efb5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -4,18 +4,7 @@ import { useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { getErrorMessage } from '@sim/utils/errors' -import { - ChipDropdown, - ChipInput, - chipVariants, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - MoreHorizontal, - Search, - toast, -} from '@/components/emcn' +import { ChipDropdown, ChipInput, Search, toast } from '@/components/emcn' import { type OrgRole, type PermissionType, @@ -33,6 +22,10 @@ import { MemberRow, MemberSection, } from '@/app/workspace/[workspaceId]/settings/components/member-list' +import { + type RowAction, + RowActionsMenu, +} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { ManageCreditsModal, type ManageCreditsTarget, @@ -118,15 +111,8 @@ export function OrganizationMemberLists({ const isActiveSearch = q.length > 0 - const buildActionsMenu = (children: React.ReactNode) => ( - - - - - {children} - + const buildActionsMenu = (actions: RowAction[]) => ( + ) const renderOrgMemberRow = (member: RosterMember) => { @@ -169,69 +155,63 @@ export function OrganizationMemberLists({ /> ) } - menu={buildActionsMenu( - <> - copyToClipboard(member.email)}> - Copy email - - {!isOwner && ( - - setCreditsTarget({ - userId: member.userId, - name: member.name, - email: member.email, - }) - } - > - Manage Credits - - )} - {canRemove && ( - - onRemoveMember({ - id: member.memberId, - role: member.role, - user: { - id: member.userId, - name: member.name, - email: member.email, - image: member.image, - }, - }) - } - > - Remove - - )} - {isSelf && isOwner && onTransferOwnership && ( - onTransferOwnership()}> - Transfer ownership - - )} - {isSelf && !isOwner && ( - - onRemoveMember({ - id: member.memberId, - role: member.role, - user: { - id: member.userId, + menu={buildActionsMenu([ + { label: 'Copy email', onSelect: () => copyToClipboard(member.email) }, + ...(!isOwner + ? [ + { + label: 'Manage Credits', + onSelect: () => + setCreditsTarget({ + userId: member.userId, name: member.name, email: member.email, - image: member.image, - }, - }) - } - > - Leave organization - - )} - - )} + }), + }, + ] + : []), + ...(canRemove + ? [ + { + label: 'Remove', + destructive: true, + onSelect: () => + onRemoveMember({ + id: member.memberId, + role: member.role, + user: { + id: member.userId, + name: member.name, + email: member.email, + image: member.image, + }, + }), + }, + ] + : []), + ...(isSelf && isOwner && onTransferOwnership + ? [{ label: 'Transfer ownership', onSelect: () => onTransferOwnership() }] + : []), + ...(isSelf && !isOwner + ? [ + { + label: 'Leave organization', + destructive: true, + onSelect: () => + onRemoveMember({ + id: member.memberId, + role: member.role, + user: { + id: member.userId, + name: member.name, + email: member.email, + image: member.image, + }, + }), + }, + ] + : []), + ])} /> ) } @@ -248,32 +228,24 @@ export function OrganizationMemberLists({ image={invitation.inviteeImage} status='Invite pending' roleControl={roleControl} - menu={buildActionsMenu( - <> - copyToClipboard(invitation.email)}> - Copy email - - - resendInvitation - .mutateAsync({ invitationId: invitation.id, orgId: organizationId }) - .catch((error) => logger.error('Failed to resend invitation', { error })) - } - > - Resend invite - - - cancelInvitation - .mutateAsync({ invitationId: invitation.id, orgId: organizationId }) - .catch((error) => logger.error('Failed to revoke invitation', { error })) - } - > - Revoke invite - - - )} + menu={buildActionsMenu([ + { label: 'Copy email', onSelect: () => copyToClipboard(invitation.email) }, + { + label: 'Resend invite', + onSelect: () => + resendInvitation + .mutateAsync({ invitationId: invitation.id, orgId: organizationId }) + .catch((error) => logger.error('Failed to resend invitation', { error })), + }, + { + label: 'Revoke invite', + destructive: true, + onSelect: () => + cancelInvitation + .mutateAsync({ invitationId: invitation.id, orgId: organizationId }) + .catch((error) => logger.error('Failed to revoke invitation', { error })), + }, + ])} /> ) @@ -346,30 +318,26 @@ export function OrganizationMemberLists({ /> } - menu={buildActionsMenu( - <> - copyToClipboard(member.email)}> - Copy email - - {canRemoveFromWorkspace && ( - - removeWorkspaceMember - .mutateAsync({ userId: member.userId, workspaceId, organizationId }) - .catch((error) => { - logger.error('Failed to remove workspace member', { error }) - toast.error("Couldn't remove member", { - description: getErrorMessage(error, 'Please try again in a moment.'), - }) - }) - } - > - Remove from workspace - - )} - - )} + menu={buildActionsMenu([ + { label: 'Copy email', onSelect: () => copyToClipboard(member.email) }, + ...(canRemoveFromWorkspace + ? [ + { + label: 'Remove from workspace', + destructive: true, + onSelect: () => + removeWorkspaceMember + .mutateAsync({ userId: member.userId, workspaceId, organizationId }) + .catch((error) => { + logger.error('Failed to remove workspace member', { error }) + toast.error("Couldn't remove member", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + }), + }, + ] + : []), + ])} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 7088804bc38..a6c1b6f6e5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -7,6 +7,7 @@ import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { NoOrganizationView, OrganizationInviteModal, @@ -74,9 +75,6 @@ export function TeamManagement() { const adminOrOwner = isAdminOrOwner(organization, session?.user?.email) const totalSeats = organizationBillingData?.data?.totalSeats ?? 0 - // Seats are consumed only by accepted members (seat count is reconciled to the - // member count on accept/removal). Pending invites are surfaced separately and - // do not count as used until they are accepted. const usedSeats = organizationBillingData?.data?.members?.length ?? 0 const reservedSeats = organizationBillingData?.data?.usedSeats ?? 0 const pendingSeats = Math.max(0, reservedSeats - usedSeats) @@ -313,10 +311,9 @@ export function TeamManagement() { } return ( -
-
-
-
+ <> + Invite -
-
- -
-
-
-

Organization

-

- Manage members and their access across every workspace in your organization. -

-
- - - - -
-
+ } + > + + + + -
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index ac17449041c..8b915775821 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -4,20 +4,7 @@ import { useCallback, useMemo, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' -import { - Chip, - ChipDropdown, - ChipInput, - chipVariants, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - MoreHorizontal, - Plus, - Search, - toast, -} from '@/components/emcn' +import { Chip, ChipDropdown, Plus, toast } from '@/components/emcn' import { RoleLockTooltip, type WorkspaceRoleSource, @@ -29,6 +16,8 @@ import { MemberRow, MemberSection, } from '@/app/workspace/[workspaceId]/settings/components/member-list' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' import { @@ -174,10 +163,14 @@ export function Teammates() { } return ( -
-
-
-
+ <> + Invite -
-
-
-
-
-

Teammates

-

- Manage your teammates in this workspace. -

-
- -
- setSearchTerm(e.target.value)} - className='flex-1' - /> -
- - - {filteredTeammates.map((teammate) => ( - { - const lockReason = teammate.isPending - ? null - : workspaceRoleLockReason(teammate.roleSource) - return ( - - handleRoleChange(teammate, role as WorkspacePermission)} - options={ROLE_OPTIONS} - matchTriggerWidth={false} - disabled={ - teammate.isPending || - !canManage || - teammate.userId === viewer?.userId || - lockReason !== null - } - /> - - ) - })()} - menu={ - - - - - - copyToClipboard(teammate.email)}> - Copy email - - {canManage && teammate.isPending && ( - <> - { + } + > + + {filteredTeammates.map((teammate) => ( + { + const lockReason = teammate.isPending + ? null + : workspaceRoleLockReason(teammate.roleSource) + return ( + + handleRoleChange(teammate, role as WorkspacePermission)} + options={ROLE_OPTIONS} + matchTriggerWidth={false} + disabled={ + teammate.isPending || + !canManage || + teammate.userId === viewer?.userId || + lockReason !== null + } + /> + + ) + })()} + menu={ + copyToClipboard(teammate.email), + }, + ...(canManage && teammate.isPending + ? [ + { + label: 'Resend invite', + onSelect: () => { if (teammate.invitationId) { resendInvitation.mutate({ invitationId: teammate.invitationId, workspaceId, }) } - }} - > - Resend invite - - { + }, + }, + { + label: 'Copy invite link', + onSelect: () => { if (teammate.invitationId && teammate.token) { copyToClipboard( buildInviteLink(teammate.invitationId, teammate.token) ) } - }} - > - Copy invite link - - { + }, + }, + { + label: 'Revoke invite', + destructive: true, + onSelect: () => { if (teammate.invitationId) { cancelInvitation.mutate({ invitationId: teammate.invitationId, workspaceId, }) } - }} - > - Revoke invite - - - )} - {canManage && !teammate.isPending && teammate.userId !== viewer?.userId && ( - { - if (teammate.userId) { - removeMember.mutate( - { userId: teammate.userId, workspaceId }, - { - onError: (error) => { - toast.error("Couldn't remove teammate", { - description: getErrorMessage( - error, - 'Please try again in a moment.' - ), - }) - }, - } - ) - } - }} - > - Remove - - )} - - - } - /> - ))} - -
-
+ }, + }, + ] + : []), + ...(canManage && !teammate.isPending && teammate.userId !== viewer?.userId + ? [ + { + label: 'Remove', + destructive: true, + onSelect: () => { + if (teammate.userId) { + removeMember.mutate( + { userId: teammate.userId, workspaceId }, + { + onError: (error) => { + toast.error("Couldn't remove teammate", { + description: getErrorMessage( + error, + 'Please try again in a moment.' + ), + }) + }, + } + ) + } + }, + }, + ] + : []), + ]} + /> + } + /> + ))} + + -
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index e3dc1996b57..8eb0681115e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -23,18 +23,15 @@ import { ChipSelect, Code, type ComboboxOption, - chipVariants, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, Label, - MoreHorizontal, Tooltip, } from '@/components/emcn' -import { ArrowLeft, Search } from '@/components/emcn/icons' +import { ArrowLeft } from '@/components/emcn/icons' import { getBaseUrl } from '@/lib/core/utils/urls' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components' import { useApiKeys } from '@/hooks/queries/api-keys' import { useCreateMcpServer } from '@/hooks/queries/mcp' @@ -72,7 +69,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro const updateToolMutation = useUpdateWorkflowMcpTool() const updateServerMutation = useUpdateWorkflowMcpServer() - // API Keys - for "Create API key" link const { data: apiKeysData } = useApiKeys(workspaceId) const { data: workspaceSettingsData } = useWorkspaceSettings(workspaceId) const userPermissions = useUserPermissionsContext() @@ -260,7 +256,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro return `claude mcp add "${safeName}" --url "${mcpServerUrl}" --header "X-API-Key:$SIM_API_KEY"` } - // Cursor supports direct URL configuration (no mcp-remote needed) if (client === 'cursor') { const cursorConfig = isPublic ? { url: mcpServerUrl } @@ -269,7 +264,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro return JSON.stringify({ mcpServers: { [safeName]: cursorConfig } }, null, 2) } - // Claude Desktop and VS Code still use mcp-remote (stdio transport) const mcpRemoteArgs = isPublic ? ['-y', 'mcp-remote', mcpServerUrl] : ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY'] @@ -465,29 +459,18 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro

- - - - - - setToolToView(tool)}> - Edit - - setToolToDelete(tool)} - disabled={deleteToolMutation.isPending} - > - Remove - - - + setToolToView(tool) }, + { + label: 'Remove', + destructive: true, + disabled: deleteToolMutation.isPending, + onSelect: () => setToolToDelete(tool), + }, + ]} + />
))} @@ -984,103 +967,81 @@ export function WorkflowMcpServers() { return ( <> -
-
-
-
- setShowAddModal(true)} - disabled={isLoading} - > - Add Server - -
-
- -
-
- setSearchTerm(e.target.value)} - /> - -
- {error ? ( -
-

- {getErrorMessage(error, 'Failed to load MCP servers')} -

-
- ) : isLoading ? null : !hasServers ? ( -
- Click "Add Server" above to get started -
- ) : ( -
- {filteredServers.map((server) => { - const count = server.toolCount || 0 - const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}` - const isDeleting = deletingServers.has(server.id) - return ( -
-
-
- - {server.name} - - {server.isPublic && ( - - Public - - )} -
-

- {toolsLabel} -

-
-
- - - - - - setSelectedServerId(server.id)}> - Details - - setServerToDelete(server)} - disabled={isDeleting} - > - Delete - - - -
+ setShowAddModal(true)} + disabled={isLoading} + > + Add Server + + } + > +
+ {error ? ( +
+

+ {getErrorMessage(error, 'Failed to load MCP servers')} +

+
+ ) : isLoading ? null : !hasServers ? ( + + Click "Add Server" above to get started + + ) : ( +
+ {filteredServers.map((server) => { + const count = server.toolCount || 0 + const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}` + const isDeleting = deletingServers.has(server.id) + return ( +
+
+
+ + {server.name} + + {server.isPublic && ( + + Public + + )}
- ) - })} - {showNoResults && ( -
- No servers found matching "{searchTerm}" +

{toolsLabel}

- )} -
+
+ setSelectedServerId(server.id) }, + { + label: 'Delete', + destructive: true, + disabled: isDeleting, + onSelect: () => setServerToDelete(server), + }, + ]} + /> +
+
+ ) + })} + {showNoResults && ( + + No servers found matching "{searchTerm}" + )}
-
+ )}
-
+ section: NavigationSection hideWhenBillingDisabled?: boolean @@ -96,10 +98,17 @@ export const sectionConfig: { key: NavigationSection; title: string }[] = [ ] export const allNavigationItems: NavigationItem[] = [ - { id: 'general', label: 'General', icon: Settings, section: 'account' }, + { + id: 'general', + label: 'General', + description: 'Manage your profile, appearance, and preferences.', + icon: Settings, + section: 'account', + }, { id: 'access-control', label: 'Access control', + description: 'Manage permission groups across your organization.', icon: ShieldCheck, section: 'enterprise', requiresHosted: true, @@ -109,6 +118,7 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'audit-logs', label: 'Audit logs', + description: 'Review activity and changes across your organization.', icon: ClipboardList, section: 'enterprise', requiresHosted: true, @@ -118,6 +128,7 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'billing', label: 'Billing', + description: 'Manage your plan, pricing, and invoices.', icon: ClipboardList, section: 'subscription', hideWhenBillingDisabled: true, @@ -125,26 +136,59 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'teammates', label: 'Teammates', + description: 'Manage your teammates in this workspace.', icon: User, section: 'subscription', }, { id: 'organization', label: 'Organization', + description: "Manage your organization's members and seats.", icon: Users, section: 'subscription', hideWhenBillingDisabled: true, requiresHosted: true, requiresTeam: true, }, - { id: 'secrets', label: 'Secrets', icon: Key, section: 'account' }, - { id: 'custom-tools', label: 'Custom tools', icon: Wrench, section: 'tools' }, - { id: 'mcp', label: 'MCP tools', icon: McpIcon, section: 'tools' }, - { id: 'apikeys', label: 'Sim API keys', icon: TerminalWindow, section: 'system' }, - { id: 'workflow-mcp-servers', label: 'MCP servers', icon: Server, section: 'system' }, + { + id: 'secrets', + label: 'Secrets', + description: 'Store environment variables for your workflows.', + icon: Key, + section: 'account', + }, + { + id: 'custom-tools', + label: 'Custom tools', + description: 'Create and manage custom tools for your agents.', + icon: Wrench, + section: 'tools', + }, + { + id: 'mcp', + label: 'MCP tools', + description: 'Connect MCP servers and use their tools in workflows.', + icon: McpIcon, + section: 'tools', + }, + { + id: 'apikeys', + label: 'Sim API keys', + description: 'Create and manage API keys for the Sim API.', + icon: TerminalWindow, + section: 'system', + }, + { + id: 'workflow-mcp-servers', + label: 'MCP servers', + description: 'Expose your workflows as tools on an MCP server.', + icon: Server, + section: 'system', + }, { id: 'byok', label: 'BYOK', + description: 'Bring your own model-provider API keys.', icon: KeySquare, section: 'system', requiresHosted: true, @@ -152,6 +196,7 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'copilot', label: 'Chat keys', + description: 'Manage the model-provider keys that power Chat.', icon: HexSimple, section: 'system', requiresHosted: true, @@ -159,6 +204,7 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'inbox', label: 'Sim mailer', + description: 'Trigger and process workflows from incoming email.', icon: Send, section: 'system', requiresMax: true, @@ -171,15 +217,23 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'credential-sets' as const, label: 'Email polling', + description: 'Share email-polling credentials across your team.', icon: Mail, section: 'system' as const, }, ] : []), - { id: 'recently-deleted', label: 'Recently deleted', icon: TrashOutline, section: 'system' }, + { + id: 'recently-deleted', + label: 'Recently deleted', + description: 'Restore items deleted in the last 30 days.', + icon: TrashOutline, + section: 'system', + }, { id: 'sso', label: 'Single sign-on', + description: 'Configure single sign-on for your organization.', icon: LogIn, section: 'enterprise', requiresHosted: true, @@ -189,6 +243,7 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'data-retention', label: 'Data retention', + description: 'Control data retention windows and PII redaction.', icon: Database, section: 'enterprise', requiresHosted: true, @@ -198,6 +253,7 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'data-drains', label: 'Data drains', + description: 'Stream your logs and events to external destinations.', icon: Upload, section: 'enterprise', requiresHosted: true, @@ -207,6 +263,7 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'whitelabeling', label: 'Whitelabeling', + description: 'Customize your workspace branding and appearance.', icon: Palette, section: 'enterprise', requiresHosted: true, @@ -216,6 +273,7 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'admin', label: 'Admin', + description: 'Superuser administration and workspace tools.', icon: Lock, section: 'superuser', requiresAdminRole: true, @@ -223,8 +281,21 @@ export const allNavigationItems: NavigationItem[] = [ { id: 'mothership', label: 'Mothership', + description: 'Internal Sim operations and license management.', icon: Server, section: 'superuser', requiresAdminRole: true, }, ] + +/** + * Title + description for a settings section, the single source of truth used by + * `SettingsPanel` to render the page header. Falls back to `null` for sections + * that are gated off (callers render no title in that case). + */ +export function getSettingsSectionMeta( + section: SettingsSection +): { label: string; description: string } | null { + const item = allNavigationItems.find((navItem) => navItem.id === section) + return item ? { label: item.label, description: item.description } : null +} diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 3566c77add1..834daee7fdd 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2760,10 +2760,10 @@ export function TinybirdIcon(props: SVGProps) { export function ThriveIcon(props: SVGProps) { return ( - + ) diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 6ac886fec28..0af913eb34b 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -7,7 +7,6 @@ import { useParams } from 'next/navigation' import { Checkbox, Chip, - ChipInput, ChipModal, ChipModalBody, ChipModalError, @@ -16,9 +15,10 @@ import { ChipModalHeader, ChipTag, Label, - Search, } from '@/components/emcn' import { getEnv, isTruthy } from '@/lib/core/config/env' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { GroupDetail } from '@/ee/access-control/components/group-detail' import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' @@ -133,11 +133,11 @@ export function AccessControl() { if (!canManage) { return ( -
+ {!organizationId ? "Access Control applies to organization workspaces. This workspace isn't part of an organization." : 'Only organization admins on Enterprise plans can manage Access Control settings.'} -
+ ) } @@ -158,85 +158,66 @@ export function AccessControl() { return ( <> -
-
-
-
- setShowCreateModal(true)}> - Create Group - -
-
- -
-
-
-

Access Control

-

- Manage permission groups across every workspace in your organization. -

-
- -
- setSearchTerm(e.target.value)} - className='flex-1' - /> + setShowCreateModal(true)}> + Create Group + + } + > + + {permissionGroups.length === 0 ? ( + + No permission groups yet. Click "Create Group" to get started. + + ) : filteredGroups.length === 0 ? ( + + No groups found matching "{searchTerm}" + + ) : ( +
+ {filteredGroups.map((group) => ( + + ))}
- - - {permissionGroups.length === 0 ? ( -
- No permission groups yet. Click "Create Group" to get started. -
- ) : filteredGroups.length === 0 ? ( -
- No groups found matching "{searchTerm}" -
- ) : ( -
- {filteredGroups.map((group) => ( - - ))} -
- )} -
-
-
-
+ )} + + - - - - - handleRemoveMember(member.id)} - > - Remove - - - + handleRemoveMember(member.id), + destructive: true, + }, + ]} + /> } /> ))} diff --git a/apps/sim/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx index 304a3817afe..bda2338b792 100644 --- a/apps/sim/ee/audit-logs/components/audit-logs.tsx +++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx @@ -19,6 +19,8 @@ import { cn } from '@/lib/core/utils/cn' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { formatDateShort } from '@/app/workspace/[workspaceId]/logs/utils' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_TYPE_OPTIONS } from '@/ee/audit-logs/constants' import { type AuditLogFilters, useAuditLogs } from '@/ee/audit-logs/hooks/audit-logs' import type { TimeRange } from '@/stores/logs/filters/types' @@ -357,102 +359,94 @@ export function AuditLogs() { }, [hasNextPage, isFetchingNextPage, fetchNextPage]) return ( -
-
-
- {/* Search + filter bar */} -
- setSearchTerm(e.target.value)} - /> - -
- - { - if (!isOpen) { - if (dateRangeAppliedRef.current) { - dateRangeAppliedRef.current = false - } else { - handleDatePickerCancel() - } - } - }} - startDate={customStartDate} - endDate={customEndDate} - onRangeChange={handleDateRangeApply} - onCancel={handleDatePickerCancel} - /> -
- -
+ +
+ setSearchTerm(e.target.value)} + /> + +
+ + { + if (!isOpen) { + if (dateRangeAppliedRef.current) { + dateRangeAppliedRef.current = false + } else { + handleDatePickerCancel() + } + } + }} + startDate={customStartDate} + endDate={customEndDate} + onRangeChange={handleDateRangeApply} + onCancel={handleDatePickerCancel} + /> +
+ +
- {/* Table */} -
-
- Timestamp - Event - Description - Actor -
+
+
+ Timestamp + Event + Description + Actor +
- {isLoading ? null : allEntries.length === 0 ? ( - debouncedSearch ? ( -
- No results for "{debouncedSearch}" -
- ) : ( -
- No audit logs found -
- ) - ) : ( -
- {allEntries.map((entry) => ( - - ))} - {hasNextPage && ( -
- -
- )} + {isLoading ? null : allEntries.length === 0 ? ( + debouncedSearch ? ( + + No results for "{debouncedSearch}" + + ) : ( + No audit logs found + ) + ) : ( +
+ {allEntries.map((entry) => ( + + ))} + {hasNextPage && ( +
+
)}
-
+ )}
-
+
) } diff --git a/apps/sim/ee/data-drains/components/data-drains-settings.tsx b/apps/sim/ee/data-drains/components/data-drains-settings.tsx index aae623477e6..8238748660f 100644 --- a/apps/sim/ee/data-drains/components/data-drains-settings.tsx +++ b/apps/sim/ee/data-drains/components/data-drains-settings.tsx @@ -7,10 +7,8 @@ import { toError } from '@sim/utils/errors' import { ChevronDown, Plus } from 'lucide-react' import { Badge, - Button, Chip, ChipConfirmModal, - ChipInput, ChipModal, ChipModalBody, ChipModalError, @@ -18,12 +16,6 @@ import { ChipModalFooter, ChipModalHeader, ChipSelect, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - MoreHorizontal, - Search, Switch, Table, TableBody, @@ -38,6 +30,9 @@ import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types' import { getUserRole } from '@/lib/workspaces/organization/utils' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { InfoNote } from '@/ee/components/info-note' import { DESTINATION_FORM_REGISTRY } from '@/ee/data-drains/destinations/registry' import { @@ -118,45 +113,40 @@ export function DataDrainsSettings() { if (!orgId) { return ( -
+ Data drains are configured per organization. Join or create one to continue. -
+ ) } if (!canManage) { return ( -
+ Only organization owners and admins can configure data drains. -
+ ) } return ( -
-
-
-
+ <> + setCreateOpen(true)}> New Drain -
-
- -
-
+ } + search={{ + value: searchTerm, + onChange: setSearchTerm, + placeholder: 'Search data drains...', + }} + > +
Drains continuously export Sim data to your own storage on a schedule. Combine with Data Retention to satisfy long-term compliance archives. - setSearchTerm(e.target.value)} - /> -
{drainsError ? (
@@ -193,23 +183,21 @@ export function DataDrainsSettings() { ) : ( -
+ No results for "{searchTerm.trim()}" -
+ ) ) : ( -
- Click "New Drain" above to get started -
+ Click "New Drain" above to get started )}
-
+ {createOpen && ( setCreateOpen(false)} /> )} -
+ ) } @@ -304,22 +292,14 @@ function DrainRow({ drain, organizationId, expanded, onToggleExpand }: DrainRowP /> e.stopPropagation()}> - - - - - - - Run now - - Test connection - - Delete - - - + {expanded && ( diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index a72ca616a09..a39addd8daa 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -34,6 +34,8 @@ import { SUPPORTED_PII_ENTITIES, } from '@/lib/guardrails/pii-entities' import { getUserRole } from '@/lib/workspaces/organization/utils' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useOrganizationRetention, @@ -426,8 +428,6 @@ export function DataRetentionSettings() { const [overrides, setOverrides] = useState([]) const [modal, setModal] = useState(null) const [showUnsaved, setShowUnsaved] = useState(false) - // Org the form was hydrated for; re-hydrate when the active org switches so - // saves don't target the new org with the previous org's config. const hydratedOrgRef = useRef(null) useEffect(() => { @@ -464,8 +464,6 @@ export function DataRetentionSettings() { const modalChanged = modal !== null && normalizePolicyDraft(modal.draft) !== normalizePolicyDraft(modal.original) - // PII-only rows are only surfaced when redaction is enabled — the route - // rejects PII writes while the flag is off, so such rows couldn't be deleted. const overrideWorkspaceIds = Array.from( new Set([ ...overrides.map((o) => o.workspaceId), @@ -647,8 +645,6 @@ export function DataRetentionSettings() { const ids = draft.workspaceIds if (ids.length === 0) return - // Clear the workspaces this edit previously owned plus the new selection, - // so deselecting a workspace removes its override instead of orphaning it. const clearIds = new Set([...modal.original.workspaceIds, ...ids]) const nextOverrides = overrides.filter((o) => !clearIds.has(o.workspaceId)) const nextPiiOverrides = piiOverrides.filter((p) => !clearIds.has(p.workspaceId)) @@ -681,8 +677,6 @@ export function DataRetentionSettings() { async function removeCurrentOverride() { if (!modal || modal.draft.isOrgDefault) return - // Remove the override(s) this row originally owned, regardless of any - // unsaved changes to the workspace multi-select in the open modal. const idSet = new Set(modal.original.workspaceIds) try { await persistSnapshot({ @@ -705,41 +699,34 @@ export function DataRetentionSettings() { if (!orgId) { return ( -
+ Data retention is configured per organization. Join or create an organization to continue. -
+ ) } if (!data) { - return ( -
- Failed to load data retention settings. -
- ) + return Failed to load data retention settings. } if (isBillingEnabled && !data.isEnterprise) { return ( -
- Data retention is available on Enterprise plans only. -
+ Data retention is available on Enterprise plans only. ) } if (!canManage) { return ( -
+ Only organization owners and admins can configure data retention settings. -
+ ) } return ( -
-
-
-
+ <> + Add override -
-
-
-
- -
- - Workspaces without an override inherit the organization defaults. - -
+ } + > + +
+ + Workspaces without an override inherit the organization defaults. + +
+ + {overrideWorkspaceIds.map((workspaceId) => ( - {overrideWorkspaceIds.map((workspaceId) => ( - - ))} -
+ ))}
-
-
-
+
+ + {modal && ( -
+ ) } diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index 048cd0a3c96..c7c0e5be19b 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -23,6 +23,8 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { getUserRole } from '@/lib/workspaces/organization/utils' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso' import { useOrganizations } from '@/hooks/queries/organization' @@ -137,33 +139,33 @@ export function SSO() { if (isBillingEnabled) { if (!activeOrganization) { return ( -
+ You must be part of an organization to configure Single Sign-On. -
+ ) } if (!hasEnterprisePlan) { return ( -
+ Single Sign-On is available on Enterprise plans only. -
+ ) } if (!canManageSSO) { return ( -
+ Only organization owners and admins can configure Single Sign-On settings. -
+ ) } } else { if (activeOrganization && !canManageSSO) { return ( -
+ Only organization owners and admins can configure Single Sign-On settings. -
+ ) } if ( @@ -173,9 +175,9 @@ export function SSO() { providers.length > 0 ) { return ( -
+ Only the user who configured SSO can manage these settings. -
+ ) } } @@ -433,74 +435,65 @@ export function SSO() { const providerCallbackUrl = `${getBaseUrl()}/api/auth/${existingProvider.providerType === 'saml' ? 'sso/saml2/callback' : 'sso/callback'}/${existingProvider.providerId}` return ( -
-
-
-
- -
-
-
-
- -

{existingProvider.providerId}

-
- - -

- {existingProvider.providerType.toUpperCase()} -

-
- - -

{existingProvider.domain}

-
- - -

- {existingProvider.issuer} -

-
- - - copyToClipboard(providerCallbackUrl)} - className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]' - aria-label='Copy callback URL' - > - {copied ? ( - - ) : ( - - )} - - } - /> -

- Configure this in your identity provider -

-
-
+ + Edit + + } + > +
+ +

{existingProvider.providerId}

+
+ + +

+ {existingProvider.providerType.toUpperCase()} +

+
+ + +

{existingProvider.domain}

+
+ + +

+ {existingProvider.issuer} +

+
+ + + copyToClipboard(providerCallbackUrl)} + className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]' + aria-label='Copy callback URL' + > + {copied ? ( + + ) : ( + + )} + + } + /> +

+ Configure this in your identity provider +

+
-
+ ) } return ( -
- {/* Off-screen inputs to prevent browser password manager autofill */} + -
-
-
- {isEditing && ( + + {isEditing && ( + + )} - )} - -
-
- -
-
- {/* Provider Type */} + + } + > +
- {/* Provider ID */} 0 ? errors.providerId.join(' ') : undefined } > - {/* Editable combobox (not ChipSelect): provider IDs accept any - validated slug, with SSO_TRUSTED_PROVIDERS as autocomplete. */} handleInputChange('providerId', value)} @@ -610,7 +598,6 @@ export function SSO() { /> - {/* Issuer URL */} - {/* Domain */} 0 ? errors.domain.join(' ') : undefined} @@ -659,7 +645,6 @@ export function SSO() { {formData.providerType === 'oidc' ? ( <> - {/* Client ID */} - {/* Client Secret */} - {/* Scopes */} 0 ? errors.scopes.join(' ') : undefined} @@ -750,7 +733,6 @@ export function SSO() { ) : ( <> - {/* Entry Point URL */} - {/* Identity Provider Certificate */} 0 ? errors.cert.join(' ') : undefined} @@ -859,7 +840,6 @@ export function SSO() { )} - {/* Callback URL */}
-
+ ) } diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index 692c6db72f4..04f09061912 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -18,6 +18,8 @@ import { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL, } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload' import { SettingRow } from '@/ee/components/setting-row' @@ -278,25 +280,25 @@ export function WhitelabelingSettings() { if (isBillingEnabled) { if (!activeOrganization) { return ( -
+ You must be part of an organization to configure whitelabeling. -
+ ) } if (!hasEnterprisePlan) { return ( -
+ Whitelabeling is available on Enterprise plans only. -
+ ) } if (!canManage) { return ( -
+ Only organization owners and admins can configure whitelabeling settings. -
+ ) } } @@ -308,222 +310,216 @@ export function WhitelabelingSettings() { const isUploading = logoUpload.isUploading || wordmarkUpload.isUploading return ( -
-
-
-
- + } + > + +
+ - {updateSettings.isPending ? 'Saving...' : 'Save'} - -
-
-
-
- -
- - setBrandName(e.target.value)} - placeholder='Your Company' - className='max-w-[320px]' - maxLength={64} + setBrandName(e.target.value)} + placeholder='Your Company' + className='max-w-[320px]' + maxLength={64} + /> + +
+ +
+ + + +
+ + {logoUpload.previewUrl && ( + + )} +
+ - -
- -
- - - -
- - {logoUpload.previewUrl && ( - - )} -
- -
-
+
+ - -
- - - -
- - {wordmarkUpload.previewUrl && ( - - )} -
- -
-
+ +
+ + + +
+ + {wordmarkUpload.previewUrl && ( + + )} +
+
-
- +
+
+
+
- -
- - - - -
-
+ +
+ + + + +
+
- -
- - setSupportEmail(e.target.value)} - placeholder='support@yourcompany.com' - /> - - - setDocumentationUrl(e.target.value)} - placeholder='https://docs.yourcompany.com' - /> - - - setTermsUrl(e.target.value)} - placeholder='https://yourcompany.com/terms' - /> - - - setPrivacyUrl(e.target.value)} - placeholder='https://yourcompany.com/privacy' - /> - -
-
+ +
+ + setSupportEmail(e.target.value)} + placeholder='support@yourcompany.com' + /> + + + setDocumentationUrl(e.target.value)} + placeholder='https://docs.yourcompany.com' + /> + + + setTermsUrl(e.target.value)} + placeholder='https://yourcompany.com/terms' + /> + + + setPrivacyUrl(e.target.value)} + placeholder='https://yourcompany.com/privacy' + /> +
-
-
+ + ) }