mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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.
|
||||
@@ -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) => {
|
||||
|
||||
+177
-225
@@ -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}
|
||||
|
||||
+64
-94
@@ -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
-4
@@ -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 "Add Server" 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 "Add Server" 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 "{searchTerm}"
|
||||
</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 "{searchTerm}"
|
||||
</SettingsEmptyState>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsPanel>
|
||||
|
||||
<McpServerFormModal
|
||||
open={showAddModal}
|
||||
|
||||
+2
-1
@@ -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>
|
||||
)}
|
||||
|
||||
+56
-76
@@ -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,
|
||||
|
||||
+97
-101
@@ -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'
|
||||
+57
@@ -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>
|
||||
)
|
||||
}
|
||||
+124
-166
@@ -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 “{searchTerm}”
|
||||
</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 “{searchTerm}”
|
||||
</SettingsEmptyState>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsPanel>
|
||||
|
||||
<UnsavedChangesModal
|
||||
open={showUnsavedChanges}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { SettingsEmptyState } from './settings-empty-state'
|
||||
+30
@@ -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'
|
||||
+124
@@ -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>
|
||||
)
|
||||
}
|
||||
+100
-132
@@ -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.'),
|
||||
})
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+24
-38
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+109
-145
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+86
-125
@@ -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 "Add Server" 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 "Add Server" 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user