feat(settings): unify all settings pages under a shared SettingsPanel layout (#5219)

* feat(settings): add SettingsPanel layout with nav-driven page titles

Introduce a SettingsPanel scaffold that owns the standard settings chrome
(fixed header bar with right-aligned actions, scroll region, centered content
column) and renders a consistent page title + description pulled from the
active section's navigation metadata. Adds a description to every nav item and
a SettingsSectionProvider in the shell so the title is required-by-default
without per-page wiring.

Migrate the account/subscription pages (general, secrets, teammates,
team-management, billing) to SettingsPanel, removing their hand-rolled shells
and title blocks.

* feat(settings): migrate all settings pages to SettingsPanel + bake in search

Extend SettingsPanel with a `search` prop (canonical search field with
optional anti-autofill hardening) so the repeated per-page search input is
owned by the layout. Migrate every settings page — account, subscription,
tools, system, enterprise, and superuser — onto SettingsPanel so each renders
a consistent nav-driven title + description, header actions, and search with
zero hand-rolled shell. Drill-down detail sub-views (MCP server, workflow MCP
server, credential set, permission group) keep their own back-button chrome.

Normalize all nav descriptions to a consistent voice and length.

* refactor(settings): drop unnecessary search anti-autofill; document SettingsPanel

Remove the preventAutofill prop from SettingsPanel — the shared search field
no longer needs the read-only-until-focus hack, so secrets uses the plain
search like every other page. Pre-existing honeypot inputs are untouched.

Add .claude/rules/sim-settings-pages.md (auto-scoped to settings pages) and a
settings-page skill documenting the SettingsPanel convention + add/audit
procedure.

* refactor(settings): extract SettingsEmptyState + RowActionsMenu, dedupe usages

Encapsulate two more repeated settings patterns into shared components:
- SettingsEmptyState — the muted empty/no-results/gate message (fill | inline),
  replacing ~42 hand-rolled status divs and normalizing stragglers that used
  text-small / text-tertiary back to the canonical text-muted + text-sm.
- RowActionsMenu — the trailing '...' row-actions dropdown, replacing ~11
  per-page DropdownMenu+MoreHorizontal blocks with a props-driven actions list.

Pure presentational refactor: every message, action, handler, disabled, and
destructive flag preserved (verified by diff review). Document both in
.claude/rules/sim-settings-pages.md.

* fix(settings): set autoComplete=off on the shared settings search field

Keeps browsers from offering saved-credential autofill in a filter box — the
lightweight standard guard, distinct from the removed read-only/preventAutofill
machinery. Matters most on the secrets page.

* chore(settings): strip non-TSDoc inline comments from touched files

Remove JSX label comments, section/explanatory // comments, separator block
comments, and copilot's commented-out MCP scaffold across the migrated files,
per the repo's no-non-TSDoc-comments standard. TSDoc and functional directives
kept. Comment-only deletions — no code or behavior changed.

* chore(skills): rename settings-page skill to add-settings-page

Aligns with the verb-prefixed naming of the other skills (add-integration, etc.).

* feat(icons): Thrive icon-only black mark on white

Drop the wordmark from ThriveIcon (tighten viewBox to the logo bounds), set the
mark to pure black; the block bgColor is already white. Synced the docs icon copy.
This commit is contained in:
Waleed
2026-06-25 18:30:41 -07:00
committed by GitHub
parent ca4e07baf3
commit a1d5870681
39 changed files with 2939 additions and 3056 deletions
+123
View File
@@ -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:
- `<div className='flex h-full flex-col bg-[var(--bg)]'>` 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 (`<h1 className='font-medium text-[var(--text-body)] text-lg'>` + `<p className='text-[var(--text-muted)] text-md'>`)
- the page-level search input
## Canonical page shape
```tsx
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
return (
<SettingsPanel
actions={
<Chip leftIcon={Plus} variant='primary' onClick={onCreate}>
Create
</Chip>
}
search={{ value: searchTerm, onChange: setSearchTerm, placeholder: 'Search …' }}
>
{/* body only — sections, lists, forms */}
</SettingsPanel>
)
```
When the page has modal/dialog siblings, wrap them with the panel in a fragment:
```tsx
return (
<>
<SettingsPanel actions={…}>{body}</SettingsPanel>
<SomeModal … />
</>
)
```
## `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 && <Chip…/>}`.
- `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<HTMLDivElement>` — 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 `<SettingsPanel>` — 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
`<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>`
or `<div className='py-4 text-center …'>`. 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 `<DropdownMenu>` + `<MoreHorizontal>` 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 (`<Chip leftIcon={ArrowLeft}>`), 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 `<SettingsPanel>` (or `<>…<SettingsPanel>…</>` with modal siblings) — no hand-rolled shell/header/scroll/column.
- [ ] It renders **no** hand-rolled `<h1>`/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.
+57
View File
@@ -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/<name>/<name>.tsx` and EE pages under `apps/sim/ee/<feature>/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 <file>`.
## 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 `<Chip leftIcon={ArrowLeft}>` 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
`<h1>` title block, replace the three closing `</div>` (column/scroll/shell)
with `</SettingsPanel>`, 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.
File diff suppressed because one or more lines are too long
@@ -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 (
<div className='flex h-full flex-col'>
{effectiveSection === 'general' && <General />}
{effectiveSection === 'secrets' && <Secrets />}
{effectiveSection === 'credential-sets' && <CredentialSets />}
{effectiveSection === 'access-control' && <AccessControl />}
{effectiveSection === 'audit-logs' && <AuditLogs />}
{effectiveSection === 'apikeys' && <ApiKeys />}
{isBillingEnabled && effectiveSection === 'billing' && <Billing />}
{effectiveSection === 'teammates' && <Teammates />}
{isBillingEnabled && effectiveSection === 'organization' && <TeamManagement />}
{effectiveSection === 'sso' && <SSO />}
{effectiveSection === 'data-retention' && <DataRetentionSettings />}
{effectiveSection === 'data-drains' && <DataDrainsSettings />}
{effectiveSection === 'whitelabeling' && <WhitelabelingSettings />}
{effectiveSection === 'byok' && <BYOK />}
{effectiveSection === 'copilot' && <Copilot />}
{effectiveSection === 'mcp' && <MCP />}
{effectiveSection === 'custom-tools' && <CustomTools />}
{effectiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{effectiveSection === 'inbox' && <Inbox />}
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
{effectiveSection === 'admin' && <Admin />}
{effectiveSection === 'mothership' && <Mothership />}
</div>
<SettingsSectionProvider section={effectiveSection}>
<div className='flex h-full flex-col'>
{effectiveSection === 'general' && <General />}
{effectiveSection === 'secrets' && <Secrets />}
{effectiveSection === 'credential-sets' && <CredentialSets />}
{effectiveSection === 'access-control' && <AccessControl />}
{effectiveSection === 'audit-logs' && <AuditLogs />}
{effectiveSection === 'apikeys' && <ApiKeys />}
{isBillingEnabled && effectiveSection === 'billing' && <Billing />}
{effectiveSection === 'teammates' && <Teammates />}
{isBillingEnabled && effectiveSection === 'organization' && <TeamManagement />}
{effectiveSection === 'sso' && <SSO />}
{effectiveSection === 'data-retention' && <DataRetentionSettings />}
{effectiveSection === 'data-drains' && <DataDrainsSettings />}
{effectiveSection === 'whitelabeling' && <WhitelabelingSettings />}
{effectiveSection === 'byok' && <BYOK />}
{effectiveSection === 'copilot' && <Copilot />}
{effectiveSection === 'mcp' && <MCP />}
{effectiveSection === 'custom-tools' && <CustomTools />}
{effectiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{effectiveSection === 'inbox' && <Inbox />}
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
{effectiveSection === 'admin' && <Admin />}
{effectiveSection === 'mothership' && <Mothership />}
</div>
</SettingsSectionProvider>
)
}
@@ -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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-6 pt-6 pb-6'>
<div className='flex flex-col gap-4'>
<div className='flex items-center justify-between'>
<Label htmlFor='super-user-mode'>Super admin mode</Label>
<Switch
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? false}
<SettingsPanel>
<div className='flex flex-col gap-4'>
<div className='flex items-center justify-between'>
<Label htmlFor='super-user-mode'>Super admin mode</Label>
<Switch
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? false}
disabled={updateSetting.isPending}
onCheckedChange={handleSuperUserModeToggle}
/>
</div>
{settings?.superUserModeEnabled && (
<>
<div className='flex items-center justify-between gap-3'>
<div className='flex flex-col gap-1'>
<Label className='text-[var(--text-primary)] text-sm'>Mothership Environment</Label>
<p className='text-[var(--text-secondary)] text-xs'>
Default uses the configured Sim agent URL.
</p>
</div>
<ChipSelect
align='start'
dropdownWidth={160}
value={settings?.mothershipEnvironment ?? 'default'}
onChange={(value) =>
handleMothershipEnvironmentChange(value as MothershipEnvironment)
}
placeholder='Select environment'
disabled={updateSetting.isPending}
onCheckedChange={handleSuperUserModeToggle}
options={MOTHERSHIP_ENV_OPTIONS}
/>
</div>
</>
)}
</div>
{settings?.superUserModeEnabled && (
<>
<div className='flex items-center justify-between gap-3'>
<div className='flex flex-col gap-1'>
<Label className='text-[var(--text-primary)] text-sm'>
Mothership Environment
</Label>
<p className='text-[var(--text-secondary)] text-xs'>
Default uses the configured Sim agent URL.
</p>
</div>
<ChipSelect
align='start'
dropdownWidth={160}
value={settings?.mothershipEnvironment ?? 'default'}
onChange={(value) =>
handleMothershipEnvironmentChange(value as MothershipEnvironment)
}
placeholder='Select environment'
disabled={updateSetting.isPending}
options={MOTHERSHIP_ENV_OPTIONS}
/>
</div>
</>
)}
</div>
<div className='h-px bg-[var(--border)]' />
<div className='h-px bg-[var(--border)]' />
<div className='flex flex-col gap-2'>
<p className='text-[var(--text-secondary)] text-sm'>
Import a workflow by ID along with its associated copilot chats.
</p>
<div className='flex gap-2'>
<ChipInput
value={workflowId}
onChange={(e) => {
setWorkflowId(e.target.value)
importWorkflow.reset()
}}
placeholder='Enter workflow ID'
disabled={importWorkflow.isPending}
/>
<Button
variant='primary'
onClick={handleImport}
disabled={importWorkflow.isPending || !workflowId.trim()}
>
{importWorkflow.isPending ? 'Importing...' : 'Import'}
</Button>
</div>
{importWorkflow.error && (
<p className='text-[var(--text-error)] text-small'>{importWorkflow.error.message}</p>
)}
{importWorkflow.isSuccess && (
<p className='text-[var(--text-secondary)] text-small'>
Workflow imported successfully (new ID: {importWorkflow.data.newWorkflowId},{' '}
{importWorkflow.data.copilotChatsImported ?? 0} copilot chats imported)
</p>
)}
</div>
<div className='flex flex-col gap-2'>
<p className='text-[var(--text-secondary)] text-sm'>
Import a workflow by ID along with its associated copilot chats.
</p>
<div className='flex gap-2'>
<ChipInput
value={workflowId}
onChange={(e) => {
setWorkflowId(e.target.value)
importWorkflow.reset()
}}
placeholder='Enter workflow ID'
disabled={importWorkflow.isPending}
/>
<Button
variant='primary'
onClick={handleImport}
disabled={importWorkflow.isPending || !workflowId.trim()}
>
{importWorkflow.isPending ? 'Importing...' : 'Import'}
</Button>
</div>
{importWorkflow.error && (
<p className='text-[var(--text-error)] text-small'>{importWorkflow.error.message}</p>
)}
{importWorkflow.isSuccess && (
<p className='text-[var(--text-secondary)] text-small'>
Workflow imported successfully (new ID: {importWorkflow.data.newWorkflowId},{' '}
{importWorkflow.data.copilotChatsImported ?? 0} copilot chats imported)
</p>
)}
</div>
<div className='h-px bg-[var(--border)]' />
<div className='h-px bg-[var(--border)]' />
<div className='flex flex-col gap-3'>
<p className='font-medium text-[var(--text-muted)] text-small'>User Management</p>
<div className='flex gap-2'>
<ChipInput
icon={Search}
value={searchInput}
onChange={(e) => 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'
/>
<Button variant='primary' onClick={handleSearch} disabled={usersLoading}>
{usersLoading ? 'Searching...' : 'Search'}
</Button>
</div>
<div className='flex flex-col gap-3'>
<p className='font-medium text-[var(--text-muted)] text-small'>User Management</p>
<div className='flex gap-2'>
<ChipInput
icon={Search}
value={searchInput}
onChange={(e) => 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'
/>
<Button variant='primary' onClick={handleSearch} disabled={usersLoading}>
{usersLoading ? 'Searching...' : 'Search'}
</Button>
</div>
{usersError && (
<p className='text-[var(--text-error)] text-small'>
{getErrorMessage(usersError, 'Failed to fetch users')}
</p>
)}
{usersError && (
<p className='text-[var(--text-error)] text-small'>
{getErrorMessage(usersError, 'Failed to fetch users')}
</p>
)}
{(setUserRole.error ||
banUser.error ||
unbanUser.error ||
impersonateUser.error ||
impersonationGuardError) && (
<p className='text-[13px] text-[var(--text-error)]'>
{impersonationGuardError ||
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
?.message ||
'Action failed. Please try again.'}
</p>
)}
{(setUserRole.error ||
banUser.error ||
unbanUser.error ||
impersonateUser.error ||
impersonationGuardError) && (
<p className='text-[13px] text-[var(--text-error)]'>
{impersonationGuardError ||
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
?.message ||
'Action failed. Please try again.'}
</p>
)}
{searchQuery.length > 0 && usersData && (
<>
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-[var(--text-tertiary)] text-caption'>
<span className='w-[200px]'>Name</span>
<span className='flex-1'>Email</span>
<span className='w-[80px]'>Role</span>
<span className='w-[80px]'>Status</span>
<span className='w-[250px] text-right'>Actions</span>
</div>
{searchQuery.length > 0 && usersData && (
<>
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-[var(--text-tertiary)] text-caption'>
<span className='w-[200px]'>Name</span>
<span className='flex-1'>Email</span>
<span className='w-[80px]'>Role</span>
<span className='w-[80px]'>Status</span>
<span className='w-[250px] text-right'>Actions</span>
</div>
{usersData.users.length === 0 && (
<SettingsEmptyState variant='inline'>No users found.</SettingsEmptyState>
)}
{usersData.users.length === 0 && (
<div className='py-4 text-center text-[var(--text-tertiary)] text-small'>
No users found.
</div>
{usersData.users.map((u) => (
<div
key={u.id}
className={cn(
'flex flex-col gap-2 px-3 py-2 text-small',
'border-[var(--border-secondary)] border-b last:border-b-0'
)}
{usersData.users.map((u) => (
<div
key={u.id}
className={cn(
'flex flex-col gap-2 px-3 py-2 text-small',
'border-[var(--border-secondary)] border-b last:border-b-0'
>
<div className='flex items-center gap-3'>
<span className='w-[200px] truncate text-[var(--text-primary)]'>
{u.name || '—'}
</span>
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
<span className='w-[80px]'>
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>
{u.role || 'user'}
</Badge>
</span>
<span className='w-[80px]'>
{u.banned ? (
<Badge variant='red'>Banned</Badge>
) : (
<Badge variant='green'>Active</Badge>
)}
>
<div className='flex items-center gap-3'>
<span className='w-[200px] truncate text-[var(--text-primary)]'>
{u.name || '—'}
</span>
<span className='flex-1 truncate text-[var(--text-secondary)]'>
{u.email}
</span>
<span className='w-[80px]'>
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>
{u.role || 'user'}
</Badge>
</span>
<span className='w-[80px]'>
{u.banned ? (
<Badge variant='red'>Banned</Badge>
) : (
<Badge variant='green'>Active</Badge>
)}
</span>
<span className='flex w-[250px] justify-end gap-1'>
{u.id !== session?.user?.id && (
<>
<Button
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => handleImpersonate(u.id)}
disabled={pendingUserIds.has(u.id)}
>
{impersonatingUserId === u.id ||
(impersonateUser.isPending &&
(impersonateUser.variables as { userId?: string } | undefined)
?.userId === u.id)
? 'Switching...'
: 'Impersonate'}
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => {
setUserRole.reset()
setUserRole.mutate({
userId: u.id,
role: u.role === 'admin' ? 'user' : 'admin',
})
}}
disabled={pendingUserIds.has(u.id)}
>
{u.role === 'admin' ? 'Demote' : 'Promote'}
</Button>
{u.banned ? (
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() => {
unbanUser.reset()
unbanUser.mutate({ userId: u.id })
}}
disabled={pendingUserIds.has(u.id)}
>
Unban
</Button>
) : (
<Button
variant='active'
className={cn(
'h-[28px] px-2 text-caption',
banUserId === u.id
? 'text-[var(--text-primary)]'
: 'text-[var(--text-error)]'
)}
onClick={() => {
if (banUserId === u.id) {
setBanUserId(null)
setBanReason('')
} else {
setBanUserId(u.id)
setBanReason('')
}
}}
disabled={pendingUserIds.has(u.id)}
>
{banUserId === u.id ? 'Cancel' : 'Ban'}
</Button>
)}
</>
)}
</span>
</div>
{banUserId === u.id && !u.banned && (
<div className='flex items-center gap-2 pl-[200px]'>
<ChipInput
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder='Reason (optional)'
className='flex-1'
/>
</span>
<span className='flex w-[250px] justify-end gap-1'>
{u.id !== session?.user?.id && (
<>
<Button
variant='primary'
className='h-[28px] px-3 text-caption'
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => handleImpersonate(u.id)}
disabled={pendingUserIds.has(u.id)}
>
{impersonatingUserId === u.id ||
(impersonateUser.isPending &&
(impersonateUser.variables as { userId?: string } | undefined)
?.userId === u.id)
? 'Switching...'
: 'Impersonate'}
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => {
banUser.reset()
banUser.mutate(
{
userId: u.id,
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
},
{
onSuccess: () => {
setBanUserId(null)
setBanReason('')
},
}
)
setUserRole.reset()
setUserRole.mutate({
userId: u.id,
role: u.role === 'admin' ? 'user' : 'admin',
})
}}
disabled={pendingUserIds.has(u.id)}
>
Confirm Ban
{u.role === 'admin' ? 'Demote' : 'Promote'}
</Button>
</div>
{u.banned ? (
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() => {
unbanUser.reset()
unbanUser.mutate({ userId: u.id })
}}
disabled={pendingUserIds.has(u.id)}
>
Unban
</Button>
) : (
<Button
variant='active'
className={cn(
'h-[28px] px-2 text-caption',
banUserId === u.id
? 'text-[var(--text-primary)]'
: 'text-[var(--text-error)]'
)}
onClick={() => {
if (banUserId === u.id) {
setBanUserId(null)
setBanReason('')
} else {
setBanUserId(u.id)
setBanReason('')
}
}}
disabled={pendingUserIds.has(u.id)}
>
{banUserId === u.id ? 'Cancel' : 'Ban'}
</Button>
)}
</>
)}
</div>
))}
</div>
{totalPages > 1 && (
<div className='flex items-center justify-between text-[var(--text-secondary)] text-small'>
<span>
Page {currentPage} of {totalPages} ({usersData.total} users)
</span>
<div className='flex gap-1'>
</div>
{banUserId === u.id && !u.banned && (
<div className='flex items-center gap-2 pl-[200px]'>
<ChipInput
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder='Reason (optional)'
className='flex-1'
/>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() =>
setAdminParams((prev) => ({
offset: Math.max(0, prev.offset - PAGE_SIZE),
}))
}
disabled={usersOffset === 0 || usersLoading}
variant='primary'
className='h-[28px] px-3 text-caption'
onClick={() => {
banUser.reset()
banUser.mutate(
{
userId: u.id,
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
},
{
onSuccess: () => {
setBanUserId(null)
setBanReason('')
},
}
)
}}
disabled={pendingUserIds.has(u.id)}
>
Previous
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() =>
setAdminParams((prev) => ({ offset: prev.offset + PAGE_SIZE }))
}
disabled={
usersOffset + PAGE_SIZE >= (usersData?.total ?? 0) || usersLoading
}
>
Next
Confirm Ban
</Button>
</div>
</div>
)}
</>
)}
</div>
))}
</div>
{totalPages > 1 && (
<div className='flex items-center justify-between text-[var(--text-secondary)] text-small'>
<span>
Page {currentPage} of {totalPages} ({usersData.total} users)
</span>
<div className='flex gap-1'>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() =>
setAdminParams((prev) => ({
offset: Math.max(0, prev.offset - PAGE_SIZE),
}))
}
disabled={usersOffset === 0 || usersLoading}
>
Previous
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() => setAdminParams((prev) => ({ offset: prev.offset + PAGE_SIZE }))}
disabled={usersOffset + PAGE_SIZE >= (usersData?.total ?? 0) || usersLoading}
>
Next
</Button>
</div>
</div>
)}
</div>
</div>
</>
)}
</div>
</div>
</SettingsPanel>
)
}
@@ -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 (
<div className='flex-shrink-0'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='API key actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={() => copyKeyName(keyName)}>Copy name</DropdownMenuItem>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={onDelete}
disabled={!canDelete}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RowActionsMenu
label='API key actions'
actions={[
{ label: 'Copy name', onSelect: () => copyKeyName(keyName) },
{ label: 'Delete', destructive: true, disabled: !canDelete, onSelect: onDelete },
]}
/>
</div>
)
}
@@ -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<ApiKey | null>(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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
{/* Fixed header bar */}
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search API keys...',
}}
actions={
<Chip
leftIcon={Plus}
variant='primary'
@@ -178,68 +152,19 @@ export function ApiKeys() {
>
Create API Key
</Chip>
</div>
</div>
{/* Scrollable content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-4 pb-6'>
{/* Search Input */}
<ChipInput
icon={Search}
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{/* Key list */}
{isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Click "Create API Key" above to get started
</div>
) : (
<div className='flex flex-col gap-6'>
{/* Workspace section */}
{!searchTerm.trim() ? (
<SettingsSection label='Workspace'>
{workspaceKeys.length === 0 ? (
<div className='text-[var(--text-muted)] text-sm'>
No workspace API keys yet
</div>
) : (
<div className='flex flex-col gap-2'>
{workspaceKeys.map((key) => (
<div key={key.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-1.5'>
<span className='max-w-[280px] truncate text-[14px] text-[var(--text-body)]'>
{key.name}
</span>
<span className='text-[var(--text-secondary)] text-sm'>
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[12px] text-[var(--text-muted)]'>
{key.displayKey || key.key}
</p>
</div>
<ApiKeyRowMenu
keyName={key.name}
onDelete={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
canDelete={canManageWorkspaceKeys}
/>
</div>
))}
</div>
)}
</SettingsSection>
) : filteredWorkspaceKeys.length > 0 ? (
<SettingsSection label='Workspace'>
}
>
{isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
<SettingsEmptyState>Click "Create API Key" above to get started</SettingsEmptyState>
) : (
<div className='flex flex-col gap-6'>
{!searchTerm.trim() ? (
<SettingsSection label='Workspace'>
{workspaceKeys.length === 0 ? (
<div className='text-[var(--text-muted)] text-sm'>No workspace API keys yet</div>
) : (
<div className='flex flex-col gap-2'>
{filteredWorkspaceKeys.map(({ key }) => (
{workspaceKeys.map((key) => (
<div key={key.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-1.5'>
@@ -265,112 +190,138 @@ export function ApiKeys() {
</div>
))}
</div>
</SettingsSection>
) : null}
{/* Personal section */}
{(!searchTerm.trim() || filteredPersonalKeys.length > 0) && (
<SettingsSection label='Personal'>
<div className='flex flex-col gap-2'>
{filteredPersonalKeys.map(({ key }) => {
const isConflict = conflicts.includes(key.name)
return (
<div key={key.id} className='flex flex-col gap-2'>
<div className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-1.5'>
<span className='max-w-[280px] truncate text-[14px] text-[var(--text-body)]'>
{key.name}
</span>
<span className='text-[var(--text-secondary)] text-sm'>
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[12px] text-[var(--text-muted)]'>
{key.displayKey || key.key}
</p>
</div>
<ApiKeyRowMenu
keyName={key.name}
onDelete={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
/>
</div>
{isConflict && (
<div className='text-[var(--text-error)] text-small leading-tight'>
Workspace API key with the same name overrides this. Rename your
personal key to use it.
</div>
)}
</div>
)
})}
</div>
</SettingsSection>
)}
{/* Show message when search has no results across both sections */}
{searchTerm.trim() &&
filteredPersonalKeys.length === 0 &&
filteredWorkspaceKeys.length === 0 &&
(personalKeys.length > 0 || workspaceKeys.length > 0) && (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No API keys found matching "{searchTerm}"
</div>
)}
</div>
)}
{/* Allow Personal API Keys Toggle */}
{!isLoading && canManageWorkspaceKeys && (
<Tooltip.Provider delayDuration={150}>
<SettingsSection label='Permissions'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-[14px] text-[var(--text-body)]'>
Allow personal API keys
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='rounded-full p-1 text-[var(--text-muted)] transition hover-hover:text-[var(--text-primary)]'
>
<Info className='size-[12px]' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs text-small'>
Allow collaborators to create and use their own keys with billing charged to
them.
</Tooltip.Content>
</Tooltip.Root>
</div>
{isLoadingSettings ? null : (
<Switch
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || updateSettingsMutation.isPending}
onCheckedChange={async (checked) => {
try {
await updateSettingsMutation.mutateAsync({
workspaceId,
allowPersonalApiKeys: checked,
})
} catch (error) {
logger.error('Error updating workspace settings:', { error })
}
}}
/>
)}
</SettingsSection>
) : filteredWorkspaceKeys.length > 0 ? (
<SettingsSection label='Workspace'>
<div className='flex flex-col gap-2'>
{filteredWorkspaceKeys.map(({ key }) => (
<div key={key.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-1.5'>
<span className='max-w-[280px] truncate text-[14px] text-[var(--text-body)]'>
{key.name}
</span>
<span className='text-[var(--text-secondary)] text-sm'>
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[12px] text-[var(--text-muted)]'>
{key.displayKey || key.key}
</p>
</div>
<ApiKeyRowMenu
keyName={key.name}
onDelete={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
canDelete={canManageWorkspaceKeys}
/>
</div>
))}
</div>
</SettingsSection>
</Tooltip.Provider>
)}
</div>
</div>
) : null}
{(!searchTerm.trim() || filteredPersonalKeys.length > 0) && (
<SettingsSection label='Personal'>
<div className='flex flex-col gap-2'>
{filteredPersonalKeys.map(({ key }) => {
const isConflict = conflicts.includes(key.name)
return (
<div key={key.id} className='flex flex-col gap-2'>
<div className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-1.5'>
<span className='max-w-[280px] truncate text-[14px] text-[var(--text-body)]'>
{key.name}
</span>
<span className='text-[var(--text-secondary)] text-sm'>
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[12px] text-[var(--text-muted)]'>
{key.displayKey || key.key}
</p>
</div>
<ApiKeyRowMenu
keyName={key.name}
onDelete={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
/>
</div>
{isConflict && (
<div className='text-[var(--text-error)] text-small leading-tight'>
Workspace API key with the same name overrides this. Rename your
personal key to use it.
</div>
)}
</div>
)
})}
</div>
</SettingsSection>
)}
{searchTerm.trim() &&
filteredPersonalKeys.length === 0 &&
filteredWorkspaceKeys.length === 0 &&
(personalKeys.length > 0 || workspaceKeys.length > 0) && (
<SettingsEmptyState variant='inline'>
No API keys found matching "{searchTerm}"
</SettingsEmptyState>
)}
</div>
)}
{!isLoading && canManageWorkspaceKeys && (
<Tooltip.Provider delayDuration={150}>
<SettingsSection label='Permissions'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-[14px] text-[var(--text-body)]'>
Allow personal API keys
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='rounded-full p-1 text-[var(--text-muted)] transition hover-hover:text-[var(--text-primary)]'
>
<Info className='size-[12px]' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs text-small'>
Allow collaborators to create and use their own keys with billing charged to
them.
</Tooltip.Content>
</Tooltip.Root>
</div>
{isLoadingSettings ? null : (
<Switch
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || updateSettingsMutation.isPending}
onCheckedChange={async (checked) => {
try {
await updateSettingsMutation.mutateAsync({
workspaceId,
allowPersonalApiKeys: checked,
})
} catch (error) {
logger.error('Error updating workspace settings:', { error })
}
}}
/>
)}
</div>
</SettingsSection>
</Tooltip.Provider>
)}
</SettingsPanel>
{/* Create API Key Modal */}
<CreateApiKeyModal
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
@@ -381,7 +332,6 @@ export function ApiKeys() {
defaultKeyType={defaultKeyType}
/>
{/* Delete Confirmation Dialog */}
<ChipConfirmModal
open={showDeleteDialog}
onOpenChange={(open) => {
@@ -406,6 +356,6 @@ export function ApiKeys() {
pendingLabel: 'Deleting...',
}}
/>
</div>
</>
)
}
@@ -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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='h-[30px]' />
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<div className='flex flex-col gap-1'>
<h1 className='font-medium text-[var(--text-body)] text-lg'>Billing</h1>
<p className='text-[var(--text-muted)] text-md'>
Manage your plan, pricing, and invoices.
</p>
</div>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2.5'>
<div className='size-9 flex-shrink-0'>
<div className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
<Credit className='size-5 text-[var(--text-icon)]' />
</div>
</div>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{planName} plan
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>{priceText}</span>
</div>
<SettingsPanel>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2.5'>
<div className='size-9 flex-shrink-0'>
<div className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
<Credit className='size-5 text-[var(--text-icon)]' />
</div>
{!subscription.isEnterprise &&
(canManageBilling ? (
<ChipLink
href={upgradeHref}
variant='border-shadow'
flush
onMouseEnter={prefetchUpgrade}
onFocus={prefetchUpgrade}
>
Explore plans
</ChipLink>
) : (
<Chip variant='border-shadow' flush disabled>
Explore plans
</Chip>
))}
</div>
{showUsageLimit && (
<UsageLimitField
currentLimit={usageLimitCurrent}
minimumLimit={usageLimitMinimum}
canEdit={permissions.canEditUsageLimit}
context={shouldUseOrganizationBillingContext ? 'organization' : 'user'}
organizationId={
shouldUseOrganizationBillingContext
? (billingOrganizationId ?? undefined)
: undefined
}
/>
)}
{showOnDemand && (
<SettingsSection label='Enable on-demand usage'>
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
Allow usage to go past included usage
</span>
<Switch
checked={isOnDemandActive}
disabled={isTogglingOnDemand || !canManageBilling}
onCheckedChange={handleToggleOnDemand}
/>
</div>
</SettingsSection>
)}
{!subscription.isFree && !subscription.isEnterprise && (
<SettingsSection label='Usage notifications'>
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
Email me when I reach 80% usage
</span>
<Switch
checked={!!billingUsageNotificationsEnabled}
disabled={updateGeneralSetting.isPending}
onCheckedChange={(value: boolean) => {
if (value !== billingUsageNotificationsEnabled) {
updateGeneralSetting.mutate({
key: 'billingUsageNotificationsEnabled',
value,
})
}
}}
/>
</div>
</SettingsSection>
)}
{(subscription.isPaid || subscription.isEnterprise) && (
<SettingsSection label='Subscription'>
<div className='flex flex-col gap-4'>
{periodEnd && (
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
{isCancelledAtPeriodEnd ? 'Access until' : 'Next billing date'}
</span>
<span className='text-[var(--text-muted)] text-small'>
{new Date(periodEnd).toLocaleDateString()}
</span>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>Payment method</span>
<Chip
flush
disabled={!canManageBilling || openBillingPortal.isPending}
onClick={handleOpenBillingPortal}
>
Manage in Stripe
</Chip>
</div>
{!subscription.isEnterprise && (
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
{isCancelledAtPeriodEnd ? 'Subscription canceled' : 'Cancel subscription'}
</span>
{isCancelledAtPeriodEnd ? (
<Chip
variant='primary'
flush
disabled={!canManageBilling}
onClick={handleRestoreSubscription}
>
Restore
</Chip>
) : (
<Chip
variant='destructive'
flush
disabled={!canManageBilling}
onClick={handleCancelSubscription}
>
Cancel
</Chip>
)}
</div>
)}
</div>
</SettingsSection>
)}
{!subscription.isFree && invoices.length > 0 && (
<SettingsSection label='Invoices'>
<div className='-mx-2 flex flex-col gap-y-0.5'>
{invoices.map((invoice) => {
const rowClassName =
'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors'
const rowContent = (
<>
<span className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-body)]'>
{invoice.date}
</span>
<Badge variant={invoice.badge.variant} size='sm'>
{invoice.badge.label}
</Badge>
<span className='flex-shrink-0 text-[12px] text-[var(--text-muted)]'>
{invoice.amount}
</span>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</>
)
return invoice.url ? (
<a
key={invoice.id}
href={invoice.url}
target='_blank'
rel='noopener noreferrer'
className={cn(rowClassName, 'hover-hover:bg-[var(--surface-active)]')}
>
{rowContent}
</a>
) : (
<div key={invoice.id} className={cn(rowClassName, 'cursor-default')}>
{rowContent}
</div>
)
})}
{invoicesData?.hasMore && (
<button
type='button'
onClick={handleOpenBillingPortal}
disabled={openBillingPortal.isPending || !canManageBilling}
aria-label='View all invoices'
className={cn(
chipVariants({ fullWidth: true }),
'text-[var(--text-muted)] text-small'
)}
>
View all
</button>
)}
</div>
</SettingsSection>
)}
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{planName} plan</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>{priceText}</span>
</div>
</div>
{!subscription.isEnterprise &&
(canManageBilling ? (
<ChipLink
href={upgradeHref}
variant='border-shadow'
flush
onMouseEnter={prefetchUpgrade}
onFocus={prefetchUpgrade}
>
Explore plans
</ChipLink>
) : (
<Chip variant='border-shadow' flush disabled>
Explore plans
</Chip>
))}
</div>
</div>
{showUsageLimit && (
<UsageLimitField
currentLimit={usageLimitCurrent}
minimumLimit={usageLimitMinimum}
canEdit={permissions.canEditUsageLimit}
context={shouldUseOrganizationBillingContext ? 'organization' : 'user'}
organizationId={
shouldUseOrganizationBillingContext ? (billingOrganizationId ?? undefined) : undefined
}
/>
)}
{showOnDemand && (
<SettingsSection label='Enable on-demand usage'>
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
Allow usage to go past included usage
</span>
<Switch
checked={isOnDemandActive}
disabled={isTogglingOnDemand || !canManageBilling}
onCheckedChange={handleToggleOnDemand}
/>
</div>
</SettingsSection>
)}
{!subscription.isFree && !subscription.isEnterprise && (
<SettingsSection label='Usage notifications'>
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
Email me when I reach 80% usage
</span>
<Switch
checked={!!billingUsageNotificationsEnabled}
disabled={updateGeneralSetting.isPending}
onCheckedChange={(value: boolean) => {
if (value !== billingUsageNotificationsEnabled) {
updateGeneralSetting.mutate({
key: 'billingUsageNotificationsEnabled',
value,
})
}
}}
/>
</div>
</SettingsSection>
)}
{(subscription.isPaid || subscription.isEnterprise) && (
<SettingsSection label='Subscription'>
<div className='flex flex-col gap-4'>
{periodEnd && (
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
{isCancelledAtPeriodEnd ? 'Access until' : 'Next billing date'}
</span>
<span className='text-[var(--text-muted)] text-small'>
{new Date(periodEnd).toLocaleDateString()}
</span>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>Payment method</span>
<Chip
flush
disabled={!canManageBilling || openBillingPortal.isPending}
onClick={handleOpenBillingPortal}
>
Manage in Stripe
</Chip>
</div>
{!subscription.isEnterprise && (
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
{isCancelledAtPeriodEnd ? 'Subscription canceled' : 'Cancel subscription'}
</span>
{isCancelledAtPeriodEnd ? (
<Chip
variant='primary'
flush
disabled={!canManageBilling}
onClick={handleRestoreSubscription}
>
Restore
</Chip>
) : (
<Chip
variant='destructive'
flush
disabled={!canManageBilling}
onClick={handleCancelSubscription}
>
Cancel
</Chip>
)}
</div>
)}
</div>
</SettingsSection>
)}
{!subscription.isFree && invoices.length > 0 && (
<SettingsSection label='Invoices'>
<div className='-mx-2 flex flex-col gap-y-0.5'>
{invoices.map((invoice) => {
const rowClassName =
'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors'
const rowContent = (
<>
<span className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-body)]'>
{invoice.date}
</span>
<Badge variant={invoice.badge.variant} size='sm'>
{invoice.badge.label}
</Badge>
<span className='flex-shrink-0 text-[12px] text-[var(--text-muted)]'>
{invoice.amount}
</span>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</>
)
return invoice.url ? (
<a
key={invoice.id}
href={invoice.url}
target='_blank'
rel='noopener noreferrer'
className={cn(rowClassName, 'hover-hover:bg-[var(--surface-active)]')}
>
{rowContent}
</a>
) : (
<div key={invoice.id} className={cn(rowClassName, 'cursor-default')}>
{rowContent}
</div>
)
})}
{invoicesData?.hasMore && (
<button
type='button'
onClick={handleOpenBillingPortal}
disabled={openBillingPortal.isPending || !canManageBilling}
aria-label='View all invoices'
className={cn(
chipVariants({ fullWidth: true }),
'text-[var(--text-muted)] text-small'
)}
>
View all
</button>
)}
</div>
</SettingsSection>
)}
</SettingsPanel>
)
}
@@ -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) {
))}
</div>
) : showNoResults ? (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState variant='inline'>
No providers found matching "{searchTerm}"
</div>
</SettingsEmptyState>
) : sections ? (
<div className='flex flex-col gap-7'>
{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.`}
</p>
<ChipModalField type='custom' title='API Key' required>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
@@ -42,6 +42,7 @@ import {
type BYOKManagerProvider,
type BYOKProviderSection,
} from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { useBYOKKeys, useDeleteBYOKKey, useUpsertBYOKKey } from '@/hooks/queries/byok-keys'
import type { BYOKProviderId } from '@/tools/types'
@@ -339,37 +340,33 @@ export function BYOK() {
}, [keys])
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col pt-6 pb-6'>
<BYOKKeyManager
multiKey
providers={PROVIDERS}
sections={PROVIDER_SECTIONS}
keysByProvider={keysByProvider}
maxKeysPerProvider={MAX_BYOK_KEYS_PER_PROVIDER}
isLoading={isLoading}
isSaving={upsertKey.isPending}
isDeleting={deleteKey.isPending}
onSaveKey={async ({ providerId, apiKey, keyId, name }) => {
await upsertKey.mutateAsync({
workspaceId,
providerId: providerId as BYOKProviderId,
apiKey,
keyId,
name,
})
}}
onDeleteKey={async (providerId, keyId) => {
await deleteKey.mutateAsync({
workspaceId,
providerId: providerId as BYOKProviderId,
keyId,
})
}}
/>
</div>
</div>
</div>
<SettingsPanel>
<BYOKKeyManager
multiKey
providers={PROVIDERS}
sections={PROVIDER_SECTIONS}
keysByProvider={keysByProvider}
maxKeysPerProvider={MAX_BYOK_KEYS_PER_PROVIDER}
isLoading={isLoading}
isSaving={upsertKey.isPending}
isDeleting={deleteKey.isPending}
onSaveKey={async ({ providerId, apiKey, keyId, name }) => {
await upsertKey.mutateAsync({
workspaceId,
providerId: providerId as BYOKProviderId,
apiKey,
keyId,
name,
})
}}
onDeleteKey={async (providerId, keyId) => {
await deleteKey.mutateAsync({
workspaceId,
providerId: providerId as BYOKProviderId,
keyId,
})
}}
/>
</SettingsPanel>
)
}
@@ -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 (
// <div className='flex items-center justify-between gap-3'>
// <div className='flex min-w-0 flex-col justify-center gap-[1px]'>
// <Skeleton className='h-5 w-[120px]' />
// <Skeleton className='h-5 w-[80px]' />
// </div>
// <Skeleton className='h-5 w-[36px] rounded-full' />
// </div>
// )
// }
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<string | null>(null)
@@ -65,20 +44,6 @@ export function Copilot() {
const [searchTerm, setSearchTerm] = useState('')
const [createError, setCreateError] = useState<string | null>(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 (
<>
<div className='flex h-full flex-col bg-[var(--bg)]'>
{/* Fixed header bar */}
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<Chip
leftIcon={Plus}
variant='primary'
onClick={() => {
setIsCreateDialogOpen(true)
setCreateError(null)
}}
disabled={isLoading}
>
Create API Key
</Chip>
</div>
</div>
{/* Scrollable Content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-4 pb-6'>
{/* MCP Tools Section — uncomment when ready to allow users to toggle MCP servers for Mothership
<div className='flex flex-col gap-2'>
<div className='font-medium text-sm text-[var(--text-secondary)]'>
MCP Tools
</div>
{mcpLoading ? (
<div className='flex flex-col gap-2'>
<McpServerSkeleton />
<McpServerSkeleton />
</div>
) : enabledServers.length === 0 ? (
<div className='text-sm text-[var(--text-muted)]'>
No MCP servers configured. Add servers in the MCP Tools tab.
</div>
) : (
<div className='flex flex-col gap-2'>
{enabledServers.map((server) => (
<div key={server.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate font-medium text-base'>{server.name}</span>
<p className='truncate text-sm text-[var(--text-muted)]'>
{server.toolCount ?? 0} tool{server.toolCount === 1 ? '' : 's'}
</p>
</div>
<Switch
checked={server.copilotEnabled ?? false}
onCheckedChange={(checked) => handleToggleCopilot(server.id, checked)}
/>
</div>
))}
</div>
)}
</div>
*/}
{/* Search Input */}
<ChipInput
icon={Search}
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{/* Keys List */}
{isLoading ? null : showEmptyState ? (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Click "Create API Key" above to get started
</div>
) : (
<div className='flex flex-col gap-2'>
{filteredKeys.map((key) => (
<div key={key.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-1.5'>
<span className='max-w-[280px] truncate text-[14px] text-[var(--text-body)]'>
{key.name || 'Unnamed Key'}
</span>
<span className='text-[var(--text-secondary)] text-sm'>
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[12px] text-[var(--text-muted)]'>
{key.displayKey}
</p>
</div>
<Chip
className='flex-shrink-0'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
>
Delete
</Chip>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search API keys...',
}}
actions={
<Chip
leftIcon={Plus}
variant='primary'
onClick={() => {
setIsCreateDialogOpen(true)
setCreateError(null)
}}
disabled={isLoading}
>
Create API Key
</Chip>
}
>
{isLoading ? null : showEmptyState ? (
<SettingsEmptyState>Click "Create API Key" above to get started</SettingsEmptyState>
) : (
<div className='flex flex-col gap-2'>
{filteredKeys.map((key) => (
<div key={key.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-1.5'>
<span className='max-w-[280px] truncate text-[14px] text-[var(--text-body)]'>
{key.name || 'Unnamed Key'}
</span>
<span className='text-[var(--text-secondary)] text-sm'>
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
))}
{showNoResults && (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No API keys found matching "{searchTerm}"
</div>
)}
<p className='truncate text-[12px] text-[var(--text-muted)]'>{key.displayKey}</p>
</div>
<Chip
className='flex-shrink-0'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
>
Delete
</Chip>
</div>
))}
{showNoResults && (
<SettingsEmptyState variant='inline'>
No API keys found matching "{searchTerm}"
</SettingsEmptyState>
)}
</div>
</div>
</div>
)}
</SettingsPanel>
{/* Create API Key Dialog */}
<ChipModal
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
@@ -290,7 +202,6 @@ export function Copilot() {
/>
</ChipModal>
{/* New API Key Dialog */}
<ChipModal
open={showNewKeyDialog}
onOpenChange={(open) => {
@@ -329,7 +240,6 @@ export function Copilot() {
/>
</ChipModal>
{/* Delete Confirmation Dialog */}
<ChipConfirmModal
open={showDeleteDialog}
onOpenChange={(open) => {
@@ -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() {
</div>
<div className='ml-4 flex items-center gap-1'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Member actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() => handleRemoveMember(member.id)}
disabled={removeMember.isPending}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RowActionsMenu
label='Member actions'
actions={[
{
label: 'Remove',
destructive: true,
disabled: removeMember.isPending,
onSelect: () => handleRemoveMember(member.id),
},
]}
/>
</div>
</div>
)
@@ -580,41 +566,30 @@ export function CredentialSets() {
</div>
<div className='ml-4 flex items-center gap-1'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Invitation actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onSelect={() => handleResendInvitation(invitation.id, email)}
disabled={
resendingInvitations.has(invitation.id) ||
(resendCooldowns[invitation.id] ?? 0) > 0
}
>
{resendingInvitations.has(invitation.id)
<RowActionsMenu
label='Invitation actions'
actions={[
{
label: resendingInvitations.has(invitation.id)
? 'Sending...'
: resendCooldowns[invitation.id]
? `Resend (${resendCooldowns[invitation.id]}s)`
: 'Resend'}
</DropdownMenuItem>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() => 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'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
: 'Cancel',
destructive: true,
disabled: cancellingInvitations.has(invitation.id),
onSelect: () => handleCancelInvitation(invitation.id),
},
]}
/>
</div>
</div>
)
@@ -630,177 +605,154 @@ export function CredentialSets() {
return (
<>
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
{canManageCredentialSets && (
<Chip leftIcon={Plus} variant='primary' onClick={() => setShowCreateModal(true)}>
Create Group
</Chip>
)}
</div>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-4 pb-6'>
<ChipInput
icon={Search}
placeholder='Search polling groups...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className='relative'>
{hasNoContent && !canManageCredentialSets ? (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
You're not a member of any polling groups yet. When someone invites you, it will
appear here.
</div>
) : hasNoResults ? (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No results found matching "{searchTerm}"
</div>
) : (
<div className='flex flex-col gap-4.5'>
{filteredInvitations.length > 0 && (
<SettingsSection label='Pending Invitations'>
<div className='flex flex-col gap-3'>
{filteredInvitations.map((invitation) => (
<div
key={invitation.invitationId}
className='flex items-center justify-between rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
{getProviderIcon(invitation.providerId)}
</div>
<div className='flex flex-col'>
<span className='text-[14px] text-[var(--text-body)]'>
{invitation.credentialSetName}
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{invitation.organizationName}
</span>
</div>
</div>
<Chip
variant='primary'
onClick={() => handleAcceptInvitation(invitation.token)}
disabled={acceptInvitation.isPending}
>
{acceptInvitation.isPending ? 'Accepting...' : 'Accept'}
</Chip>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search polling groups...',
}}
actions={
canManageCredentialSets && (
<Chip leftIcon={Plus} variant='primary' onClick={() => setShowCreateModal(true)}>
Create Group
</Chip>
)
}
>
<div className='relative'>
{hasNoContent && !canManageCredentialSets ? (
<SettingsEmptyState>
You're not a member of any polling groups yet. When someone invites you, it will
appear here.
</SettingsEmptyState>
) : hasNoResults ? (
<SettingsEmptyState variant='inline'>
No results found matching "{searchTerm}"
</SettingsEmptyState>
) : (
<div className='flex flex-col gap-4.5'>
{filteredInvitations.length > 0 && (
<SettingsSection label='Pending Invitations'>
<div className='flex flex-col gap-3'>
{filteredInvitations.map((invitation) => (
<div
key={invitation.invitationId}
className='flex items-center justify-between rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
{getProviderIcon(invitation.providerId)}
</div>
))}
<div className='flex flex-col'>
<span className='text-[14px] text-[var(--text-body)]'>
{invitation.credentialSetName}
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{invitation.organizationName}
</span>
</div>
</div>
<Chip
variant='primary'
onClick={() => handleAcceptInvitation(invitation.token)}
disabled={acceptInvitation.isPending}
>
{acceptInvitation.isPending ? 'Accepting...' : 'Accept'}
</Chip>
</div>
</SettingsSection>
)}
{filteredMemberships.length > 0 && (
<SettingsSection label='My Memberships'>
<div className='flex flex-col gap-3'>
{filteredMemberships.map((membership) => (
<div
key={membership.membershipId}
className='flex items-center justify-between rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
{getProviderIcon(membership.providerId)}
</div>
<div className='flex flex-col'>
<span className='text-[14px] text-[var(--text-body)]'>
{membership.credentialSetName}
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{membership.organizationName}
</span>
</div>
</div>
<Chip
onClick={() =>
handleLeave(
membership.credentialSetId,
membership.credentialSetName
)
}
disabled={leaveCredentialSet.isPending}
>
Leave
</Chip>
</div>
))}
</div>
</SettingsSection>
)}
{canManageCredentialSets &&
(filteredOwnedSets.length > 0 ||
ownedSetsLoading ||
(!searchTerm.trim() && ownedSets.length === 0)) && (
<SettingsSection label='Manage'>
{ownedSetsLoading ? null : !searchTerm.trim() && ownedSets.length === 0 ? (
<div className='text-[var(--text-muted)] text-sm'>
No polling groups created yet
</div>
) : (
<div className='flex flex-col gap-3'>
{filteredOwnedSets.map((set) => (
<div
key={set.id}
className='flex items-center justify-between rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
{getProviderIcon(set.providerId)}
</div>
<div className='flex flex-col'>
<span className='text-[14px] text-[var(--text-body)]'>
{set.name}
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className='flex items-center gap-1'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Group actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={() => setViewingSet(set)}>
Details
</DropdownMenuItem>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() => handleDeleteClick(set)}
disabled={deletingSetIds.has(set.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
)}
</SettingsSection>
)}
</div>
))}
</div>
</SettingsSection>
)}
{filteredMemberships.length > 0 && (
<SettingsSection label='My Memberships'>
<div className='flex flex-col gap-3'>
{filteredMemberships.map((membership) => (
<div
key={membership.membershipId}
className='flex items-center justify-between rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
{getProviderIcon(membership.providerId)}
</div>
<div className='flex flex-col'>
<span className='text-[14px] text-[var(--text-body)]'>
{membership.credentialSetName}
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{membership.organizationName}
</span>
</div>
</div>
<Chip
onClick={() =>
handleLeave(membership.credentialSetId, membership.credentialSetName)
}
disabled={leaveCredentialSet.isPending}
>
Leave
</Chip>
</div>
))}
</div>
</SettingsSection>
)}
{canManageCredentialSets &&
(filteredOwnedSets.length > 0 ||
ownedSetsLoading ||
(!searchTerm.trim() && ownedSets.length === 0)) && (
<SettingsSection label='Manage'>
{ownedSetsLoading ? null : !searchTerm.trim() && ownedSets.length === 0 ? (
<div className='text-[var(--text-muted)] text-sm'>
No polling groups created yet
</div>
) : (
<div className='flex flex-col gap-3'>
{filteredOwnedSets.map((set) => (
<div
key={set.id}
className='flex items-center justify-between rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
{getProviderIcon(set.providerId)}
</div>
<div className='flex flex-col'>
<span className='text-[14px] text-[var(--text-body)]'>
{set.name}
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className='flex items-center gap-1'>
<RowActionsMenu
label='Group actions'
actions={[
{ label: 'Details', onSelect: () => setViewingSet(set) },
{
label: 'Delete',
destructive: true,
disabled: deletingSetIds.has(set.id),
onSelect: () => handleDeleteClick(set),
},
]}
/>
</div>
</div>
))}
</div>
)}
</SettingsSection>
)}
</div>
</div>
)}
</div>
</div>
</SettingsPanel>
<ChipModal
open={showCreateModal}
@@ -5,18 +5,10 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Chip,
ChipConfirmModal,
ChipInput,
chipVariants,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
MoreHorizontal,
Search,
} from '@/components/emcn'
import { Chip, ChipConfirmModal } from '@/components/emcn'
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 { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
@@ -96,92 +88,70 @@ export function CustomTools() {
return (
<>
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<Chip
leftIcon={Plus}
variant='primary'
onClick={() => setShowAddForm(true)}
disabled={isLoading}
>
Add Tool
</Chip>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search tools...',
disabled: isLoading,
}}
actions={
<Chip
leftIcon={Plus}
variant='primary'
onClick={() => setShowAddForm(true)}
disabled={isLoading}
>
Add Tool
</Chip>
}
>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-sm leading-tight'>
{getErrorMessage(error, 'Failed to load tools')}
</p>
</div>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-4 pb-6'>
<ChipInput
icon={Search}
placeholder='Search tools...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
/>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-sm leading-tight'>
{getErrorMessage(error, 'Failed to load tools')}
</p>
</div>
) : isLoading ? null : showEmptyState ? (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Click "Add Tool" above to get started
</div>
) : (
<div className='flex flex-col gap-2'>
{filteredTools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{tool.title || 'Unnamed Tool'}
</span>
{tool.schema?.function?.description && (
<p className='truncate text-[12px] text-[var(--text-muted)]'>
{tool.schema.function.description}
</p>
)}
</div>
<div className='flex flex-shrink-0 items-center gap-1'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Tool actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={() => setEditingTool(tool.id)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() => handleDeleteClick(tool.id)}
disabled={deletingTools.has(tool.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
{showNoResults && (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No tools found matching "{searchTerm}"
</div>
)}
) : isLoading ? null : showEmptyState ? (
<SettingsEmptyState>Click "Add Tool" above to get started</SettingsEmptyState>
) : (
<div className='flex flex-col gap-2'>
{filteredTools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{tool.title || 'Unnamed Tool'}
</span>
{tool.schema?.function?.description && (
<p className='truncate text-[12px] text-[var(--text-muted)]'>
{tool.schema.function.description}
</p>
)}
</div>
<div className='flex flex-shrink-0 items-center gap-1'>
<RowActionsMenu
label='Tool actions'
actions={[
{ label: 'Edit', onSelect: () => setEditingTool(tool.id) },
{
label: 'Delete',
destructive: true,
disabled: deletingTools.has(tool.id),
onSelect: () => handleDeleteClick(tool.id),
},
]}
/>
</div>
</div>
))}
{showNoResults && (
<SettingsEmptyState variant='inline'>
No tools found matching "{searchTerm}"
</SettingsEmptyState>
)}
</div>
</div>
</div>
)}
</SettingsPanel>
<CustomToolModal
open={showAddForm || !!editingTool}
@@ -29,6 +29,7 @@ import { isHosted } from '@/lib/core/config/env-flags'
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone'
import { getBaseUrl } from '@/lib/core/utils/urls'
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 { useBrandConfig } from '@/ee/whitelabeling'
@@ -269,289 +270,282 @@ export function General() {
}
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
{/* Fixed header bar */}
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div className='flex items-center'>
{isHosted && (
<Chip onClick={() => window.open('/?home', '_blank', 'noopener,noreferrer')}>
Home Page
</Chip>
)}
</div>
{!isAuthDisabled && (
<div className='flex items-center gap-1'>
<Chip onClick={handleSignOut}>Sign out</Chip>
<Chip onClick={() => setShowResetPasswordModal(true)}>Reset password</Chip>
</div>
)}
</div>
{/* Scrollable content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pt-6 pb-6'>
<SettingsSection label='Profile'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-3'>
<div className='relative'>
<div
role='button'
tabIndex={0}
className={`group relative flex size-9 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full transition-all hover-hover:bg-[var(--bg)] ${!imageUrl ? 'border border-[var(--border)]' : ''}`}
onClick={handleProfilePictureClick}
onKeyDown={(event) =>
handleKeyboardActivation(event, handleProfilePictureClick)
}
>
{(() => {
if (imageUrl) {
return (
<Image
src={imageUrl}
alt={profile?.name || 'User'}
width={36}
height={36}
unoptimized
className={`h-full w-full object-cover transition-opacity duration-300 ${
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
}`}
/>
)
}
<>
<SettingsPanel
actions={
<>
{isHosted && (
<Chip onClick={() => window.open('/?home', '_blank', 'noopener,noreferrer')}>
Home Page
</Chip>
)}
{!isAuthDisabled && (
<>
<Chip onClick={handleSignOut}>Sign out</Chip>
<Chip onClick={() => setShowResetPasswordModal(true)}>Reset password</Chip>
</>
)}
</>
}
>
<SettingsSection label='Profile'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-3'>
<div className='relative'>
<div
role='button'
tabIndex={0}
className={`group relative flex size-9 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full transition-all hover-hover:bg-[var(--bg)] ${!imageUrl ? 'border border-[var(--border)]' : ''}`}
onClick={handleProfilePictureClick}
onKeyDown={(event) => handleKeyboardActivation(event, handleProfilePictureClick)}
>
{(() => {
if (imageUrl) {
return (
<span className='font-medium text-[var(--text-primary)] text-base'>
{getInitials(profile?.name) || ''}
</span>
<Image
src={imageUrl}
alt={profile?.name || 'User'}
width={36}
height={36}
unoptimized
className={`h-full w-full object-cover transition-opacity duration-300 ${
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
}`}
/>
)
})()}
<div
className={`absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity ${
isUploadingProfilePicture
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
{isUploadingProfilePicture ? (
<div className='size-4 animate-spin rounded-full border-2 border-white border-t-transparent' />
) : (
<Camera className='size-4 text-white' />
)}
</div>
</div>
<Input
type='file'
accept='image/png,image/jpeg,image/jpg'
className='hidden'
ref={profilePictureInputRef}
onChange={handleProfilePictureChange}
disabled={isUploadingProfilePicture}
/>
</div>
<div className='flex flex-1 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-2'>
{isEditingName ? (
<>
<div className='relative inline-flex'>
<span
className='invisible whitespace-pre font-medium text-base'
aria-hidden='true'
>
{name || ' '}
</span>
<input
ref={inputRef}
value={name}
onChange={(e) => 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'
/>
</div>
<Button
variant='ghost'
className='size-[12px] flex-shrink-0 p-0'
onClick={handleUpdateName}
disabled={updateProfile.isPending}
aria-label='Save name'
>
<Check className='size-[12px]' />
</Button>
</>
}
return (
<span className='font-medium text-[var(--text-primary)] text-base'>
{getInitials(profile?.name) || ''}
</span>
)
})()}
<div
className={`absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity ${
isUploadingProfilePicture
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
{isUploadingProfilePicture ? (
<div className='size-4 animate-spin rounded-full border-2 border-white border-t-transparent' />
) : (
<>
<h3 className='font-medium text-base'>{profile?.name || ''}</h3>
<Button
variant='ghost'
className='size-[10.5px] flex-shrink-0 p-0'
onClick={() => setIsEditingName(true)}
aria-label='Edit name'
>
<Pencil className='size-[10.5px]' />
</Button>
</>
<Camera className='size-4 text-white' />
)}
</div>
<p className='text-[var(--text-tertiary)] text-sm'>{profile?.email || ''}</p>
</div>
<Input
type='file'
accept='image/png,image/jpeg,image/jpg'
className='hidden'
ref={profilePictureInputRef}
onChange={handleProfilePictureChange}
disabled={isUploadingProfilePicture}
/>
</div>
<div className='flex flex-1 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-2'>
{isEditingName ? (
<>
<div className='relative inline-flex'>
<span
className='invisible whitespace-pre font-medium text-base'
aria-hidden='true'
>
{name || ' '}
</span>
<input
ref={inputRef}
value={name}
onChange={(e) => 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'
/>
</div>
<Button
variant='ghost'
className='size-[12px] flex-shrink-0 p-0'
onClick={handleUpdateName}
disabled={updateProfile.isPending}
aria-label='Save name'
>
<Check className='size-[12px]' />
</Button>
</>
) : (
<>
<h3 className='font-medium text-base'>{profile?.name || ''}</h3>
<Button
variant='ghost'
className='size-[10.5px] flex-shrink-0 p-0'
onClick={() => setIsEditingName(true)}
aria-label='Edit name'
>
<Pencil className='size-[10.5px]' />
</Button>
</>
)}
</div>
<p className='text-[var(--text-tertiary)] text-sm'>{profile?.email || ''}</p>
</div>
{uploadError && <p className='text-[var(--text-error)] text-sm'>{uploadError}</p>}
</div>
</SettingsSection>
{uploadError && <p className='text-[var(--text-error)] text-sm'>{uploadError}</p>}
</div>
</SettingsSection>
<SettingsSection label='Preferences'>
<div className='flex flex-col gap-4'>
<div className='flex items-center justify-between'>
<Label htmlFor='theme-select'>Theme</Label>
<div className={DROPDOWN_TRIGGER_CLASS}>
<ChipSelect
align='start'
fullWidth
dropdownWidth='trigger'
value={settings?.theme}
onChange={handleThemeChange}
placeholder='Select theme'
options={[
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]}
/>
</div>
</div>
<div className='flex items-center justify-between gap-4'>
<Label>Timezone</Label>
<div className={DROPDOWN_TRIGGER_CLASS}>
<ChipCombobox
align='start'
dropdownWidth={240}
searchable
searchPlaceholder='Search timezones'
value={settings?.timezone ?? getBrowserTimezone()}
onChange={handleTimezoneChange}
placeholder='Select timezone'
options={TIMEZONE_OPTIONS}
/>
</div>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1.5'>
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='size-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Automatically connect blocks when dropped near each other</p>
<Tooltip.Preview
src='/tooltips/auto-connect-on-drop.mp4'
alt='Auto-connect on drop example'
loop={true}
/>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Switch
id='auto-connect'
checked={settings?.autoConnect ?? true}
onCheckedChange={handleAutoConnectChange}
<SettingsSection label='Preferences'>
<div className='flex flex-col gap-4'>
<div className='flex items-center justify-between'>
<Label htmlFor='theme-select'>Theme</Label>
<div className={DROPDOWN_TRIGGER_CLASS}>
<ChipSelect
align='start'
fullWidth
dropdownWidth='trigger'
value={settings?.theme}
onChange={handleThemeChange}
placeholder='Select theme'
options={[
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]}
/>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1.5'>
<Label htmlFor='error-notifications'>Canvas error notifications</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='size-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Show error popups on blocks when a workflow run fails</p>
<Tooltip.Preview
src='/tooltips/canvas-error-notification.mp4'
alt='Canvas error notification example'
/>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Switch
id='error-notifications'
checked={settings?.errorNotificationsEnabled ?? true}
onCheckedChange={handleErrorNotificationsChange}
/>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='snap-to-grid'>Snap to grid</Label>
<div className={DROPDOWN_TRIGGER_CLASS}>
<ChipSelect
align='start'
fullWidth
dropdownWidth='trigger'
value={String(snapToGridValue)}
onChange={handleSnapToGridChange}
placeholder='Select size'
options={[
{ label: 'Off', value: '0' },
{ label: '10px', value: '10' },
{ label: '20px', value: '20' },
{ label: '30px', value: '30' },
{ label: '40px', value: '40' },
{ label: '50px', value: '50' },
]}
/>
</div>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='show-action-bar'>Show canvas controls</Label>
<Switch
id='show-action-bar'
checked={settings?.showActionBar ?? true}
onCheckedChange={handleShowActionBarChange}
/>
</div>
{isTrainingEnabled && (
<div className='flex items-center justify-between'>
<Label htmlFor='training-controls'>Training controls</Label>
<Switch
id='training-controls'
checked={settings?.showTrainingControls ?? false}
onCheckedChange={handleTrainingControlsChange}
/>
</div>
)}
</div>
</SettingsSection>
<SettingsSection label='Privacy'>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between'>
<Label htmlFor='telemetry'>Allow anonymous telemetry</Label>
<Switch
id='telemetry'
checked={settings?.telemetryEnabled ?? true}
onCheckedChange={handleTelemetryToggle}
<div className='flex items-center justify-between gap-4'>
<Label>Timezone</Label>
<div className={DROPDOWN_TRIGGER_CLASS}>
<ChipCombobox
align='start'
dropdownWidth={240}
searchable
searchPlaceholder='Search timezones'
value={settings?.timezone ?? getBrowserTimezone()}
onChange={handleTimezoneChange}
placeholder='Select timezone'
options={TIMEZONE_OPTIONS}
/>
</div>
<p className='text-[var(--text-muted)] text-small'>
We use OpenTelemetry to collect anonymous usage data to improve Sim. You can opt-out
at any time.
</p>
</div>
</SettingsSection>
</div>
</div>
{/* Password Reset Confirmation Modal */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1.5'>
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='size-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Automatically connect blocks when dropped near each other</p>
<Tooltip.Preview
src='/tooltips/auto-connect-on-drop.mp4'
alt='Auto-connect on drop example'
loop={true}
/>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Switch
id='auto-connect'
checked={settings?.autoConnect ?? true}
onCheckedChange={handleAutoConnectChange}
/>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1.5'>
<Label htmlFor='error-notifications'>Canvas error notifications</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='size-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Show error popups on blocks when a workflow run fails</p>
<Tooltip.Preview
src='/tooltips/canvas-error-notification.mp4'
alt='Canvas error notification example'
/>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Switch
id='error-notifications'
checked={settings?.errorNotificationsEnabled ?? true}
onCheckedChange={handleErrorNotificationsChange}
/>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='snap-to-grid'>Snap to grid</Label>
<div className={DROPDOWN_TRIGGER_CLASS}>
<ChipSelect
align='start'
fullWidth
dropdownWidth='trigger'
value={String(snapToGridValue)}
onChange={handleSnapToGridChange}
placeholder='Select size'
options={[
{ label: 'Off', value: '0' },
{ label: '10px', value: '10' },
{ label: '20px', value: '20' },
{ label: '30px', value: '30' },
{ label: '40px', value: '40' },
{ label: '50px', value: '50' },
]}
/>
</div>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='show-action-bar'>Show canvas controls</Label>
<Switch
id='show-action-bar'
checked={settings?.showActionBar ?? true}
onCheckedChange={handleShowActionBarChange}
/>
</div>
{isTrainingEnabled && (
<div className='flex items-center justify-between'>
<Label htmlFor='training-controls'>Training controls</Label>
<Switch
id='training-controls'
checked={settings?.showTrainingControls ?? false}
onCheckedChange={handleTrainingControlsChange}
/>
</div>
)}
</div>
</SettingsSection>
<SettingsSection label='Privacy'>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between'>
<Label htmlFor='telemetry'>Allow anonymous telemetry</Label>
<Switch
id='telemetry'
checked={settings?.telemetryEnabled ?? true}
onCheckedChange={handleTelemetryToggle}
/>
</div>
<p className='text-[var(--text-muted)] text-small'>
We use OpenTelemetry to collect anonymous usage data to improve Sim. You can opt-out
at any time.
</p>
</div>
</SettingsSection>
</SettingsPanel>
<ChipModal
open={showResetPasswordModal}
onOpenChange={setShowResetPasswordModal}
@@ -582,6 +576,6 @@ export function General() {
}}
/>
</ChipModal>
</div>
</>
)
}
@@ -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() {
<div className='min-h-0 flex-1 overflow-y-auto'>
{isLoading ? null : filteredTasks.length === 0 ? (
searchTerm.trim() ? (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState variant='inline'>
{`No tasks matching "${searchTerm}"`}
</div>
</SettingsEmptyState>
) : (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState>
{config?.address
? `No email tasks yet. Send an email to ${config.address} to get started.`
: 'No email tasks yet.'}
</div>
</SettingsEmptyState>
)
) : (
<div className='flex flex-col gap-0.5'>
@@ -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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pt-6 pb-6'>
<InboxEnableToggle />
<SettingsPanel>
<InboxEnableToggle />
{config?.enabled && (
<>
<InboxSettingsTab />
{config?.enabled && (
<>
<InboxSettingsTab />
<SettingsSection label='Inbox'>
<p className='mb-3 text-[12px] text-[var(--text-muted)]'>
Email tasks received by this workspace.
</p>
<InboxTaskList />
</SettingsSection>
</>
)}
</div>
</div>
</div>
<SettingsSection label='Inbox'>
<p className='mb-3 text-[12px] text-[var(--text-muted)]'>
Email tasks received by this workspace.
</p>
<InboxTaskList />
</SettingsSection>
</>
)}
</SettingsPanel>
)
}
@@ -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({
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-1'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Server actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={onViewDetails}>Details</DropdownMenuItem>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={onRemove}
disabled={isDeleting}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RowActionsMenu
label='Server actions'
actions={[
{ label: 'Details', onSelect: onViewDetails },
{ label: 'Delete', destructive: true, disabled: isDeleting, onSelect: onRemove },
]}
/>
</div>
</div>
)
@@ -632,70 +607,59 @@ export function MCP() {
return (
<>
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<Chip
leftIcon={Plus}
variant='primary'
onClick={() => setShowAddModal(true)}
disabled={serversLoading}
>
Add Server
</Chip>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search MCPs...',
}}
actions={
<Chip
leftIcon={Plus}
variant='primary'
onClick={() => setShowAddModal(true)}
disabled={serversLoading}
>
Add Server
</Chip>
}
>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-xs leading-tight'>
{getErrorMessage(error, 'Failed to load MCP servers')}
</p>
</div>
</div>
) : serversLoading ? null : !hasServers ? (
<SettingsEmptyState>Click &quot;Add Server&quot; above to get started</SettingsEmptyState>
) : (
<div className='flex flex-col gap-2'>
{filteredServers.map((server) => {
if (!server?.id) return null
const tools = toolsByServer[server.id] || []
const isLoadingTools = toolsLoading || toolsFetching
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-4 pb-6'>
<ChipInput
icon={Search}
placeholder='Search MCPs...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-xs leading-tight'>
{getErrorMessage(error, 'Failed to load MCP servers')}
</p>
</div>
) : serversLoading ? null : !hasServers ? (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Click &quot;Add Server&quot; above to get started
</div>
) : (
<div className='flex flex-col gap-2'>
{filteredServers.map((server) => {
if (!server?.id) return null
const tools = toolsByServer[server.id] || []
const isLoadingTools = toolsLoading || toolsFetching
return (
<ServerListItem
key={server.id}
server={server}
tools={tools}
isDeleting={deletingServers.has(server.id)}
isLoadingTools={isLoadingTools}
isRefreshing={refreshingServerId === server.id}
onRemove={() => handleRemoveServer(server.id)}
onViewDetails={() => handleViewDetails(server.id)}
/>
)
})}
{showNoResults && (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No servers found matching &quot;{searchTerm}&quot;
</div>
)}
</div>
return (
<ServerListItem
key={server.id}
server={server}
tools={tools}
isDeleting={deletingServers.has(server.id)}
isLoadingTools={isLoadingTools}
isRefreshing={refreshingServerId === server.id}
onRemove={() => handleRemoveServer(server.id)}
onViewDetails={() => handleViewDetails(server.id)}
/>
)
})}
{showNoResults && (
<SettingsEmptyState variant='inline'>
No servers found matching &quot;{searchTerm}&quot;
</SettingsEmptyState>
)}
</div>
</div>
</div>
)}
</SettingsPanel>
<McpServerFormModal
open={showAddModal}
@@ -1,6 +1,7 @@
'use client'
import type { ReactNode } from 'react'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
const ROW_CLASSES = 'flex items-center gap-2.5 p-2'
@@ -88,7 +89,7 @@ export function MemberSection({
return (
<SettingsSection label={label}>
{isEmpty ? (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>{emptyText}</div>
<SettingsEmptyState variant='inline'>{emptyText}</SettingsEmptyState>
) : (
<div className='-mx-2 flex flex-col gap-y-0.5'>{children}</div>
)}
@@ -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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-6 pt-6 pb-6'>
{/* Environment selector */}
<SettingsPanel>
<div className='flex flex-col gap-6'>
<div className='flex items-center gap-2'>
<Label className='text-[var(--text-secondary)] text-sm'>Environment</Label>
<ChipSelect
align='start'
dropdownWidth={160}
value={environment}
onChange={(value) => setMothershipParams({ env: value as MothershipEnv })}
placeholder='Select environment'
options={ENV_OPTIONS}
/>
</div>
<div className='flex gap-1 border-[var(--border-secondary)] border-b pb-px'>
{TABS.map((tab) => (
<button
key={tab.id}
type='button'
onClick={() => setMothershipParams({ tab: tab.id })}
className={cn(
'relative px-3 py-2 font-medium text-sm transition-colors',
activeTab === tab.id
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover-hover:hover:text-[var(--text-secondary)]'
)}
>
{tab.label}
{activeTab === tab.id && (
<span className='absolute right-0 bottom-0 left-0 h-[2px] bg-[var(--text-primary)]' />
)}
</button>
))}
</div>
<div className='flex items-center gap-3'>
<div className='flex items-center gap-2'>
<Label className='text-[var(--text-secondary)] text-sm'>Environment</Label>
<ChipSelect
align='start'
dropdownWidth={160}
value={environment}
onChange={(value) => setMothershipParams({ env: value as MothershipEnv })}
placeholder='Select environment'
options={ENV_OPTIONS}
<Label className='text-[var(--text-secondary)] text-caption'>From</Label>
<ChipInput
type='datetime-local'
value={start}
onChange={(e) => setStart(e.target.value)}
/>
</div>
{/* Tab bar */}
<div className='flex gap-1 border-[var(--border-secondary)] border-b pb-px'>
{TABS.map((tab) => (
<button
key={tab.id}
type='button'
onClick={() => setMothershipParams({ tab: tab.id })}
className={cn(
'relative px-3 py-2 font-medium text-sm transition-colors',
activeTab === tab.id
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover-hover:hover:text-[var(--text-secondary)]'
)}
>
{tab.label}
{activeTab === tab.id && (
<span className='absolute right-0 bottom-0 left-0 h-[2px] bg-[var(--text-primary)]' />
)}
</button>
))}
<div className='flex items-center gap-2'>
<Label className='text-[var(--text-secondary)] text-caption'>To</Label>
<ChipInput type='datetime-local' value={end} onChange={(e) => setEnd(e.target.value)} />
</div>
{/* Time range (shared across tabs) */}
<div className='flex items-center gap-3'>
<div className='flex items-center gap-2'>
<Label className='text-[var(--text-secondary)] text-caption'>From</Label>
<ChipInput
type='datetime-local'
value={start}
onChange={(e) => setStart(e.target.value)}
/>
</div>
<div className='flex items-center gap-2'>
<Label className='text-[var(--text-secondary)] text-caption'>To</Label>
<ChipInput
type='datetime-local'
value={end}
onChange={(e) => setEnd(e.target.value)}
/>
</div>
</div>
<Divider />
{activeTab === 'overview' && (
<OverviewTab environment={environment} start={toRFC3339(start)} end={toRFC3339(end)} />
)}
{activeTab === 'licenses' && <LicensesTab environment={environment} />}
{activeTab === 'byok' && <ByokTab />}
</div>
<Divider />
{activeTab === 'overview' && (
<OverviewTab environment={environment} start={toRFC3339(start)} end={toRFC3339(end)} />
)}
{activeTab === 'licenses' && <LicensesTab environment={environment} />}
{activeTab === 'byok' && <ByokTab />}
</div>
</div>
</SettingsPanel>
)
}
/* ─── 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 (
<div className='flex flex-col gap-5'>
{/* Summary cards */}
<div className='grid grid-cols-4 gap-3'>
<StatCard
label='Total Requests'
@@ -265,7 +253,6 @@ function OverviewTab({
/>
</div>
{/* User breakdown */}
<SectionLabel>User Breakdown</SectionLabel>
{breakdownLoading && (
<div className='flex flex-col gap-2'>
@@ -311,7 +298,6 @@ function OverviewTab({
</div>
)}
{/* Recent requests */}
<Divider />
<SectionLabel>Recent Requests ({requests?.count ?? '…'})</SectionLabel>
{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 }) {
<span className='w-[140px] text-right'>Created</span>
</div>
{data.licenses.length === 0 && (
<div className='py-4 text-center text-[var(--text-tertiary)] text-small'>
No licenses found.
</div>
<SettingsEmptyState variant='inline'>No licenses found.</SettingsEmptyState>
)}
{data.licenses.map(
(lic: {
@@ -519,8 +501,6 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
)
}
/* ─── Shared components ─── */
function StatCard({
label,
value,
@@ -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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-6 pb-6'>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search deleted items...'
value={urlSearchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
className='min-w-0 flex-1'
/>
<SortDropdown
config={{
options: SORT_OPTIONS,
active: activeSort,
onSort: (column, direction) => {
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,
}),
}}
/>
</div>
<SettingsPanel>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search deleted items...'
value={urlSearchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
className='min-w-0 flex-1'
/>
<SortDropdown
config={{
options: SORT_OPTIONS,
active: activeSort,
onSort: (column, direction) => {
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,
}),
}}
/>
</div>
<ChipModalTabs
tabs={TABS.map((tab) => ({ value: tab.id, label: tab.label }))}
value={activeTab}
onChange={(v) => setRecentlyDeletedFilters({ tab: v as RecentlyDeletedTab })}
/>
<ChipModalTabs
tabs={TABS.map((tab) => ({ value: tab.id, label: tab.label }))}
value={activeTab}
onChange={(v) => setRecentlyDeletedFilters({ tab: v as RecentlyDeletedTab })}
/>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-sm leading-tight'>
{toError(error).message || 'Failed to load deleted items'}
</p>
</div>
) : isLoading ? null : filtered.length === 0 ? (
showNoResults ? (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
{`No items found matching \u201c${urlSearchTerm}\u201d`}
</div>
) : (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
No deleted items
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-sm leading-tight'>
{toError(error).message || 'Failed to load deleted items'}
</p>
</div>
) : isLoading ? null : filtered.length === 0 ? (
showNoResults ? (
<SettingsEmptyState variant='inline'>
{`No items found matching \u201c${urlSearchTerm}\u201d`}
</SettingsEmptyState>
) : (
<SettingsEmptyState>No deleted items</SettingsEmptyState>
)
) : (
<div className='flex flex-col gap-2'>
{filtered.map((resource) => {
const isRestoring = restoringIds.has(resource.id)
const isRestored = restoredItems.has(resource.id)
return (
<div
key={resource.id}
className='flex items-center gap-2.5 rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<ResourceIcon resource={resource} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
{resource.name}
</span>
<span className='text-[var(--text-muted)] text-small'>
{TYPE_LABEL[resource.type]}
{' \u00b7 '}
Deleted {formatDate(resource.deletedAt)}
</span>
</div>
{isRestoring ? (
<Button variant='primary' size='sm' disabled className='shrink-0'>
Restoring...
</Button>
) : isRestored ? (
<div className='flex shrink-0 items-center gap-2'>
<span className='text-[var(--text-muted)] text-small'>Restored</span>
<Button variant='primary' size='sm' onClick={() => handleView(resource)}>
View
</Button>
</div>
) : (
<Button
variant='primary'
size='sm'
onClick={() => void handleRestore(resource)}
className='shrink-0'
>
Restore
</Button>
)}
</div>
)
) : (
<div className='flex flex-col gap-2'>
{filtered.map((resource) => {
const isRestoring = restoringIds.has(resource.id)
const isRestored = restoredItems.has(resource.id)
return (
<div
key={resource.id}
className='flex items-center gap-2.5 rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<ResourceIcon resource={resource} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
{resource.name}
</span>
<span className='text-[var(--text-muted)] text-small'>
{TYPE_LABEL[resource.type]}
{' \u00b7 '}
Deleted {formatDate(resource.deletedAt)}
</span>
</div>
{isRestoring ? (
<Button variant='primary' size='sm' disabled className='shrink-0'>
Restoring...
</Button>
) : isRestored ? (
<div className='flex shrink-0 items-center gap-2'>
<span className='text-[var(--text-muted)] text-small'>Restored</span>
<Button variant='primary' size='sm' onClick={() => handleView(resource)}>
View
</Button>
</div>
) : (
<Button
variant='primary'
size='sm'
onClick={() => void handleRestore(resource)}
className='shrink-0'
>
Restore
</Button>
)}
</div>
)
})}
</div>
)}
})}
</div>
</div>
</div>
)}
</SettingsPanel>
)
}
@@ -0,0 +1 @@
export { type RowAction, RowActionsMenu } from './row-actions-menu'
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label={label}
className={cn(chipVariants({ flush: true }), triggerClassName)}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{actions.map((action) => (
<DropdownMenuItem
key={action.label}
onSelect={action.onSelect}
disabled={action.disabled}
className={action.destructive ? 'text-[var(--text-error)]' : undefined}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Secret actions'
className={cn(chipVariants({ flush: true }), 'ml-2')}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{onViewDetails && (
<DropdownMenuItem onSelect={onViewDetails}>View details</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={onCopyName}>Copy name</DropdownMenuItem>
{onDelete && (
<DropdownMenuItem className='text-[var(--text-error)]' onSelect={onDelete}>
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<RowActionsMenu
label='Secret actions'
triggerClassName='ml-2'
actions={[
...(onViewDetails ? [{ label: 'View details', onSelect: onViewDetails }] : []),
{ label: 'Copy name', onSelect: onCopyName },
...(onDelete ? [{ label: 'Delete', destructive: true, onSelect: onDelete }] : []),
]}
/>
)
}
@@ -944,36 +922,39 @@ export function SecretsManager() {
return (
<>
<div className='flex h-full flex-col bg-[var(--bg)]'>
{/* Hidden honeypot inputs to prevent browser autofill */}
<div className='hidden'>
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
tabIndex={-1}
readOnly
/>
<input
type='password'
name='fakepasswordremembered'
autoComplete='current-password'
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
tabIndex={-1}
readOnly
/>
</div>
<div className='hidden'>
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
tabIndex={-1}
readOnly
/>
<input
type='password'
name='fakepasswordremembered'
autoComplete='current-password'
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
tabIndex={-1}
readOnly
/>
</div>
{/* Fixed header bar */}
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<SettingsPanel
scrollContainerRef={scrollContainerRef}
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search secrets...',
}}
actions={
<>
{hasChanges && (
<Chip onClick={handleCancel} disabled={isListSaving}>
Discard
@@ -997,110 +978,87 @@ export function SecretsManager() {
{isListSaving ? 'Saving...' : 'Save'}
</Chip>
)}
</div>
</div>
{/* Scrollable content */}
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pt-4 pb-6'>
{/* Search */}
<ChipInput
icon={Search}
placeholder='Search secrets...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
name='env_search_field'
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
/>
{/* Secrets grid */}
{!isLoading && (
<div className='flex flex-col gap-7'>
{(!searchTerm.trim() ||
filteredWorkspaceEntries.length > 0 ||
filteredNewWorkspaceRows.length > 0) && (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Workspace</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className={`${GRID_COLS} gap-y-2`}>
{(searchTerm.trim()
? filteredWorkspaceEntries
: Object.entries(workspaceVars)
).map(([key, value]) => {
const cred = workspaceEnvKeyToCredential.get(key)
const canEditRow = cred?.role === 'admin'
return (
<WorkspaceVariableRow
key={key}
envKey={key}
value={value}
renamingKey={renamingKey}
pendingKeyValue={pendingKeyValue}
hasCredential={Boolean(cred)}
canEdit={canEditRow}
canRename={canCreateWorkspaceSecret && canEditRow}
onRenameStart={setRenamingKey}
onPendingKeyChange={setPendingKeyValue}
onRenameEnd={handleWorkspaceKeyRename}
onValueChange={handleWorkspaceValueChange}
onDelete={handleDeleteWorkspaceVar}
onViewDetails={handleViewDetails}
/>
)
})}
{canCreateWorkspaceSecret &&
(searchTerm.trim()
? filteredNewWorkspaceRows
: newWorkspaceRows.map((row, index) => ({ row, originalIndex: index }))
).map(({ row, originalIndex }) => (
<NewWorkspaceVariableRow
key={row.id || originalIndex}
envVar={row}
index={originalIndex}
onUpdate={updateNewWorkspaceRow}
onPaste={handleWorkspacePaste}
/>
))}
</div>
</section>
)}
{(!searchTerm.trim() || filteredEnvVars.length > 0) && (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Personal</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className={`${GRID_COLS} gap-y-2`}>
{filteredEnvVars.map(({ envVar, originalIndex }) => (
<div key={envVar.id || originalIndex} className='contents'>
{renderEnvVarRow(envVar, originalIndex)}
</div>
))}
</div>
</section>
)}
{searchTerm.trim() &&
filteredEnvVars.length === 0 &&
filteredWorkspaceEntries.length === 0 &&
filteredNewWorkspaceRows.length === 0 &&
(envVars.length > 0 ||
Object.keys(workspaceVars).length > 0 ||
newWorkspaceRows.length > 0) && (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No secrets found matching &ldquo;{searchTerm}&rdquo;
</div>
)}
</div>
</>
}
>
{!isLoading && (
<div className='flex flex-col gap-7'>
{(!searchTerm.trim() ||
filteredWorkspaceEntries.length > 0 ||
filteredNewWorkspaceRows.length > 0) && (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Workspace</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className={`${GRID_COLS} gap-y-2`}>
{(searchTerm.trim()
? filteredWorkspaceEntries
: Object.entries(workspaceVars)
).map(([key, value]) => {
const cred = workspaceEnvKeyToCredential.get(key)
const canEditRow = cred?.role === 'admin'
return (
<WorkspaceVariableRow
key={key}
envKey={key}
value={value}
renamingKey={renamingKey}
pendingKeyValue={pendingKeyValue}
hasCredential={Boolean(cred)}
canEdit={canEditRow}
canRename={canCreateWorkspaceSecret && canEditRow}
onRenameStart={setRenamingKey}
onPendingKeyChange={setPendingKeyValue}
onRenameEnd={handleWorkspaceKeyRename}
onValueChange={handleWorkspaceValueChange}
onDelete={handleDeleteWorkspaceVar}
onViewDetails={handleViewDetails}
/>
)
})}
{canCreateWorkspaceSecret &&
(searchTerm.trim()
? filteredNewWorkspaceRows
: newWorkspaceRows.map((row, index) => ({ row, originalIndex: index }))
).map(({ row, originalIndex }) => (
<NewWorkspaceVariableRow
key={row.id || originalIndex}
envVar={row}
index={originalIndex}
onUpdate={updateNewWorkspaceRow}
onPaste={handleWorkspacePaste}
/>
))}
</div>
</section>
)}
{(!searchTerm.trim() || filteredEnvVars.length > 0) && (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Personal</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className={`${GRID_COLS} gap-y-2`}>
{filteredEnvVars.map(({ envVar, originalIndex }) => (
<div key={envVar.id || originalIndex} className='contents'>
{renderEnvVarRow(envVar, originalIndex)}
</div>
))}
</div>
</section>
)}
{searchTerm.trim() &&
filteredEnvVars.length === 0 &&
filteredWorkspaceEntries.length === 0 &&
filteredNewWorkspaceRows.length === 0 &&
(envVars.length > 0 ||
Object.keys(workspaceVars).length > 0 ||
newWorkspaceRows.length > 0) && (
<SettingsEmptyState variant='inline'>
No secrets found matching &ldquo;{searchTerm}&rdquo;
</SettingsEmptyState>
)}
</div>
</div>
</div>
)}
</SettingsPanel>
<UnsavedChangesModal
open={showUnsavedChanges}
@@ -0,0 +1 @@
export { SettingsEmptyState } from './settings-empty-state'
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/core/utils/cn'
interface SettingsEmptyStateProps {
children: ReactNode
/**
* `fill` centers the message in the available height — an empty list, or a
* not-entitled / loading gate. `inline` sits in normal flow — a search that
* matched nothing. Defaults to `fill`.
*/
variant?: 'fill' | 'inline'
}
/**
* Canonical muted status message for settings surfaces: empty lists, search
* "no results", and entitlement/loading gates. Centralizes the text token and
* spacing so every settings page reads identically.
*/
export function SettingsEmptyState({ children, variant = 'fill' }: SettingsEmptyStateProps) {
return (
<div
className={cn(
'text-center text-[var(--text-muted)] text-sm',
variant === 'fill' ? 'flex h-full items-center justify-center' : 'py-4'
)}
>
{children}
</div>
)
}
@@ -0,0 +1 @@
export { SettingsPanel, SettingsSectionProvider } from './settings-panel'
@@ -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<SettingsSection | null>(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 (
<SettingsSectionContext.Provider value={section}>{children}</SettingsSectionContext.Provider>
)
}
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<HTMLDivElement>
/**
* 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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex h-[30px] items-center gap-1'>{actions}</div>
</div>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<div
className={cn('mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6', contentClassName)}
>
{(resolvedTitle || resolvedDescription) && (
<div className='flex flex-col gap-1'>
{resolvedTitle && (
<h1 className='font-medium text-[var(--text-body)] text-lg'>{resolvedTitle}</h1>
)}
{resolvedDescription && (
<p className='text-[var(--text-muted)] text-md'>{resolvedDescription}</p>
)}
</div>
)}
{search && (
<ChipInput
icon={Search}
placeholder={search.placeholder ?? 'Search...'}
value={search.value}
onChange={(event) => search.onChange(event.target.value)}
disabled={search.disabled}
autoComplete='off'
className='w-full'
/>
)}
{children}
</div>
</div>
</div>
)
}
@@ -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) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type='button' aria-label='Member actions' className={chipVariants({ flush: true })}>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>{children}</DropdownMenuContent>
</DropdownMenu>
const buildActionsMenu = (actions: RowAction[]) => (
<RowActionsMenu label='Member actions' actions={actions} />
)
const renderOrgMemberRow = (member: RosterMember) => {
@@ -169,69 +155,63 @@ export function OrganizationMemberLists({
/>
)
}
menu={buildActionsMenu(
<>
<DropdownMenuItem onSelect={() => copyToClipboard(member.email)}>
Copy email
</DropdownMenuItem>
{!isOwner && (
<DropdownMenuItem
onSelect={() =>
setCreditsTarget({
userId: member.userId,
name: member.name,
email: member.email,
})
}
>
Manage Credits
</DropdownMenuItem>
)}
{canRemove && (
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() =>
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,
},
})
}
>
Remove
</DropdownMenuItem>
)}
{isSelf && isOwner && onTransferOwnership && (
<DropdownMenuItem onSelect={() => onTransferOwnership()}>
Transfer ownership
</DropdownMenuItem>
)}
{isSelf && !isOwner && (
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() =>
onRemoveMember({
id: member.memberId,
role: member.role,
user: {
id: member.userId,
name: member.name,
email: member.email,
image: member.image,
},
})
}
>
Leave organization
</DropdownMenuItem>
)}
</>
)}
}),
},
]
: []),
...(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(
<>
<DropdownMenuItem onSelect={() => copyToClipboard(invitation.email)}>
Copy email
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
resendInvitation
.mutateAsync({ invitationId: invitation.id, orgId: organizationId })
.catch((error) => logger.error('Failed to resend invitation', { error }))
}
>
Resend invite
</DropdownMenuItem>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() =>
cancelInvitation
.mutateAsync({ invitationId: invitation.id, orgId: organizationId })
.catch((error) => logger.error('Failed to revoke invitation', { error }))
}
>
Revoke invite
</DropdownMenuItem>
</>
)}
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({
/>
</RoleLockTooltip>
}
menu={buildActionsMenu(
<>
<DropdownMenuItem onSelect={() => copyToClipboard(member.email)}>
Copy email
</DropdownMenuItem>
{canRemoveFromWorkspace && (
<DropdownMenuItem
className='text-[var(--text-error)]'
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.'),
})
})
}
>
Remove from workspace
</DropdownMenuItem>
)}
</>
)}
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.'),
})
}),
},
]
: []),
])}
/>
)
}
@@ -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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<>
<SettingsPanel
actions={
<Chip
leftIcon={Plus}
variant='primary'
@@ -326,37 +323,26 @@ export function TeamManagement() {
>
Invite
</Chip>
</div>
</div>
}
>
<TeamSeatsOverview
subscriptionData={orgSubscription}
isLoadingSubscription={isOrgBillingLoading}
totalSeats={totalSeats}
usedSeats={usedSeats}
pendingSeats={pendingSeats}
/>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<div className='flex flex-col gap-1'>
<h1 className='font-medium text-[var(--text-body)] text-lg'>Organization</h1>
<p className='text-[var(--text-muted)] text-md'>
Manage members and their access across every workspace in your organization.
</p>
</div>
<TeamSeatsOverview
subscriptionData={orgSubscription}
isLoadingSubscription={isOrgBillingLoading}
totalSeats={totalSeats}
usedSeats={usedSeats}
pendingSeats={pendingSeats}
/>
<OrganizationMemberLists
organizationId={displayOrganization.id}
roster={roster ?? null}
isLoadingRoster={isLoadingRoster}
currentUserId={session?.user?.id ?? ''}
currentUserEmail={session?.user?.email ?? ''}
onRemoveMember={handleRemoveMember}
onTransferOwnership={handleOpenTransferDialog}
/>
</div>
</div>
<OrganizationMemberLists
organizationId={displayOrganization.id}
roster={roster ?? null}
isLoadingRoster={isLoadingRoster}
currentUserId={session?.user?.id ?? ''}
currentUserEmail={session?.user?.email ?? ''}
onRemoveMember={handleRemoveMember}
onTransferOwnership={handleOpenTransferDialog}
/>
</SettingsPanel>
<OrganizationInviteModal
open={inviteModalOpen}
@@ -403,6 +389,6 @@ export function TeamManagement() {
})
}
/>
</div>
</>
)
}
@@ -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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search teammates...',
}}
actions={
<Chip
leftIcon={Plus}
variant='primary'
@@ -188,149 +181,120 @@ export function Teammates() {
>
Invite
</Chip>
</div>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<div className='flex flex-col gap-1'>
<h1 className='font-medium text-[var(--text-body)] text-lg'>Teammates</h1>
<p className='text-[var(--text-muted)] text-md'>
Manage your teammates in this workspace.
</p>
</div>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search teammates...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1'
/>
</div>
<MemberSection
label={`Teammates (${teammates.length})`}
isEmpty={showNoResults}
emptyText={
searchTerm.trim() ? `No teammates found matching “${searchTerm}”` : 'No teammates yet'
}
>
{filteredTeammates.map((teammate) => (
<MemberRow
key={teammate.key}
name={teammate.name}
email={teammate.email}
image={teammate.image}
status={teammate.status}
roleControl={(() => {
const lockReason = teammate.isPending
? null
: workspaceRoleLockReason(teammate.roleSource)
return (
<RoleLockTooltip reason={lockReason}>
<ChipDropdown
value={teammate.role}
onChange={(role) => handleRoleChange(teammate, role as WorkspacePermission)}
options={ROLE_OPTIONS}
matchTriggerWidth={false}
disabled={
teammate.isPending ||
!canManage ||
teammate.userId === viewer?.userId ||
lockReason !== null
}
/>
</RoleLockTooltip>
)
})()}
menu={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Teammate actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={() => copyToClipboard(teammate.email)}>
Copy email
</DropdownMenuItem>
{canManage && teammate.isPending && (
<>
<DropdownMenuItem
onSelect={() => {
}
>
<MemberSection
label={`Teammates (${teammates.length})`}
isEmpty={showNoResults}
emptyText={
searchTerm.trim() ? `No teammates found matching “${searchTerm}”` : 'No teammates yet'
}
>
{filteredTeammates.map((teammate) => (
<MemberRow
key={teammate.key}
name={teammate.name}
email={teammate.email}
image={teammate.image}
status={teammate.status}
roleControl={(() => {
const lockReason = teammate.isPending
? null
: workspaceRoleLockReason(teammate.roleSource)
return (
<RoleLockTooltip reason={lockReason}>
<ChipDropdown
value={teammate.role}
onChange={(role) => handleRoleChange(teammate, role as WorkspacePermission)}
options={ROLE_OPTIONS}
matchTriggerWidth={false}
disabled={
teammate.isPending ||
!canManage ||
teammate.userId === viewer?.userId ||
lockReason !== null
}
/>
</RoleLockTooltip>
)
})()}
menu={
<RowActionsMenu
label='Teammate actions'
actions={[
{
label: 'Copy email',
onSelect: () => copyToClipboard(teammate.email),
},
...(canManage && teammate.isPending
? [
{
label: 'Resend invite',
onSelect: () => {
if (teammate.invitationId) {
resendInvitation.mutate({
invitationId: teammate.invitationId,
workspaceId,
})
}
}}
>
Resend invite
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
},
},
{
label: 'Copy invite link',
onSelect: () => {
if (teammate.invitationId && teammate.token) {
copyToClipboard(
buildInviteLink(teammate.invitationId, teammate.token)
)
}
}}
>
Copy invite link
</DropdownMenuItem>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() => {
},
},
{
label: 'Revoke invite',
destructive: true,
onSelect: () => {
if (teammate.invitationId) {
cancelInvitation.mutate({
invitationId: teammate.invitationId,
workspaceId,
})
}
}}
>
Revoke invite
</DropdownMenuItem>
</>
)}
{canManage && !teammate.isPending && teammate.userId !== viewer?.userId && (
<DropdownMenuItem
className='text-[var(--text-error)]'
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.'
),
})
},
}
)
}
}}
>
Remove
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
}
/>
))}
</MemberSection>
</div>
</div>
},
},
]
: []),
...(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.'
),
})
},
}
)
}
},
},
]
: []),
]}
/>
}
/>
))}
</MemberSection>
</SettingsPanel>
<InviteModal
open={isInviteModalOpen}
@@ -339,6 +303,6 @@ export function Teammates() {
inviteDisabledReason={inviteDisabledReason}
organizationId={activeWorkspace?.organizationId ?? null}
/>
</div>
</>
)
}
@@ -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
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-1'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Tool actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={() => setToolToView(tool)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() => setToolToDelete(tool)}
disabled={deleteToolMutation.isPending}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RowActionsMenu
label='Tool actions'
actions={[
{ label: 'Edit', onSelect: () => setToolToView(tool) },
{
label: 'Remove',
destructive: true,
disabled: deleteToolMutation.isPending,
onSelect: () => setToolToDelete(tool),
},
]}
/>
</div>
</div>
))}
@@ -984,103 +967,81 @@ export function WorkflowMcpServers() {
return (
<>
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<Chip
leftIcon={Plus}
variant='primary'
onClick={() => setShowAddModal(true)}
disabled={isLoading}
>
Add Server
</Chip>
</div>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-4 pb-6'>
<ChipInput
icon={Search}
placeholder='Search servers...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className='min-h-0 flex-1'>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-sm leading-tight'>
{getErrorMessage(error, 'Failed to load MCP servers')}
</p>
</div>
) : isLoading ? null : !hasServers ? (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Click &quot;Add Server&quot; above to get started
</div>
) : (
<div className='flex flex-col gap-2'>
{filteredServers.map((server) => {
const count = server.toolCount || 0
const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}`
const isDeleting = deletingServers.has(server.id)
return (
<div key={server.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-1.5'>
<span className='max-w-[200px] truncate text-[14px] text-[var(--text-body)]'>
{server.name}
</span>
{server.isPublic && (
<Badge variant='outline' size='sm'>
Public
</Badge>
)}
</div>
<p className='truncate text-[12px] text-[var(--text-muted)]'>
{toolsLabel}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-1'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Server actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={() => setSelectedServerId(server.id)}>
Details
</DropdownMenuItem>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() => setServerToDelete(server)}
disabled={isDeleting}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search servers...',
}}
actions={
<Chip
leftIcon={Plus}
variant='primary'
onClick={() => setShowAddModal(true)}
disabled={isLoading}
>
Add Server
</Chip>
}
>
<div className='min-h-0 flex-1'>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-sm leading-tight'>
{getErrorMessage(error, 'Failed to load MCP servers')}
</p>
</div>
) : isLoading ? null : !hasServers ? (
<SettingsEmptyState>
Click &quot;Add Server&quot; above to get started
</SettingsEmptyState>
) : (
<div className='flex flex-col gap-2'>
{filteredServers.map((server) => {
const count = server.toolCount || 0
const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}`
const isDeleting = deletingServers.has(server.id)
return (
<div key={server.id} className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-1.5'>
<span className='max-w-[200px] truncate text-[14px] text-[var(--text-body)]'>
{server.name}
</span>
{server.isPublic && (
<Badge variant='outline' size='sm'>
Public
</Badge>
)}
</div>
)
})}
{showNoResults && (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No servers found matching "{searchTerm}"
<p className='truncate text-[12px] text-[var(--text-muted)]'>{toolsLabel}</p>
</div>
)}
</div>
<div className='flex flex-shrink-0 items-center gap-1'>
<RowActionsMenu
label='Server actions'
actions={[
{ label: 'Details', onSelect: () => setSelectedServerId(server.id) },
{
label: 'Delete',
destructive: true,
disabled: isDeleting,
onSelect: () => setServerToDelete(server),
},
]}
/>
</div>
</div>
)
})}
{showNoResults && (
<SettingsEmptyState variant='inline'>
No servers found matching "{searchTerm}"
</SettingsEmptyState>
)}
</div>
</div>
)}
</div>
</div>
</SettingsPanel>
<CreateWorkflowMcpServerModal
open={showAddModal}
@@ -57,6 +57,8 @@ export type NavigationSection =
export interface NavigationItem {
id: SettingsSection
label: string
/** One-line summary shown as the page subtitle under the title. */
description: string
icon: React.ComponentType<{ className?: string }>
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
}
File diff suppressed because one or more lines are too long
@@ -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 (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState>
{!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.'}
</div>
</SettingsEmptyState>
)
}
@@ -158,85 +158,66 @@ export function AccessControl() {
return (
<>
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<Chip leftIcon={Plus} variant='primary' onClick={() => setShowCreateModal(true)}>
Create Group
</Chip>
</div>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<div className='flex flex-col gap-1'>
<h1 className='font-medium text-[var(--text-body)] text-lg'>Access Control</h1>
<p className='text-[var(--text-muted)] text-md'>
Manage permission groups across every workspace in your organization.
</p>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search permission groups...',
}}
actions={
<Chip leftIcon={Plus} variant='primary' onClick={() => setShowCreateModal(true)}>
Create Group
</Chip>
}
>
<SettingsSection label={`Permission groups (${permissionGroups.length})`}>
{permissionGroups.length === 0 ? (
<SettingsEmptyState variant='inline'>
No permission groups yet. Click "Create Group" to get started.
</SettingsEmptyState>
) : filteredGroups.length === 0 ? (
<SettingsEmptyState variant='inline'>
No groups found matching "{searchTerm}"
</SettingsEmptyState>
) : (
<div className='-mx-2 flex flex-col gap-y-0.5'>
{filteredGroups.map((group) => (
<button
key={group.id}
type='button'
onClick={() => setSelectedGroupId(group.id)}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 flex-1 flex-col'>
<div className='flex items-center gap-2'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{group.name}
</span>
{group.isDefault && (
<ChipTag variant='gray' className='flex-shrink-0'>
Default
</ChipTag>
)}
</div>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{group.isDefault
? 'Everyone in the organization'
: `${
group.memberCount === 0
? 'All members'
: `${group.memberCount} member${group.memberCount === 1 ? '' : 's'}`
} · ${group.workspaces.length} workspace${
group.workspaces.length === 1 ? '' : 's'
}`}
</span>
</div>
<ArrowRight className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
))}
</div>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search permission groups...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1'
/>
</div>
<SettingsSection label={`Permission groups (${permissionGroups.length})`}>
{permissionGroups.length === 0 ? (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No permission groups yet. Click "Create Group" to get started.
</div>
) : filteredGroups.length === 0 ? (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No groups found matching "{searchTerm}"
</div>
) : (
<div className='-mx-2 flex flex-col gap-y-0.5'>
{filteredGroups.map((group) => (
<button
key={group.id}
type='button'
onClick={() => setSelectedGroupId(group.id)}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 flex-1 flex-col'>
<div className='flex items-center gap-2'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{group.name}
</span>
{group.isDefault && (
<ChipTag variant='gray' className='flex-shrink-0'>
Default
</ChipTag>
)}
</div>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{group.isDefault
? 'Everyone in the organization'
: `${
group.memberCount === 0
? 'All members'
: `${group.memberCount} member${group.memberCount === 1 ? '' : 's'}`
} · ${group.workspaces.length} workspace${
group.workspaces.length === 1 ? '' : 's'
}`}
</span>
</div>
<ArrowRight className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
))}
</div>
)}
</SettingsSection>
</div>
</div>
</div>
)}
</SettingsSection>
</SettingsPanel>
<ChipModal
open={showCreateModal}
@@ -17,13 +17,7 @@ import {
ChipModalHeader,
ChipModalTabs,
ChipTag,
chipVariants,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Info,
MoreHorizontal,
Search,
Skeleton,
Switch,
@@ -38,6 +32,7 @@ import {
MemberAvatar,
MemberRow,
} from '@/app/workspace/[workspaceId]/settings/components/member-list'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { getAllBlocks } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
@@ -1086,9 +1081,7 @@ export function GroupDetail({
try {
const providerId = getProviderFromModel(model)
counts[providerId] = (counts[providerId] ?? 0) + 1
} catch {
// Unknown/blacklisted provider — omit from counts.
}
} catch {}
}
return counts
}, [editingConfig.deniedModels])
@@ -1414,25 +1407,16 @@ export function GroupDetail({
image={member.userImage}
status={`Added ${new Date(member.assignedAt).toLocaleDateString()}`}
menu={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Member actions'
className={chipVariants({ flush: true })}
>
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
className='text-[var(--text-error)]'
onSelect={() => handleRemoveMember(member.id)}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RowActionsMenu
label='Member actions'
actions={[
{
label: 'Remove',
onSelect: () => handleRemoveMember(member.id),
destructive: true,
},
]}
/>
}
/>
))}
@@ -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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-4 pb-6'>
{/* Search + filter bar */}
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
className='min-w-0 flex-1'
placeholder='Search audit logs...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<ChipSelect
options={RESOURCE_TYPE_OPTIONS}
multiSelect
multiSelectValues={selectedTypes}
onMultiSelectChange={setSelectedTypes}
placeholder='All types'
displayLabel={typeDisplayLabel}
searchable
searchPlaceholder='Search types...'
showAllOption
allOptionLabel='All types'
align='start'
/>
<div className='relative'>
<ChipSelect
options={TIME_RANGE_OPTIONS}
value={timeRange}
onChange={handleTimeRangeChange}
placeholder='All time'
displayLabel={timeDisplayLabel}
maxHeight={320}
align='start'
/>
<DatePicker
mode='range'
showTrigger={false}
showTime
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
if (dateRangeAppliedRef.current) {
dateRangeAppliedRef.current = false
} else {
handleDatePickerCancel()
}
}
}}
startDate={customStartDate}
endDate={customEndDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
</div>
<Button variant='ghost' onClick={handleRefresh} disabled={isVisuallyRefreshing}>
<RefreshCw animate={isVisuallyRefreshing} className='size-[14px]' />
</Button>
</div>
<SettingsPanel>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
className='min-w-0 flex-1'
placeholder='Search audit logs...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<ChipSelect
options={RESOURCE_TYPE_OPTIONS}
multiSelect
multiSelectValues={selectedTypes}
onMultiSelectChange={setSelectedTypes}
placeholder='All types'
displayLabel={typeDisplayLabel}
searchable
searchPlaceholder='Search types...'
showAllOption
allOptionLabel='All types'
align='start'
/>
<div className='relative'>
<ChipSelect
options={TIME_RANGE_OPTIONS}
value={timeRange}
onChange={handleTimeRangeChange}
placeholder='All time'
displayLabel={timeDisplayLabel}
maxHeight={320}
align='start'
/>
<DatePicker
mode='range'
showTrigger={false}
showTime
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
if (dateRangeAppliedRef.current) {
dateRangeAppliedRef.current = false
} else {
handleDatePickerCancel()
}
}
}}
startDate={customStartDate}
endDate={customEndDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
</div>
<Button variant='ghost' onClick={handleRefresh} disabled={isVisuallyRefreshing}>
<RefreshCw animate={isVisuallyRefreshing} className='size-[14px]' />
</Button>
</div>
{/* Table */}
<div className='flex flex-col'>
<div className='flex items-center gap-3 px-3 pb-1 text-[var(--text-tertiary)] text-caption'>
<span className='w-[160px] flex-shrink-0'>Timestamp</span>
<span className='w-[180px] flex-shrink-0'>Event</span>
<span className='min-w-0 flex-1'>Description</span>
<span className='w-[160px] flex-shrink-0 text-right'>Actor</span>
</div>
<div className='flex flex-col'>
<div className='flex items-center gap-3 px-3 pb-1 text-[var(--text-tertiary)] text-caption'>
<span className='w-[160px] flex-shrink-0'>Timestamp</span>
<span className='w-[180px] flex-shrink-0'>Event</span>
<span className='min-w-0 flex-1'>Description</span>
<span className='w-[160px] flex-shrink-0 text-right'>Actor</span>
</div>
{isLoading ? null : allEntries.length === 0 ? (
debouncedSearch ? (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
No results for "{debouncedSearch}"
</div>
) : (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
No audit logs found
</div>
)
) : (
<div className='flex flex-col gap-0.5'>
{allEntries.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} />
))}
{hasNextPage && (
<div className='flex justify-center py-4'>
<Button variant='ghost' onClick={handleLoadMore} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
{isLoading ? null : allEntries.length === 0 ? (
debouncedSearch ? (
<SettingsEmptyState variant='inline'>
No results for "{debouncedSearch}"
</SettingsEmptyState>
) : (
<SettingsEmptyState>No audit logs found</SettingsEmptyState>
)
) : (
<div className='flex flex-col gap-0.5'>
{allEntries.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} />
))}
{hasNextPage && (
<div className='flex justify-center py-4'>
<Button variant='ghost' onClick={handleLoadMore} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</div>
</div>
)}
</div>
</div>
</SettingsPanel>
)
}
@@ -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 (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState>
Data drains are configured per organization. Join or create one to continue.
</div>
</SettingsEmptyState>
)
}
if (!canManage) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState>
Only organization owners and admins can configure data drains.
</div>
</SettingsEmptyState>
)
}
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<>
<SettingsPanel
actions={
<Chip leftIcon={Plus} variant='primary' onClick={() => setCreateOpen(true)}>
New Drain
</Chip>
</div>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-4 pb-6'>
}
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search data drains...',
}}
>
<div className='flex flex-col gap-4.5'>
<InfoNote>
Drains continuously export Sim data to your own storage on a schedule. Combine with Data
Retention to satisfy long-term compliance archives.
</InfoNote>
<ChipInput
icon={Search}
placeholder='Search data drains...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div>
{drainsError ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
@@ -193,23 +183,21 @@ export function DataDrainsSettings() {
</TableBody>
</Table>
) : (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState variant='inline'>
No results for "{searchTerm.trim()}"
</div>
</SettingsEmptyState>
)
) : (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Click "New Drain" above to get started
</div>
<SettingsEmptyState>Click "New Drain" above to get started</SettingsEmptyState>
)}
</div>
</div>
</div>
</SettingsPanel>
{createOpen && (
<CreateDrainModal organizationId={orgId} onClose={() => setCreateOpen(false)} />
)}
</div>
</>
)
}
@@ -304,22 +292,14 @@ function DrainRow({ drain, organizationId, expanded, onToggleExpand }: DrainRowP
/>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='sm' aria-label='Drain actions'>
<MoreHorizontal className='size-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={handleRunNow} disabled={!drain.enabled}>
Run now
</DropdownMenuItem>
<DropdownMenuItem onClick={handleTest}>Test connection</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className='text-[var(--text-error)]'>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RowActionsMenu
label='Drain actions'
actions={[
{ label: 'Run now', onSelect: handleRunNow, disabled: !drain.enabled },
{ label: 'Test connection', onSelect: handleTest },
{ label: 'Delete', onSelect: handleDelete, destructive: true },
]}
/>
</TableCell>
</TableRow>
{expanded && (
@@ -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<RetentionOverride[]>([])
const [modal, setModal] = useState<ActiveModal | null>(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<string | null>(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 (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState>
Data retention is configured per organization. Join or create an organization to continue.
</div>
</SettingsEmptyState>
)
}
if (!data) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Failed to load data retention settings.
</div>
)
return <SettingsEmptyState>Failed to load data retention settings.</SettingsEmptyState>
}
if (isBillingEnabled && !data.isEnterprise) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Data retention is available on Enterprise plans only.
</div>
<SettingsEmptyState>Data retention is available on Enterprise plans only.</SettingsEmptyState>
)
}
if (!canManage) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState>
Only organization owners and admins can configure data retention settings.
</div>
</SettingsEmptyState>
)
}
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<>
<SettingsPanel
actions={
<Chip
leftIcon={Plus}
variant='primary'
@@ -748,59 +735,56 @@ export function DataRetentionSettings() {
>
Add override
</Chip>
</div>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-8 pt-6 pb-6'>
<SettingsSection label='Retention policies'>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>
Workspaces without an override inherit the organization defaults.
</span>
<div className='-mx-2 flex flex-col gap-y-0.5'>
}
>
<SettingsSection label='Retention policies'>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>
Workspaces without an override inherit the organization defaults.
</span>
<div className='-mx-2 flex flex-col gap-y-0.5'>
<button
type='button'
onClick={openEditOrg}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 flex-1 flex-col'>
<div className='flex items-center gap-2'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
Organization
</span>
<ChipTag variant='gray' className='flex-shrink-0'>
Default
</ChipTag>
</div>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{orgRowSummary()}
</span>
</div>
<ArrowRight className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
{overrideWorkspaceIds.map((workspaceId) => (
<button
key={workspaceId}
type='button'
onClick={openEditOrg}
onClick={() => openEditOverride(workspaceId)}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 flex-1 flex-col'>
<div className='flex items-center gap-2'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
Organization
</span>
<ChipTag variant='gray' className='flex-shrink-0'>
Default
</ChipTag>
</div>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{workspaceName(workspaceId)}
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{orgRowSummary()}
{overrideRowSummary(workspaceId)}
</span>
</div>
<ArrowRight className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
{overrideWorkspaceIds.map((workspaceId) => (
<button
key={workspaceId}
type='button'
onClick={() => openEditOverride(workspaceId)}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{workspaceName(workspaceId)}
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{overrideRowSummary(workspaceId)}
</span>
</div>
<ArrowRight className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
))}
</div>
))}
</div>
</SettingsSection>
</div>
</div>
</div>
</SettingsSection>
</SettingsPanel>
{modal && (
<PolicyModal
draft={modal.draft}
@@ -838,6 +822,6 @@ export function DataRetentionSettings() {
}}
/>
</ChipModal>
</div>
</>
)
}
+104 -124
View File
@@ -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 (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-small'>
<SettingsEmptyState>
You must be part of an organization to configure Single Sign-On.
</div>
</SettingsEmptyState>
)
}
if (!hasEnterprisePlan) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-small'>
<SettingsEmptyState>
Single Sign-On is available on Enterprise plans only.
</div>
</SettingsEmptyState>
)
}
if (!canManageSSO) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-small'>
<SettingsEmptyState>
Only organization owners and admins can configure Single Sign-On settings.
</div>
</SettingsEmptyState>
)
}
} else {
if (activeOrganization && !canManageSSO) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-small'>
<SettingsEmptyState>
Only organization owners and admins can configure Single Sign-On settings.
</div>
</SettingsEmptyState>
)
}
if (
@@ -173,9 +175,9 @@ export function SSO() {
providers.length > 0
) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-small'>
<SettingsEmptyState>
Only the user who configured SSO can manage these settings.
</div>
</SettingsEmptyState>
)
}
}
@@ -433,74 +435,65 @@ export function SSO() {
const providerCallbackUrl = `${getBaseUrl()}/api/auth/${existingProvider.providerType === 'saml' ? 'sso/saml2/callback' : 'sso/callback'}/${existingProvider.providerId}`
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<Button onClick={handleEdit} variant='primary'>
Edit
</Button>
</div>
<SettingsPanel
actions={
<Button onClick={handleEdit} variant='primary'>
Edit
</Button>
}
>
<div className='flex flex-col gap-4.5'>
<FormField label='Provider ID'>
<p className='text-[var(--text-primary)] text-small'>{existingProvider.providerId}</p>
</FormField>
<FormField label='Provider Type'>
<p className='text-[var(--text-primary)] text-small'>
{existingProvider.providerType.toUpperCase()}
</p>
</FormField>
<FormField label='Domain'>
<p className='text-[var(--text-primary)] text-small'>{existingProvider.domain}</p>
</FormField>
<FormField label='Issuer URL'>
<p className='break-all font-mono text-[var(--text-primary)] text-small leading-relaxed'>
{existingProvider.issuer}
</p>
</FormField>
<FormField label='Callback URL'>
<ChipInput
value={providerCallbackUrl}
readOnly
endAdornment={
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(providerCallbackUrl)}
className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]'
aria-label='Copy callback URL'
>
{copied ? (
<Check className='size-[14px]' />
) : (
<Clipboard className='size-[14px]' />
)}
</Button>
}
/>
<p className='text-[var(--text-muted)] text-small'>
Configure this in your identity provider
</p>
</FormField>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-6 pb-6'>
<FormField label='Provider ID'>
<p className='text-[var(--text-primary)] text-small'>{existingProvider.providerId}</p>
</FormField>
<FormField label='Provider Type'>
<p className='text-[var(--text-primary)] text-small'>
{existingProvider.providerType.toUpperCase()}
</p>
</FormField>
<FormField label='Domain'>
<p className='text-[var(--text-primary)] text-small'>{existingProvider.domain}</p>
</FormField>
<FormField label='Issuer URL'>
<p className='break-all font-mono text-[var(--text-primary)] text-small leading-relaxed'>
{existingProvider.issuer}
</p>
</FormField>
<FormField label='Callback URL'>
<ChipInput
value={providerCallbackUrl}
readOnly
endAdornment={
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(providerCallbackUrl)}
className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]'
aria-label='Copy callback URL'
>
{copied ? (
<Check className='size-[14px]' />
) : (
<Clipboard className='size-[14px]' />
)}
</Button>
}
/>
<p className='text-[var(--text-muted)] text-small'>
Configure this in your identity provider
</p>
</FormField>
</div>
</div>
</div>
</SettingsPanel>
)
}
return (
<form
onSubmit={handleSubmit}
autoComplete='off'
className='flex h-full flex-col bg-[var(--bg)]'
>
{/* Off-screen inputs to prevent browser password manager autofill */}
<form onSubmit={handleSubmit} autoComplete='off' className='flex h-full flex-col'>
<input
type='text'
name='fakeusernameremembered'
@@ -527,48 +520,46 @@ export function SSO() {
/>
<input type='text' name='hidden' className='hidden' autoComplete='off' />
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center gap-1'>
{isEditing && (
<SettingsPanel
actions={
<>
{isEditing && (
<Button
type='button'
variant='default'
onClick={() => {
setIsEditing(false)
setFormData(DEFAULT_FORM_DATA)
setErrors(DEFAULT_ERRORS)
setShowErrors(false)
setShowAdvanced(false)
}}
>
Cancel
</Button>
)}
<Button
type='button'
variant='default'
onClick={() => {
setIsEditing(false)
setFormData(DEFAULT_FORM_DATA)
setErrors(DEFAULT_ERRORS)
setShowErrors(false)
setShowAdvanced(false)
}}
type='submit'
variant='primary'
disabled={
configureSSOMutation.isPending ||
hasAnyErrors(errors) ||
!isFormValid() ||
(isEditing && !hasChanges())
}
>
Cancel
{configureSSOMutation.isPending
? isEditing
? 'Updating...'
: 'Saving...'
: isEditing
? 'Update'
: 'Save'}
</Button>
)}
<Button
type='submit'
variant='primary'
disabled={
configureSSOMutation.isPending ||
hasAnyErrors(errors) ||
!isFormValid() ||
(isEditing && !hasChanges())
}
>
{configureSSOMutation.isPending
? isEditing
? 'Updating...'
: 'Saving...'
: isEditing
? 'Update'
: 'Save'}
</Button>
</div>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-4.5 pt-6 pb-6'>
{/* Provider Type */}
</>
}
>
<div className='flex flex-col gap-4.5'>
<FormField label='Provider Type'>
<ChipSelect
align='start'
@@ -589,15 +580,12 @@ export function SSO() {
</p>
</FormField>
{/* Provider ID */}
<FormField
label='Provider ID'
error={
showErrors && errors.providerId.length > 0 ? errors.providerId.join(' ') : undefined
}
>
{/* Editable combobox (not ChipSelect): provider IDs accept any
validated slug, with SSO_TRUSTED_PROVIDERS as autocomplete. */}
<ChipCombobox
value={formData.providerId}
onChange={(value: string) => handleInputChange('providerId', value)}
@@ -610,7 +598,6 @@ export function SSO() {
/>
</FormField>
{/* Issuer URL */}
<FormField
label='Issuer URL'
error={
@@ -633,7 +620,6 @@ export function SSO() {
/>
</FormField>
{/* Domain */}
<FormField
label='Domain'
error={showErrors && errors.domain.length > 0 ? errors.domain.join(' ') : undefined}
@@ -659,7 +645,6 @@ export function SSO() {
{formData.providerType === 'oidc' ? (
<>
{/* Client ID */}
<FormField
label='Client ID'
error={
@@ -682,7 +667,6 @@ export function SSO() {
/>
</FormField>
{/* Client Secret */}
<FormField
label='Client Secret'
error={
@@ -727,7 +711,6 @@ export function SSO() {
/>
</FormField>
{/* Scopes */}
<FormField
label='Scopes'
error={showErrors && errors.scopes.length > 0 ? errors.scopes.join(' ') : undefined}
@@ -750,7 +733,6 @@ export function SSO() {
</>
) : (
<>
{/* Entry Point URL */}
<FormField
label='Entry Point URL'
error={
@@ -772,7 +754,6 @@ export function SSO() {
/>
</FormField>
{/* Identity Provider Certificate */}
<FormField
label='Identity Provider Certificate'
error={showErrors && errors.cert.length > 0 ? errors.cert.join(' ') : undefined}
@@ -859,7 +840,6 @@ export function SSO() {
</>
)}
{/* Callback URL */}
<FormField label='Callback URL'>
<ChipInput
value={callbackUrl}
@@ -885,7 +865,7 @@ export function SSO() {
</p>
</FormField>
</div>
</div>
</SettingsPanel>
</form>
)
}
@@ -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 (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState>
You must be part of an organization to configure whitelabeling.
</div>
</SettingsEmptyState>
)
}
if (!hasEnterprisePlan) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState>
Whitelabeling is available on Enterprise plans only.
</div>
</SettingsEmptyState>
)
}
if (!canManage) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
<SettingsEmptyState>
Only organization owners and admins can configure whitelabeling settings.
</div>
</SettingsEmptyState>
)
}
}
@@ -308,222 +310,216 @@ export function WhitelabelingSettings() {
const isUploading = logoUpload.isUploading || wordmarkUpload.isUploading
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<div />
<div className='flex items-center'>
<Button
variant='primary'
onClick={handleSave}
disabled={updateSettings.isPending || isUploading || !hasChanges}
<SettingsPanel
actions={
<Button
variant='primary'
onClick={handleSave}
disabled={updateSettings.isPending || isUploading || !hasChanges}
>
{updateSettings.isPending ? 'Saving...' : 'Save'}
</Button>
}
>
<SettingsSection label='Brand Identity'>
<div className='flex flex-col gap-5'>
<SettingRow
label='Brand name'
description='Replaces "Sim" in the sidebar and select UI elements.'
>
{updateSettings.isPending ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pt-6 pb-6'>
<SettingsSection label='Brand Identity'>
<div className='flex flex-col gap-5'>
<SettingRow
label='Brand name'
description='Replaces "Sim" in the sidebar and select UI elements.'
>
<ChipInput
value={brandName}
onChange={(e) => setBrandName(e.target.value)}
placeholder='Your Company'
className='max-w-[320px]'
maxLength={64}
<ChipInput
value={brandName}
onChange={(e) => setBrandName(e.target.value)}
placeholder='Your Company'
className='max-w-[320px]'
maxLength={64}
/>
</SettingRow>
<div className='grid grid-cols-2 gap-4'>
<SettingRow
label='Logo'
labelTooltip='Shown in the collapsed sidebar. Square image — PNG, JPEG, or SVG, max 5MB.'
>
<div className='flex items-center gap-4'>
<DropZone onDrop={logoUpload.handleFileDrop}>
<button
type='button'
onClick={logoUpload.handleThumbnailClick}
disabled={logoUpload.isUploading}
className='group relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{logoUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
) : logoUpload.previewUrl ? (
<Image
src={logoUpload.previewUrl}
alt='Logo'
fill
className='object-contain p-1'
unoptimized
/>
) : (
<ImageIcon className='size-5 text-[var(--text-muted)]' />
)}
</button>
</DropZone>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={logoUpload.handleThumbnailClick}
disabled={logoUpload.isUploading}
className='text-[13px]'
>
{logoUpload.previewUrl ? 'Change' : 'Upload'}
</Button>
{logoUpload.previewUrl && (
<Button
variant='ghost'
size='sm'
onClick={logoUpload.handleRemove}
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
<X className='size-[14px]' />
</Button>
)}
</div>
<input
ref={logoUpload.fileInputRef}
type='file'
accept='image/png,image/jpeg,image/jpg,image/svg+xml,image/webp'
onChange={logoUpload.handleFileChange}
className='hidden'
/>
</SettingRow>
<div className='grid grid-cols-2 gap-4'>
<SettingRow
label='Logo'
labelTooltip='Shown in the collapsed sidebar. Square image — PNG, JPEG, or SVG, max 5MB.'
>
<div className='flex items-center gap-4'>
<DropZone onDrop={logoUpload.handleFileDrop}>
<button
type='button'
onClick={logoUpload.handleThumbnailClick}
disabled={logoUpload.isUploading}
className='group relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{logoUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
) : logoUpload.previewUrl ? (
<Image
src={logoUpload.previewUrl}
alt='Logo'
fill
className='object-contain p-1'
unoptimized
/>
) : (
<ImageIcon className='size-5 text-[var(--text-muted)]' />
)}
</button>
</DropZone>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={logoUpload.handleThumbnailClick}
disabled={logoUpload.isUploading}
className='text-[13px]'
>
{logoUpload.previewUrl ? 'Change' : 'Upload'}
</Button>
{logoUpload.previewUrl && (
<Button
variant='ghost'
size='sm'
onClick={logoUpload.handleRemove}
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
<X className='size-[14px]' />
</Button>
)}
</div>
<input
ref={logoUpload.fileInputRef}
type='file'
accept='image/png,image/jpeg,image/jpg,image/svg+xml,image/webp'
onChange={logoUpload.handleFileChange}
className='hidden'
/>
</div>
</SettingRow>
<SettingRow
label='Wordmark'
labelTooltip='Shown in the expanded sidebar. Wide image — PNG, JPEG, or SVG, max 5MB.'
>
<div className='flex items-center gap-4'>
<DropZone onDrop={wordmarkUpload.handleFileDrop} className='min-w-0 flex-1'>
<button
type='button'
onClick={wordmarkUpload.handleThumbnailClick}
disabled={wordmarkUpload.isUploading}
className='group relative flex h-16 w-full items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{wordmarkUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
) : wordmarkUpload.previewUrl ? (
<Image
src={wordmarkUpload.previewUrl}
alt='Wordmark'
fill
className='object-contain p-2'
unoptimized
/>
) : (
<ImageIcon className='size-5 text-[var(--text-muted)]' />
)}
</button>
</DropZone>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={wordmarkUpload.handleThumbnailClick}
disabled={wordmarkUpload.isUploading}
className='text-[13px]'
>
{wordmarkUpload.previewUrl ? 'Change' : 'Upload'}
</Button>
{wordmarkUpload.previewUrl && (
<Button
variant='ghost'
size='sm'
onClick={wordmarkUpload.handleRemove}
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
<X className='size-[14px]' />
</Button>
)}
</div>
<input
ref={wordmarkUpload.fileInputRef}
type='file'
accept='image/png,image/jpeg,image/jpg,image/svg+xml,image/webp'
onChange={wordmarkUpload.handleFileChange}
className='hidden'
/>
</div>
</SettingRow>
</div>
</div>
</SettingsSection>
</SettingRow>
<SettingsSection label='Colors'>
<div className='grid grid-cols-2 gap-4'>
<ColorInput
label='Primary color'
value={primaryColor}
onChange={setPrimaryColor}
placeholder='#33c482'
/>
<ColorInput
label='Primary hover color'
value={primaryHoverColor}
onChange={setPrimaryHoverColor}
placeholder='#2dac72'
/>
<ColorInput
label='Accent color'
value={accentColor}
onChange={setAccentColor}
placeholder='#33b4ff'
/>
<ColorInput
label='Accent hover color'
value={accentHoverColor}
onChange={setAccentHoverColor}
placeholder='#29a0e8'
/>
</div>
</SettingsSection>
<SettingsSection label='Links'>
<div className='flex flex-col gap-4'>
<SettingRow label='Support email'>
<ChipInput
type='email'
value={supportEmail}
onChange={(e) => setSupportEmail(e.target.value)}
placeholder='support@yourcompany.com'
<SettingRow
label='Wordmark'
labelTooltip='Shown in the expanded sidebar. Wide image — PNG, JPEG, or SVG, max 5MB.'
>
<div className='flex items-center gap-4'>
<DropZone onDrop={wordmarkUpload.handleFileDrop} className='min-w-0 flex-1'>
<button
type='button'
onClick={wordmarkUpload.handleThumbnailClick}
disabled={wordmarkUpload.isUploading}
className='group relative flex h-16 w-full items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{wordmarkUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
) : wordmarkUpload.previewUrl ? (
<Image
src={wordmarkUpload.previewUrl}
alt='Wordmark'
fill
className='object-contain p-2'
unoptimized
/>
) : (
<ImageIcon className='size-5 text-[var(--text-muted)]' />
)}
</button>
</DropZone>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={wordmarkUpload.handleThumbnailClick}
disabled={wordmarkUpload.isUploading}
className='text-[13px]'
>
{wordmarkUpload.previewUrl ? 'Change' : 'Upload'}
</Button>
{wordmarkUpload.previewUrl && (
<Button
variant='ghost'
size='sm'
onClick={wordmarkUpload.handleRemove}
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
<X className='size-[14px]' />
</Button>
)}
</div>
<input
ref={wordmarkUpload.fileInputRef}
type='file'
accept='image/png,image/jpeg,image/jpg,image/svg+xml,image/webp'
onChange={wordmarkUpload.handleFileChange}
className='hidden'
/>
</SettingRow>
<SettingRow label='Documentation URL'>
<ChipInput
type='url'
value={documentationUrl}
onChange={(e) => setDocumentationUrl(e.target.value)}
placeholder='https://docs.yourcompany.com'
/>
</SettingRow>
<SettingRow label='Terms of service URL'>
<ChipInput
type='url'
value={termsUrl}
onChange={(e) => setTermsUrl(e.target.value)}
placeholder='https://yourcompany.com/terms'
/>
</SettingRow>
<SettingRow label='Privacy policy URL'>
<ChipInput
type='url'
value={privacyUrl}
onChange={(e) => setPrivacyUrl(e.target.value)}
placeholder='https://yourcompany.com/privacy'
/>
</SettingRow>
</div>
</SettingsSection>
</div>
</SettingRow>
</div>
</div>
</div>
</div>
</SettingsSection>
<SettingsSection label='Colors'>
<div className='grid grid-cols-2 gap-4'>
<ColorInput
label='Primary color'
value={primaryColor}
onChange={setPrimaryColor}
placeholder='#33c482'
/>
<ColorInput
label='Primary hover color'
value={primaryHoverColor}
onChange={setPrimaryHoverColor}
placeholder='#2dac72'
/>
<ColorInput
label='Accent color'
value={accentColor}
onChange={setAccentColor}
placeholder='#33b4ff'
/>
<ColorInput
label='Accent hover color'
value={accentHoverColor}
onChange={setAccentHoverColor}
placeholder='#29a0e8'
/>
</div>
</SettingsSection>
<SettingsSection label='Links'>
<div className='flex flex-col gap-4'>
<SettingRow label='Support email'>
<ChipInput
type='email'
value={supportEmail}
onChange={(e) => setSupportEmail(e.target.value)}
placeholder='support@yourcompany.com'
/>
</SettingRow>
<SettingRow label='Documentation URL'>
<ChipInput
type='url'
value={documentationUrl}
onChange={(e) => setDocumentationUrl(e.target.value)}
placeholder='https://docs.yourcompany.com'
/>
</SettingRow>
<SettingRow label='Terms of service URL'>
<ChipInput
type='url'
value={termsUrl}
onChange={(e) => setTermsUrl(e.target.value)}
placeholder='https://yourcompany.com/terms'
/>
</SettingRow>
<SettingRow label='Privacy policy URL'>
<ChipInput
type='url'
value={privacyUrl}
onChange={(e) => setPrivacyUrl(e.target.value)}
placeholder='https://yourcompany.com/privacy'
/>
</SettingRow>
</div>
</SettingsSection>
</SettingsPanel>
)
}