chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function SettingsSectionError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Something went wrong'
description='An unexpected error occurred. Please try again or refresh the page.'
loggerName='SettingsSectionError'
/>
)
}
@@ -0,0 +1,18 @@
import {
SettingsHeaderProvider,
SettingsHeaderShell,
} from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
/**
* Persistent chrome for the settings panel pages. The header bar, title,
* description, scroll region, and centered column live in the shell and stay
* mounted across section navigation — only the body swaps. Scoped to `[section]`
* so detail routes (e.g. `secrets/[credentialId]`) keep their own chrome.
*/
export default function SettingsSectionLayout({ children }: { children: React.ReactNode }) {
return (
<SettingsHeaderProvider>
<SettingsHeaderShell>{children}</SettingsHeaderShell>
</SettingsHeaderProvider>
)
}
@@ -0,0 +1,76 @@
import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { prefetchGeneralSettings, prefetchSubscriptionData, prefetchUserProfile } from './prefetch'
import { SettingsPage } from './settings'
/**
* Legacy settings sections that moved to top-level workspace routes.
* Old bookmarks and emails deep-link here; without the redirect the
* section renderer has no matching branch and shows an empty panel.
*/
const SETTINGS_REDIRECTS: Record<string, (workspaceId: string) => string> = {
integrations: (id) => `/workspace/${id}/integrations`,
skills: (id) => `/workspace/${id}/skills`,
}
const SECTION_TITLES: Record<string, string> = {
general: 'General',
secrets: 'Secrets',
'access-control': 'Access Control',
'audit-logs': 'Audit Logs',
apikeys: 'Sim API Keys',
byok: 'BYOK',
subscription: 'Subscription',
billing: 'Billing',
teammates: 'Teammates',
team: 'Team',
sso: 'Single Sign-On',
whitelabeling: 'Whitelabeling',
copilot: 'Chat Keys',
forks: 'Workspace Forks',
mcp: 'MCP Tools',
'custom-tools': 'Custom Tools',
'workflow-mcp-servers': 'MCP Servers',
'data-retention': 'Data Retention',
'recently-deleted': 'Recently Deleted',
debug: 'Debug',
} as const
export async function generateMetadata({
params,
}: {
params: Promise<{ section: string }>
}): Promise<Metadata> {
const { section } = await params
return { title: SECTION_TITLES[section] ?? 'Settings' }
}
export default async function SettingsSectionPage({
params,
}: {
params: Promise<{ workspaceId: string; section: string }>
}) {
const { workspaceId, section } = await params
const redirectTo = SETTINGS_REDIRECTS[section]
if (redirectTo) redirect(redirectTo(workspaceId))
const queryClient = getQueryClient()
void prefetchGeneralSettings(queryClient)
void prefetchUserProfile(queryClient)
if (isBillingEnabled) void prefetchSubscriptionData(queryClient)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={null}>
<SettingsPage section={section as SettingsSection} />
</Suspense>
</HydrationBoundary>
)
}
@@ -0,0 +1,78 @@
import type { QueryClient } from '@tanstack/react-query'
import { headers } from 'next/headers'
import { getSession } from '@/lib/auth'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { getUserProfile, getUserSettings } from '@/lib/users/queries'
import {
GENERAL_SETTINGS_STALE_TIME,
generalSettingsKeys,
mapGeneralSettingsResponse,
} from '@/hooks/queries/general-settings'
import { SUBSCRIPTION_DATA_STALE_TIME, subscriptionKeys } from '@/hooks/queries/subscription'
import {
mapUserProfileResponse,
USER_PROFILE_STALE_TIME,
userProfileKeys,
} from '@/hooks/queries/user-profile'
/**
* Prefetch general settings server-side via the shared data layer.
* Uses the same query keys as the client `useGeneralSettings` hook
* so data is shared via HydrationBoundary.
*/
export function prefetchGeneralSettings(queryClient: QueryClient) {
return queryClient.prefetchQuery({
queryKey: generalSettingsKeys.settings(),
queryFn: async () => {
const session = await getSession()
const data = await getUserSettings(session?.user?.id ?? null)
return mapGeneralSettingsResponse(data)
},
staleTime: GENERAL_SETTINGS_STALE_TIME,
})
}
/**
* Prefetch subscription data server-side. Unlike the other prefetches this goes
* through the internal billing API rather than calling the data layer directly:
* the billing summary contains `Date` fields (and an untyped `metadata` blob) that
* `NextResponse.json` serializes to the string wire shape the client caches. Going
* through the route yields that exact shape, avoiding a Date-vs-string mismatch
* between server-hydrated and client-fetched data. Uses the same query key as the
* client `useSubscriptionData` hook (with includeOrg=false) so data is shared via
* HydrationBoundary.
*/
export function prefetchSubscriptionData(queryClient: QueryClient) {
return queryClient.prefetchQuery({
queryKey: subscriptionKeys.user(false),
queryFn: async () => {
const h = await headers()
const cookie = h.get('cookie')
const response = await fetch(`${getInternalApiBaseUrl()}/api/billing?context=user`, {
headers: cookie ? { cookie } : {},
})
if (!response.ok) throw new Error(`Subscription prefetch failed: ${response.status}`)
return response.json()
},
staleTime: SUBSCRIPTION_DATA_STALE_TIME,
})
}
/**
* Prefetch user profile server-side via the shared data layer.
* Uses the same query keys as the client `useUserProfile` hook
* so data is shared via HydrationBoundary.
*/
export function prefetchUserProfile(queryClient: QueryClient) {
return queryClient.prefetchQuery({
queryKey: userProfileKeys.profile(),
queryFn: async () => {
const session = await getSession()
if (!session?.user?.id) throw new Error('Unauthorized')
const user = await getUserProfile(session.user.id)
if (!user) throw new Error('User not found')
return mapUserProfileResponse(user)
},
staleTime: USER_PROFILE_STALE_TIME,
})
}
@@ -0,0 +1,65 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
/**
* Co-located, typed URL query-param definitions for the settings section pages.
* The client hook consumes this typed param definition as the single source of
* truth.
*
* `mcpServerId` deep-links the MCP settings tab to a specific server so the row
* can be focused/opened from a shared link.
*/
export const mcpServerIdParam = {
key: 'mcpServerId',
parser: parseAsString,
} as const
/** Opening a server is a destination → push to history; clear on close. */
export const mcpServerIdUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const
/**
* `fork-id` deep-links the Forks settings tab to a specific fork's detail
* sub-view (mirrors `mcpServerId` on the MCP tab).
*/
export const forkIdParam = {
key: 'fork-id',
parser: parseAsString,
} as const
/** Opening a fork's detail is a destination → push to history; clear on close. */
export const forkIdUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const
/**
* `fork-view` deep-links the Forks settings tab to its workspace-scoped Activity
* view (opened from the page header's "See activity" action).
*/
export const forkViewParam = {
key: 'fork-view',
parser: parseAsStringLiteral(['activity'] as const),
} as const
/** Opening the activity view is a destination → push to history; clear on close. */
export const forkViewUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const
/**
* `fork-direction` is the sync direction (push/pull) on the parent fork's detail
* page — shareable view state, so a copied link opens the same side of the sync.
*/
export const forkSyncDirectionParam = {
key: 'fork-direction',
parser: parseAsStringLiteral(['push', 'pull'] as const).withDefault('push'),
} as const
/** Toggling direction is in-place view state → replace history; clear at the push default. */
export const forkSyncDirectionUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1,151 @@
'use client'
import { useEffect } from 'react'
import dynamic from 'next/dynamic'
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 { useSettingsBeforeUnload } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-before-unload'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
const Admin = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin)
)
const ApiKeys = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then(
(m) => m.ApiKeys
)
)
const BYOK = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK)
)
const Copilot = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then((m) => m.Copilot)
)
const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks))
const Secrets = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets)
)
const CustomTools = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then(
(m) => m.CustomTools
)
)
const Inbox = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox)
)
const MCP = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP)
)
const Mothership = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then(
(m) => m.Mothership
)
)
const RecentlyDeleted = dynamic(() =>
import(
'@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted'
).then((m) => m.RecentlyDeleted)
)
const Billing = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing)
)
const Teammates = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates').then(
(m) => m.Teammates
)
)
const TeamManagement = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then(
(m) => m.TeamManagement
)
)
const WorkflowMcpServers = dynamic(() =>
import(
'@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers'
).then((m) => m.WorkflowMcpServers)
)
const AccessControl = dynamic(() =>
import('@/ee/access-control/components/access-control').then((m) => m.AccessControl)
)
const CustomBlocks = dynamic(() =>
import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks)
)
const AuditLogs = dynamic(() =>
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
)
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO))
const DataRetentionSettings = dynamic(() =>
import('@/ee/data-retention/components/data-retention-settings').then(
(m) => m.DataRetentionSettings
)
)
const DataDrainsSettings = dynamic(() =>
import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
)
const WhitelabelingSettings = dynamic(
() =>
import('@/ee/whitelabeling/components/whitelabeling-settings').then(
(m) => m.WhitelabelingSettings
),
{ ssr: false }
)
interface SettingsPageProps {
section: SettingsSection
}
export function SettingsPage({ section }: SettingsPageProps) {
const { data: session, isPending: sessionLoading } = useSession()
const posthog = usePostHog()
useSettingsBeforeUnload()
const isAdminRole = session?.user?.role === 'admin'
const normalizedSection: SettingsSection =
(section as string) === 'subscription' ? 'billing' : section
const effectiveSection =
!isBillingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization')
? 'general'
: normalizedSection === 'admin' && !sessionLoading && !isAdminRole
? 'general'
: normalizedSection === 'mothership' && !sessionLoading && !isAdminRole
? 'general'
: normalizedSection
useEffect(() => {
if (sessionLoading) return
captureEvent(posthog, 'settings_tab_viewed', { section: effectiveSection })
}, [effectiveSection, sessionLoading, posthog])
return (
<SettingsSectionProvider section={effectiveSection}>
{effectiveSection === 'general' && <General />}
{effectiveSection === 'secrets' && <Secrets />}
{effectiveSection === 'access-control' && <AccessControl />}
{effectiveSection === 'custom-blocks' && <CustomBlocks />}
{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 === 'forks' && <Forks />}
{effectiveSection === 'custom-tools' && <CustomTools />}
{effectiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{effectiveSection === 'inbox' && <Inbox />}
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
{effectiveSection === 'admin' && <Admin />}
{effectiveSection === 'mothership' && <Mothership />}
</SettingsSectionProvider>
)
}
@@ -0,0 +1,247 @@
'use client'
import { useState } from 'react'
import {
Calendar,
Chip,
ChipCombobox,
ChipLink,
type ComboboxOption,
chipVariants,
cn,
Popover,
PopoverAnchor,
PopoverContent,
toast,
} from '@sim/emcn'
import { ArrowLeft, Download } from '@sim/emcn/icons'
import { formatDateTime } from '@sim/utils/formatting'
import { useQueryStates } from 'nuqs'
import type { UsageLogEntry, UsageLogPeriod } from '@/lib/api/contracts/user'
import { formatApportionedCreditCost, formatCreditsLabel } from '@/lib/billing/credits/conversion'
import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels'
import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { formatDateShort } from '@/app/workspace/[workspaceId]/logs/utils'
import {
creditUsageParsers,
creditUsageUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { useUsageLogs } from '@/hooks/queries/usage-logs'
const PERIOD_OPTIONS: ComboboxOption[] = [
{ value: '1d', label: 'Today' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: 'all', label: 'All time' },
{ value: 'custom', label: 'Custom range' },
]
/** Workflow-sourced rows name the specific workflow; everything else uses the plain source label. */
function rowLabel(log: UsageLogEntry): string {
if (log.source === 'workflow' && log.workflowName) return `Workflow: ${log.workflowName}`
return USAGE_LOG_SOURCE_LABELS[log.source]
}
interface UsageLogRowProps {
log: UsageLogEntry
}
function UsageLogRow({ log }: UsageLogRowProps) {
return (
<div className='flex items-center gap-2.5 rounded-lg p-2 text-left'>
<span className='w-[150px] flex-shrink-0 text-[var(--text-muted)] text-caption'>
{formatDateTime(new Date(log.createdAt))}
</span>
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>
{rowLabel(log)}
</span>
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption tabular-nums'>
{formatApportionedCreditCost(log.creditCost, log.dollarCost)}
</span>
</div>
)
}
interface CreditUsageViewProps {
workspaceId: string
}
export function CreditUsageView({ workspaceId }: CreditUsageViewProps) {
const billingHref = `/workspace/${workspaceId}/settings/billing`
const [{ period, startDate, endDate }, setFilters] = useQueryStates(
creditUsageParsers,
creditUsageUrlKeys
)
const [datePickerOpen, setDatePickerOpen] = useState(false)
const handlePeriodChange = (value: string) => {
if (value === 'custom') {
setDatePickerOpen(true)
return
}
setFilters({ period: value as UsageLogPeriod, startDate: null, endDate: null })
}
const handleDateRangeApply = (nextStart: string, nextEnd: string) => {
setFilters({ period: 'custom', startDate: nextStart, endDate: nextEnd })
setDatePickerOpen(false)
}
const handleDatePickerCancel = () => {
setDatePickerOpen(false)
}
/**
* Downloads a CSV of every log matching the current filter. Fetches rather
* than navigating a plain anchor to the export URL so the client can read
* the `X-Export-Truncated` response header and surface it — an anchor
* navigation has no way to inspect the response before the browser commits
* to the download.
*/
const handleExport = async () => {
const params = new URLSearchParams({ period })
if (period === 'custom') {
if (startDate) params.set('startDate', startDate)
if (endDate) params.set('endDate', endDate)
}
// boundary-raw-fetch: downloads a CSV blob and reads a response header before saving — a plain anchor navigation can't do either
const response = await fetch(`/api/users/me/usage-logs/export?${params.toString()}`)
if (!response.ok) {
toast.error('Failed to export usage logs')
return
}
if (response.headers.get('X-Export-Truncated') === '1') {
toast.info('Export truncated — narrow the date range to see everything')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `credit-usage-${period}-${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
const periodDisplayLabel =
period === 'custom' && startDate && endDate
? `${formatDateShort(startDate)} - ${formatDateShort(endDate)}`
: (PERIOD_OPTIONS.find((option) => option.value === period)?.label ?? 'Last 30 days')
const {
data,
isLoading,
isError,
isPlaceholderData,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useUsageLogs({
period,
startDate: period === 'custom' ? startDate || undefined : undefined,
endDate: period === 'custom' ? endDate || undefined : undefined,
})
const logs = data?.pages.flatMap((page) => page.logs) ?? []
const totalCredits = data?.pages[0]?.summary.totalCredits ?? 0
return (
<CredentialDetailLayout
back={
<ChipLink href={billingHref} leftIcon={ArrowLeft}>
Billing
</ChipLink>
}
actions={
<Chip
leftIcon={Download}
onClick={() => void handleExport()}
disabled={logs.length === 0 || isPlaceholderData}
>
Export
</Chip>
}
>
<div className='flex flex-col gap-1'>
<h1 className='font-medium text-[var(--text-body)] text-lg'>Credit usage</h1>
<p className='text-[var(--text-muted)] text-md'>
Every credit-consuming event behind your usage.
</p>
</div>
<div className='flex items-center justify-between'>
<span className='text-[var(--text-muted)] text-small'>
Total: {formatCreditsLabel(totalCredits)}
</span>
<div className='relative'>
<ChipCombobox
options={PERIOD_OPTIONS}
value={period}
onChange={handlePeriodChange}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{periodDisplayLabel}</span>
}
/>
<Popover
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) handleDatePickerCancel()
}}
>
<PopoverAnchor className='pointer-events-none absolute inset-0' />
<PopoverContent align='start' sideOffset={4} className='w-auto p-0'>
<Calendar
mode='range'
showTime
startDate={startDate ?? undefined}
endDate={endDate ?? undefined}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
</PopoverContent>
</Popover>
</div>
</div>
<div
className={cn(
'-mx-2 flex flex-col gap-y-0.5',
isPlaceholderData && 'opacity-50 transition-opacity'
)}
>
{isLoading ? (
<SettingsEmptyState variant='inline'>Loading usage</SettingsEmptyState>
) : isError ? (
<SettingsEmptyState variant='inline'>Couldn't load credit usage.</SettingsEmptyState>
) : logs.length === 0 ? (
<SettingsEmptyState variant='inline'>No credit usage in this period.</SettingsEmptyState>
) : (
<>
{logs.map((log) => (
<UsageLogRow key={log.id} log={log} />
))}
{hasNextPage && (
<button
type='button'
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
aria-label='Load more usage'
className={cn(
chipVariants({ fullWidth: true }),
'text-[var(--text-muted)] text-small'
)}
>
{isFetchingNextPage ? 'Loading' : 'Load more'}
</button>
)}
</>
)}
</div>
</CredentialDetailLayout>
)
}
@@ -0,0 +1,30 @@
'use client'
import { Chip } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components/credential-detail'
/**
* Route-level loading fallback (Next.js convention) and the `Suspense`
* fallback in `page.tsx` — `CreditUsageView` reads `useSearchParams` via
* nuqs, so it must suspend behind a boundary. Rendering the real chrome
* here means a suspend never flashes a blank frame.
*/
export default function CreditUsageLoading() {
return (
<CredentialDetailLayout
back={
<Chip leftIcon={ArrowLeft} disabled>
Billing
</Chip>
}
>
<div className='flex flex-col gap-1'>
<h1 className='font-medium text-[var(--text-body)] text-lg'>Credit usage</h1>
<p className='text-[var(--text-muted)] text-md'>
Every credit-consuming event behind your usage.
</p>
</div>
</CredentialDetailLayout>
)
}
@@ -0,0 +1,46 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { CreditUsageView } from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view'
import CreditUsageLoading from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/loading'
export const metadata: Metadata = {
title: 'Credit usage',
}
/**
* `CreditUsageView` reads URL query params via nuqs (which uses
* `useSearchParams` internally), so it must sit under a Suspense boundary.
* The fallback renders the real chrome so a suspend never shows a blank
* frame — `loading.tsx` covers the route-navigation transition the same way.
*
* Enterprise accounts manage billing out-of-band and never see this page —
* Billing settings already hides the link to it, but hiding a link doesn't
* stop direct navigation, so this redirects server-side before anything
* renders (matching `getHighestPrioritySubscription`'s use elsewhere for
* server-side plan checks).
*/
export default async function CreditUsagePage({
params,
}: {
params: Promise<{ workspaceId: string }>
}) {
const { workspaceId } = await params
const session = await getSession()
if (session?.user?.id) {
const subscription = await getHighestPrioritySubscription(session.user.id)
if (isEnterprise(subscription?.plan)) {
redirect(`/workspace/${workspaceId}/settings/billing`)
}
}
return (
<Suspense fallback={<CreditUsageLoading />}>
<CreditUsageView workspaceId={workspaceId} />
</Suspense>
)
}
@@ -0,0 +1,25 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
import { usageLogPeriodSchema } from '@/lib/api/contracts/user'
/**
* Co-located, typed URL query-param definitions for the Credit usage page.
*
* - `period` shares its literal values with {@link usageLogPeriodSchema} so
* the URL parser can never drift from the API contract it filters.
* - `startDate`/`endDate` are the applied custom range bounds, only
* meaningful when `period` is `'custom'`. No static default makes sense for
* them, so they're left nullable rather than defaulted to `''` — matching
* the equivalent fields in the main Logs page's search-params.ts.
*/
export const creditUsageParsers = {
period: parseAsStringLiteral(usageLogPeriodSchema.options).withDefault('30d'),
startDate: parseAsString,
endDate: parseAsString,
} as const
/** Filter view-state: clean URLs, no back-stack churn. */
export const creditUsageUrlKeys = {
history: 'replace',
shallow: true,
clearOnDefault: true,
} as const
@@ -0,0 +1,151 @@
'use client'
import { type ReactNode, useState } from 'react'
import { cn } from '@sim/emcn'
import { ChevronDown } from 'lucide-react'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
/**
* One row of an activity/audit log. `details`, when present, renders inside the
* expandable bordered box below the row; omit it to make the row non-expandable.
*/
export interface ActivityLogEntry {
id: string
timestamp: ReactNode
/** Leading badge conveying the action/status (typically a `Badge`). */
event: ReactNode
description: ReactNode
actor: ReactNode
details?: ReactNode
}
/**
* Event-column width presets, shared by the header and every row so the column
* stays aligned: `wide` fits the audit log's long action badges; `compact` suits
* short operation badges (Fork / Push / Rollback), returning the spare width to
* the flexible description column.
*/
const EVENT_COLUMN_WIDTH_CLASS = {
wide: 'w-[180px]',
compact: 'w-[90px]',
} as const
type EventColumnWidth = keyof typeof EVENT_COLUMN_WIDTH_CLASS
function ActivityLogRow({
entry,
eventColumn,
}: {
entry: ActivityLogEntry
eventColumn: EventColumnWidth
}) {
const [expanded, setExpanded] = useState(false)
const expandable = entry.details != null
return (
<div
className={cn(
'rounded-md transition-colors',
expandable && 'hover-hover:bg-[var(--surface-2)]',
expanded && 'bg-[var(--surface-2)]'
)}
>
<button
type='button'
className='flex w-full items-center gap-3 px-3 py-2 text-left'
onClick={() => expandable && setExpanded(!expanded)}
disabled={!expandable}
>
<span className='w-[160px] flex-shrink-0 text-[var(--text-secondary)] text-small'>
{entry.timestamp}
</span>
<span className={cn(EVENT_COLUMN_WIDTH_CLASS[eventColumn], 'flex-shrink-0')}>
{entry.event}
</span>
<span className='min-w-0 flex-1 text-[var(--text-primary)] text-small'>
{typeof entry.description === 'string' ? (
<FloatingOverflowText label={entry.description} className='block truncate' />
) : (
entry.description
)}
</span>
<span className='flex w-[160px] flex-shrink-0 items-center justify-end gap-1.5 text-[var(--text-secondary)] text-small'>
{typeof entry.actor === 'string' ? (
<FloatingOverflowText label={entry.actor} className='block min-w-0 truncate' />
) : (
<span className='min-w-0 truncate'>{entry.actor}</span>
)}
{expandable && (
<ChevronDown
className={cn(
'size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
expanded && 'rotate-180'
)}
/>
)}
</span>
</button>
{expandable && expanded && (
<div className='px-3 pb-2'>
<div className='flex flex-col gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-3)] p-3 text-small'>
{entry.details}
</div>
</div>
)}
</div>
)
}
export interface ActivityLogProps {
entries: ActivityLogEntry[]
/** Header label for the badge column. */
eventLabel?: string
/** Header label for the wide middle column. */
descriptionLabel?: string
/** Badge-column width preset; use `compact` when every badge is a short word. Defaults to `wide`. */
eventColumn?: EventColumnWidth
/** Rendered below the header when there are no entries (the header stays visible). */
emptyState?: ReactNode
/** Rendered after the rows (e.g. a "Load more" control). */
footer?: ReactNode
}
/**
* Canonical expandable activity/audit-log table: a four-column header
* (Timestamp / event / description / Actor) over rows that expand to a bordered
* detail box. Shared by the enterprise audit log and the fork Activity view so
* both read identically — callers own data fetching and map their rows to
* {@link ActivityLogEntry}.
*/
export function ActivityLog({
entries,
eventLabel = 'Event',
descriptionLabel = 'Description',
eventColumn = 'wide',
emptyState,
footer,
}: ActivityLogProps) {
return (
<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={cn(EVENT_COLUMN_WIDTH_CLASS[eventColumn], 'flex-shrink-0')}>
{eventLabel}
</span>
<span className='min-w-0 flex-1'>{descriptionLabel}</span>
<span className='w-[160px] flex-shrink-0 text-right'>Actor</span>
</div>
{entries.length === 0 ? (
emptyState
) : (
<div className='flex flex-col gap-0.5'>
{entries.map((entry) => (
<ActivityLogRow key={entry.id} entry={entry} eventColumn={eventColumn} />
))}
{footer}
</div>
)}
</div>
)
}
@@ -0,0 +1 @@
export { ActivityLog, type ActivityLogEntry, type ActivityLogProps } from './activity-log'
@@ -0,0 +1,441 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, ChipInput, ChipSelect, cn, Label, Search, Switch } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import type { MothershipEnvironment } from '@/lib/api/contracts'
import { useSession } from '@/lib/auth/auth-client'
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,
useImpersonateUser,
useSetUserRole,
useUnbanUser,
} from '@/hooks/queries/admin-users'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useImportWorkflow } from '@/hooks/queries/workflows'
const PAGE_SIZE = 20 as const
const MOTHERSHIP_ENV_OPTIONS: { value: MothershipEnvironment; label: string }[] = [
{ value: 'default', label: 'Default' },
{ value: 'dev', label: 'Dev' },
{ value: 'staging', label: 'Staging' },
{ value: 'prod', label: 'Prod' },
]
export function Admin() {
const params = useParams()
const workspaceId = params?.workspaceId as string
const { data: session } = useSession()
const { data: settings } = useGeneralSettings()
const updateSetting = useUpdateGeneralSetting()
const importWorkflow = useImportWorkflow()
const setUserRole = useSetUserRole()
const banUser = useBanUser()
const unbanUser = useUnbanUser()
const impersonateUser = useImpersonateUser()
const [workflowId, setWorkflowId] = useState('')
const [{ q: searchQuery, offset: usersOffset }, setAdminParams] = useQueryStates(
adminParsers,
adminUrlKeys
)
const [searchInput, setSearchInput] = useState(searchQuery)
const [banUserId, setBanUserId] = useState<string | null>(null)
const [banReason, setBanReason] = useState('')
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(null)
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
const {
data: usersData,
isLoading: usersLoading,
error: usersError,
} = useAdminUsers(usersOffset, PAGE_SIZE, searchQuery)
const handleSearch = () => {
const trimmed = searchInput.trim()
setAdminParams({ q: trimmed.length > 0 ? trimmed : null, offset: null })
}
const lastSyncedSearchRef = useRef(searchQuery)
useEffect(() => {
if (searchQuery === lastSyncedSearchRef.current) return
lastSyncedSearchRef.current = searchQuery
setSearchInput((current) => (current === searchQuery ? current : searchQuery))
}, [searchQuery])
const totalPages = Math.ceil((usersData?.total ?? 0) / PAGE_SIZE)
const currentPage = Math.floor(usersOffset / PAGE_SIZE) + 1
const handleSuperUserModeToggle = async (checked: boolean) => {
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
}
}
const handleMothershipEnvironmentChange = async (nextEnvironment: MothershipEnvironment) => {
if (nextEnvironment !== settings?.mothershipEnvironment && !updateSetting.isPending) {
await updateSetting.mutateAsync({
key: 'mothershipEnvironment',
value: nextEnvironment,
})
}
}
const handleImport = () => {
if (!workflowId.trim()) return
importWorkflow.mutate(
{ workflowId: workflowId.trim(), targetWorkspaceId: workspaceId },
{ onSuccess: () => setWorkflowId('') }
)
}
const handleImpersonate = (userId: string) => {
setImpersonationGuardError(null)
if (session?.user?.role !== 'admin') {
setImpersonatingUserId(null)
setImpersonationGuardError('Only admins can impersonate users.')
return
}
setImpersonatingUserId(userId)
impersonateUser.reset()
impersonateUser.mutate(
{ userId },
{
onError: () => {
setImpersonatingUserId(null)
},
onSuccess: () => {
window.location.assign('/workspace')
},
}
)
}
const pendingUserIds = useMemo(() => {
const ids = new Set<string>()
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
ids.add((setUserRole.variables as { userId: string }).userId)
if (banUser.isPending && (banUser.variables as { userId?: string })?.userId)
ids.add((banUser.variables as { userId: string }).userId)
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
ids.add((unbanUser.variables as { userId: string }).userId)
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
ids.add((impersonateUser.variables as { userId: string }).userId)
if (impersonatingUserId) ids.add(impersonatingUserId)
return ids
}, [
setUserRole.isPending,
setUserRole.variables,
banUser.isPending,
banUser.variables,
unbanUser.isPending,
unbanUser.variables,
impersonateUser.isPending,
impersonateUser.variables,
impersonatingUserId,
])
return (
<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}
options={MOTHERSHIP_ENV_OPTIONS}
/>
</div>
</>
)}
</div>
<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='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>
{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-[var(--text-error)] text-small'>
{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>
{usersData.users.length === 0 && (
<SettingsEmptyState variant='inline'>No users found.</SettingsEmptyState>
)}
{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>
)}
</span>
<span className='flex w-[250px] justify-end gap-1'>
{u.id !== session?.user?.id && (
<>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
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-caption'
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'
/>
<Button
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)}
>
Confirm Ban
</Button>
</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>
</SettingsPanel>
)
}
@@ -0,0 +1 @@
export { Admin } from './admin'
@@ -0,0 +1,21 @@
import { parseAsInteger, parseAsString } from 'nuqs/server'
/**
* Co-located, typed URL query-param definitions for the Admin user-management
* view.
*
* - `q` is the committed user search query. The visible input keeps its own
* local state; only the submitted query lands in the URL (search here is an
* explicit submit, not a per-keystroke debounce).
* - `offset` is the 0-based pagination offset into the admin user list.
*/
export const adminParsers = {
q: parseAsString.withDefault(''),
offset: parseAsInteger.withDefault(0),
} as const
/** Search/pagination view-state: clean URLs, no back-stack churn. */
export const adminUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1,372 @@
'use client'
import { useMemo, useState } from 'react'
import { ChipConfirmModal, Switch, Tooltip, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Info, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
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 type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
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,
useApiKeys,
useDeleteApiKey,
useUpdateWorkspaceApiKeySettings,
} from '@/hooks/queries/api-keys'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { CreateApiKeyModal } from './components'
const logger = createLogger('ApiKeys')
/** Stable empty references so memoized derivations don't re-run while data loads. */
const EMPTY_KEYS: ApiKey[] = []
const EMPTY_KEY_NAMES: string[] = []
/** Copies an API key's name and confirms with a toast. */
function copyKeyName(name: string) {
void navigator.clipboard.writeText(name)
toast.success('Copied name to clipboard')
}
/** Formats an API key's last-used timestamp, or "Never" when unused. */
function formatLastUsed(dateString?: string | null): string {
if (!dateString) return 'Never'
return formatDate(new Date(dateString))
}
interface ApiKeyRowMenuProps {
keyName: string
onDelete: () => void
/** When false, the Delete item is disabled (e.g. non-admins on workspace keys). */
canDelete?: boolean
}
/**
* Trailing `...` actions menu for an API key row. Mirrors the Secrets /
* Teammates row menu so the settings experience is consistent.
*/
function ApiKeyRowMenu({ keyName, onDelete, canDelete = true }: ApiKeyRowMenuProps) {
return (
<div className='flex-shrink-0'>
<RowActionsMenu
label='API key actions'
actions={[
{ label: 'Copy name', onSelect: () => copyKeyName(keyName) },
{ label: 'Delete', destructive: true, disabled: !canDelete, onSelect: onDelete },
]}
/>
</div>
)
}
export function ApiKeys() {
const { data: session } = useSession()
const userId = session?.user?.id
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const {
data: apiKeysData,
isLoading: isLoadingKeys,
refetch: refetchApiKeys,
} = useApiKeys(workspaceId)
const { data: workspaceSettingsData, isLoading: isLoadingSettings } =
useWorkspaceSettings(workspaceId)
const deleteApiKeyMutation = useDeleteApiKey()
const updateSettingsMutation = useUpdateWorkspaceApiKeySettings()
const workspaceKeys = apiKeysData?.workspaceKeys ?? EMPTY_KEYS
const personalKeys = apiKeysData?.personalKeys ?? EMPTY_KEYS
const conflicts = apiKeysData?.conflicts ?? EMPTY_KEY_NAMES
const isLoading = isLoadingKeys || isLoadingSettings
const allowPersonalApiKeys =
workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [deleteKey, setDeleteKey] = useState<ApiKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
const createButtonDisabled = isLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
const filteredWorkspaceKeys = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
const result: { key: ApiKey; originalIndex: number }[] = []
for (let index = 0; index < workspaceKeys.length; index++) {
const key = workspaceKeys[index]
if (term === '' || key.name.toLowerCase().includes(term)) {
result.push({ key, originalIndex: index })
}
}
return result
}, [workspaceKeys, searchTerm])
const filteredPersonalKeys = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
const result: { key: ApiKey; originalIndex: number }[] = []
for (let index = 0; index < personalKeys.length; index++) {
const key = personalKeys[index]
if (term === '' || key.name.toLowerCase().includes(term)) {
result.push({ key, originalIndex: index })
}
}
return result
}, [personalKeys, searchTerm])
const handleDeleteKey = async () => {
if (!userId || !deleteKey) return
try {
const isWorkspaceKey = workspaceKeys.some((k) => k.id === deleteKey.id)
const keyTypeToDelete = isWorkspaceKey ? 'workspace' : 'personal'
setShowDeleteDialog(false)
setDeleteKey(null)
await deleteApiKeyMutation.mutateAsync({
workspaceId,
keyId: deleteKey.id,
keyType: keyTypeToDelete,
})
} catch (error) {
logger.error('Error deleting API key:', { error })
refetchApiKeys()
}
}
const actions: SettingsAction[] = [
{
text: 'Create API key',
icon: Plus,
variant: 'primary',
onSelect: () => {
if (createButtonDisabled) return
setIsCreateDialogOpen(true)
},
disabled: createButtonDisabled,
},
]
return (
<>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search API keys...',
}}
actions={actions}
>
{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'>
{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-[var(--text-body)] text-sm'>
{key.name}
</span>
<span className='text-[var(--text-secondary)] text-sm'>
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[var(--text-muted)] text-caption'>
{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'>
<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-[var(--text-body)] text-sm'>
{key.name}
</span>
<span className='text-[var(--text-secondary)] text-sm'>
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[var(--text-muted)] text-caption'>
{key.displayKey || key.key}
</p>
</div>
<ApiKeyRowMenu
keyName={key.name}
onDelete={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
canDelete={canManageWorkspaceKeys}
/>
</div>
))}
</div>
</SettingsSection>
) : 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-[var(--text-body)] text-sm'>
{key.name}
</span>
<span className='text-[var(--text-secondary)] text-sm'>
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[var(--text-muted)] text-caption'>
{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-[var(--text-body)] text-sm'>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>
<CreateApiKeyModal
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
workspaceId={workspaceId}
existingKeyNames={[...workspaceKeys, ...personalKeys].map((k) => k.name)}
allowPersonalApiKeys={allowPersonalApiKeys}
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
/>
<ChipConfirmModal
open={showDeleteDialog}
onOpenChange={(open) => {
if (!open) {
setShowDeleteDialog(false)
setDeleteKey(null)
}
}}
srTitle='Delete API key'
title='Delete API key'
text={[
'Deleting ',
{ text: deleteKey?.name ?? 'this key', bold: true },
' ',
{ text: 'will immediately revoke access for any integrations using it.', error: true },
' This action cannot be undone.',
]}
confirm={{
label: 'Delete',
onClick: handleDeleteKey,
pending: deleteApiKeyMutation.isPending,
pendingLabel: 'Deleting...',
}}
/>
</>
)
}
@@ -0,0 +1,197 @@
'use client'
import { useState } from 'react'
import {
ButtonGroup,
ButtonGroupItem,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
SecretReveal,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type ApiKey, useCreateApiKey } from '@/hooks/queries/api-keys'
const logger = createLogger('CreateApiKeyModal')
interface CreateApiKeyModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
existingKeyNames?: string[]
allowPersonalApiKeys?: boolean
canManageWorkspaceKeys?: boolean
defaultKeyType?: 'personal' | 'workspace'
source?: 'settings' | 'deploy_modal'
onKeyCreated?: (key: ApiKey) => void
}
const EMPTY_KEY_NAMES: string[] = []
/**
* Reusable modal for creating API keys.
* Used in both the API keys settings page and the deploy modal.
*/
export function CreateApiKeyModal({
open,
onOpenChange,
workspaceId,
existingKeyNames = EMPTY_KEY_NAMES,
allowPersonalApiKeys = true,
canManageWorkspaceKeys = false,
defaultKeyType = 'personal',
source = 'settings',
onKeyCreated,
}: CreateApiKeyModalProps) {
const [keyName, setKeyName] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>(defaultKeyType)
const [createError, setCreateError] = useState<string | null>(null)
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const createApiKeyMutation = useCreateApiKey()
const handleCreateKey = async () => {
const trimmedName = keyName.trim()
if (!trimmedName) return
const isDuplicate = existingKeyNames.some(
(name) => name.toLowerCase() === trimmedName.toLowerCase()
)
if (isDuplicate) {
setCreateError(
keyType === 'workspace'
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`
)
return
}
setCreateError(null)
try {
const data = await createApiKeyMutation.mutateAsync({
workspaceId,
name: trimmedName,
keyType,
source,
})
setNewKey(data.key)
setShowNewKeyDialog(true)
setKeyName('')
setKeyType(defaultKeyType)
setCreateError(null)
onOpenChange(false)
onKeyCreated?.(data.key)
} catch (error: unknown) {
logger.error('API key creation failed:', { error })
const errorMessage = getErrorMessage(error, 'Failed to create API key. Please try again.')
if (errorMessage.toLowerCase().includes('already exists')) {
setCreateError(errorMessage)
} else {
setCreateError('Failed to create API key. Please check your connection and try again.')
}
}
}
const handleClose = () => {
onOpenChange(false)
setKeyName('')
setKeyType(defaultKeyType)
setCreateError(null)
}
return (
<>
{/* Create API Key Dialog */}
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Create new API key'>
<ChipModalHeader onClose={handleClose}>Create new API key</ChipModalHeader>
<ChipModalBody>
{canManageWorkspaceKeys && (
<ChipModalField type='custom' title='API Key Type'>
<ButtonGroup
value={keyType}
onValueChange={(value) => {
setKeyType(value as 'personal' | 'workspace')
if (createError) setCreateError(null)
}}
>
<ButtonGroupItem value='personal' disabled={!allowPersonalApiKeys}>
Personal
</ButtonGroupItem>
<ButtonGroupItem value='workspace'>Workspace</ButtonGroupItem>
</ButtonGroup>
</ChipModalField>
)}
<ChipModalField
type='input'
title='Enter a name for your API key to help you identify it later.'
value={keyName}
onChange={(value) => {
setKeyName(value)
if (createError) setCreateError(null)
}}
placeholder='e.g., Development, Production'
autoComplete='off'
required
/>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
aria-hidden='true'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<ChipModalError>{createError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
primaryAction={{
label: createApiKeyMutation.isPending ? 'Creating...' : 'Create',
onClick: handleCreateKey,
disabled:
!keyName.trim() ||
createApiKeyMutation.isPending ||
(keyType === 'workspace' && !canManageWorkspaceKeys),
}}
/>
</ChipModal>
{/* New API Key Dialog - shows the created key */}
<ChipModal
open={showNewKeyDialog}
onOpenChange={(dialogOpen: boolean) => {
setShowNewKeyDialog(dialogOpen)
if (!dialogOpen) {
setNewKey(null)
}
}}
srTitle='Your API key has been created'
>
<ChipModalHeader onClose={() => setShowNewKeyDialog(false)}>
Your API key has been created
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='custom' title="Copy it now — it won't be shown again">
{newKey && <SecretReveal value={newKey.key} />}
</ChipModalField>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setShowNewKeyDialog(false)}
primaryAction={{ label: 'Done', onClick: () => setShowNewKeyDialog(false) }}
/>
</ChipModal>
</>
)
}
@@ -0,0 +1 @@
export { CreateApiKeyModal } from './create-api-key-modal'
@@ -0,0 +1 @@
export { CreateApiKeyModal } from './create-api-key-modal'
@@ -0,0 +1 @@
export { ApiKeys } from './api-keys'
@@ -0,0 +1,640 @@
'use client'
import {
ArrowRight,
Badge,
Chip,
ChipLink,
Credit,
chipVariants,
cn,
Switch,
Tooltip,
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import { useSession, useSubscription } from '@/lib/auth/auth-client'
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
import {
getCoveredUsage,
getIsOnDemandActive,
getOnDemandOffLimit,
isOnDemandOffDisabled,
} from '@/lib/billing/on-demand'
import {
getDisplayPlanName,
getPlanTierCredits,
getPlanTierDollars,
isEnterprise,
isFree,
isPaid,
isPro,
isTeam,
} from '@/lib/billing/plan-helpers'
import {
getEffectiveSeats,
hasPaidSubscriptionStatus,
hasUsableSubscriptionAccess,
} from '@/lib/billing/subscriptions/utils'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section'
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,
useUpdateGeneralSetting,
} from '@/hooks/queries/general-settings'
import {
useOrganizationBilling,
useOrganizations,
useUpdateOrganizationUsageLimit,
} from '@/hooks/queries/organization'
import {
prefetchUpgradeBillingData,
useInvoices,
useOpenBillingPortal,
useSubscriptionData,
useUpdateUsageLimit,
useUsageLimitData,
} from '@/hooks/queries/subscription'
import { prefetchWorkspaceSettings, useWorkspaceSettings } from '@/hooks/queries/workspace'
const logger = createLogger('Billing')
type InvoiceStatusBadge = { variant: 'green' | 'amber' | 'red' | 'gray'; label: string }
const INVOICE_STATUS_BADGES: Record<string, InvoiceStatusBadge> = {
paid: { variant: 'green', label: 'Paid' },
open: { variant: 'amber', label: 'Open' },
uncollectible: { variant: 'red', label: 'Uncollectible' },
void: { variant: 'gray', label: 'Void' },
}
/** Resolve a Stripe invoice status to its badge presentation. */
function getInvoiceStatusBadge(status: string | null): InvoiceStatusBadge {
return INVOICE_STATUS_BADGES[status ?? ''] ?? { variant: 'gray', label: status ?? 'Unknown' }
}
/** Cached currency formatters, keyed by upper-cased ISO currency code. */
const invoiceAmountFormatters = new Map<string, Intl.NumberFormat>()
/** Resolve (and memoize) an `Intl.NumberFormat` for a currency code. */
function getInvoiceAmountFormatter(currency: string): Intl.NumberFormat {
const code = currency.toUpperCase()
let formatter = invoiceAmountFormatters.get(code)
if (!formatter) {
formatter = new Intl.NumberFormat(undefined, { style: 'currency', currency: code })
invoiceAmountFormatters.set(code, formatter)
}
return formatter
}
/** Format a minor-unit (e.g. cents) amount as a localized currency string. */
function formatInvoiceAmount(amountMinor: number, currency: string): string {
return getInvoiceAmountFormatter(currency).format(amountMinor / 100)
}
export function Billing() {
const { workspaceId } = useParams<{ workspaceId: string }>()
const router = useRouter()
const queryClient = useQueryClient()
const {
data: subscriptionData,
isLoading: isSubscriptionLoading,
refetch: refetchSubscription,
} = useSubscriptionData({
includeOrg: true,
})
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
const { data: orgsData } = useOrganizations()
const activeOrgId = orgsData?.activeOrganization?.id
const workspaceOrganizationId = workspaceData?.settings?.workspace?.organizationId ?? null
const billingOrganizationId =
workspaceOrganizationId ?? subscriptionData?.data?.organization?.id ?? activeOrgId ?? null
const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
billingOrganizationId || ''
)
const updateUserLimit = useUpdateUsageLimit()
const updateOrgLimit = useUpdateOrganizationUsageLimit()
const billingUsageNotificationsEnabled = useBillingUsageNotifications()
const updateGeneralSetting = useUpdateGeneralSetting()
const { data: session } = useSession()
const betterAuthSubscription = useSubscription()
const openBillingPortal = useOpenBillingPortal()
const upgradeHref = buildUpgradeHref(workspaceId)
/**
* Warm the Upgrade route bundle and the exact queries that page gates on, so
* the click navigates into already-cached data instead of a loading state.
*/
const prefetchUpgrade = () => {
router.prefetch(upgradeHref)
prefetchUpgradeBillingData(queryClient)
prefetchWorkspaceSettings(queryClient, workspaceId)
}
const hasOrgScopedSubscription = Boolean(subscriptionData?.data?.isOrgScoped)
const isLoading =
isSubscriptionLoading ||
isUsageLimitLoading ||
isWorkspaceLoading ||
(hasOrgScopedSubscription && isOrgBillingLoading)
const subscription = {
isFree: isFree(subscriptionData?.data?.plan),
isPro: isPro(subscriptionData?.data?.plan),
isTeam: isTeam(subscriptionData?.data?.plan),
isEnterprise: isEnterprise(subscriptionData?.data?.plan),
isPaid:
isPaid(subscriptionData?.data?.plan) &&
hasPaidSubscriptionStatus(subscriptionData?.data?.status),
/**
* True when the subscription is attached to an org (regardless of plan
* name). Drives routing of usage-limit edits and whether we show pooled
* or personal usage.
*/
isOrgScoped: Boolean(subscriptionData?.data?.isOrgScoped),
plan: subscriptionData?.data?.plan || 'free',
status: subscriptionData?.data?.status || 'inactive',
seats: getEffectiveSeats(subscriptionData?.data),
}
const usage = {
current: subscriptionData?.data?.usage?.current || 0,
limit: subscriptionData?.data?.usage?.limit || 0,
percentUsed: subscriptionData?.data?.usage?.percentUsed || 0,
}
const usageLimitData = {
currentLimit: usageLimitResponse?.data?.currentLimit || 0,
minimumLimit: usageLimitResponse?.data?.minimumLimit || getPlanTierDollars(subscription.plan),
}
const isBlocked = Boolean(subscriptionData?.data?.billingBlocked)
const userRole = subscriptionData?.data?.organization?.role ?? 'member'
const isTeamAdmin = isOrgAdminRole(userRole)
const shouldUseOrganizationBillingContext = subscription.isOrgScoped && isTeamAdmin
const { data: invoicesData } = useInvoices({
context: shouldUseOrganizationBillingContext ? 'organization' : 'user',
organizationId: shouldUseOrganizationBillingContext
? (billingOrganizationId ?? undefined)
: undefined,
enabled: !subscription.isFree,
})
const planIncludedAmount =
subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: getPlanTierCredits(subscription.plan) / CREDIT_MULTIPLIER
const effectiveUsageLimit =
subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
const effectiveCurrentUsage =
subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null
? organizationBillingData.data.totalCurrentUsage
: usage.current
/**
* Goodwill credits are already baked into the usage limit by
* `setUsageLimitForCredits` (limit = planBase + creditBalance). `covered` is
* that same never-billed ceiling, so on-demand is "on" only when the limit is
* raised above it — a credit grant alone must not read as on-demand.
* `creditBalance` is the org's balance for org-scoped admins (resolved
* server-side by `getCreditBalance`) and the user's balance otherwise.
*/
const creditBalance = subscriptionData?.data?.creditBalance ?? 0
const covered = getCoveredUsage(planIncludedAmount, creditBalance)
const isOnDemandActive = getIsOnDemandActive({
isPaid: subscription.isPaid,
planIncludedAmount,
effectiveUsageLimit,
covered,
})
/**
* When usage already sits above `covered`, turning on-demand off would re-cap
* the limit at current usage and the switch would bounce straight back on
* (see `getOnDemandOffLimit`). Disable it and explain why via tooltip instead
* of accepting a no-op click; it re-enables once usage drops back to/below
* covered (e.g. the next billing reset).
*/
const onDemandLockedOn = isOnDemandOffDisabled({
isOnDemandActive,
effectiveCurrentUsage,
covered,
})
const permissions = getSubscriptionPermissions(
{
isFree: subscription.isFree,
isPro: subscription.isPro,
isTeam: subscription.isTeam,
isEnterprise: subscription.isEnterprise,
isPaid: subscription.isPaid,
isOrgScoped: subscription.isOrgScoped,
plan: subscription.plan || 'free',
status: subscription.status || 'inactive',
},
{ isTeamAdmin, userRole: userRole || 'member' }
)
const hasUsablePaidAccess = subscription.isPaid
? hasUsableSubscriptionAccess(subscription.status, isBlocked)
: false
const isTogglingOnDemand = updateUserLimit.isPending || updateOrgLimit.isPending
const handleToggleOnDemand = async () => {
if (!permissions.canEditUsageLimit) {
toast.error("Can't change on-demand usage", {
description: 'Only organization admins can change on-demand usage.',
})
return
}
try {
if (shouldUseOrganizationBillingContext && !billingOrganizationId) {
throw new Error(
'Organization billing context is unavailable. Please refresh and try again.'
)
}
const nextLimit = isOnDemandActive
? getOnDemandOffLimit(effectiveCurrentUsage, covered)
: ON_DEMAND_UNLIMITED
if (shouldUseOrganizationBillingContext) {
await updateOrgLimit.mutateAsync({
organizationId: billingOrganizationId!,
limit: nextLimit,
})
} else {
await updateUserLimit.mutateAsync({ limit: nextLimit })
}
} catch (error) {
logger.error('Failed to toggle on-demand billing', { error })
toast.error("Couldn't update on-demand usage", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
}
}
const handleOpenBillingPortal = () => {
if (!permissions.canEditUsageLimit) {
toast.error("Can't manage payment method", {
description: 'Only organization admins can manage billing.',
})
return
}
const portalWindow = window.open('', '_blank')
const context = subscription.isOrgScoped ? 'organization' : 'user'
if (context === 'organization' && !billingOrganizationId) {
portalWindow?.close()
toast.error('Billing unavailable', {
description: 'Organization billing context is unavailable. Please refresh and try again.',
})
return
}
openBillingPortal.mutate(
{
context,
organizationId: billingOrganizationId ?? undefined,
returnUrl: window.location.href,
},
{
onSuccess: (data) => {
if (portalWindow) portalWindow.location.href = data.url
else window.location.href = data.url
},
onError: (error) => {
portalWindow?.close()
logger.error('Failed to open billing portal', { error })
toast.error("Couldn't open billing portal", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
},
}
)
}
const handleCancelSubscription = async () => {
if (!permissions.canEditUsageLimit) {
toast.error("Can't cancel subscription", {
description: 'Only organization admins can cancel the subscription.',
})
return
}
if (!betterAuthSubscription.cancel) return
try {
if (subscription.isOrgScoped && !billingOrganizationId) {
throw new Error(
'Organization billing context is unavailable. Please refresh and try again.'
)
}
const referenceId = subscription.isOrgScoped ? billingOrganizationId : session?.user?.id
const returnUrl = getBaseUrl() + window.location.pathname
await betterAuthSubscription.cancel({ returnUrl, referenceId: referenceId || '' })
} catch (error) {
logger.error('Failed to cancel subscription', { error })
toast.error("Couldn't cancel subscription", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
}
}
const handleRestoreSubscription = async () => {
if (!permissions.canEditUsageLimit) {
toast.error("Can't restore subscription", {
description: 'Only organization admins can restore the subscription.',
})
return
}
if (!betterAuthSubscription.restore) return
try {
if (subscription.isOrgScoped && !billingOrganizationId) {
throw new Error(
'Organization billing context is unavailable. Please refresh and try again.'
)
}
const referenceId = subscription.isOrgScoped ? billingOrganizationId : session?.user?.id
await betterAuthSubscription.restore({ referenceId: referenceId || '' })
await refetchSubscription()
} catch (error) {
logger.error('Failed to restore subscription', { error })
toast.error("Couldn't restore subscription", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
}
}
if (isLoading) return null
if (!subscriptionData?.data) return null
const plan = subscription.plan
const planName = getDisplayPlanName(plan)
const billingPeriod =
subscriptionData.data.billingInterval === 'year' ? 'billed annually' : 'billed monthly'
const priceText = subscription.isEnterprise
? 'Custom pricing'
: `$${getPlanTierDollars(plan)} per user/month, ${billingPeriod}`
const periodEnd = subscriptionData.data.periodEnd ?? null
const isCancelledAtPeriodEnd = subscriptionData.data.cancelAtPeriodEnd === true
const invoices = (invoicesData?.invoices ?? []).map((invoice) => ({
id: invoice.id,
date: formatDate(new Date(invoice.created * 1000)),
amount: formatInvoiceAmount(invoice.total, invoice.currency),
badge: getInvoiceStatusBadge(invoice.status),
url: invoice.hostedInvoiceUrl ?? invoice.invoicePdf,
}))
const canManageBilling = permissions.canEditUsageLimit
const showUsageLimit = !subscription.isFree && !subscription.isEnterprise
const showOnDemand = hasUsablePaidAccess && !subscription.isEnterprise
const usageLimitCurrent =
subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
const usageLimitMinimum =
subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: usageLimitData.minimumLimit
return (
<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>
</div>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[var(--text-body)] text-sm'>{planName} plan</span>
<span className='truncate text-[var(--text-muted)] text-caption'>{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>
{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>
{onDemandLockedOn ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Switch checked disabled onCheckedChange={handleToggleOnDemand} />
</span>
</Tooltip.Trigger>
<Tooltip.Content className='max-w-[260px]'>
<p>
{
"Your usage is above your plan's included amount, so on-demand can't be turned off yet. It turns off once usage drops below it — at the latest when your billing period resets."
}
</p>
</Tooltip.Content>
</Tooltip.Root>
) : (
<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-[var(--text-body)] text-sm'>
{invoice.date}
</span>
<Badge variant={invoice.badge.variant} size='sm'>
{invoice.badge.label}
</Badge>
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
{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>
)}
{!subscription.isEnterprise && <CreditUsageSection workspaceId={workspaceId} />}
</SettingsPanel>
)
}
@@ -0,0 +1,38 @@
'use client'
import { ChipLink } from '@sim/emcn'
import { formatCreditsLabel } from '@/lib/billing/credits/conversion'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useUsageSummary } from '@/hooks/queries/usage-logs'
/** Period the compact Billing glance summarizes; the full page offers finer control. */
const SUMMARY_PERIOD = '30d'
interface CreditUsageSectionProps {
workspaceId: string
}
/**
* Compact "how much have I used" glance in Billing settings — a single total
* plus a link to the full, filterable Credit usage page. Shown to every plan
* except Enterprise, which manages billing out-of-band.
*/
export function CreditUsageSection({ workspaceId }: CreditUsageSectionProps) {
const { data: totalCredits, isPending, isError } = useUsageSummary(SUMMARY_PERIOD)
return (
<SettingsSection label='Credit usage'>
<div className='flex items-center justify-between px-2'>
<div className='flex flex-col justify-center gap-[1px]'>
<span className='text-[var(--text-body)] text-sm tabular-nums'>
{isPending || isError ? '—' : formatCreditsLabel(totalCredits ?? 0)}
</span>
<span className='text-[var(--text-muted)] text-caption'>Last 30 days</span>
</div>
<ChipLink href={`/workspace/${workspaceId}/settings/billing/credit-usage`}>
View usage logs
</ChipLink>
</div>
</SettingsSection>
)
}
@@ -0,0 +1,142 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChipInput, Info, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useUpdateOrganizationUsageLimit } from '@/hooks/queries/organization'
import { useUpdateUsageLimit } from '@/hooks/queries/subscription'
import { useDebounce } from '@/hooks/use-debounce'
/** Delay before a usage-limit edit is auto-saved once the user stops typing. */
const AUTOSAVE_DELAY_MS = 1000
/** Static help accessory for the usage-limit header; hoisted so it's a stable reference. */
const USAGE_LIMIT_INFO = (
<Info side='top' className='text-[var(--text-muted)]'>
{
"Max usage to consume per month, set in credits — Sim's usage unit (1,000 credits = $5). By default, it's your plan's included usage, but you can set it beyond."
}
</Info>
)
interface UsageLimitFieldProps {
/** Current monthly usage limit, in dollars. */
currentLimit: number
/** Lowest limit the plan allows, in dollars. */
minimumLimit: number
/** Whether the viewer may edit the limit (org admins / solo paid users). */
canEdit: boolean
/** Routes the save to the user or organization mutation. */
context: 'user' | 'organization'
/** Required when {@link context} is `'organization'`. */
organizationId?: string
}
/**
* Editable monthly usage-limit field. Seeds from the resolved limit and
* auto-saves a debounced, validated value to either the user or the
* organization — matching the Subscription tab's edit logic. When the viewer
* cannot edit (e.g. a non-admin team member) the resolved value is shown
* read-only.
*/
export function UsageLimitField({
currentLimit,
minimumLimit,
canEdit,
context,
organizationId,
}: UsageLimitFieldProps) {
const { mutate: saveUserLimit } = useUpdateUsageLimit()
const { mutate: saveOrgLimit } = useUpdateOrganizationUsageLimit()
const [draft, setDraft] = useState('')
const debouncedDraft = useDebounce(draft, AUTOSAVE_DELAY_MS)
const syncedRef = useRef<number | null>(null)
/**
* Read the latest limit inside the auto-save effect WITHOUT making it a
* dependency. If `currentLimit` were a dep, an external change (e.g. the
* on-demand toggle optimistically bumping the limit) would re-run the effect
* with a stale `debouncedDraft` and save the old value, clobbering the toggle.
*/
const currentLimitRef = useRef(currentLimit)
currentLimitRef.current = currentLimit
useEffect(() => {
if (currentLimit == null || syncedRef.current === currentLimit) return
// Display in credits; the prop is dollars. Integer credits round-trip exactly
// through creditsToDollars/dollarsToCredits, so the value never drifts. The
// on-demand "uncapped" sentinel renders as a blank field (No Usage Limit
// placeholder) rather than a meaningless giant credit number.
const lastSyncedDraft =
syncedRef.current == null || syncedRef.current >= ON_DEMAND_UNLIMITED
? ''
: String(dollarsToCredits(syncedRef.current))
const isClean = draft === '' || draft === lastSyncedDraft
syncedRef.current = currentLimit
if (isClean) {
setDraft(currentLimit >= ON_DEMAND_UNLIMITED ? '' : String(dollarsToCredits(currentLimit)))
}
}, [currentLimit, draft])
useEffect(() => {
if (!canEdit) return
const currentLimit = currentLimitRef.current
if (currentLimit == null || debouncedDraft.trim() === '') return
const parsedCredits = Number.parseFloat(debouncedDraft)
if (Number.isNaN(parsedCredits)) {
toast.error('Usage limit must be a number')
return
}
if (parsedCredits === dollarsToCredits(currentLimit)) return
const minimumCredits = dollarsToCredits(minimumLimit)
if (parsedCredits < minimumCredits) {
toast.error(`Usage limit must be at least ${minimumCredits.toLocaleString()} credits`)
return
}
// Store dollars; the input is credits. Convert once at the boundary.
const limitDollars = creditsToDollars(parsedCredits)
const onError = (error: unknown) => {
toast.error("Couldn't update usage limit", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
}
if (context === 'organization') {
if (!organizationId) {
toast.error("Couldn't update usage limit", {
description: 'Organization billing context is unavailable. Please refresh and try again.',
})
return
}
saveOrgLimit({ organizationId, limit: limitDollars }, { onError })
return
}
saveUserLimit({ limit: limitDollars }, { onError })
}, [debouncedDraft, minimumLimit, canEdit, context, organizationId, saveOrgLimit, saveUserLimit])
return (
<SettingsSection label='Usage limit' headerAccessory={USAGE_LIMIT_INFO}>
<ChipInput
type='number'
inputMode='numeric'
min={dollarsToCredits(minimumLimit)}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={
currentLimit == null
? 'Enter monthly usage limit'
: currentLimit >= ON_DEMAND_UNLIMITED
? 'No Usage Limit'
: String(dollarsToCredits(currentLimit))
}
disabled={!canEdit}
inputClassName='[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
/>
</SettingsSection>
)
}
@@ -0,0 +1,63 @@
export interface SubscriptionPermissions {
canUpgradeToPro: boolean
canUpgradeToTeam: boolean
canViewEnterprise: boolean
canManageTeam: boolean
canEditUsageLimit: boolean
canCancelSubscription: boolean
showTeamMemberView: boolean
showUpgradePlans: boolean
isEnterpriseMember: boolean
canViewUsageInfo: boolean
}
export interface SubscriptionState {
isFree: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isPaid: boolean
/**
* True when the subscription's `referenceId` is an organization. Source
* of truth for scope-based decisions — `pro_*` plans that have been
* transferred to an org are org-scoped even though `isTeam` is false.
*/
isOrgScoped: boolean
plan: string
status: string
}
export interface UserRole {
isTeamAdmin: boolean
userRole: string
}
export function getSubscriptionPermissions(
subscription: SubscriptionState,
userRole: UserRole
): SubscriptionPermissions {
const { isFree, isPro, isEnterprise, isPaid, isOrgScoped } = subscription
const { isTeamAdmin } = userRole
// Non-admin org members see the "team member" view: no edit / no cancel
// / no upgrade, pooled usage display.
const orgMemberOnly = isOrgScoped && !isTeamAdmin
const orgAdminOrSolo = !isOrgScoped || isTeamAdmin
const isEnterpriseMember = isEnterprise && !isTeamAdmin
const canViewUsageInfo = !isEnterpriseMember
return {
canUpgradeToPro: isFree,
canUpgradeToTeam: isFree || (isPro && !isOrgScoped),
canViewEnterprise: !isEnterprise && !orgMemberOnly,
canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise,
canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo,
canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo,
showTeamMemberView: orgMemberOnly,
showUpgradePlans:
(isFree || (isPro && !isOrgScoped) || (isOrgScoped && isTeamAdmin)) && !isEnterprise,
isEnterpriseMember,
canViewUsageInfo,
}
}
@@ -0,0 +1,474 @@
'use client'
import { useMemo, useState } from 'react'
import {
Button,
Chip,
ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
cn,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Eye, EyeOff, Search } from 'lucide-react'
import {
CHIP_FIELD_INPUT,
CHIP_FIELD_SHELL,
} 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 { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
const logger = createLogger('BYOKKeyManager')
export interface BYOKManagerProvider {
id: string
name: string
icon: React.ComponentType<{ className?: string }>
description: string
placeholder: string
}
/** A stored key as rendered by the manager in multi-key mode. */
export interface BYOKManagerKey {
id: string
name: string | null
maskedKey: string
}
/**
* Optional provider grouping. Each provider id should belong to exactly one
* section; rows keep their {@link BYOKKeyManagerBaseProps.providers} order
* within a group. When omitted, providers render as a single flat list.
*/
export interface BYOKProviderSection {
label: string
ids: string[]
}
interface BYOKKeyManagerBaseProps {
/** Providers to render, in display order. */
providers: BYOKManagerProvider[]
isLoading: boolean
isSaving?: boolean
isDeleting?: boolean
/** Labeled provider groups. When omitted, renders a single flat list. */
sections?: BYOKProviderSection[]
/** Optional subtitle shown above the provider list. */
description?: string
/** Show the provider search box (hidden when there are only a couple). */
showSearch?: boolean
}
/** One key per provider; saving replaces the stored key. */
interface BYOKSingleKeyModeProps {
multiKey?: false
/** Provider ids that currently have a stored key. */
configuredProviderIds: Set<string>
/** Persist a key. Throw to surface an error in the modal. */
onSave: (providerId: string, apiKey: string) => Promise<void>
/** Remove a key. */
onDelete: (providerId: string) => Promise<void>
}
/** Multiple keys per provider; requests round-robin across them. */
interface BYOKMultiKeyModeProps {
multiKey: true
/** Stored keys grouped by provider id, in rotation order. */
keysByProvider: ReadonlyMap<string, BYOKManagerKey[]>
/** Maximum keys allowed per provider. */
maxKeysPerProvider: number
/**
* Persist a key. `keyId` updates that key in place; otherwise a new key is
* added. Throw to surface an error in the modal.
*/
onSaveKey: (params: {
providerId: string
apiKey: string
keyId?: string
name: string
}) => Promise<void>
/** Remove a single key. */
onDeleteKey: (providerId: string, keyId: string) => Promise<void>
}
type BYOKKeyManagerProps = BYOKKeyManagerBaseProps &
(BYOKSingleKeyModeProps | BYOKMultiKeyModeProps)
interface EditingState {
providerId: string
/** Set when updating an existing key in multi-key mode. */
keyId?: string
}
interface DeleteConfirmState {
providerId: string
/** Set when deleting a single key in multi-key mode. */
keyId?: string
}
const NO_KEYS: BYOKManagerKey[] = []
/**
* Shared BYOK key list + add/update/delete modals. Used by both the workspace
* BYOK settings page (multi-key mode, with per-provider round-robin pools)
* and the enterprise mothership BYOK tab (single-key mode) so the two stay
* visually identical; only the provider set and the backing store differ.
*
* Renders content only (search, provider sections, modals) — the caller owns
* the page chrome (background, scroll container, and `max-w` centering).
*/
export function BYOKKeyManager(props: BYOKKeyManagerProps) {
const {
providers,
isLoading,
isSaving = false,
isDeleting = false,
sections,
description,
showSearch = true,
} = props
const [searchTerm, setSearchTerm] = useState('')
const [editing, setEditing] = useState<EditingState | null>(null)
const [apiKeyInput, setApiKeyInput] = useState('')
const [nameInput, setNameInput] = useState('')
const [showApiKey, setShowApiKey] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState | null>(null)
const [managingProviderId, setManagingProviderId] = useState<string | null>(null)
const filteredProviders = useMemo(() => {
if (!searchTerm.trim()) return providers
const searchLower = searchTerm.toLowerCase()
return providers.filter(
(p) =>
p.name.toLowerCase().includes(searchLower) ||
p.description.toLowerCase().includes(searchLower)
)
}, [searchTerm, providers])
const filteredIds = useMemo(
() => new Set(filteredProviders.map((p) => p.id)),
[filteredProviders]
)
const getProviderKeys = (providerId: string): BYOKManagerKey[] =>
props.multiKey ? (props.keysByProvider.get(providerId) ?? NO_KEYS) : NO_KEYS
const hasStoredKey = (providerId: string): boolean =>
props.multiKey
? getProviderKeys(providerId).length > 0
: props.configuredProviderIds.has(providerId)
const showNoResults = searchTerm.trim() !== '' && filteredProviders.length === 0
const editingMeta = providers.find((p) => p.id === editing?.providerId)
const deleteMeta = providers.find((p) => p.id === deleteConfirm?.providerId)
const managingMeta = providers.find((p) => p.id === managingProviderId) ?? null
const isUpdatingExistingKey = props.multiKey
? !!editing?.keyId
: !!editing && hasStoredKey(editing.providerId)
const isDeletingLastKey =
!!deleteConfirm &&
(!props.multiKey ||
!deleteConfirm.keyId ||
getProviderKeys(deleteConfirm.providerId).length === 1)
const openEditModal = (providerId: string, key?: BYOKManagerKey) => {
setManagingProviderId(null)
setEditing({ providerId, keyId: key?.id })
setApiKeyInput('')
setNameInput(key?.name ?? '')
setShowApiKey(false)
setError(null)
}
const closeEditModal = () => {
setEditing(null)
setApiKeyInput('')
setNameInput('')
setShowApiKey(false)
setError(null)
}
const openDeleteConfirm = (providerId: string, keyId?: string) => {
setManagingProviderId(null)
setDeleteConfirm({ providerId, keyId })
}
const handleSave = async () => {
if (!editing || !apiKeyInput.trim() || isSaving) return
setError(null)
try {
if (props.multiKey) {
await props.onSaveKey({
providerId: editing.providerId,
apiKey: apiKeyInput.trim(),
keyId: editing.keyId,
name: nameInput.trim(),
})
} else {
await props.onSave(editing.providerId, apiKeyInput.trim())
}
closeEditModal()
} catch (err) {
setError(getErrorMessage(err, 'Failed to save API key'))
logger.error('Failed to save BYOK key', { error: err })
}
}
const handleDelete = async () => {
if (!deleteConfirm) return
try {
if (props.multiKey) {
const { providerId, keyId } = deleteConfirm
if (!keyId) {
logger.error('Delete confirmation is missing a keyId in multi-key mode', { providerId })
setDeleteConfirm(null)
return
}
await props.onDeleteKey(providerId, keyId)
} else {
await props.onDelete(deleteConfirm.providerId)
}
setDeleteConfirm(null)
} catch (err) {
logger.error('Failed to delete BYOK key', { error: err })
}
}
const renderActions = (provider: BYOKManagerProvider) => {
if (!hasStoredKey(provider.id)) {
return (
<Chip variant='primary' onClick={() => openEditModal(provider.id)}>
Add Key
</Chip>
)
}
if (props.multiKey) {
const keyCount = getProviderKeys(provider.id).length
return (
<div className='flex flex-shrink-0 items-center gap-2'>
<span className='text-[var(--text-muted)] text-caption'>
{keyCount} {keyCount === 1 ? 'key' : 'keys'}
</span>
<Chip onClick={() => setManagingProviderId(provider.id)}>Manage</Chip>
</div>
)
}
return (
<div className='flex flex-shrink-0 items-center gap-2'>
<Chip onClick={() => openEditModal(provider.id)}>Update</Chip>
<Chip onClick={() => openDeleteConfirm(provider.id)}>Delete</Chip>
</div>
)
}
const renderRow = (provider: BYOKManagerProvider) => {
const Icon = provider.icon
return (
<SettingsResourceRow
key={provider.id}
icon={<Icon />}
title={provider.name}
description={provider.description}
trailing={renderActions(provider)}
/>
)
}
return (
<>
<div className='flex flex-col gap-4.5'>
{showSearch && (
<div className={CHIP_FIELD_SHELL}>
<Search
className='size-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<input
aria-label='Search providers'
placeholder='Search providers...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
className={cn(CHIP_FIELD_INPUT, 'disabled:cursor-not-allowed disabled:opacity-60')}
/>
</div>
)}
{description && <p className='text-[var(--text-secondary)] text-sm'>{description}</p>}
{isLoading ? (
<div className='flex flex-col gap-2'>
{providers.map((p) => (
<BYOKKeySkeleton key={p.id} />
))}
</div>
) : showNoResults ? (
<SettingsEmptyState variant='inline'>
No providers found matching "{searchTerm}"
</SettingsEmptyState>
) : sections ? (
<div className='flex flex-col gap-7'>
{sections.map((section) => {
const rows = providers.filter(
(p) => section.ids.includes(p.id) && filteredIds.has(p.id)
)
if (rows.length === 0) return null
return (
<SettingsSection key={section.label} label={section.label}>
<div className='flex flex-col gap-2'>{rows.map(renderRow)}</div>
</SettingsSection>
)
})}
</div>
) : (
<div className='flex flex-col gap-2'>{filteredProviders.map(renderRow)}</div>
)}
</div>
{props.multiKey && (
<BYOKProviderKeysModal
open={!!managingProviderId}
onOpenChange={(open) => {
if (!open) setManagingProviderId(null)
}}
provider={managingMeta}
keys={managingProviderId ? getProviderKeys(managingProviderId) : NO_KEYS}
maxKeys={props.maxKeysPerProvider}
onAddKey={() => managingProviderId && openEditModal(managingProviderId)}
onUpdateKey={(key) => managingProviderId && openEditModal(managingProviderId, key)}
onDeleteKey={(key) => managingProviderId && openDeleteConfirm(managingProviderId, key.id)}
/>
)}
<ChipModal
open={!!editing}
onOpenChange={(open) => {
if (!open) closeEditModal()
}}
srTitle='Add/Update API Key'
>
<ChipModalHeader onClose={closeEditModal}>
{editingMeta && (
<>
{isUpdatingExistingKey ? 'Update' : 'Add'} {editingMeta.name} API Key
</>
)}
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
{props.multiKey
? `Requests are distributed evenly across all ${editingMeta?.name} keys in this workspace. Your key is encrypted and stored securely.`
: `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>
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
aria-hidden='true'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<div className={CHIP_FIELD_SHELL}>
<input
aria-label='API Key'
type={showApiKey ? 'text' : 'password'}
value={apiKeyInput}
onChange={(e) => {
setApiKeyInput(e.target.value)
if (error) setError(null)
}}
placeholder={editingMeta?.placeholder}
className={CHIP_FIELD_INPUT}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
}}
name='byok_api_key'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
<Button
variant='quiet'
className='size-[18px] shrink-0 rounded-sm p-0'
onClick={() => setShowApiKey(!showApiKey)}
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
>
{showApiKey ? <EyeOff className='size-[13px]' /> : <Eye className='size-[13px]' />}
</Button>
</div>
</ChipModalField>
{props.multiKey && (
<ChipModalField
type='input'
title='Name'
value={nameInput}
onChange={setNameInput}
placeholder='e.g. Production key'
maxLength={120}
onSubmit={handleSave}
/>
)}
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={closeEditModal}
cancelDisabled={isSaving}
primaryAction={{
label: isSaving ? 'Saving...' : 'Save',
onClick: handleSave,
disabled: !apiKeyInput.trim() || isSaving,
}}
/>
</ChipModal>
<ChipConfirmModal
open={!!deleteConfirm}
onOpenChange={(open) => {
if (!open) setDeleteConfirm(null)
}}
srTitle='Delete API Key'
title='Delete API Key'
text={[
'Are you sure you want to delete the ',
{ text: deleteMeta?.name ?? 'selected', bold: true },
' API key? ',
isDeletingLastKey
? { text: 'This workspace will revert to using platform hosted keys.', error: true }
: `Requests will continue using the remaining ${deleteMeta?.name ?? 'provider'} keys.`,
' This action cannot be undone.',
]}
confirm={{
label: 'Delete',
onClick: handleDelete,
pending: isDeleting,
pendingLabel: 'Deleting...',
}}
/>
</>
)
}
@@ -0,0 +1,83 @@
'use client'
import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader } from '@sim/emcn'
import type {
BYOKManagerKey,
BYOKManagerProvider,
} from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager'
interface BYOKProviderKeysModalProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Provider whose keys are being managed; null while closed. */
provider: BYOKManagerProvider | null
keys: BYOKManagerKey[]
/** Maximum keys allowed per provider; disables adding once reached. */
maxKeys: number
onAddKey: () => void
onUpdateKey: (key: BYOKManagerKey) => void
onDeleteKey: (key: BYOKManagerKey) => void
}
/**
* Lists every stored key for one provider with per-key update/delete actions.
* Requests round-robin across the listed keys; the footer's primary action
* adds another key until {@link BYOKProviderKeysModalProps.maxKeys} is
* reached.
*/
export function BYOKProviderKeysModal({
open,
onOpenChange,
provider,
keys,
maxKeys,
onAddKey,
onUpdateKey,
onDeleteKey,
}: BYOKProviderKeysModalProps) {
const close = () => onOpenChange(false)
const atCapacity = keys.length >= maxKeys
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Manage API Keys'>
<ChipModalHeader onClose={close}>{provider && `${provider.name} API Keys`}</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
Requests are distributed evenly across these keys. Your keys are encrypted and stored
securely.
</p>
<div className='flex flex-col gap-2 px-2'>
{keys.map((key) => (
<div key={key.id} className='flex items-center justify-between gap-2.5'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate text-[var(--text-body)] text-sm'>
{key.name ?? 'Unnamed key'}
</span>
<span className='truncate font-mono text-[var(--text-muted)] text-caption'>
{key.maskedKey}
</span>
</div>
<div className='flex flex-shrink-0 items-center gap-2'>
<Chip onClick={() => onUpdateKey(key)}>Update</Chip>
<Chip onClick={() => onDeleteKey(key)}>Delete</Chip>
</div>
</div>
))}
</div>
{atCapacity && (
<p className='px-2 text-[var(--text-muted)] text-caption'>
Key limit reached ({maxKeys} keys per provider).
</p>
)}
</ChipModalBody>
<ChipModalFooter
onCancel={close}
primaryAction={{
label: 'Add Key',
onClick: onAddKey,
disabled: atCapacity,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,22 @@
import { Skeleton } from '@sim/emcn'
/**
* Skeleton component for BYOK provider key items.
*/
export function BYOKKeySkeleton() {
return (
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-3'>
<Skeleton className='size-9 flex-shrink-0 rounded-md' />
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[16px] w-[100px]' />
<Skeleton className='h-[14px] w-[200px]' />
</div>
</div>
<div className='flex flex-shrink-0 items-center gap-2'>
<Skeleton className='h-[32px] w-[72px] rounded-md' />
<Skeleton className='h-[32px] w-[64px] rounded-md' />
</div>
</div>
)
}
@@ -0,0 +1,389 @@
'use client'
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import {
AnthropicIcon,
BasetenIcon,
BrandfetchIcon,
ContextDevIcon,
DatagmaIcon,
DropcontactIcon,
EnrowIcon,
ExaAIIcon,
FalIcon,
FindymailIcon,
FirecrawlIcon,
FireworksIcon,
GeminiIcon,
GoogleIcon,
HunterIOIcon,
IcypeasIcon,
JinaAIIcon,
LeadMagicIcon,
LinkupIcon,
MillionVerifierIcon,
MistralIcon,
NeverBounceIcon,
OllamaIcon,
OpenAIIcon,
ParallelIcon,
PeopleDataLabsIcon,
PerplexityIcon,
ProspeoIcon,
SerperIcon,
TogetherIcon,
WizaIcon,
xAIIcon,
ZeroBounceIcon,
} from '@/components/icons'
import { MAX_BYOK_KEYS_PER_PROVIDER } from '@/lib/api/contracts/byok-keys'
import {
BYOKKeyManager,
type BYOKManagerKey,
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'
const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [
{
id: 'openai',
name: 'OpenAI',
icon: OpenAIIcon,
description: 'LLM calls and Knowledge Base embeddings',
placeholder: 'sk-...',
},
{
id: 'anthropic',
name: 'Anthropic',
icon: AnthropicIcon,
description: 'LLM calls',
placeholder: 'sk-ant-...',
},
{
id: 'google',
name: 'Google',
icon: GeminiIcon,
description: 'LLM calls',
placeholder: 'Enter your API key',
},
{
id: 'mistral',
name: 'Mistral',
icon: MistralIcon,
description: 'LLM calls and Knowledge Base OCR',
placeholder: 'Enter your API key',
},
{
id: 'xai',
name: 'xAI',
icon: xAIIcon,
description: 'LLM calls',
placeholder: 'xai-...',
},
{
id: 'fireworks',
name: 'Fireworks',
icon: FireworksIcon,
description: 'LLM calls',
placeholder: 'Enter your Fireworks API key',
},
{
id: 'together',
name: 'Together AI',
icon: TogetherIcon,
description: 'LLM calls',
placeholder: 'Enter your Together AI API key',
},
{
id: 'baseten',
name: 'Baseten',
icon: BasetenIcon,
description: 'LLM calls',
placeholder: 'Enter your Baseten API key',
},
{
id: 'ollama-cloud',
name: 'Ollama Cloud',
icon: OllamaIcon,
description: 'LLM calls',
placeholder: 'Enter your Ollama API key',
},
{
id: 'falai',
name: 'Fal.ai',
icon: FalIcon,
description: 'Image and video generation',
placeholder: 'Enter your Fal.ai API key',
},
{
id: 'firecrawl',
name: 'Firecrawl',
icon: FirecrawlIcon,
description: 'Web scraping, crawling, search, and extraction',
placeholder: 'Enter your Firecrawl API key',
},
{
id: 'exa',
name: 'Exa',
icon: ExaAIIcon,
description: 'AI-powered search and research',
placeholder: 'Enter your Exa API key',
},
{
id: 'context_dev',
name: 'Context.dev',
icon: ContextDevIcon,
description: 'Web scraping, crawling, search, and brand intelligence',
placeholder: 'Enter your Context.dev API key',
},
{
id: 'serper',
name: 'Serper',
icon: SerperIcon,
description: 'Google search API',
placeholder: 'Enter your Serper API key',
},
{
id: 'linkup',
name: 'Linkup',
icon: LinkupIcon,
description: 'Web search and content retrieval',
placeholder: 'Enter your Linkup API key',
},
{
id: 'parallel_ai',
name: 'Parallel AI',
icon: ParallelIcon,
description: 'Web search, extraction, and deep research',
placeholder: 'Enter your Parallel AI API key',
},
{
id: 'perplexity',
name: 'Perplexity',
icon: PerplexityIcon,
description: 'AI-powered chat and web search',
placeholder: 'pplx-...',
},
{
id: 'jina',
name: 'Jina AI',
icon: JinaAIIcon,
description: 'Web reading and search',
placeholder: 'jina_...',
},
{
id: 'google_cloud',
name: 'Google Cloud',
icon: GoogleIcon,
description: 'Translate, Maps, PageSpeed, and Books APIs',
placeholder: 'Enter your Google Cloud API key',
},
{
id: 'brandfetch',
name: 'Brandfetch',
icon: BrandfetchIcon,
description: 'Brand assets, logos, colors, and company info',
placeholder: 'Enter your Brandfetch API key',
},
{
id: 'hunter',
name: 'Hunter',
icon: HunterIOIcon,
description: 'Email finder, verification, and domain search',
placeholder: 'Enter your Hunter.io API key',
},
{
id: 'peopledatalabs',
name: 'People Data Labs',
icon: PeopleDataLabsIcon,
description: 'Person and company enrichment, search, and identity',
placeholder: 'Enter your People Data Labs API key',
},
{
id: 'findymail',
name: 'Findymail',
icon: FindymailIcon,
description: 'Email finder, verification, and phone lookup',
placeholder: 'Enter your Findymail API key',
},
{
id: 'prospeo',
name: 'Prospeo',
icon: ProspeoIcon,
description: 'Person and company enrichment and search',
placeholder: 'Enter your Prospeo API key',
},
{
id: 'wiza',
name: 'Wiza',
icon: WizaIcon,
description: 'Prospect search, individual reveal, and company enrichment',
placeholder: 'Enter your Wiza API key',
},
{
id: 'datagma',
name: 'Datagma',
icon: DatagmaIcon,
description: 'Email, phone, person, and company enrichment',
placeholder: 'Enter your Datagma API key',
},
{
id: 'dropcontact',
name: 'Dropcontact',
icon: DropcontactIcon,
description: 'GDPR-compliant contact enrichment and email finding',
placeholder: 'Enter your Dropcontact API key',
},
{
id: 'leadmagic',
name: 'LeadMagic',
icon: LeadMagicIcon,
description: 'Email finding, validation, and B2B profile enrichment',
placeholder: 'Enter your LeadMagic API key',
},
{
id: 'icypeas',
name: 'Icypeas',
icon: IcypeasIcon,
description: 'Email finding and verification',
placeholder: 'Enter your Icypeas API key',
},
{
id: 'enrow',
name: 'Enrow',
icon: EnrowIcon,
description: 'Email finding and verification',
placeholder: 'Enter your Enrow API key',
},
{
id: 'zerobounce',
name: 'ZeroBounce',
icon: ZeroBounceIcon,
description: 'Real-time email validation and deliverability checks',
placeholder: 'Enter your ZeroBounce API key',
},
{
id: 'neverbounce',
name: 'NeverBounce',
icon: NeverBounceIcon,
description: 'Real-time email verification and list cleaning',
placeholder: 'Enter your NeverBounce API key',
},
{
id: 'millionverifier',
name: 'MillionVerifier',
icon: MillionVerifierIcon,
description: 'Real-time email verification and deliverability checks',
placeholder: 'Enter your MillionVerifier API key',
},
]
/**
* Provider groupings rendered as labeled sections. Every provider id in
* {@link PROVIDERS} belongs to exactly one section; rows keep their
* {@link PROVIDERS} order within each group.
*/
const PROVIDER_SECTIONS: BYOKProviderSection[] = [
{
label: 'Models',
ids: [
'openai',
'anthropic',
'google',
'mistral',
'xai',
'fireworks',
'together',
'baseten',
'ollama-cloud',
'falai',
],
},
{
label: 'Search & web',
ids: [
'firecrawl',
'exa',
'context_dev',
'serper',
'linkup',
'parallel_ai',
'perplexity',
'jina',
'google_cloud',
],
},
{
label: 'Enrichment',
ids: [
'brandfetch',
'hunter',
'peopledatalabs',
'findymail',
'prospeo',
'wiza',
'datagma',
'dropcontact',
'leadmagic',
'icypeas',
'enrow',
'zerobounce',
'neverbounce',
'millionverifier',
],
},
]
export function BYOK() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const { data, isLoading } = useBYOKKeys(workspaceId)
const upsertKey = useUpsertBYOKKey()
const deleteKey = useDeleteBYOKKey()
const keysByProvider = useMemo(() => {
const grouped = new Map<string, BYOKManagerKey[]>()
for (const key of data?.keys ?? []) {
const providerKeys = grouped.get(key.providerId) ?? []
providerKeys.push({ id: key.id, name: key.name, maskedKey: key.maskedKey })
grouped.set(key.providerId, providerKeys)
}
return grouped
}, [data?.keys])
return (
<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>
)
}
@@ -0,0 +1 @@
export { BYOK } from './byok'
@@ -0,0 +1,272 @@
'use client'
import { useMemo, useState } from 'react'
import {
Chip,
ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
SecretReveal,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Plus } from 'lucide-react'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import {
type CopilotKey,
useCopilotKeys,
useDeleteCopilotKey,
useGenerateCopilotKey,
} from '@/hooks/queries/copilot-keys'
const logger = createLogger('CopilotSettings')
/** Formats a key's last-used timestamp, falling back to "Never" when unset. */
function formatLastUsed(dateString?: string | null): string {
if (!dateString) return 'Never'
return formatDate(new Date(dateString))
}
/**
* Copilot Keys management component for handling API keys used with the Copilot feature.
* Provides functionality to create, view, and delete copilot API keys.
*/
export function Copilot() {
const { data: keys = [], isLoading } = useCopilotKeys()
const generateKey = useGenerateCopilotKey()
const deleteKeyMutation = useDeleteCopilotKey()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [newKey, setNewKey] = useState<string | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [createError, setCreateError] = useState<string | null>(null)
const filteredKeys = useMemo(() => {
if (!searchTerm.trim()) return keys
const term = searchTerm.toLowerCase()
return keys.filter(
(key) =>
key.name?.toLowerCase().includes(term) || key.displayKey?.toLowerCase().includes(term)
)
}, [keys, searchTerm])
const handleCreateKey = async () => {
if (!newKeyName.trim()) return
const trimmedName = newKeyName.trim()
const isDuplicate = keys.some((k) => k.name === trimmedName)
if (isDuplicate) {
setCreateError(
`A Chat API key named "${trimmedName}" already exists. Please choose a different name.`
)
return
}
setCreateError(null)
try {
const data = await generateKey.mutateAsync({ name: trimmedName })
if (data?.key?.apiKey) {
setNewKey(data.key.apiKey)
setShowNewKeyDialog(true)
setNewKeyName('')
setCreateError(null)
setIsCreateDialogOpen(false)
}
} catch (error) {
logger.error('Failed to generate copilot API key', { error })
setCreateError('Failed to create API key. Please check your connection and try again.')
}
}
const handleDeleteKey = async () => {
if (!deleteKey) return
try {
setShowDeleteDialog(false)
const keyToDelete = deleteKey
setDeleteKey(null)
await deleteKeyMutation.mutateAsync({ keyId: keyToDelete.id })
} catch (error) {
logger.error('Failed to delete copilot API key', { error })
}
}
const hasKeys = keys.length > 0
const showEmptyState = !hasKeys
const showNoResults = searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0
const actions: SettingsAction[] = [
{
text: 'Create API key',
icon: Plus,
variant: 'primary',
onSelect: () => {
setIsCreateDialogOpen(true)
setCreateError(null)
},
disabled: isLoading,
},
]
return (
<>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search API keys...',
}}
actions={actions}
>
{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-[var(--text-body)] text-sm'>
{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-[var(--text-muted)] text-caption'>{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>
)}
</SettingsPanel>
<ChipModal
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
srTitle='Create new API key'
>
<ChipModalHeader onClose={() => setIsCreateDialogOpen(false)}>
Create new API key
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
This key will allow access to Chat features. Make sure to copy it after creation as you
won't be able to see it again.
</p>
<ChipModalField
type='input'
title='Key name'
value={newKeyName}
onChange={(value) => {
setNewKeyName(value)
if (createError) setCreateError(null)
}}
placeholder='e.g., Development, Production'
required
/>
<ChipModalError>{createError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => {
setIsCreateDialogOpen(false)
setNewKeyName('')
setCreateError(null)
}}
primaryAction={{
label: generateKey.isPending ? 'Creating...' : 'Create',
onClick: handleCreateKey,
disabled: !newKeyName.trim() || generateKey.isPending,
}}
/>
</ChipModal>
<ChipModal
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) {
setNewKey(null)
}
}}
srTitle='Your API key has been created'
>
<ChipModalHeader
onClose={() => {
setShowNewKeyDialog(false)
setNewKey(null)
}}
>
Your API key has been created
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='custom' title="Copy it now — it won't be shown again">
{newKey && <SecretReveal value={newKey} />}
</ChipModalField>
</ChipModalBody>
<ChipModalFooter
onCancel={() => {
setShowNewKeyDialog(false)
setNewKey(null)
}}
primaryAction={{
label: 'Done',
onClick: () => {
setShowNewKeyDialog(false)
setNewKey(null)
},
}}
/>
</ChipModal>
<ChipConfirmModal
open={showDeleteDialog}
onOpenChange={(open) => {
if (!open) {
setShowDeleteDialog(false)
setDeleteKey(null)
}
}}
srTitle='Delete API key'
title='Delete API key'
text={[
'Deleting ',
{ text: deleteKey?.name || 'Unnamed Key', bold: true },
' ',
{ text: 'will immediately revoke access for any integrations using it.', error: true },
' This action cannot be undone.',
]}
confirm={{
label: 'Delete',
onClick: handleDeleteKey,
pending: deleteKeyMutation.isPending,
pendingLabel: 'Deleting...',
}}
/>
</>
)
}
@@ -0,0 +1 @@
export { Copilot } from './copilot'
@@ -0,0 +1,197 @@
'use client'
import { useState } from 'react'
import { ChipConfirmModal } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
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'
const logger = createLogger('CustomToolsSettings')
export function CustomTools() {
const params = useParams()
const workspaceId = params.workspaceId as string
const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId)
const deleteToolMutation = useDeleteCustomTool()
const [searchTerm, setSearchTerm] = useState('')
const [deletingTools, setDeletingTools] = useState<Set<string>>(() => new Set())
const [editingTool, setEditingTool] = useState<string | null>(null)
const [showAddForm, setShowAddForm] = useState(false)
const [toolToDelete, setToolToDelete] = useState<{ id: string; name: string } | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const filteredTools = tools.filter((tool) => {
if (!searchTerm.trim()) return true
const searchLower = searchTerm.toLowerCase()
return (
tool.title.toLowerCase().includes(searchLower) ||
tool.schema?.function?.name?.toLowerCase().includes(searchLower) ||
tool.schema?.function?.description?.toLowerCase().includes(searchLower)
)
})
const handleDeleteClick = (toolId: string) => {
const tool = tools.find((t) => t.id === toolId)
if (!tool) return
setToolToDelete({
id: toolId,
name: tool.title || tool.schema?.function?.name || 'this custom tool',
})
setShowDeleteDialog(true)
}
const handleDeleteTool = async () => {
if (!toolToDelete) return
const tool = tools.find((t) => t.id === toolToDelete.id)
if (!tool) return
setDeletingTools((prev) => new Set(prev).add(toolToDelete.id))
setShowDeleteDialog(false)
try {
await deleteToolMutation.mutateAsync({
workspaceId: tool.workspaceId ?? null,
toolId: toolToDelete.id,
})
logger.info(`Deleted custom tool: ${toolToDelete.id}`)
} catch (error) {
logger.error('Error deleting custom tool:', error)
} finally {
setDeletingTools((prev) => {
const next = new Set(prev)
next.delete(toolToDelete.id)
return next
})
setToolToDelete(null)
}
}
const handleToolSaved = () => {
setShowAddForm(false)
setEditingTool(null)
refetchTools()
}
const hasTools = tools && tools.length > 0
const showEmptyState = !hasTools && !showAddForm && !editingTool
const showNoResults = searchTerm.trim() && filteredTools.length === 0 && tools.length > 0
const actions: SettingsAction[] = [
{
text: 'Add tool',
icon: Plus,
variant: 'primary',
onSelect: () => setShowAddForm(true),
disabled: isLoading,
},
]
return (
<>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search tools...',
disabled: isLoading,
}}
actions={actions}
>
{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 ? (
<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-[var(--text-body)] text-sm'>
{tool.title || 'Unnamed Tool'}
</span>
{tool.schema?.function?.description && (
<p className='truncate text-[var(--text-muted)] text-caption'>
{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>
)}
</SettingsPanel>
<CustomToolModal
open={showAddForm || !!editingTool}
onOpenChange={(open) => {
if (!open) {
setShowAddForm(false)
setEditingTool(null)
}
}}
onSave={handleToolSaved}
onDelete={() => {}}
blockId=''
initialValues={
editingTool
? (() => {
const tool = tools.find((t) => t.id === editingTool)
return tool?.schema
? { id: tool.id, schema: tool.schema, code: tool.code }
: undefined
})()
: undefined
}
/>
<ChipConfirmModal
open={showDeleteDialog}
onOpenChange={(open) => {
if (!open) setShowDeleteDialog(false)
}}
srTitle='Delete Custom Tool'
title='Delete Custom Tool'
text={[
'Are you sure you want to delete ',
{ text: toolToDelete?.name ?? 'this tool', bold: true },
'? This action cannot be undone.',
]}
confirm={{ label: 'Delete', onClick: handleDeleteTool }}
/>
</>
)
}
@@ -0,0 +1 @@
export { CustomTools } from './custom-tools'
@@ -0,0 +1,581 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
Button,
ChipCombobox,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalFooter,
ChipModalHeader,
ChipSelect,
Input,
Label,
Switch,
Tooltip,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { Camera, Check, Info, Pencil } from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { telemetryContract } from '@/lib/api/contracts/telemetry'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
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'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import {
useResetPassword,
useUpdateUserProfile,
useUserProfile,
} from '@/hooks/queries/user-profile'
import { clearUserData } from '@/stores'
const logger = createLogger('General')
/** Human-friendly timezone options for the picker, common zones first. */
const TIMEZONE_OPTIONS = getTimezoneOptions()
/**
* Shared trigger width for the three appearance dropdowns (Theme, Timezone, Snap
* to grid) so they line up as one column instead of three differently-sized
* pills. Wide enough for the longest common timezone label.
*/
const DROPDOWN_TRIGGER_CLASS = 'w-[240px] flex-shrink-0'
/**
* Extracts initials from a user's name.
* @param name - The user's full name
* @returns Up to 2 characters: first letters of first and last name, or just the first letter
*/
function getInitials(name: string | undefined | null): string {
if (!name?.trim()) return ''
const parts = name.trim().split(' ')
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
}
return parts[0][0].toUpperCase()
}
export function General() {
const router = useRouter()
const brandConfig = useBrandConfig()
const { data: session } = useSession()
const { data: profile, isLoading: isProfileLoading } = useUserProfile()
const updateProfile = useUpdateUserProfile()
const { data: settings, isLoading: isSettingsLoading } = useGeneralSettings()
const updateSetting = useUpdateGeneralSetting()
const isLoading = isProfileLoading || isSettingsLoading
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
const isAuthDisabled = session?.user?.id === ANONYMOUS_USER_ID
const [name, setName] = useState(profile?.name || '')
const [isEditingName, setIsEditingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const prevProfileNameRef = useRef<string | undefined>(profile?.name)
if (profile?.name && profile.name !== prevProfileNameRef.current) {
prevProfileNameRef.current = profile.name
setName(profile.name)
}
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
const resetPassword = useResetPassword()
const [uploadError, setUploadError] = useState<string | null>(null)
const snapToGridValue = settings?.snapToGridSize ?? 0
const {
previewUrl: profilePictureUrl,
fileInputRef: profilePictureInputRef,
handleThumbnailClick: handleProfilePictureClick,
handleFileChange: handleProfilePictureChange,
isUploading: isUploadingProfilePicture,
} = useProfilePictureUpload({
currentImage: profile?.image || null,
onUpload: (url: string | null) => {
updateProfile
.mutateAsync({ image: url })
.then(() => {
setUploadError(null)
})
.catch(() => {
setUploadError(
url ? 'Failed to update profile picture' : 'Failed to remove profile picture'
)
})
},
onError: (error: string) => {
setUploadError(error)
setTimeout(() => setUploadError(null), 5000)
},
})
useEffect(() => {
if (isEditingName && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditingName])
const handleUpdateName = async () => {
const trimmedName = name.trim()
if (!trimmedName) {
return
}
if (trimmedName === profile?.name) {
setIsEditingName(false)
return
}
try {
await updateProfile.mutateAsync({ name: trimmedName })
setIsEditingName(false)
} catch (error) {
logger.error('Error updating name:', error)
setName(profile?.name || '')
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleUpdateName()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
}
const handleCancelEdit = () => {
setIsEditingName(false)
setName(profile?.name || '')
}
const handleInputBlur = () => {
handleUpdateName()
}
const handleSignOut = async () => {
try {
await Promise.all([signOut(), clearUserData()])
router.push('/login?fromLogout=true')
} catch (error) {
logger.error('Error signing out:', { error })
router.push('/login?fromLogout=true')
}
}
const handleResetPasswordConfirm = async () => {
if (!profile?.email) return
resetPassword.mutate(
{
email: profile.email,
redirectTo: `${getBaseUrl()}/reset-password`,
},
{
onSuccess: () => {
setTimeout(() => {
setShowResetPasswordModal(false)
resetPassword.reset()
}, 1500)
},
onError: (error) => {
logger.error('Error resetting password:', error)
setTimeout(() => resetPassword.reset(), 5000)
},
}
)
}
const handleThemeChange = async (value: string) => {
await updateSetting.mutateAsync({ key: 'theme', value: value as 'system' | 'light' | 'dark' })
}
const handleTimezoneChange = async (value: string) => {
await updateSetting.mutateAsync({ key: 'timezone', value })
}
const handleAutoConnectChange = async (checked: boolean) => {
if (checked !== settings?.autoConnect && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'autoConnect', value: checked })
}
}
const handleSnapToGridChange = async (value: string) => {
const newValue = Number.parseInt(value, 10)
if (newValue !== settings?.snapToGridSize && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'snapToGridSize', value: newValue })
}
}
const handleShowActionBarChange = async (checked: boolean) => {
if (checked !== settings?.showActionBar && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'showActionBar', value: checked })
}
}
const handleTrainingControlsChange = async (checked: boolean) => {
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
}
}
const handleErrorNotificationsChange = async (checked: boolean) => {
if (checked !== settings?.errorNotificationsEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'errorNotificationsEnabled', value: checked })
}
}
const handleTelemetryToggle = async (checked: boolean) => {
if (checked !== settings?.telemetryEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'telemetryEnabled', value: checked })
if (checked) {
if (typeof window !== 'undefined') {
requestJson(telemetryContract, {
body: {
category: 'consent',
action: 'enable_from_settings',
timestamp: new Date().toISOString(),
},
}).catch(() => {})
}
}
}
}
const imageUrl = profilePictureUrl || profile?.image || brandConfig.logoUrl
if (isLoading) {
return null
}
const actions: SettingsAction[] = [
...(isHosted
? [
{
text: 'Home page',
onSelect: () => window.open('/?home', '_blank', 'noopener,noreferrer'),
},
]
: []),
...(!isAuthDisabled
? [
{ text: 'Sign out', onSelect: handleSignOut },
{ text: 'Reset password', onSelect: () => setShowResetPasswordModal(true) },
]
: []),
]
return (
<>
<SettingsPanel actions={actions}>
<SettingsSection label='Profile'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-3'>
<div className='relative'>
<button
type='button'
aria-label='Change profile picture'
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}
>
{(() => {
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'
}`}
/>
)
}
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' />
) : (
<Camera className='size-4 text-white' />
)}
</div>
</button>
<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}
aria-label='Your name'
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>
</div>
{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}
/>
</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}
srTitle='Reset Password'
>
<ChipModalHeader onClose={() => setShowResetPasswordModal(false)}>
Reset Password
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
A password reset link will be sent to{' '}
<span className='font-medium text-[var(--text-primary)]'>{profile?.email}</span>. Click
the link in the email to create a new password.
</p>
<ChipModalError>{resetPassword.error?.message}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setShowResetPasswordModal(false)}
cancelDisabled={resetPassword.isPending || resetPassword.isSuccess}
primaryAction={{
label: resetPassword.isPending
? 'Sending...'
: resetPassword.isSuccess
? 'Sent'
: 'Send Reset Email',
onClick: handleResetPasswordConfirm,
disabled: resetPassword.isPending || resetPassword.isSuccess,
}}
/>
</ChipModal>
</>
)
}
@@ -0,0 +1 @@
export { General } from './general'
@@ -0,0 +1,131 @@
'use client'
import { useCallback, useState } from 'react'
import {
ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
Label,
Switch,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useInboxConfig, useToggleInbox } from '@/hooks/queries/inbox'
const logger = createLogger('InboxEnableToggle')
export function InboxEnableToggle() {
const params = useParams()
const workspaceId = params.workspaceId as string
const { data: config } = useInboxConfig(workspaceId)
const toggleInbox = useToggleInbox()
const [isEnableOpen, setIsEnableOpen] = useState(false)
const [isDisableOpen, setIsDisableOpen] = useState(false)
const [enableUsername, setEnableUsername] = useState('')
const handleToggle = useCallback(async (checked: boolean) => {
if (checked) {
setIsEnableOpen(true)
return
}
setIsDisableOpen(true)
}, [])
const handleDisable = useCallback(async () => {
try {
await toggleInbox.mutateAsync({ workspaceId, enabled: false })
setIsDisableOpen(false)
} catch (error) {
logger.error('Failed to disable inbox', { error })
}
}, [workspaceId, toggleInbox.mutateAsync])
const handleEnable = useCallback(async () => {
try {
await toggleInbox.mutateAsync({
workspaceId,
enabled: true,
username: enableUsername.trim() || undefined,
})
setIsEnableOpen(false)
setEnableUsername('')
} catch (error) {
logger.error('Failed to enable inbox', { error })
}
}, [workspaceId, enableUsername, toggleInbox.mutateAsync])
return (
<>
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-1'>
<Label htmlFor='inbox-enabled'>Enable email inbox</Label>
<p className='text-[var(--text-muted)] text-caption'>
Allow this workspace to receive tasks via email
</p>
</div>
<Switch
id='inbox-enabled'
checked={config?.enabled ?? false}
onCheckedChange={handleToggle}
disabled={toggleInbox.isPending}
/>
</div>
<ChipModal open={isEnableOpen} onOpenChange={setIsEnableOpen} srTitle='Enable email inbox'>
<ChipModalHeader onClose={() => setIsEnableOpen(false)}>Enable email inbox</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
An email address will be created for this workspace. Anyone in the allowed senders list
can email it to create tasks.
</p>
<ChipModalField
type='input'
title='Email prefix'
value={enableUsername}
onChange={setEnableUsername}
placeholder='Optional — leave blank to auto-generate'
/>
<p className='px-2 text-[var(--text-muted)] text-sm'>
Leave blank for an auto-generated address.
</p>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setIsEnableOpen(false)}
primaryAction={{
label: 'Enable',
onClick: handleEnable,
disabled: toggleInbox.isPending,
}}
/>
</ChipModal>
<ChipConfirmModal
open={isDisableOpen}
onOpenChange={setIsDisableOpen}
srTitle='Disable email inbox'
title='Disable email inbox'
text={[
'Are you sure you want to disable the inbox',
config?.address && ' ',
config?.address && { text: config.address, bold: true },
'? Any emails sent to this address after disabling will not be delivered. This action cannot be undone.',
]}
confirm={{
label: 'Disable inbox',
onClick: handleDisable,
pending: toggleInbox.isPending,
pendingLabel: 'Disabling...',
}}
>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
Your existing conversations and task history will be preserved.
</p>
</ChipConfirmModal>
</>
)
}
@@ -0,0 +1 @@
export { InboxEnableToggle } from './inbox-enable-toggle'
@@ -0,0 +1,316 @@
'use client'
import { useCallback, useState } from 'react'
import {
Badge,
Chip,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
Tooltip,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { Check, Clipboard, Pencil, Plus, Trash2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
useAddInboxSender,
useInboxConfig,
useInboxSenders,
useRemoveInboxSender,
useUpdateInboxAddress,
} from '@/hooks/queries/inbox'
export function InboxSettingsTab() {
const params = useParams()
const workspaceId = params.workspaceId as string
const { data: config } = useInboxConfig(workspaceId)
const { data: sendersData, isLoading: sendersLoading } = useInboxSenders(workspaceId)
const updateAddress = useUpdateInboxAddress()
const addSender = useAddInboxSender()
const removeSender = useRemoveInboxSender()
const [isAddSenderOpen, setIsAddSenderOpen] = useState(false)
const [newSenderEmail, setNewSenderEmail] = useState('')
const [newSenderLabel, setNewSenderLabel] = useState('')
const [addSenderError, setAddSenderError] = useState<string | null>(null)
const [isEditAddressOpen, setIsEditAddressOpen] = useState(false)
const [newUsername, setNewUsername] = useState('')
const [editAddressError, setEditAddressError] = useState<string | null>(null)
const [removeSenderError, setRemoveSenderError] = useState<string | null>(null)
const [copiedAddress, setCopiedAddress] = useState(false)
const handleCopyAddress = useCallback(() => {
if (config?.address) {
navigator.clipboard.writeText(config.address)
setCopiedAddress(true)
setTimeout(() => setCopiedAddress(false), 2000)
}
}, [config?.address])
const handleEditAddress = useCallback(async () => {
if (!newUsername.trim()) return
setEditAddressError(null)
try {
await updateAddress.mutateAsync({ workspaceId, username: newUsername.trim() })
setIsEditAddressOpen(false)
setNewUsername('')
} catch (error) {
setEditAddressError(getErrorMessage(error, 'Failed to update address'))
}
}, [workspaceId, newUsername, updateAddress.mutateAsync])
const handleAddSender = useCallback(async () => {
if (!newSenderEmail.trim()) return
setAddSenderError(null)
try {
await addSender.mutateAsync({
workspaceId,
email: newSenderEmail.trim(),
label: newSenderLabel.trim() || undefined,
})
setIsAddSenderOpen(false)
setNewSenderEmail('')
setNewSenderLabel('')
} catch (error) {
setAddSenderError(getErrorMessage(error, 'Failed to add sender'))
}
}, [workspaceId, newSenderEmail, newSenderLabel, addSender.mutateAsync])
const handleRemoveSender = useCallback(
async (senderId: string) => {
setRemoveSenderError(null)
try {
await removeSender.mutateAsync({ workspaceId, senderId })
} catch (error) {
setRemoveSenderError(getErrorMessage(error, 'Failed to remove sender'))
}
},
[workspaceId, removeSender.mutateAsync]
)
return (
<>
<div className='flex flex-col gap-7'>
{config?.address && (
<SettingsSection label="Sim's email">
<div className='flex flex-col gap-1.5'>
<div className='flex items-center justify-between'>
<p className='text-[var(--text-muted)] text-caption'>
Send emails here to create tasks.
</p>
<div className='flex items-center gap-1.5'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={handleCopyAddress}
className='-my-1 flex size-5 items-center justify-center'
aria-label='Copy address'
>
{copiedAddress ? (
<Check className='size-[14px] text-[var(--text-success)]' />
) : (
<Clipboard className='size-[14px] text-[var(--text-icon)]' />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>{copiedAddress ? 'Copied!' : 'Copy'}</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={() => {
setNewUsername('')
setEditAddressError(null)
setIsEditAddressOpen(true)
}}
className='-my-1 flex size-5 items-center justify-center'
aria-label='Edit address'
>
<Pencil className='size-[14px] text-[var(--text-icon)]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Edit</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
<ChipInput
value={config.address}
readOnly
inputClassName='cursor-default font-mono text-small'
/>
</div>
</SettingsSection>
)}
<SettingsSection label='Allowed senders'>
<div className='flex flex-col gap-1.5'>
<p className='text-[var(--text-muted)] text-caption'>
Only emails from these addresses can create tasks.
</p>
<div className='mt-1 flex flex-col gap-[1px] overflow-hidden rounded-lg border border-[var(--border)]'>
{sendersLoading ? null : (
<>
{sendersData?.workspaceMembers.map((member) => (
<div
key={member.email}
className='flex items-center justify-between border-[var(--border)] border-b px-3 py-2.5 last:border-b-0'
>
<div className='flex items-center gap-2'>
<span className='text-[var(--text-body)] text-sm'>{member.email}</span>
<Badge variant='gray' className='text-xs'>
member
</Badge>
</div>
</div>
))}
{sendersData?.senders.map((sender) => (
<div
key={sender.id}
className='flex items-center justify-between border-[var(--border)] border-b px-3 py-2.5 last:border-b-0'
>
<div className='flex items-center gap-2'>
<span className='text-[var(--text-body)] text-sm'>{sender.email}</span>
{sender.label && (
<span className='text-[var(--text-muted)] text-caption'>
({sender.label})
</span>
)}
</div>
<Chip
flush
leftIcon={Trash2}
aria-label='Remove sender'
onClick={() => handleRemoveSender(sender.id)}
/>
</div>
))}
{sendersData?.workspaceMembers.length === 0 &&
sendersData?.senders.length === 0 && (
<div className='px-3 py-2.5 text-[var(--text-muted)] text-caption'>
No allowed senders configured.
</div>
)}
</>
)}
</div>
{removeSenderError && (
<p className='px-3 text-[var(--text-error)] text-caption leading-tight'>
{removeSenderError}
</p>
)}
<Chip
className='mt-1 w-fit'
leftIcon={Plus}
onClick={() => {
setNewSenderEmail('')
setNewSenderLabel('')
setAddSenderError(null)
setIsAddSenderOpen(true)
}}
>
Add sender
</Chip>
</div>
</SettingsSection>
</div>
<ChipModal
open={isAddSenderOpen}
onOpenChange={setIsAddSenderOpen}
srTitle='Add allowed sender'
>
<ChipModalHeader onClose={() => setIsAddSenderOpen(false)}>
Add allowed sender
</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='email'
title='Email address'
value={newSenderEmail}
onChange={(v) => {
setNewSenderEmail(v)
if (addSenderError) setAddSenderError(null)
}}
required
placeholder='user@example.com'
/>
<ChipModalField
type='input'
title='Label'
value={newSenderLabel}
onChange={setNewSenderLabel}
placeholder='e.g., John from Marketing'
/>
<ChipModalError>{addSenderError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setIsAddSenderOpen(false)}
primaryAction={{
label: 'Add',
onClick: handleAddSender,
disabled: !newSenderEmail.trim() || addSender.isPending,
}}
/>
</ChipModal>
<ChipModal
open={isEditAddressOpen}
onOpenChange={setIsEditAddressOpen}
srTitle='Change email address'
>
<ChipModalHeader onClose={() => setIsEditAddressOpen(false)}>
Change email address
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
Changing your email address will create a new inbox.{' '}
<span className='font-medium text-[var(--text-primary)]'>
The old address will stop receiving emails immediately.
</span>
</p>
<ChipModalField
type='input'
title='New email prefix'
value={newUsername}
onChange={(value) => {
setNewUsername(value)
if (editAddressError) setEditAddressError(null)
}}
onSubmit={handleEditAddress}
placeholder='e.g., new-acme'
error={editAddressError}
/>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setIsEditAddressOpen(false)}
cancelDisabled={updateAddress.isPending}
primaryAction={{
label: updateAddress.isPending ? 'Updating...' : 'Change address',
onClick: handleEditAddress,
disabled: !newUsername.trim() || updateAddress.isPending,
variant: 'destructive',
}}
/>
</ChipModal>
</>
)
}
@@ -0,0 +1 @@
export { InboxSettingsTab } from './inbox-settings-tab'
@@ -0,0 +1,231 @@
'use client'
import { useCallback, useMemo } from 'react'
import { Badge, ChipInput, ChipSelect, Search } from '@sim/emcn'
import { formatRelativeTime } from '@sim/utils/formatting'
import { ArrowRight, Paperclip } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import {
type InboxStatusFilter,
inboxTaskParsers,
inboxTaskUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/components/inbox/search-params'
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'
const STATUS_OPTIONS = [
{ value: 'all', label: 'All statuses' },
{ value: 'completed', label: 'Completed' },
{ value: 'processing', label: 'Processing' },
{ value: 'received', label: 'Received' },
{ value: 'failed', label: 'Failed' },
{ value: 'rejected', label: 'Rejected' },
] as const
type StatusFilter = InboxStatusFilter
/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const
const STATUS_BADGES: Record<
string,
{ label: string; variant: 'gray' | 'amber' | 'green' | 'red' | 'gray-secondary' }
> = {
received: { label: 'Received', variant: 'gray' },
processing: { label: 'Processing', variant: 'amber' },
completed: { label: 'Complete', variant: 'green' },
failed: { label: 'Failed', variant: 'red' },
rejected: { label: 'Rejected', variant: 'gray-secondary' },
}
export function InboxTaskList() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const [{ status: statusFilter, search: searchTerm }, setInboxFilters] = useQueryStates(
inboxTaskParsers,
inboxTaskUrlKeys
)
/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. Filtering below is cheap in-memory over the loaded
* tasks, so it reads the instant value too.
*/
const setSearchTerm = useCallback(
(value: string) => {
const next = value.length > 0 ? value : null
setInboxFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setInboxFilters]
)
const { data: config } = useInboxConfig(workspaceId)
const { data: tasksData, isLoading } = useInboxTasks(workspaceId, {
status: statusFilter,
})
const filteredTasks = useMemo(() => {
if (!tasksData?.tasks) return []
if (!searchTerm.trim()) return tasksData.tasks
const term = searchTerm.toLowerCase()
return tasksData.tasks.filter(
(t) =>
t.subject?.toLowerCase().includes(term) ||
t.fromEmail?.toLowerCase().includes(term) ||
t.bodyPreview?.toLowerCase().includes(term)
)
}, [tasksData?.tasks, searchTerm])
const handleTaskClick = useCallback(
(task: InboxTaskItem) => {
if (task.chatId && (task.status === 'completed' || task.status === 'failed')) {
router.push(`/workspace/${workspaceId}/chat/${task.chatId}`)
}
},
[workspaceId, router]
)
return (
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search tasks...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='min-w-0 flex-1'
/>
<ChipSelect
align='start'
value={statusFilter}
onChange={(value) => {
if (STATUS_OPTIONS.some((option) => option.value === value)) {
setInboxFilters({ status: value as StatusFilter })
}
}}
options={STATUS_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value }))}
/>
</div>
<div className='min-h-0 flex-1 overflow-y-auto'>
{isLoading ? null : filteredTasks.length === 0 ? (
searchTerm.trim() ? (
<SettingsEmptyState variant='inline'>
{`No tasks matching "${searchTerm}"`}
</SettingsEmptyState>
) : (
<SettingsEmptyState>
{config?.address
? `No email tasks yet. Send an email to ${config.address} to get started.`
: 'No email tasks yet.'}
</SettingsEmptyState>
)
) : (
<div className='flex flex-col gap-0.5'>
{filteredTasks.map((task) => {
const statusBadge = STATUS_BADGES[task.status] || STATUS_BADGES.received
const isClickable =
task.chatId && (task.status === 'completed' || task.status === 'failed')
const rowClassName = `flex w-full items-center gap-2.5 rounded-lg p-2 text-left transition-colors ${
isClickable
? 'cursor-pointer hover-hover:bg-[var(--surface-active)]'
: 'cursor-default'
}`
const rowContent = (
<>
<div className='flex min-w-0 flex-1 flex-col'>
<div className='flex min-w-0 items-center gap-1.5'>
<span className='truncate text-[var(--text-body)] text-sm'>
{task.subject}
</span>
{task.hasAttachments && (
<Paperclip className='size-[12px] flex-shrink-0 text-[var(--text-muted)]' />
)}
</div>
<span className='truncate text-[var(--text-muted)] text-caption'>
{task.fromName || task.fromEmail}
</span>
{task.status === 'rejected' && task.rejectionReason && (
<span className='truncate text-[var(--text-muted)] text-caption line-through'>
{formatRejectionReason(task.rejectionReason)}
</span>
)}
{task.status === 'failed' && task.errorMessage && (
<span className='truncate text-[var(--text-error)] text-caption'>
{task.errorMessage}
</span>
)}
{task.status === 'completed' && task.resultSummary && (
<span className='truncate text-[var(--text-muted)] text-caption'>
{task.resultSummary}
</span>
)}
{task.status !== 'completed' &&
task.status !== 'failed' &&
task.status !== 'rejected' &&
task.bodyPreview && (
<span className='truncate text-[var(--text-muted)] text-caption'>
{task.bodyPreview}
</span>
)}
</div>
<div className='flex flex-shrink-0 items-center gap-2'>
<span className='whitespace-nowrap text-[var(--text-muted)] text-caption'>
{formatRelativeTime(task.createdAt)}
</span>
<Badge variant={statusBadge.variant} className='text-xs'>
{task.status === 'processing' && (
<span className='mr-1 inline-block size-[6px] animate-pulse rounded-full bg-[var(--badge-amber-text)]' />
)}
{statusBadge.label}
</Badge>
{isClickable && (
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
)}
</div>
</>
)
return isClickable ? (
<button
key={task.id}
type='button'
className={rowClassName}
onClick={() => handleTaskClick(task)}
>
{rowContent}
</button>
) : (
<div key={task.id} className={rowClassName}>
{rowContent}
</div>
)
})}
</div>
)}
</div>
</div>
)
}
function formatRejectionReason(reason: string): string {
switch (reason) {
case 'sender_not_allowed':
return 'Sender not allowed'
case 'automated_sender':
return 'Automated sender'
case 'rate_limit_exceeded':
return 'Rate limit exceeded'
case 'not_entitled':
return 'Plan no longer includes Sim Mailer'
default:
return reason
}
}
@@ -0,0 +1 @@
export { InboxTaskList } from './inbox-task-list'
@@ -0,0 +1,3 @@
export { InboxEnableToggle } from './inbox-enable-toggle'
export { InboxSettingsTab } from './inbox-settings-tab'
export { InboxTaskList } from './inbox-task-list'
@@ -0,0 +1,82 @@
'use client'
import { Chip } from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
InboxEnableToggle,
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 { useInboxConfig } from '@/hooks/queries/inbox'
export function Inbox() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { data: config, isLoading } = useInboxConfig(workspaceId)
const { canAdmin } = useUserPermissionsContext()
if (isLoading) {
return null
}
if (!config?.entitled) {
if (config?.enabled && canAdmin) {
return (
<SettingsPanel>
<InboxEnableToggle />
</SettingsPanel>
)
}
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 flex-col items-center justify-center gap-4 py-20'>
<div className='text-center'>
<h3 className='font-medium text-[var(--text-primary)] text-md'>
Sim Mailer requires an active Max plan
</h3>
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>
Upgrade to Max and ensure billing is active to receive tasks via email and let Sim
work on your behalf.
</p>
</div>
<Chip
variant='primary'
rightIcon={ArrowRight}
onClick={() => router.push(`/workspace/${workspaceId}/settings/billing`)}
>
Upgrade to Max
</Chip>
</div>
</div>
</div>
</div>
)
}
return (
<SettingsPanel>
<InboxEnableToggle />
{config?.enabled && (
<>
<InboxSettingsTab />
<SettingsSection label='Inbox'>
<p className='mb-3 text-[var(--text-muted)] text-caption'>
Email tasks received by this workspace.
</p>
<InboxTaskList />
</SettingsSection>
</>
)}
</SettingsPanel>
)
}
@@ -0,0 +1 @@
export { Inbox } from './inbox'
@@ -0,0 +1,32 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
/** Selectable status filters for the inbox task list. */
export const INBOX_STATUS_FILTERS = [
'all',
'completed',
'processing',
'received',
'failed',
'rejected',
] as const
export type InboxStatusFilter = (typeof INBOX_STATUS_FILTERS)[number]
/**
* Co-located, typed URL query-param definitions for the inbox task list.
*
* - `status` is the active status filter (feeds the tasks query key).
* - `search` is the subject/sender/body name filter. The input is controlled
* directly by the nuqs value; only its URL write is debounced via
* `limitUrlUpdates` (`debounce`) on the setter — never written per keystroke.
*/
export const inboxTaskParsers = {
status: parseAsStringLiteral(INBOX_STATUS_FILTERS).withDefault('all'),
search: parseAsString.withDefault(''),
} as const
/** Status/search view-state: clean URLs, no back-stack churn. */
export const inboxTaskUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1,5 @@
export {
type McpServerFormConfig,
McpServerFormModal,
type McpServerFormModalProps,
} from './mcp-server-form-modal'
@@ -0,0 +1,5 @@
export {
type McpServerFormConfig,
McpServerFormModal,
type McpServerFormModalProps,
} from './mcp-server-form-modal'
@@ -0,0 +1,816 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
Button,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
type ChipModalFooterAction,
ChipModalHeader,
cn,
SecretInput,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ChevronDown, ChevronRight } from 'lucide-react'
import type { McpAuthType, McpTransport } from '@/lib/mcp/types'
import {
checkEnvVarTrigger,
EnvVarDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { useMcpServerTest } from '@/hooks/queries/mcp'
const logger = createLogger('McpServerFormModal')
interface HeaderEntry {
key: string
value: string
}
interface McpServerFormData {
name: string
transport: McpTransport
url?: string
timeout?: number
headers?: HeaderEntry[]
oauthClientId?: string
oauthClientSecret?: string
hasOauthClientSecret?: boolean
}
export interface McpServerFormConfig {
name: string
transport: McpTransport
url: string
headers: Record<string, string>
timeout: number
oauthClientId?: string
oauthClientSecret?: string
authType?: McpAuthType
}
export interface McpServerFormModalProps {
open: boolean
onOpenChange: (open: boolean) => void
mode: 'add' | 'edit'
initialData?: McpServerFormData
onSubmit: (config: McpServerFormConfig) => Promise<void>
workspaceId: string
availableEnvVars?: Set<string>
allowedMcpDomains: string[] | null
}
const ENV_VAR_PATTERN = /\{\{[^}]+\}\}/
function hasEnvVarInHostname(url: string): boolean {
const globalPattern = new RegExp(ENV_VAR_PATTERN.source, 'g')
if (url.trim().replace(globalPattern, '').trim() === '') return true
const protocolEnd = url.indexOf('://')
if (protocolEnd === -1) return ENV_VAR_PATTERN.test(url)
const afterProtocol = url.substring(protocolEnd + 3)
const authorityEnd = afterProtocol.search(/[/?#]/)
const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd)
return ENV_VAR_PATTERN.test(authority)
}
function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean {
if (allowedDomains === null) return true
if (!url) return false
if (hasEnvVarInHostname(url)) return true
try {
const hostname = new URL(url).hostname.toLowerCase()
return allowedDomains.includes(hostname)
} catch {
return false
}
}
const DEFAULT_FORM_DATA: McpServerFormData = {
name: '',
transport: 'streamable-http',
url: '',
timeout: 30000,
headers: [{ key: '', value: '' }],
}
type InputFieldType = 'url' | 'header-key' | 'header-value'
interface EnvVarDropdownConfig {
searchTerm: string
cursorPosition: number
workspaceId: string
onSelect: (value: string) => void
onClose: () => void
}
function getTestButtonLabel(
testResult: { success: boolean; error?: string; authRequired?: boolean } | null,
isTestingConnection: boolean
): string {
if (isTestingConnection) return 'Testing...'
if (testResult?.success) return 'Connection success'
if (testResult?.authRequired) return 'Requires OAuth'
if (testResult && !testResult.success) return 'No connection: retry'
return 'Test Connection'
}
interface FormattedInputProps {
ref?: React.RefObject<HTMLInputElement | null>
placeholder: string
value: string
scrollLeft: number
showEnvVars: boolean
envVarProps: EnvVarDropdownConfig
availableEnvVars?: Set<string>
className?: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onScroll: (scrollLeft: number) => void
}
function FormattedInput({
ref,
placeholder,
value,
scrollLeft,
showEnvVars,
envVarProps,
availableEnvVars,
className,
onChange,
onScroll,
}: FormattedInputProps) {
const handleScroll = (e: { currentTarget: HTMLInputElement }) => {
onScroll(e.currentTarget.scrollLeft)
}
return (
<div className={cn('relative', className)}>
<ChipInput
ref={ref}
placeholder={placeholder}
value={value}
onChange={onChange}
onScroll={handleScroll}
onInput={handleScroll}
inputClassName='font-medium font-sans text-transparent caret-[var(--text-primary)]'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-2 py-1.5 font-medium font-sans text-sm'>
<div className='whitespace-nowrap' style={{ transform: `translateX(-${scrollLeft}px)` }}>
{formatDisplayText(value, { availableEnvVars })}
</div>
</div>
{showEnvVars && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={envVarProps.onSelect}
searchTerm={envVarProps.searchTerm}
inputValue={value}
cursorPosition={envVarProps.cursorPosition}
workspaceId={envVarProps.workspaceId}
onClose={envVarProps.onClose}
className='w-full'
maxHeight='200px'
style={{ position: 'absolute', top: '100%', left: 0, zIndex: 99999 }}
/>
)}
</div>
)
}
interface HeaderRowProps {
header: HeaderEntry
index: number
headerScrollLeft: Record<string, number>
showEnvVars: boolean
activeInputField: InputFieldType | null
activeHeaderIndex: number | null
envSearchTerm: string
cursorPosition: number
workspaceId: string
availableEnvVars?: Set<string>
onInputChange: (field: InputFieldType, value: string, index?: number) => void
onHeaderScroll: (key: string, scrollLeft: number) => void
onEnvVarSelect: (value: string) => void
onEnvVarClose: () => void
}
function HeaderRow({
header,
index,
headerScrollLeft,
showEnvVars,
activeInputField,
activeHeaderIndex,
envSearchTerm,
cursorPosition,
workspaceId,
availableEnvVars,
onInputChange,
onHeaderScroll,
onEnvVarSelect,
onEnvVarClose,
}: HeaderRowProps) {
const isKeyActive =
showEnvVars && activeInputField === 'header-key' && activeHeaderIndex === index
const isValueActive =
showEnvVars && activeInputField === 'header-value' && activeHeaderIndex === index
const envVarProps: EnvVarDropdownConfig = {
searchTerm: envSearchTerm,
cursorPosition,
workspaceId,
onSelect: onEnvVarSelect,
onClose: onEnvVarClose,
}
return (
<div className='relative flex items-center gap-2'>
<FormattedInput
placeholder='Name'
value={header.key || ''}
scrollLeft={headerScrollLeft[`key-${index}`] || 0}
showEnvVars={isKeyActive}
envVarProps={envVarProps}
availableEnvVars={availableEnvVars}
className='flex-1'
onChange={(e) => onInputChange('header-key', e.target.value, index)}
onScroll={(sl) => onHeaderScroll(`key-${index}`, sl)}
/>
<FormattedInput
placeholder='Value'
value={header.value || ''}
scrollLeft={headerScrollLeft[`value-${index}`] || 0}
showEnvVars={isValueActive}
envVarProps={envVarProps}
availableEnvVars={availableEnvVars}
className='flex-1'
onChange={(e) => onInputChange('header-value', e.target.value, index)}
onScroll={(sl) => onHeaderScroll(`value-${index}`, sl)}
/>
</div>
)
}
function headersToRecord(headers: HeaderEntry[] | undefined): Record<string, string> {
const record: Record<string, string> = {}
for (const header of headers || []) {
if (header.key.trim()) {
record[header.key] = header.value
}
}
return record
}
function extractStringHeaders(headers: unknown): Record<string, string> {
if (typeof headers !== 'object' || headers === null) return {}
return Object.fromEntries(
Object.entries(headers).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
}
/**
* Updates a headers array with auto-add (new empty row when typing in last)
* and auto-remove (drop non-last empty rows).
*/
function updateHeadersArray(
headers: HeaderEntry[],
index: number,
field: 'key' | 'value',
value: string
): HeaderEntry[] {
const updated = [...headers]
if (updated[index]) {
updated[index] = { ...updated[index], [field]: value }
}
const lastIdx = updated.length - 1
if (index === lastIdx && updated[lastIdx] && (updated[lastIdx].key || updated[lastIdx].value)) {
updated.push({ key: '', value: '' })
}
const lastIndex = updated.length - 1
return updated.filter((h, i) => i === lastIndex || h.key !== '' || h.value !== '')
}
export function McpServerFormModal({
open,
onOpenChange,
mode,
initialData,
onSubmit,
workspaceId,
availableEnvVars,
allowedMcpDomains,
}: McpServerFormModalProps) {
const urlInputRef = useRef<HTMLInputElement>(null)
const [formData, setFormData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
const [originalData, setOriginalData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
const [formMode, setFormMode] = useState<'form' | 'json'>('form')
const [jsonInput, setJsonInput] = useState('')
const [jsonError, setJsonError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
const [showEnvVars, setShowEnvVars] = useState(false)
const [envSearchTerm, setEnvSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
const [activeInputField, setActiveInputField] = useState<InputFieldType | null>(null)
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
const [showAdvanced, setShowAdvanced] = useState(false)
const [oauthClientSecretTouched, setOauthClientSecretTouched] = useState(false)
const [prevOpen, setPrevOpen] = useState(false)
if (open && !prevOpen) {
const data = initialData ?? DEFAULT_FORM_DATA
setFormData(data)
setOriginalData(structuredClone(data))
setFormMode('form')
setJsonInput('')
setJsonError(null)
setIsSubmitting(false)
setSubmitError(null)
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
setUrlScrollLeft(0)
setHeaderScrollLeft({})
setShowAdvanced(!!(data.oauthClientId || data.oauthClientSecret || data.hasOauthClientSecret))
setOauthClientSecretTouched(false)
}
if (open !== prevOpen) {
setPrevOpen(open)
}
// Clear stale TanStack Query mutation state when the modal opens.
// mutation.reset() is a side effect that can't be called during render.
useEffect(() => {
if (open) {
clearTestResult()
}
}, [open, clearTestResult])
const resetEnvVarState = () => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}
const handleInputChange = (field: InputFieldType, value: string, headerIndex?: number) => {
const input = document.activeElement as HTMLInputElement
const pos = input?.selectionStart || 0
setCursorPosition(pos)
if (testResult) clearTestResult()
if (submitError) setSubmitError(null)
const envVarTrigger = checkEnvVarTrigger(value, pos)
setShowEnvVars(envVarTrigger.show)
setEnvSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
if (envVarTrigger.show) {
setActiveInputField(field)
setActiveHeaderIndex(headerIndex ?? null)
} else {
resetEnvVarState()
}
if (field === 'url') {
setFormData((prev) => ({ ...prev, url: value }))
} else if (headerIndex !== undefined) {
const headerField = field === 'header-key' ? 'key' : 'value'
setFormData((prev) => ({
...prev,
headers: updateHeadersArray(prev.headers || [], headerIndex, headerField, value),
}))
}
}
const handleEnvVarSelect = (newValue: string) => {
if (activeInputField === 'url') {
setFormData((prev) => ({ ...prev, url: newValue }))
} else if (activeHeaderIndex !== null) {
const field = activeInputField === 'header-key' ? 'key' : 'value'
const processedValue = field === 'key' ? newValue.replace(/[{}]/g, '') : newValue
setFormData((prev) => ({
...prev,
headers: updateHeadersArray(prev.headers || [], activeHeaderIndex, field, processedValue),
}))
}
resetEnvVarState()
}
const handleHeaderScroll = (key: string, sl: number) => {
setHeaderScrollLeft((prev) => ({ ...prev, [key]: sl }))
}
const isDomainBlocked =
!!formData.url?.trim() && !isDomainAllowed(formData.url, allowedMcpDomains)
const isFormValid = !!(formData.name.trim() && formData.url?.trim())
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
const computeHasChanges = (): boolean => {
if (mode === 'add') return true
if (formData.name !== originalData.name) return true
if (formData.url !== originalData.url) return true
if (formData.transport !== originalData.transport) return true
if ((formData.oauthClientId || '') !== (originalData.oauthClientId || '')) return true
if (oauthClientSecretTouched) return true
const currentHeaders = formData.headers || []
const origHeaders = originalData.headers || []
if (currentHeaders.length !== origHeaders.length) return true
for (let i = 0; i < currentHeaders.length; i++) {
if (
currentHeaders[i].key !== origHeaders[i].key ||
currentHeaders[i].value !== origHeaders[i].value
)
return true
}
return false
}
const hasChanges = computeHasChanges()
const parseJsonConfig = (
json: string
): { name: string; url: string; headers: Record<string, string> } | null => {
try {
const parsed = JSON.parse(json)
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
const entries = Object.entries(parsed.mcpServers)
if (entries.length === 0) {
setJsonError('No servers found in mcpServers')
return null
}
if (entries.length > 1) {
setJsonError(
`Only the first server ("${entries[0][0]}") will be imported. Paste each config separately to add others.`
)
}
const [name, config] = entries[0] as [string, Record<string, unknown>]
if (!config.url || typeof config.url !== 'string') {
setJsonError('Server config must include a "url" field')
return null
}
if (entries.length <= 1) setJsonError(null)
return { name, url: config.url, headers: extractStringHeaders(config.headers) }
}
if (parsed.url && typeof parsed.url === 'string') {
setJsonError(null)
return { name: '', url: parsed.url, headers: extractStringHeaders(parsed.headers) }
}
setJsonError('JSON must contain "mcpServers" or a "url" field')
return null
} catch {
setJsonError('Invalid JSON')
return null
}
}
const handleTestConnection = async () => {
if (!isFormValid) return
await testConnection({
name: formData.name,
transport: formData.transport,
url: formData.url!,
headers: headersToRecord(formData.headers),
timeout: formData.timeout,
workspaceId,
})
}
const handleSubmitForm = async () => {
if (!isFormValid || isDomainBlocked) return
setIsSubmitting(true)
setSubmitError(null)
try {
const headers = headersToRecord(formData.headers)
const oauthClientId = formData.oauthClientId?.trim()
const oauthClientSecret = formData.oauthClientSecret?.trim()
const originalClientId = (originalData.oauthClientId || '').trim()
const oauthClientIdChanged = (oauthClientId || '') !== originalClientId
const connectionResult = await testConnection({
name: formData.name,
transport: formData.transport,
url: formData.url!,
headers,
timeout: formData.timeout,
workspaceId,
})
if (!connectionResult.success && !connectionResult.authRequired) {
setSubmitError(
connectionResult.error || 'Connection test failed. Please check the URL and try again.'
)
return
}
await onSubmit({
name: formData.name.trim(),
transport: formData.transport,
url: formData.url!,
headers,
timeout: formData.timeout || 30000,
authType: connectionResult.authType,
oauthClientId:
mode === 'edit'
? oauthClientIdChanged
? (oauthClientId ?? '')
: undefined
: oauthClientId || undefined,
oauthClientSecret:
mode === 'edit'
? oauthClientSecretTouched
? (oauthClientSecret ?? '')
: undefined
: oauthClientSecret || undefined,
})
onOpenChange(false)
} catch (error) {
setSubmitError(getErrorMessage(error, 'Failed to save server'))
logger.error('Failed to save MCP server:', error)
} finally {
setIsSubmitting(false)
}
}
const handleSubmitJson = async () => {
const config = parseJsonConfig(jsonInput)
if (!config) return
if (!config.name) {
setJsonError('Server name is required. Use: { "mcpServers": { "name": { "url": "..." } } }')
return
}
if (!isDomainAllowed(config.url, allowedMcpDomains)) {
setJsonError('Domain not permitted by server policy')
return
}
setIsSubmitting(true)
setSubmitError(null)
try {
const connectionResult = await testConnection({
name: config.name,
transport: 'streamable-http',
url: config.url,
headers: config.headers,
timeout: 30000,
workspaceId,
})
if (!connectionResult.success && !connectionResult.authRequired) {
setSubmitError(
connectionResult.error || 'Connection test failed. Please check the URL and try again.'
)
return
}
await onSubmit({
name: config.name,
transport: 'streamable-http',
url: config.url,
headers: config.headers,
timeout: 30000,
authType: connectionResult.authType,
})
onOpenChange(false)
} catch (error) {
setSubmitError(getErrorMessage(error, 'Failed to save server'))
logger.error('Failed to save MCP server from JSON:', error)
} finally {
setIsSubmitting(false)
}
}
const isSubmitDisabled =
isSubmitting || !isFormValid || isDomainBlocked || (mode === 'edit' && !hasChanges)
const title = mode === 'add' ? 'Add New MCP Server' : 'Edit MCP Server'
const submitLabel = mode === 'add' ? 'Add MCP' : 'Save'
const handleToggleJsonMode = () => {
if (testResult) clearTestResult()
setFormMode(formMode === 'form' ? 'json' : 'form')
setJsonError(null)
setSubmitError(null)
}
const secondaryAction: ChipModalFooterAction | undefined =
mode === 'add'
? {
label: formMode === 'form' ? 'Edit JSON' : 'Edit Form',
onClick: handleToggleJsonMode,
}
: formMode === 'form'
? {
label: testButtonLabel,
onClick: handleTestConnection,
disabled: isTestingConnection || !isFormValid || isDomainBlocked,
}
: undefined
const primaryAction: ChipModalFooterAction =
formMode === 'json'
? {
label: isSubmitting ? 'Adding...' : submitLabel,
onClick: handleSubmitJson,
disabled: isSubmitting || !jsonInput.trim(),
}
: {
label: isSubmitting ? (mode === 'add' ? 'Adding...' : 'Saving...') : submitLabel,
onClick: handleSubmitForm,
disabled: isSubmitDisabled,
}
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={title} size='lg'>
<ChipModalHeader onClose={() => onOpenChange(false)}>{title}</ChipModalHeader>
<ChipModalBody>
{formMode === 'json' ? (
<ChipModalField
type='textarea'
title='Configuration'
value={jsonInput}
onChange={(value) => {
setJsonInput(value)
if (jsonError) setJsonError(null)
if (testResult) clearTestResult()
if (submitError) setSubmitError(null)
}}
placeholder={`{\n "mcpServers": {\n "server-name": {\n "url": "https://...",\n "headers": {\n "X-API-Key": "..."\n }\n }\n }\n}`}
minHeight={280}
resizable
error={jsonError}
/>
) : (
<>
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
aria-hidden='true'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='password'
name='fakepasswordremembered'
autoComplete='current-password'
aria-hidden='true'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<ChipModalField
type='input'
title='Server Name'
value={formData.name}
onChange={(value) => {
if (testResult) clearTestResult()
if (submitError) setSubmitError(null)
setFormData((prev) => ({ ...prev, name: value }))
}}
placeholder='e.g., My MCP Server'
/>
<ChipModalField
type='custom'
title='Server URL'
error={isDomainBlocked ? 'Domain not permitted by server policy' : undefined}
>
<FormattedInput
ref={urlInputRef}
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
value={formData.url || ''}
scrollLeft={urlScrollLeft}
showEnvVars={showEnvVars && activeInputField === 'url'}
envVarProps={{
searchTerm: envSearchTerm,
cursorPosition,
workspaceId,
onSelect: handleEnvVarSelect,
onClose: resetEnvVarState,
}}
availableEnvVars={availableEnvVars}
onChange={(e) => handleInputChange('url', e.target.value)}
onScroll={setUrlScrollLeft}
/>
</ChipModalField>
<ChipModalField type='custom' title='Headers'>
<div className='flex max-h-[140px] flex-col gap-2 overflow-y-auto'>
{(formData.headers || []).map((header, index) => (
<HeaderRow
key={index}
header={header}
index={index}
headerScrollLeft={headerScrollLeft}
showEnvVars={showEnvVars}
activeInputField={activeInputField}
activeHeaderIndex={activeHeaderIndex}
envSearchTerm={envSearchTerm}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
onInputChange={handleInputChange}
onHeaderScroll={handleHeaderScroll}
onEnvVarSelect={handleEnvVarSelect}
onEnvVarClose={resetEnvVarState}
/>
))}
</div>
</ChipModalField>
<Button
type='button'
variant='ghost'
onClick={() => setShowAdvanced((v) => !v)}
className='gap-1 self-start px-2 py-0 text-small'
>
{showAdvanced ? (
<ChevronDown className='size-[14px]' />
) : (
<ChevronRight className='size-[14px]' />
)}
Advanced settings
</Button>
{showAdvanced && (
<>
<ChipModalField type='custom' title='Client ID'>
<ChipInput
placeholder='OAuth Client ID (optional)'
value={formData.oauthClientId || ''}
name='mcp_oauth_client_id'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
onChange={(e) => {
if (testResult) clearTestResult()
if (submitError) setSubmitError(null)
setFormData((prev) => ({ ...prev, oauthClientId: e.target.value }))
}}
/>
</ChipModalField>
<ChipModalField
type='custom'
title='Client Secret'
hint="Only needed for servers that don't support automatic client registration."
>
<SecretInput
placeholder='OAuth Client Secret (optional)'
value={formData.oauthClientSecret || ''}
name='mcp_oauth_client_secret'
autoComplete='new-password'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
onChange={(value) => {
if (testResult) clearTestResult()
if (submitError) setSubmitError(null)
setOauthClientSecretTouched(value.length > 0)
setFormData((prev) => ({ ...prev, oauthClientSecret: value }))
}}
/>
</ChipModalField>
</>
)}
</>
)}
<ChipModalError>{submitError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
secondaryActions={secondaryAction ? [secondaryAction] : undefined}
primaryAction={primaryAction}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export { MCP } from './mcp'
@@ -0,0 +1,689 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Badge, Button, Chip, ChipConfirmModal, cn, Tooltip } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ChevronDown, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
import {
getIssueBadgeLabel,
getIssueBadgeVariant,
getMcpToolIssue,
type McpToolIssue,
} from '@/lib/mcp/tool-validation'
import type { McpTransport } from '@/lib/mcp/types'
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 {
type McpServer,
type McpTool,
useAllowedMcpDomains,
useCreateMcpServer,
useDeleteMcpServer,
useForceRefreshMcpTools,
useMcpServers,
useMcpToolsQuery,
useRefreshMcpServer,
useStoredMcpTools,
useUpdateMcpServer,
} from '@/hooks/queries/mcp'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { McpServerFormModal } from './components'
const logger = createLogger('McpSettings')
function formatTransportLabel(transport: string): string {
return transport
.split('-')
.map((word) =>
['http', 'sse', 'stdio'].includes(word.toLowerCase())
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join('-')
}
function formatToolsLabel(tools: McpTool[], connectionStatus?: string): string {
if (connectionStatus === 'error') {
return 'Unable to connect'
}
const count = tools.length
const plural = count !== 1 ? 's' : ''
const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : ''
return `${count} tool${plural}${names}`
}
interface ServerListItemProps {
server: McpServer
tools: McpTool[]
isDeleting: boolean
isLoadingTools?: boolean
isRefreshing?: boolean
onRemove: () => void
onViewDetails: () => void
}
function ServerListItem({
server,
tools,
isDeleting,
isLoadingTools = false,
isRefreshing = false,
onRemove,
onViewDetails,
}: ServerListItemProps) {
const transportLabel = formatTransportLabel(server.transport || 'http')
const toolsLabel = formatToolsLabel(tools, server.connectionStatus)
const isError = server.connectionStatus === 'error'
return (
<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-[200px] truncate text-[var(--text-body)] text-sm'>
{server.name || 'Unnamed Server'}
</span>
<span className='text-[var(--text-muted)] text-caption'>({transportLabel})</span>
</div>
<p
className={cn(
'truncate text-sm',
isError ? 'text-[var(--text-error)]' : 'text-[var(--text-muted)]'
)}
>
{isRefreshing
? 'Refreshing...'
: isLoadingTools && tools.length === 0
? 'Loading...'
: toolsLabel}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-1'>
<RowActionsMenu
label='Server actions'
actions={[
{ label: 'Details', onSelect: onViewDetails },
{ label: 'Delete', destructive: true, disabled: isDeleting, onSelect: onRemove },
]}
/>
</div>
</div>
)
}
function buildEditInitialData(server: McpServer) {
const entries: { key: string; value: string }[] = server.headers
? Object.entries(server.headers).map(([key, value]) => ({ key, value }))
: []
if (entries.length === 0) entries.push({ key: '', value: '' })
const last = entries[entries.length - 1]
if (last.key !== '' || last.value !== '') entries.push({ key: '', value: '' })
return {
name: server.name || '',
transport: (server.transport as McpTransport) || 'streamable-http',
url: server.url || '',
timeout: 30000,
headers: entries,
oauthClientId: server.oauthClientId || undefined,
hasOauthClientSecret: server.hasOauthClientSecret === true,
}
}
export function MCP() {
const params = useParams()
const workspaceId = params.workspaceId as string
const {
data: servers = [],
isLoading: serversLoading,
error: serversError,
} = useMcpServers(workspaceId)
const {
data: mcpToolsData = [],
error: toolsError,
toolsStateByServer,
} = useMcpToolsQuery(workspaceId)
const { data: storedTools = [], refetch: refetchStoredTools } = useStoredMcpTools(workspaceId)
const forceRefreshToolsMutation = useForceRefreshMcpTools()
const forceRefreshTools = forceRefreshToolsMutation.mutate
const createServerMutation = useCreateMcpServer()
const deleteServerMutation = useDeleteMcpServer()
const refreshServerMutation = useRefreshMcpServer()
const updateServerMutation = useUpdateMcpServer()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const [showAddModal, setShowAddModal] = useState(false)
const [editingServerId, setEditingServerId] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [deletingServers, setDeletingServers] = useState<Set<string>>(() => new Set())
const { connectingServers: connectingOauthServers, startOauthForServer } = useMcpOauthPopup({
workspaceId,
})
const [serverToDeleteId, setServerToDeleteId] = useState<string | null>(null)
const showDeleteDialog = serverToDeleteId !== null
const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, {
...mcpServerIdParam.parser,
...mcpServerIdUrlKeys,
})
const initialServerIdRef = useRef(selectedServerId)
const didDeepLinkRefreshRef = useRef(false)
useEffect(() => {
if (didDeepLinkRefreshRef.current) return
if (!initialServerIdRef.current) return
didDeepLinkRefreshRef.current = true
forceRefreshTools(workspaceId)
refetchStoredTools()
}, [workspaceId, forceRefreshTools, refetchStoredTools])
const [expandedTools, setExpandedTools] = useState<Set<string>>(() => new Set())
const handleRemoveServer = (serverId: string) => {
setServerToDeleteId(serverId)
}
const confirmDeleteServer = async () => {
if (!serverToDeleteId) return
const serverId = serverToDeleteId
setServerToDeleteId(null)
setDeletingServers((prev) => new Set(prev).add(serverId))
try {
await deleteServerMutation.mutateAsync({ workspaceId, serverId })
logger.info(`Removed MCP server: ${serverId}`)
} catch (error) {
logger.error('Failed to remove MCP server:', error)
} finally {
setDeletingServers((prev) => {
const newSet = new Set(prev)
newSet.delete(serverId)
return newSet
})
}
}
const toolsByServer = (mcpToolsData || []).reduce(
(acc, tool) => {
if (!tool?.serverId) return acc
if (!acc[tool.serverId]) {
acc[tool.serverId] = []
}
acc[tool.serverId].push(tool)
return acc
},
{} as Record<string, typeof mcpToolsData>
)
const filteredServers = (servers || []).filter((server) =>
server.name?.toLowerCase().includes(searchTerm.toLowerCase())
)
const handleViewDetails = (serverId: string) => {
setSelectedServerId(serverId)
forceRefreshTools(workspaceId)
refetchStoredTools()
}
const handleBackToList = () => {
setSelectedServerId(null)
setExpandedTools(new Set())
}
const toggleToolExpanded = (toolName: string) => {
setExpandedTools((prev) => {
const newSet = new Set(prev)
if (newSet.has(toolName)) {
newSet.delete(toolName)
} else {
newSet.add(toolName)
}
return newSet
})
}
const handleRefreshServer = async (serverId: string) => {
try {
const result = await refreshServerMutation.mutateAsync({ workspaceId, serverId })
logger.info(
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
)
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)
try {
const { data: workflowData } = await requestJson(getWorkflowStateContract, {
params: { id: activeWorkflowId },
})
if (workflowData?.state?.blocks) {
useSubBlockStore
.getState()
.initializeFromWorkflow(
activeWorkflowId,
workflowData.state.blocks as Record<string, BlockState>
)
}
} catch (reloadError) {
logger.warn('Failed to reload workflow subblock values:', reloadError)
}
}
} catch (error) {
logger.error('Failed to refresh MCP server:', error)
}
}
useEffect(() => {
if (!refreshServerMutation.isSuccess) return
const timeout = window.setTimeout(() => refreshServerMutation.reset(), 3000)
return () => window.clearTimeout(timeout)
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation object is unstable; isSuccess flag is the trigger
}, [refreshServerMutation.isSuccess])
const refreshingServerId = refreshServerMutation.isPending
? refreshServerMutation.variables?.serverId
: null
const refreshedServerId = refreshServerMutation.isSuccess
? refreshServerMutation.variables?.serverId
: null
const refreshedWorkflowsUpdated = refreshServerMutation.data?.workflowsUpdated
const editingServer = editingServerId
? (servers.find((s) => s.id === editingServerId) as McpServer | undefined)
: undefined
const editInitialData = editingServer ? buildEditInitialData(editingServer) : undefined
const selectedServer = (() => {
if (!selectedServerId) return null
const server = servers.find((s) => s.id === selectedServerId) as McpServer | undefined
if (!server) return null
const serverTools = (toolsByServer[selectedServerId] || []) as McpTool[]
return { server, tools: serverTools }
})()
const getStoredToolIssues = (
serverId: string,
toolName: string
): { issue: McpToolIssue; workflowName: string }[] => {
const relevantStoredTools = storedTools.filter(
(st) => st.serverId === serverId && st.toolName === toolName
)
const serverStates = servers.map((s) => ({
id: s.id,
url: s.url,
connectionStatus: s.connectionStatus,
lastError: s.lastError || undefined,
}))
const discoveredTools = mcpToolsData.map((t) => ({
serverId: t.serverId,
name: t.name,
inputSchema: t.inputSchema,
}))
const issues: { issue: McpToolIssue; workflowName: string }[] = []
for (const storedTool of relevantStoredTools) {
const issue = getMcpToolIssue(
{
serverId: storedTool.serverId,
serverUrl: storedTool.serverUrl,
toolName: storedTool.toolName,
schema: storedTool.schema,
},
serverStates,
discoveredTools
)
if (issue) {
issues.push({ issue, workflowName: storedTool.workflowName })
}
}
return issues
}
const error = toolsError || serversError
const hasServers = servers && servers.length > 0
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
if (selectedServer) {
const { server, tools } = selectedServer
const transportLabel = formatTransportLabel(server.transport || 'http')
const refreshLabel =
refreshingServerId === server.id
? 'Refreshing...'
: refreshedServerId === server.id
? refreshedWorkflowsUpdated
? `Synced (${refreshedWorkflowsUpdated} workflow${refreshedWorkflowsUpdated === 1 ? '' : 's'})`
: 'Refreshed'
: 'Refresh tools'
return (
<SettingsPanel
back={{ text: 'MCP tools', icon: ArrowLeft, onSelect: handleBackToList }}
title={server.name || 'Unnamed Server'}
actions={[
{
text: refreshLabel,
onSelect: () => handleRefreshServer(server.id),
disabled: refreshingServerId === server.id || refreshedServerId === server.id,
},
{
text: 'Edit',
onSelect: () => setEditingServerId(server.id),
},
]}
>
<SettingsSection label='Server'>
<div className='flex flex-col gap-4.5'>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>Server Name</span>
<p className='text-[var(--text-body)] text-sm'>{server.name || 'Unnamed Server'}</p>
</div>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>Transport</span>
<p className='text-[var(--text-body)] text-sm'>{transportLabel}</p>
</div>
{server.url && (
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>URL</span>
<p className='break-all text-[var(--text-body)] text-sm'>{server.url}</p>
</div>
)}
{server.connectionStatus === 'error' && (
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>Status</span>
<p className='text-[var(--text-error)] text-sm'>
{server.lastError || 'Unable to connect'}
</p>
</div>
)}
{server.authType === 'oauth' && server.connectionStatus !== 'connected' && (
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>Authentication</span>
<div>
<Chip
variant='primary'
disabled={connectingOauthServers.has(server.id)}
onClick={async () => {
await startOauthForServer(server.id)
}}
>
{connectingOauthServers.has(server.id) ? 'Connecting…' : 'Connect with OAuth'}
</Chip>
</div>
</div>
)}
</div>
</SettingsSection>
<SettingsSection label={`Tools (${tools.length})`}>
{tools.length === 0 ? (
<p className='text-[var(--text-muted)] text-sm'>No tools available</p>
) : (
<div className='flex flex-col gap-2'>
{tools.map((tool) => {
const issues = getStoredToolIssues(server.id, tool.name)
const affectedWorkflows = issues.map((i) => i.workflowName)
const isExpanded = expandedTools.has(tool.name)
const hasParams =
tool.inputSchema?.properties &&
Object.keys(tool.inputSchema.properties).length > 0
const requiredParams = tool.inputSchema?.required || []
return (
<div
key={tool.name}
className='overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-3)]'
>
<Button
type='button'
variant='ghost'
onClick={() => hasParams && toggleToolExpanded(tool.name)}
className={cn(
'flex h-auto w-full items-start justify-between rounded-none px-2.5 py-2 text-left text-sm',
hasParams && 'cursor-pointer hover-hover:bg-[var(--surface-4)]'
)}
disabled={!hasParams}
>
<div className='flex-1'>
<div className='flex h-[16px] items-center gap-1.5'>
<p className='font-medium text-[var(--text-primary)] text-sm leading-none'>
{tool.name}
</p>
{issues.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='flex items-center'>
<Badge variant={getIssueBadgeVariant(issues[0].issue)} size='sm'>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{tool.description && (
<p className='mt-1 text-[var(--text-tertiary)] text-sm'>
{tool.description}
</p>
)}
</div>
{hasParams && (
<ChevronDown
className={cn(
'mt-0.5 size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
)}
</Button>
{isExpanded && hasParams && (
<div className='border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
<p className='mb-1.5 font-medium text-[var(--text-muted)] text-xs uppercase tracking-wide'>
Parameters
</p>
<div className='flex flex-col gap-1.5'>
{Object.entries(tool.inputSchema!.properties!).map(
([paramName, param]) => {
const isRequired = requiredParams.includes(paramName)
const paramType =
typeof param === 'object' && param !== null
? (param as { type?: string }).type || 'any'
: 'any'
const paramDesc =
typeof param === 'object' && param !== null
? (param as { description?: string }).description
: undefined
return (
<div
key={paramName}
className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-3)] px-2 py-1.5'
>
<div className='flex items-center gap-1.5'>
<span className='font-medium text-[var(--text-primary)] text-small'>
{paramName}
</span>
<Badge variant='outline' size='sm'>
{paramType}
</Badge>
{isRequired && (
<Badge variant='default' size='sm'>
required
</Badge>
)}
</div>
{paramDesc && (
<p className='mt-[3px] text-[var(--text-tertiary)] text-xs leading-relaxed'>
{paramDesc}
</p>
)}
</div>
)
}
)}
</div>
</div>
)}
</div>
)
})}
</div>
)}
</SettingsSection>
<McpServerFormModal
open={editingServerId !== null}
onOpenChange={(open) => {
if (!open) setEditingServerId(null)
}}
mode='edit'
initialData={editInitialData}
onSubmit={async (config) => {
const currentServer = servers.find((s) => s.id === selectedServerId)
await updateServerMutation.mutateAsync({
workspaceId,
serverId: selectedServerId!,
updates: {
...config,
enabled: currentServer?.enabled ?? true,
},
})
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</SettingsPanel>
)
}
return (
<>
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search MCPs...',
}}
actions={[
{
text: 'Add server',
icon: Plus,
variant: 'primary',
onSelect: () => setShowAddModal(true),
disabled: serversLoading,
},
]}
>
{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 ? (
<SettingsEmptyState>Click &quot;Add server&quot; above to get started</SettingsEmptyState>
) : (
<div className='flex flex-col gap-2'>
{filteredServers.map((server) => {
if (!server?.id) return null
const tools = toolsByServer[server.id] || []
const serverToolsState = toolsStateByServer.get(server.id)
const isLoadingTools = serverToolsState
? serverToolsState.isLoading || serverToolsState.isFetching
: false
return (
<ServerListItem
key={server.id}
server={server}
tools={tools}
isDeleting={deletingServers.has(server.id)}
isLoadingTools={isLoadingTools}
isRefreshing={refreshingServerId === server.id}
onRemove={() => handleRemoveServer(server.id)}
onViewDetails={() => handleViewDetails(server.id)}
/>
)
})}
{showNoResults && (
<SettingsEmptyState variant='inline'>
No servers found matching &quot;{searchTerm}&quot;
</SettingsEmptyState>
)}
</div>
)}
</SettingsPanel>
<McpServerFormModal
open={showAddModal}
onOpenChange={setShowAddModal}
mode='add'
onSubmit={async (config) => {
const result = await createServerMutation.mutateAsync({
workspaceId,
config: { ...config, enabled: true },
})
if (result.authType === 'oauth') {
await startOauthForServer(result.serverId)
}
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
<ChipConfirmModal
open={showDeleteDialog}
onOpenChange={(open) => {
if (!open) setServerToDeleteId(null)
}}
srTitle='Delete MCP Server'
title='Delete MCP Server'
text={[
'Are you sure you want to delete ',
{
text: servers.find((s) => s.id === serverToDeleteId)?.name || 'this server',
bold: true,
},
'? This action cannot be undone.',
]}
confirm={{ label: 'Delete', onClick: confirmDeleteServer }}
/>
</>
)
}
@@ -0,0 +1 @@
export { MemberAvatar, MemberRow, MemberSection } from './member-list'
@@ -0,0 +1,98 @@
'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'
const ROW_EMAIL_CLASSES = 'min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'
const ROW_STATUS_CLASSES = 'flex-shrink-0 text-[var(--text-muted)] text-caption'
interface MemberAvatarProps {
name: string
image: string | null
}
/**
* 14px circular avatar used in member rows. Falls back to the first letter of
* the member's name when no image is available.
*/
export function MemberAvatar({ name, image }: MemberAvatarProps) {
if (image) {
return (
<img
src={image}
alt={name}
referrerPolicy='no-referrer'
className='size-[14px] flex-shrink-0 rounded-full border border-[var(--border)] object-cover'
/>
)
}
return (
<span className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{name.charAt(0).toUpperCase()}
</span>
)
}
interface MemberRowProps {
name: string
email: string
image: string | null
/** Muted trailing text, e.g. "Joined 6/3/2026" or "Invite pending". */
status: string
/** Role control rendered before the actions menu (e.g. a `ChipDropdown`). */
roleControl?: ReactNode
/** Trailing actions menu (e.g. the `...` `DropdownMenu`). */
menu?: ReactNode
}
/**
* Single member row: avatar, email, status, an optional role control, and an
* optional actions menu. Shared by the workspace Teammates page and the
* Organization page so both render identical chrome.
*/
export function MemberRow({ name, email, image, status, roleControl, menu }: MemberRowProps) {
return (
<div className={ROW_CLASSES}>
<MemberAvatar name={name} image={image} />
<span className={ROW_EMAIL_CLASSES}>{email}</span>
<span className={ROW_STATUS_CLASSES}>{status}</span>
{roleControl}
{menu}
</div>
)
}
interface MemberSectionProps {
/** Section label, e.g. "Teammates (3)" or a workspace name with a count. */
label: string
/** Renders the empty state instead of the row group. */
isEmpty?: boolean
/** Copy shown when {@link isEmpty} is true. */
emptyText?: string
/** Member rows. */
children: ReactNode
}
/**
* Labeled section wrapping a group of {@link MemberRow}s. Matches the
* Teammates section rhythm (label, divider, negative-margin row group).
*/
export function MemberSection({
label,
isEmpty = false,
emptyText = 'No members yet',
children,
}: MemberSectionProps) {
return (
<SettingsSection label={label}>
{isEmpty ? (
<SettingsEmptyState variant='inline'>{emptyText}</SettingsEmptyState>
) : (
<div className='-mx-2 flex flex-col gap-y-0.5'>{children}</div>
)}
</SettingsSection>
)
}
@@ -0,0 +1 @@
export { Mothership } from './mothership'
@@ -0,0 +1,525 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { Badge, Button, ChipInput, ChipSelect, cn, Label, Skeleton } from '@sim/emcn'
import { formatDateTime } from '@sim/utils/formatting'
import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { AnthropicIcon, OpenAIIcon } from '@/components/icons'
import {
BYOKKeyManager,
type BYOKManagerProvider,
} from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager'
import {
type MothershipTab,
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,
useDeleteMothershipByok,
useGenerateLicense,
useMothershipByokKeys,
useMothershipLicenses,
useMothershipRequests,
useMothershipUserBreakdown,
useUpsertMothershipByok,
} from '@/hooks/queries/mothership-admin'
const ENTERPRISE_BYOK_PROVIDERS: BYOKManagerProvider[] = [
{
id: 'openai',
name: 'OpenAI',
icon: OpenAIIcon,
description: 'Enterprise mothership LLM calls',
placeholder: 'sk-...',
},
{
id: 'anthropic',
name: 'Anthropic',
icon: AnthropicIcon,
description: 'Enterprise mothership LLM calls',
placeholder: 'sk-ant-...',
},
]
const TABS: { id: MothershipTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'licenses', label: 'Licenses' },
{ id: 'byok', label: 'BYOK' },
]
const ENV_OPTIONS: { value: MothershipEnv; label: string }[] = [
{ value: 'dev', label: 'Dev' },
{ value: 'staging', label: 'Staging' },
{ value: 'prod', label: 'Prod' },
]
function defaultTimeRange() {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 7)
return {
start: start.toISOString().slice(0, 16),
end: end.toISOString().slice(0, 16),
}
}
function toRFC3339(local: string) {
if (!local) return ''
return new Date(local).toISOString()
}
function formatCost(cost: number) {
return `$${cost.toFixed(4)}`
}
function formatDate(d: string | null | undefined) {
if (!d) return '—'
const date = new Date(d)
if (Number.isNaN(date.getTime())) return '—'
return formatDateTime(date)
}
function Divider() {
return <div className='h-px bg-[var(--border)]' />
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <p className='font-medium text-[var(--text-muted)] text-small'>{children}</p>
}
export function Mothership() {
const [{ tab: activeTab, env: environment }, setMothershipParams] = useQueryStates(
mothershipParsers,
mothershipUrlKeys
)
const defaults = useMemo(() => defaultTimeRange(), [])
const [start, setStart] = useState(defaults.start)
const [end, setEnd] = useState(defaults.end)
return (
<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-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>
</SettingsPanel>
)
}
function ByokTab() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const { data, isLoading } = useMothershipByokKeys(workspaceId)
const upsert = useUpsertMothershipByok()
const del = useDeleteMothershipByok()
const configuredProviderIds = useMemo(
() => new Set(((data?.keys as MothershipByokKey[]) ?? []).map((k) => k.provider)),
[data]
)
return (
<BYOKKeyManager
providers={ENTERPRISE_BYOK_PROVIDERS}
configuredProviderIds={configuredProviderIds}
isLoading={isLoading}
isSaving={upsert.isPending}
isDeleting={del.isPending}
showSearch={false}
description="Store a customer-provided Anthropic or OpenAI key for this workspace. It is encrypted at rest in the mothership and used only for this workspace's enterprise requests."
onSave={async (provider, apiKey) => {
await upsert.mutateAsync({ workspaceId, provider, apiKey })
}}
onDelete={async (provider) => {
await del.mutateAsync({ workspaceId, provider })
}}
/>
)
}
function OverviewTab({
environment,
start,
end,
}: {
environment: MothershipEnv
start: string
end: string
}) {
const { data: breakdown, isLoading: breakdownLoading } = useMothershipUserBreakdown(
environment,
start,
end
)
const { data: requests, isLoading: requestsLoading } = useMothershipRequests(
environment,
start,
end
)
return (
<div className='flex flex-col gap-5'>
<div className='grid grid-cols-4 gap-3'>
<StatCard
label='Total Requests'
value={breakdown?.total_requests}
loading={breakdownLoading}
/>
<StatCard label='Unique Users' value={breakdown?.total_users} loading={breakdownLoading} />
<StatCard
label='Total Cost'
value={
breakdown?.users
? formatCost(
breakdown.users.reduce(
(s: number, u: { total_cost: number }) => s + u.total_cost,
0
)
)
: undefined
}
loading={breakdownLoading}
/>
<StatCard
label='Avg Cost/Request'
value={
breakdown?.total_requests && breakdown.users
? formatCost(
breakdown.users.reduce(
(s: number, u: { total_cost: number }) => s + u.total_cost,
0
) / breakdown.total_requests
)
: undefined
}
loading={breakdownLoading}
/>
</div>
<SectionLabel>User Breakdown</SectionLabel>
{breakdownLoading && (
<div className='flex flex-col gap-2'>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className='h-[36px] w-full rounded-md' />
))}
</div>
)}
{breakdown?.users && (
<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='flex-1'>User ID</span>
<span className='w-[100px] text-right'>Requests</span>
<span className='w-[100px] text-right'>Cost</span>
<span className='w-[160px] text-right'>Last Request</span>
</div>
{breakdown.users.map(
(u: {
user_id: string
request_count: number
total_cost: number
last_request: string
}) => (
<div
key={u.user_id}
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-small last:border-b-0'
>
<span className='flex-1 truncate font-mono text-[var(--text-primary)] text-caption'>
{u.user_id}
</span>
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
{u.request_count}
</span>
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
{formatCost(u.total_cost)}
</span>
<span className='w-[160px] text-right text-[var(--text-tertiary)] text-caption'>
{formatDate(u.last_request)}
</span>
</div>
)
)}
</div>
)}
<Divider />
<SectionLabel>Recent Requests ({requests?.count ?? '…'})</SectionLabel>
{requestsLoading && (
<div className='flex flex-col gap-2'>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className='h-[36px] w-full rounded-md' />
))}
</div>
)}
{requests?.requests && (
<div className='max-h-[400px] overflow-auto'>
<div className='flex flex-col gap-0.5'>
<div className='sticky top-0 z-10 flex items-center gap-3 border-[var(--border-secondary)] border-b bg-[var(--surface-1)] px-3 py-2 text-[var(--text-tertiary)] text-caption'>
<span className='w-[180px]'>Request ID</span>
<span className='w-[80px]'>Model</span>
<span className='w-[80px] text-right'>Duration</span>
<span className='w-[80px] text-right'>Cost</span>
<span className='w-[60px] text-right'>Tools</span>
<span className='w-[70px] text-right'>Status</span>
<span className='flex-1 text-right'>Time</span>
</div>
{requests.requests
.slice(0, 100)
.map(
(r: {
request_id: string
model: string
duration_ms: number
billed_total_cost: number
tool_call_count: number
error: boolean
aborted: boolean
created_at: string
}) => (
<div
key={r.request_id}
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-1.5 text-small last:border-b-0'
>
<span className='w-[180px] truncate font-mono text-[var(--text-primary)] text-xs'>
{r.request_id ?? '—'}
</span>
<span className='w-[80px] truncate text-[var(--text-secondary)] text-caption'>
{(r.model ?? '').replace('claude-', '')}
</span>
<span className='w-[80px] text-right text-[var(--text-secondary)] text-caption'>
{r.duration_ms ? `${(r.duration_ms / 1000).toFixed(1)}s` : '—'}
</span>
<span className='w-[80px] text-right text-[var(--text-secondary)] text-caption'>
{formatCost(r.billed_total_cost ?? 0)}
</span>
<span className='w-[60px] text-right text-[var(--text-secondary)] text-caption'>
{r.tool_call_count ?? 0}
</span>
<span className='w-[70px] text-right'>
{r.error ? (
<Badge variant='red'>Error</Badge>
) : r.aborted ? (
<Badge variant='amber'>Abort</Badge>
) : (
<Badge variant='green'>OK</Badge>
)}
</span>
<span className='flex-1 text-right text-[var(--text-tertiary)] text-caption'>
{formatDate(r.created_at)}
</span>
</div>
)
)}
</div>
</div>
)}
</div>
)
}
function LicensesTab({ environment }: { environment: MothershipEnv }) {
const { data, isLoading, refetch } = useMothershipLicenses(environment)
const generateLicense = useGenerateLicense(environment)
const [newName, setNewName] = useState('')
const [newExpiry, setNewExpiry] = useState('')
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
const handleGenerate = useCallback(() => {
if (!newName.trim()) return
generateLicense.mutate(
{
name: newName.trim(),
...(newExpiry ? { expirationDate: newExpiry } : {}),
},
{
onSuccess: (result) => {
setGeneratedKey(result.license_key)
setNewName('')
setNewExpiry('')
refetch()
},
}
)
}, [newName, newExpiry, generateLicense, refetch])
return (
<div className='flex flex-col gap-5'>
<SectionLabel>Generate License</SectionLabel>
<div className='flex items-end gap-2'>
<div className='flex flex-col gap-1'>
<Label className='text-[var(--text-secondary)] text-caption'>Enterprise Name</Label>
<ChipInput
value={newName}
onChange={(e) => {
setNewName(e.target.value)
setGeneratedKey(null)
}}
placeholder='e.g. Acme Corp'
className='w-[200px]'
/>
</div>
<div className='flex flex-col gap-1'>
<Label className='text-[var(--text-secondary)] text-caption'>Expiration (optional)</Label>
<ChipInput
type='date'
value={newExpiry}
onChange={(e) => setNewExpiry(e.target.value)}
className='w-[160px]'
/>
</div>
<Button
variant='primary'
className='h-[32px]'
onClick={handleGenerate}
disabled={generateLicense.isPending || !newName.trim()}
>
{generateLicense.isPending ? 'Generating...' : 'Generate'}
</Button>
</div>
{generatedKey && (
<div className='rounded-md border border-[var(--border-secondary)] bg-[var(--surface-hover)] p-3'>
<p className='mb-1 text-[var(--text-secondary)] text-caption'>
License key (only shown once):
</p>
<code className='block break-all font-mono text-[var(--text-primary)] text-caption'>
{generatedKey}
</code>
</div>
)}
{generateLicense.error && (
<p className='text-[var(--text-error)] text-small'>{generateLicense.error.message}</p>
)}
<Divider />
<SectionLabel>All Licenses</SectionLabel>
{isLoading && (
<div className='flex flex-col gap-2'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-[40px] w-full rounded-md' />
))}
</div>
)}
{data?.licenses && (
<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='flex-1'>Name</span>
<span className='w-[100px] text-right'>Validations</span>
<span className='w-[140px] text-right'>Expiration</span>
<span className='w-[140px] text-right'>Created</span>
</div>
{data.licenses.length === 0 && (
<SettingsEmptyState variant='inline'>No licenses found.</SettingsEmptyState>
)}
{data.licenses.map(
(lic: {
id: string
name: string
count: number
expiration_date?: string
created_at: string
}) => (
<div
key={lic.id}
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-small last:border-b-0'
>
<span className='flex-1 text-[var(--text-primary)]'>{lic.name}</span>
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
{lic.count}
</span>
<span className='w-[140px] text-right text-[var(--text-tertiary)] text-caption'>
{lic.expiration_date ? formatDate(lic.expiration_date) : 'Never'}
</span>
<span className='w-[140px] text-right text-[var(--text-tertiary)] text-caption'>
{formatDate(lic.created_at)}
</span>
</div>
)
)}
</div>
)}
</div>
)
}
function StatCard({
label,
value,
loading,
}: {
label: string
value?: string | number
loading?: boolean
}) {
return (
<div className='rounded-md border border-[var(--border-secondary)] p-3'>
<p className='text-[var(--text-tertiary)] text-caption'>{label}</p>
{loading ? (
<Skeleton className='mt-1 h-[24px] w-[80px] rounded-sm' />
) : (
<p className='mt-1 font-medium text-[var(--text-primary)] text-lg'>{value ?? '—'}</p>
)}
</div>
)
}
@@ -0,0 +1,35 @@
import { parseAsStringLiteral } from 'nuqs/server'
/** Mothership admin tabs. */
export const MOTHERSHIP_TABS = ['overview', 'licenses', 'byok'] as const
export type MothershipTab = (typeof MOTHERSHIP_TABS)[number]
/**
* Mothership environments. Mirrors the `MothershipEnv` union (including the
* `default` member) so the literal parser accepts every value the selector and
* query hooks emit, even though only dev/staging/prod are surfaced as buttons.
*/
export const MOTHERSHIP_ENVIRONMENTS = ['default', 'dev', 'staging', 'prod'] as const
export type MothershipEnvironmentParam = (typeof MOTHERSHIP_ENVIRONMENTS)[number]
/**
* Co-located, typed URL query-param definitions for the Mothership admin view.
*
* - `tab` is the active section tab.
* - `env` is the selected environment.
*
* The time-range inputs are free-form datetimes (not a finite enum) and
* intentionally stay in local component state.
*/
export const mothershipParsers = {
tab: parseAsStringLiteral(MOTHERSHIP_TABS).withDefault('overview'),
env: parseAsStringLiteral(MOTHERSHIP_ENVIRONMENTS).withDefault('dev'),
} as const
/** Tab/environment view-state: clean URLs, no back-stack churn. */
export const mothershipUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1 @@
export { RecentlyDeleted } from './recently-deleted'
@@ -0,0 +1,508 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { Chip, ChipInput, ChipModalTabs } from '@sim/emcn'
import { Folder, Search, Workflow } from '@sim/emcn/icons'
import { toError } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
import {
DEFAULT_RECENTLY_DELETED_SORT_COLUMN,
DEFAULT_RECENTLY_DELETED_SORT_DIRECTION,
RECENTLY_DELETED_SORT_COLUMNS,
type RecentlyDeletedSortColumn,
type RecentlyDeletedTab,
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 { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { useFolders, useRestoreFolder } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useRestoreTable, useTablesList } from '@/hooks/queries/tables'
import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows'
import {
useRestoreWorkspaceFileFolder,
useWorkspaceFileFolders,
} from '@/hooks/queries/workspace-file-folders'
import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
type ResourceType =
| 'all'
| 'workflow'
| 'table'
| 'knowledge'
| 'file'
| 'folder'
| 'workspace_folder'
function getResourceHref(
workspaceId: string,
type: Exclude<ResourceType, 'all'>,
id: string
): string {
const base = `/workspace/${workspaceId}`
switch (type) {
case 'workflow':
return `${base}/w/${id}`
case 'table':
return `${base}/tables/${id}`
case 'knowledge':
return `${base}/knowledge/${id}`
case 'file':
return `${base}/files/${id}`
case 'folder':
return `${base}/w`
case 'workspace_folder':
return `${base}/files?folderId=${id}`
}
}
type SortColumn = 'deleted' | 'name' | 'type'
interface SortConfig {
column: SortColumn
direction: 'asc' | 'desc'
}
const DEFAULT_SORT: SortConfig = { column: 'deleted', direction: 'desc' }
/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const
const SORT_OPTIONS: ColumnOption[] = [
{ id: 'deleted', label: 'Deleted' },
{ id: 'name', label: 'Name' },
{ id: 'type', label: 'Type' },
]
const ICON_CLASS = 'size-5'
const RESOURCE_TYPE_TO_MOTHERSHIP: Partial<
Record<Exclude<ResourceType, 'all'>, MothershipResourceType>
> = {
workflow: 'workflow',
table: 'table',
knowledge: 'knowledgebase',
file: 'file',
}
interface DeletedResource {
id: string
name: string
type: Exclude<ResourceType, 'all'>
deletedAt: Date
workspaceId: string
color?: string
}
interface RestoredResourceEntry {
resource: DeletedResource
displayIndex: number
}
const TABS: { id: ResourceType; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'workflow', label: 'Workflows' },
{ id: 'folder', label: 'Folders' },
{ id: 'table', label: 'Tables' },
{ id: 'knowledge', label: 'Knowledge Bases' },
{ id: 'file', label: 'Files' },
]
const TYPE_LABEL: Record<Exclude<ResourceType, 'all'>, string> = {
workflow: 'Workflow',
folder: 'Folder',
workspace_folder: 'File Folder',
table: 'Table',
knowledge: 'Knowledge Base',
file: 'File',
}
function ResourceIcon({ resource }: { resource: DeletedResource }) {
if (resource.type === 'workflow') {
return <Workflow className={`${ICON_CLASS} shrink-0 text-[var(--text-icon)]`} />
}
if (resource.type === 'folder' || resource.type === 'workspace_folder') {
const color = resource.color ?? '#6B7280'
return <Folder className={ICON_CLASS} style={{ color }} />
}
const mothershipType = RESOURCE_TYPE_TO_MOTHERSHIP[resource.type]
if (!mothershipType) return null
const config = RESOURCE_REGISTRY[mothershipType]
return config.renderTabIcon(
{ type: mothershipType, id: resource.id, title: resource.name },
ICON_CLASS
)
}
function matchesActiveTab(resource: DeletedResource, activeTab: ResourceType): boolean {
if (activeTab === 'all') return true
if (activeTab === 'file') return resource.type === 'file' || resource.type === 'workspace_folder'
return resource.type === activeTab
}
export function RecentlyDeleted() {
const params = useParams()
const router = useRouter()
const workspaceId = params?.workspaceId as string
const [
{ tab: activeTab, sort: sortColumn, dir: sortDirection, search: urlSearchTerm },
setRecentlyDeletedFilters,
] = useQueryStates(recentlyDeletedParsers, recentlyDeletedUrlKeys)
/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. Filtering below is cheap in-memory over a small list, so
* it reads the instant value too.
*/
const setSearchTerm = useCallback(
(value: string) => {
const trimmed = value.trim()
const next = trimmed.length > 0 ? trimmed : null
setRecentlyDeletedFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setRecentlyDeletedFilters]
)
const activeSort = useMemo<SortConfig | null>(
() =>
sortColumn === DEFAULT_RECENTLY_DELETED_SORT_COLUMN &&
sortDirection === DEFAULT_RECENTLY_DELETED_SORT_DIRECTION
? null
: { column: sortColumn, direction: sortDirection },
[sortColumn, sortDirection]
)
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
const [restoredItems, setRestoredItems] = useState<Map<string, RestoredResourceEntry>>(new Map())
const workflowsQuery = useWorkflows(workspaceId, { scope: 'archived' })
const foldersQuery = useFolders(workspaceId, { scope: 'archived' })
const activeFoldersQuery = useFolders(workspaceId)
const tablesQuery = useTablesList(workspaceId, 'archived')
const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' })
const filesQuery = useWorkspaceFiles(workspaceId, 'archived')
const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived')
const restoreWorkflow = useRestoreWorkflow()
const restoreFolder = useRestoreFolder()
const restoreTable = useRestoreTable()
const restoreKnowledgeBase = useRestoreKnowledgeBase()
const restoreWorkspaceFile = useRestoreWorkspaceFile()
const restoreWorkspaceFileFolder = useRestoreWorkspaceFileFolder()
const isLoading =
workflowsQuery.isLoading ||
foldersQuery.isLoading ||
tablesQuery.isLoading ||
knowledgeQuery.isLoading ||
filesQuery.isLoading ||
workspaceFoldersQuery.isLoading
const error =
workflowsQuery.error ||
foldersQuery.error ||
tablesQuery.error ||
knowledgeQuery.error ||
filesQuery.error ||
workspaceFoldersQuery.error
const resources = useMemo<DeletedResource[]>(() => {
const items: DeletedResource[] = []
for (const wf of workflowsQuery.data ?? []) {
items.push({
id: wf.id,
name: wf.name,
type: 'workflow',
deletedAt: wf.archivedAt ? new Date(wf.archivedAt) : new Date(wf.lastModified),
workspaceId: wf.workspaceId ?? workspaceId,
})
}
for (const folder of foldersQuery.data ?? []) {
items.push({
id: folder.id,
name: folder.name,
type: 'folder',
deletedAt: folder.archivedAt ? new Date(folder.archivedAt) : new Date(folder.updatedAt),
workspaceId: folder.workspaceId,
color: folder.color,
})
}
for (const t of tablesQuery.data ?? []) {
items.push({
id: t.id,
name: t.name,
type: 'table',
deletedAt: new Date(t.archivedAt ?? t.updatedAt),
workspaceId: t.workspaceId,
})
}
for (const kb of knowledgeQuery.data ?? []) {
items.push({
id: kb.id,
name: kb.name,
type: 'knowledge',
deletedAt: kb.deletedAt ? new Date(kb.deletedAt) : new Date(kb.updatedAt),
workspaceId: kb.workspaceId ?? workspaceId,
})
}
for (const f of filesQuery.data ?? []) {
items.push({
id: f.id,
name: f.name,
type: 'file',
deletedAt: new Date(f.deletedAt ?? f.uploadedAt),
workspaceId: f.workspaceId,
})
}
for (const wf of workspaceFoldersQuery.data ?? []) {
items.push({
id: wf.id,
name: wf.name,
type: 'workspace_folder',
deletedAt: wf.deletedAt ? new Date(wf.deletedAt) : new Date(wf.updatedAt),
workspaceId: wf.workspaceId,
})
}
return items
}, [
workflowsQuery.data,
foldersQuery.data,
tablesQuery.data,
knowledgeQuery.data,
filesQuery.data,
workspaceFoldersQuery.data,
workspaceId,
])
const filtered = useMemo(() => {
let items = resources.filter((resource) => matchesActiveTab(resource, activeTab))
if (urlSearchTerm.trim()) {
const normalized = urlSearchTerm.toLowerCase()
items = items.filter((r) => r.name.toLowerCase().includes(normalized))
}
const col = (activeSort ?? DEFAULT_SORT).column
const dir = (activeSort ?? DEFAULT_SORT).direction
items.sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'type':
cmp = a.type.localeCompare(b.type)
break
case 'deleted':
cmp = a.deletedAt.getTime() - b.deletedAt.getTime()
break
}
return dir === 'asc' ? cmp : -cmp
})
const itemIds = new Set(items.map((item) => item.id))
for (const [id, entry] of restoredItems) {
if (itemIds.has(id)) continue
if (!matchesActiveTab(entry.resource, activeTab)) continue
if (
urlSearchTerm.trim() &&
!entry.resource.name.toLowerCase().includes(urlSearchTerm.toLowerCase())
) {
continue
}
items.splice(Math.min(entry.displayIndex, items.length), 0, entry.resource)
}
return items
}, [resources, activeTab, urlSearchTerm, activeSort, restoredItems])
const showNoResults = urlSearchTerm.trim() && filtered.length === 0 && resources.length > 0
function handleView(resource: DeletedResource) {
if (resource.type === 'folder') {
const setExpanded = useFolderStore.getState().setExpanded
const byId = new Map<string, WorkflowFolder>()
for (const folder of foldersQuery.data ?? []) byId.set(folder.id, folder)
for (const folder of activeFoldersQuery.data ?? []) byId.set(folder.id, folder)
let current: WorkflowFolder | undefined = byId.get(resource.id)
const seen = new Set<string>()
while (current && !seen.has(current.id)) {
seen.add(current.id)
setExpanded(current.id, true)
current = current.parentId ? byId.get(current.parentId) : undefined
}
}
const href = getResourceHref(resource.workspaceId, resource.type, resource.id)
router.push(href)
}
async function handleRestore(resource: DeletedResource) {
const displayIndex = Math.max(
0,
filtered.findIndex((item) => item.id === resource.id)
)
setRestoringIds((prev) => new Set(prev).add(resource.id))
try {
switch (resource.type) {
case 'workflow':
await restoreWorkflow.mutateAsync({
workflowId: resource.id,
workspaceId: resource.workspaceId,
})
break
case 'folder':
await restoreFolder.mutateAsync({
folderId: resource.id,
workspaceId: resource.workspaceId,
})
break
case 'table':
await restoreTable.mutateAsync(resource.id)
break
case 'knowledge':
await restoreKnowledgeBase.mutateAsync(resource.id)
break
case 'file':
await restoreWorkspaceFile.mutateAsync({
workspaceId: resource.workspaceId,
fileId: resource.id,
})
break
case 'workspace_folder':
await restoreWorkspaceFileFolder.mutateAsync({
workspaceId: resource.workspaceId,
folderId: resource.id,
})
break
}
setRestoredItems((prev) => new Map(prev).set(resource.id, { resource, displayIndex }))
} catch {
return
} finally {
setRestoringIds((prev) => {
const next = new Set(prev)
next.delete(resource.id)
return next
})
}
}
return (
<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 })}
/>
{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 (
<SettingsResourceRow
key={resource.id}
icon={<ResourceIcon resource={resource} />}
title={resource.name}
description={
<>
{TYPE_LABEL[resource.type]}
{' \u00b7 '}
Deleted {formatDate(resource.deletedAt)}
</>
}
trailing={
isRestoring ? (
<Chip variant='primary' disabled className='shrink-0'>
Restoring...
</Chip>
) : isRestored ? (
<div className='flex shrink-0 items-center gap-2'>
<span className='text-[var(--text-muted)] text-small'>Restored</span>
<Chip variant='primary' onClick={() => handleView(resource)}>
View
</Chip>
</div>
) : (
<Chip
variant='primary'
onClick={() => void handleRestore(resource)}
className='shrink-0'
>
Restore
</Chip>
)
}
/>
)
})}
</div>
)}
</SettingsPanel>
)
}
@@ -0,0 +1,49 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
/** Selectable resource-type tabs in the Recently Deleted view. */
export const RECENTLY_DELETED_TABS = [
'all',
'workflow',
'folder',
'table',
'knowledge',
'file',
] as const
export type RecentlyDeletedTab = (typeof RECENTLY_DELETED_TABS)[number]
/** Sortable columns for the deleted-items list. */
export const RECENTLY_DELETED_SORT_COLUMNS = ['deleted', 'name', 'type'] as const
export type RecentlyDeletedSortColumn = (typeof RECENTLY_DELETED_SORT_COLUMNS)[number]
const SORT_DIRECTIONS = ['asc', 'desc'] as const
/** Default sort: most-recently-deleted first. */
export const DEFAULT_RECENTLY_DELETED_SORT_COLUMN: RecentlyDeletedSortColumn = 'deleted'
export const DEFAULT_RECENTLY_DELETED_SORT_DIRECTION = 'desc'
/**
* Co-located, typed URL query-param definitions for the Recently Deleted
* settings view.
*
* - `tab` is the active resource-type filter.
* - `sort` / `dir` follow the shared sort convention.
* - `search` is the name filter. The input is controlled directly by the nuqs
* value; only its URL write is debounced via `limitUrlUpdates` (`debounce`) on
* the setter — never written on every keystroke.
*/
export const recentlyDeletedParsers = {
tab: parseAsStringLiteral(RECENTLY_DELETED_TABS).withDefault('all'),
sort: parseAsStringLiteral(RECENTLY_DELETED_SORT_COLUMNS).withDefault(
DEFAULT_RECENTLY_DELETED_SORT_COLUMN
),
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_RECENTLY_DELETED_SORT_DIRECTION),
search: parseAsString.withDefault(''),
} as const
/** Tab/filter/sort view-state: clean URLs, no back-stack churn. */
export const recentlyDeletedUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1 @@
export { type RowAction, RowActionsMenu } from './row-actions-menu'
@@ -0,0 +1,76 @@
import {
chipVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
MoreHorizontal,
Tooltip,
} from '@sim/emcn'
export interface RowAction {
label: string
onSelect: () => void
/** Renders in the error color (e.g. Delete). */
destructive?: boolean
disabled?: boolean
/** Hover tooltip on the item (e.g. why it's disabled) — mirrors `SettingsAction.tooltip`. */
tooltip?: string
}
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.
*
* An action with a `tooltip` gets its item wrapped in a plain span tooltip
* trigger (the settings-header chip pattern) — a disabled item is
* `pointer-events-none`, so the wrapper is what keeps hover working.
*/
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) => {
const item = (
<DropdownMenuItem
key={action.label}
onSelect={action.onSelect}
disabled={action.disabled}
className={action.destructive ? 'text-[var(--text-error)]' : undefined}
>
{action.label}
</DropdownMenuItem>
)
return action.tooltip ? (
<Tooltip.Root key={action.label}>
<Tooltip.Trigger asChild>
<span className='block'>{item}</span>
</Tooltip.Trigger>
<Tooltip.Content>{action.tooltip}</Tooltip.Content>
</Tooltip.Root>
) : (
item
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,33 @@
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
interface SaveDiscardActionsConfig {
dirty: boolean
saving: boolean
onSave: () => void
onDiscard: () => void
saveDisabled?: boolean
savingLabel?: string
saveLabel?: string
}
/** The dirty-gated Discard + Save action pair for settings surfaces — empty when not dirty. */
export function saveDiscardActions({
dirty,
saving,
onSave,
onDiscard,
saveDisabled = false,
savingLabel = 'Saving...',
saveLabel = 'Save',
}: SaveDiscardActionsConfig): SettingsAction[] {
if (!dirty) return []
return [
{ text: 'Discard', onSelect: onDiscard, disabled: saving },
{
text: saving ? savingLabel : saveLabel,
variant: 'primary',
onSelect: onSave,
disabled: saving || saveDisabled,
},
]
}
@@ -0,0 +1 @@
export { SecretsManager } from './secrets-manager'
@@ -0,0 +1 @@
export { SecretValueField } from './secret-value-field'
@@ -0,0 +1,93 @@
'use client'
import type { ComponentProps, CSSProperties } from 'react'
import { useState } from 'react'
import { ChipInput } from '@sim/emcn'
const BULLET = '\u2022'
/**
* Viewers always see this many bullets regardless of the real value, which the
* server withholds (empty string) for non-admins. A fixed length also avoids
* leaking the secret's length.
*/
const VIEWER_MASK_LENGTH = 10
type SecretValueFieldProps = Omit<
ComponentProps<'input'>,
'type' | 'value' | 'onChange' | 'readOnly'
> & {
value: string
onChange?: (value: string) => void
/**
* Whether the caller may reveal (on focus) and edit the value. When `false`
* the real value is never shown — only a fixed-length mask — and the field is
* read-only (e.g. a non-admin viewer).
*/
canEdit?: boolean
/** Render the real value without masking, e.g. an overridden/conflicted field. */
unmasked?: boolean
/** Force read-only even when {@link canEdit} is true (e.g. a conflicted field). */
readOnly?: boolean
}
/**
* The single source of truth for displaying an environment-variable value:
* masks the value with bullets while unfocused, reveals it on focus for editors,
* and keeps the field read-only (masked) for viewers who can't edit. Shared by
* the secrets list and the secret detail page so masking never diverges.
*
* Rendered as a {@link ChipInput}; the chip chrome carries the canonical 30px
* chip-field height, and the caller's `className` only positions it (e.g.
* `col-span-2`). Values arrive already decrypted for authorized callers; this
* component only governs on-screen visibility.
*/
export function SecretValueField({
value,
onChange,
canEdit = true,
unmasked = false,
readOnly = false,
onFocus,
onBlur,
style,
className,
...props
}: SecretValueFieldProps) {
const [focused, setFocused] = useState(false)
const editable = canEdit && !readOnly
const maskActive = canEdit && !unmasked && !focused
const displayValue = canEdit ? value : BULLET.repeat(VIEWER_MASK_LENGTH)
const mergedStyle: CSSProperties | undefined = maskActive
? ({ ...style, WebkitTextSecurity: 'disc' } as CSSProperties)
: style
return (
<ChipInput
{...props}
className={className}
type='text'
value={displayValue}
readOnly
style={mergedStyle}
onChange={(event) => {
if (editable) onChange?.(event.target.value)
}}
onFocus={(event) => {
if (editable) event.currentTarget.removeAttribute('readOnly')
event.currentTarget.scrollLeft = 0
setFocused(true)
onFocus?.(event)
}}
onBlur={(event) => {
setFocused(false)
onBlur?.(event)
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
)
}
@@ -0,0 +1 @@
export { SecretsManager } from './secrets-manager'
@@ -0,0 +1,101 @@
'use client'
import { useState } from 'react'
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useSession } from '@/lib/auth/auth-client'
import type { WorkspaceCredential } from '@/hooks/queries/credentials'
import {
usePersonalEnvironment,
useSavePersonalEnvironment,
useUpsertWorkspaceEnvironment,
useWorkspaceEnvironment,
} from '@/hooks/queries/environment'
const logger = createLogger('SecretValue')
interface UseSecretValueParams {
workspaceId: string
credential: WorkspaceCredential | null
}
/**
* Reads and persists a single secret's value, reusing the secrets list's
* environment queries/mutations so decryption and save semantics never diverge.
*
* - Asymmetric save model: workspace env merges per-key (PUT); personal env is
* replace-all (POST), so a personal save refetches the latest set first and
* rebuilds it with only this key changed, avoiding dropped keys from a stale
* cache. If that set can't be resolved (refetch failed and nothing is cached)
* the save aborts, since a single-key replace-all would wipe the rest of the
* store; a successfully-loaded empty set still saves (first personal key).
* - Scope-aware edit permission: workspace requires the credential admin role;
* personal values live in the owner's own environment, so only the owner can
* edit. A personal key shadowed by a same-named workspace key is surfaced as
* `isConflicted` (read-only), since editing it would have no runtime effect.
* - The draft seeds from the resolved value but preserves unsaved edits when the
* value changes upstream (e.g. a concurrent save by another admin).
*/
export function useSecretValue({ workspaceId, credential }: UseSecretValueParams) {
const { data: session } = useSession()
const isPersonal = credential?.type === 'env_personal'
const envKey = credential?.envKey ?? ''
const { data: personalEnvData, refetch: refetchPersonal } = usePersonalEnvironment()
const { data: workspaceEnvData } = useWorkspaceEnvironment(workspaceId)
const savePersonal = useSavePersonalEnvironment()
const upsertWorkspace = useUpsertWorkspaceEnvironment()
const currentValue = isPersonal
? (personalEnvData?.[envKey]?.value ?? '')
: (workspaceEnvData?.workspace?.[envKey] ?? '')
const isConflicted =
isPersonal && envKey.length > 0 && envKey in (workspaceEnvData?.workspace ?? {})
const canEdit = isPersonal
? Boolean(session?.user?.id && credential?.envOwnerUserId === session.user.id)
: credential?.role === 'admin'
const [draft, setDraft] = useState(currentValue)
const [seeded, setSeeded] = useState(currentValue)
if (currentValue !== seeded) {
setSeeded(currentValue)
if (draft === seeded) setDraft(currentValue)
}
const isDirty = draft !== currentValue
const isSaving = savePersonal.isPending || upsertWorkspace.isPending
const save = async () => {
if (!credential || !canEdit || isConflicted || !isDirty || isSaving) return
try {
if (isPersonal) {
const { data: latest } = await refetchPersonal()
if (!latest) {
toast.error("Couldn't save value", {
description: 'Could not load your latest secrets. Please try again in a moment.',
})
logger.warn('Aborted personal secret save: latest environment unavailable')
return
}
const merged: Record<string, string> = Object.fromEntries(
Object.entries(latest).map(([key, entry]) => [key, entry.value])
)
merged[envKey] = draft
await savePersonal.mutateAsync({ variables: merged })
} else {
await upsertWorkspace.mutateAsync({ workspaceId, variables: { [envKey]: draft } })
}
} catch (error) {
toast.error("Couldn't save value", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
logger.error('Failed to save secret value', error)
}
}
return { value: draft, setValue: setDraft, canEdit, isConflicted, isDirty, save, isSaving }
}
@@ -0,0 +1 @@
export { Secrets } from './secrets'
@@ -0,0 +1,9 @@
import { SecretsManager } from '@/app/workspace/[workspaceId]/settings/components/secrets/components'
export function Secrets() {
return (
<div className='h-full min-h-0'>
<SecretsManager />
</div>
)
}
@@ -0,0 +1 @@
export { SettingsEmptyState } from './settings-empty-state'
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
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,219 @@
'use client'
import {
type ComponentType,
createContext,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Chip, ChipInput, ChipLink, Search, Tooltip } from '@sim/emcn'
/** `useLayoutEffect` on the client (flush header changes before paint), `useEffect` during SSR. */
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
/** The strict contract for a settings header action — rendered as a {@link Chip}, data only. */
export interface SettingsAction {
text: string
icon?: ComponentType<{ className?: string }>
variant?: 'primary' | 'destructive'
active?: boolean
onSelect: () => void
disabled?: boolean
/** Hover/focus tooltip (e.g. why the action is disabled) — the shell renders it; no per-page JSX. */
tooltip?: string
/** Warm a lazy resource on hover/focus (e.g. prefetch the upgrade flow). */
onPrefetch?: () => void
}
export interface SettingsHeaderSearch {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
}
/** Left-aligned back chip for a detail sub-view, returning to the section's list. */
export interface SettingsBackAction {
text: string
icon?: ComponentType<{ className?: string }>
onSelect: () => void
}
export interface SettingsHeaderConfig {
title?: string
description?: string
docsLink?: string
back?: SettingsBackAction
actions?: SettingsAction[]
search?: SettingsHeaderSearch
/** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */
scrollContainerRef?: Ref<HTMLDivElement>
}
const EMPTY_CONFIG: SettingsHeaderConfig = {}
const RegisterContext = createContext<((config: SettingsHeaderConfig) => void) | null>(null)
interface ReadContextValue {
configRef: { current: SettingsHeaderConfig }
signature: string
}
const ReadContext = createContext<ReadContextValue | null>(null)
/**
* Serializes only the visible/structural fields — callbacks stay in the ref and
* the shell dereferences them at call time, so registering never loops and never
* serves a stale handler. `scrollContainerRef` is intentionally excluded (refs are
* identity-stable and ride along with the first content-bearing register). Any new
* VISIBLE field must be added here too.
*/
function computeSignature(c: SettingsHeaderConfig): string {
return JSON.stringify({
t: c.title ?? '',
d: c.description ?? '',
k: c.docsLink ?? '',
b: c.back ? [c.back.text, c.back.icon ? 1 : 0] : null,
a: c.actions?.map((x) => [
x.text,
x.variant ?? '',
x.active ?? false,
x.disabled ?? false,
x.icon ? 1 : 0,
x.tooltip ?? '',
x.onPrefetch ? 1 : 0,
]),
s: c.search ? [c.search.value, c.search.placeholder ?? '', c.search.disabled ?? false] : null,
})
}
export function SettingsHeaderProvider({ children }: { children: ReactNode }) {
const configRef = useRef<SettingsHeaderConfig>(EMPTY_CONFIG)
const [signature, setSignature] = useState('')
const register = useCallback((config: SettingsHeaderConfig) => {
configRef.current = config
const next = computeSignature(config)
setSignature((prev) => (prev === next ? prev : next))
}, [])
const readValue = useMemo<ReadContextValue>(() => ({ configRef, signature }), [signature])
return (
<RegisterContext.Provider value={register}>
<ReadContext.Provider value={readValue}>{children}</ReadContext.Provider>
</RegisterContext.Provider>
)
}
/** Registers a section's header content into the persistent settings chrome. */
export function useSettingsHeader(config: SettingsHeaderConfig) {
const register = useContext(RegisterContext)
useIsomorphicLayoutEffect(() => {
register?.(config)
})
useIsomorphicLayoutEffect(() => {
return () => register?.(EMPTY_CONFIG)
}, [register])
}
/**
* The single owner of settings page chrome: the header bar (back chip, Docs link,
* action chips), the scroll region, and the centered column led by the title +
* description, then search and `{children}`.
*/
export function SettingsHeaderShell({ children }: { children: ReactNode }) {
const read = useContext(ReadContext)
const configRef = read?.configRef
const config = configRef?.current ?? EMPTY_CONFIG
const { title, description, docsLink, back, actions, search, scrollContainerRef } = config
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]'>
{back ? (
<Chip leftIcon={back.icon} onClick={() => configRef?.current.back?.onSelect()}>
{back.text}
</Chip>
) : (
<div />
)}
<div className='flex h-[30px] items-center gap-1'>
{docsLink && (
<ChipLink href={docsLink} target='_blank' rel='noopener noreferrer'>
Docs
</ChipLink>
)}
{actions?.map((action, index) => {
const chip = (
<Chip
key={action.text}
variant={action.variant}
active={action.active}
leftIcon={action.icon}
onClick={() => configRef?.current.actions?.[index]?.onSelect()}
onMouseEnter={
action.onPrefetch
? () => configRef?.current.actions?.[index]?.onPrefetch?.()
: undefined
}
onFocus={
action.onPrefetch
? () => configRef?.current.actions?.[index]?.onPrefetch?.()
: undefined
}
disabled={action.disabled}
>
{action.text}
</Chip>
)
return action.tooltip ? (
<Tooltip.Root key={action.text}>
<Tooltip.Trigger asChild>
<span className='inline-flex'>{chip}</span>
</Tooltip.Trigger>
<Tooltip.Content>{action.tooltip}</Tooltip.Content>
</Tooltip.Root>
) : (
chip
)
})}
</div>
</div>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>
{(title || description) && (
<div className='flex flex-col gap-1'>
{title && <h1 className='font-medium text-[var(--text-body)] text-lg'>{title}</h1>}
{description && <p className='text-[var(--text-muted)] text-md'>{description}</p>}
</div>
)}
{search && (
<ChipInput
icon={Search}
placeholder={search.placeholder ?? 'Search...'}
value={search.value}
onChange={(event) => configRef?.current.search?.onChange(event.target.value)}
disabled={search.disabled}
autoComplete='off'
className='w-full'
/>
)}
{children}
</div>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { SettingsPanel, SettingsSectionProvider } from './settings-panel'
@@ -0,0 +1,93 @@
'use client'
import { createContext, type ReactNode, type Ref, useContext } from 'react'
import {
type SettingsAction,
type SettingsBackAction,
type SettingsHeaderSearch,
useSettingsHeader,
} from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
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 SettingsPanelProps {
/** Body content rendered below the header in the centered content column. */
children?: ReactNode
/** Strict top-right action chips — data only (`text`/`icon`/`variant`/…), never JSX. */
actions?: SettingsAction[]
/** Left-aligned back chip for a detail sub-view; omit on list/panel pages. */
back?: SettingsBackAction
/** Renders the canonical search field directly below the title. */
search?: SettingsHeaderSearch
/** Overrides the nav-driven title (e.g. for a detail sub-view). */
title?: string
/**
* Overrides the nav-driven description. When `title` is set (a detail sub-view),
* the description is used verbatim — it never falls back to the section's meta
* blurb, so an entity with no description renders no description.
*/
description?: string
/** Overrides the nav-driven docs link (the "Docs" link rendered in the header bar). */
docsLink?: string
/** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */
scrollContainerRef?: Ref<HTMLDivElement>
}
/**
* Registers a settings section's header content (title, description, docs link,
* action chips, search) into the persistent settings layout, then renders the
* section body. It owns **no** chrome: the header bar, scroll region, centered
* column, and spacing all live in the layout's `SettingsHeaderShell` and stay
* mounted across section navigation. Sections supply data only — the structured
* `actions` contract makes it impossible to inject a `<div>` or a padding change.
*/
export function SettingsPanel({
children,
actions,
back,
search,
title,
description,
docsLink,
scrollContainerRef,
}: SettingsPanelProps) {
const section = useSettingsSection()
const meta = section ? getSettingsSectionMeta(section) : null
useSettingsHeader({
title: title ?? meta?.label,
description: title !== undefined ? description : (description ?? meta?.description),
docsLink: docsLink ?? meta?.docsLink,
back,
actions,
search,
scrollContainerRef,
})
return <>{children}</>
}
@@ -0,0 +1 @@
export { SettingsResourceRow } from './settings-resource-row'
@@ -0,0 +1,57 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
/**
* The canonical settings "resource row": a rounded-bordered icon tile, a
* title + muted description text block, and an optional trailing slot
* (action chips, a {@link RowActionsMenu}, a status label, etc.).
*
* Single source of truth for the credential-style row shared by the BYOK key
* manager and recently-deleted lists — never re-derive the
* tile/text chrome per consumer. The tile force-sizes any `<svg>`/`<img>` it
* contains to 20px, so callers pass their raw icon node without pre-sizing it.
*/
interface SettingsResourceRowProps {
/** Icon node centered in the tile; a `<svg>` is normalized to 20px, an `<img>` to 20px (or the full tile when `iconFill`). */
icon: ReactNode
/**
* Let an image icon fill the tile edge-to-edge instead of clamping to 20px.
* Use for uploaded image/logo icons (e.g. custom blocks); glyph `<svg>`s still
* normalize to 20px so a fallback icon doesn't balloon.
*/
iconFill?: boolean
/** Primary line — truncates. */
title: ReactNode
/** Secondary muted line — truncates. */
description?: ReactNode
/** Trailing element pinned to the row's end (chips, actions menu, status). */
trailing?: ReactNode
}
const TILE_BASE =
'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] [&_svg]:size-5'
export function SettingsResourceRow({
icon,
iconFill = false,
title,
description,
trailing,
}: SettingsResourceRowProps) {
return (
<div className='flex items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
<div className={cn(TILE_BASE, iconFill ? '[&_img]:size-full' : '[&_img]:size-5')}>
{icon}
</div>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate text-[var(--text-body)] text-sm'>{title}</span>
{description != null && (
<span className='truncate text-[var(--text-muted)] text-caption'>{description}</span>
)}
</div>
</div>
{trailing}
</div>
)
}
@@ -0,0 +1,33 @@
import type { ReactNode } from 'react'
interface SettingsSectionProps {
label: string
/** Optional node rendered immediately to the right of the label (e.g. an info tooltip). */
headerAccessory?: ReactNode
/** Optional control pinned to the far right of the header row (e.g. a Select All chip). */
action?: ReactNode
children: ReactNode
}
/**
* Labeled section primitive that matches the integrations page visual rhythm:
* a muted small label, a thin divider, then the body content.
*/
export function SettingsSection({
label,
headerAccessory,
action,
children,
}: SettingsSectionProps) {
return (
<section className='flex flex-col'>
<div className='flex items-center gap-1.5 pl-0.5'>
<span className='text-[var(--text-muted)] text-small'>{label}</span>
{headerAccessory}
{action && <div className='ml-auto'>{action}</div>}
</div>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
{children}
</section>
)
}
@@ -0,0 +1,7 @@
export { ManageCreditsModal } from './manage-credits-modal'
export { NoOrganizationView } from './no-organization-view'
export { OrganizationInviteModal } from './organization-invite-modal'
export { OrganizationMemberLists } from './organization-member-lists'
export { RemoveMemberDialog } from './remove-member-dialog'
export { TeamSeatsOverview } from './team-seats-overview'
export { TransferOwnershipDialog } from './transfer-ownership-dialog'
@@ -0,0 +1 @@
export { ManageCreditsModal, type ManageCreditsTarget } from './manage-credits-modal'
@@ -0,0 +1,142 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
Info,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import {
useOrganizationMemberUsageLimit,
useUpdateOrganizationMemberUsageLimit,
} from '@/hooks/queries/organization'
export interface ManageCreditsTarget {
userId: string
name: string
email: string
}
interface ManageCreditsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
organizationId: string
member: ManageCreditsTarget | null
}
/**
* Modal for viewing a member's credits used in the organization's workspaces and
* setting their per-member credit limit. "Credits used" is a read-only chip;
* "Credit limit" is editable (blank = no limit). Hosted-only feature — surfaced
* only from the Organization tab, which already requires hosted + Team plan.
*/
export function ManageCreditsModal({
open,
onOpenChange,
organizationId,
member,
}: ManageCreditsModalProps) {
const userId = member?.userId
const { data, isLoading } = useOrganizationMemberUsageLimit(organizationId, userId, open)
const updateLimit = useUpdateOrganizationMemberUsageLimit()
const [draft, setDraft] = useState('')
const [error, setError] = useState<string | null>(null)
// Seed the draft from server data only until the admin starts typing, so a
// background refetch (window focus, post-save invalidation) can't clobber an
// in-progress edit. Reset when the modal closes.
const hasEditedRef = useRef(false)
useEffect(() => {
if (!open) {
hasEditedRef.current = false
return
}
if (data && !hasEditedRef.current) {
setDraft(data.creditLimit === null ? '' : String(data.creditLimit))
setError(null)
}
}, [open, data])
const trimmed = draft.trim()
const parsedLimit = trimmed === '' ? null : Number(trimmed)
const isValid =
trimmed === '' || (parsedLimit !== null && Number.isInteger(parsedLimit) && parsedLimit >= 0)
const currentLimit = data?.creditLimit ?? null
const isDirty = parsedLimit !== currentLimit
const isSaving = updateLimit.isPending
const creditsUsed = data ? data.creditsUsed.toLocaleString() : '—'
const creditsUsedTitle = data
? `Credits used this ${data.billingInterval === 'year' ? 'year' : 'month'}`
: 'Credits used'
const handleSave = () => {
if (!userId) return
if (!isValid) {
setError('Enter a whole number of credits, or leave blank for no limit.')
return
}
setError(null)
updateLimit.mutate(
{ orgId: organizationId, userId, creditLimit: parsedLimit },
{
onSuccess: () => onOpenChange(false),
onError: (err) => setError(getErrorMessage(err, 'Failed to update credit limit')),
}
)
}
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Manage credits'>
<ChipModalHeader onClose={() => onOpenChange(false)}>
{member ? `Manage credits — ${member.name || member.email}` : 'Manage credits'}
</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='copy'
title={creditsUsedTitle}
value={isLoading ? 'Loading…' : creditsUsed}
copyLabel='Copy credits used'
/>
<ChipModalField
type='input'
inputType='number'
title={
<span className='inline-flex items-center gap-1.5'>
Credit limit
<Info side='top'>
{
"Set in credits — Sim's usage unit (1,000 credits = $5). Caps this member's usage across this organization's workspaces each billing period."
}
</Info>
</span>
}
value={draft}
onChange={(value) => {
hasEditedRef.current = true
setDraft(value)
}}
placeholder='No limit'
hint='Leave blank for no limit.'
disabled={isLoading || isSaving}
/>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={isSaving}
primaryAction={{
label: isSaving ? 'Saving…' : 'Save',
onClick: handleSave,
disabled: !isValid || !isDirty || isSaving || isLoading,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export { NoOrganizationView } from './no-organization-view'
@@ -0,0 +1,199 @@
import {
Button,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
Label,
} from '@sim/emcn'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
interface NoOrganizationViewProps {
hasTeamPlan: boolean
hasEnterprisePlan: boolean
orgName: string
orgSlug: string
setOrgSlug: (slug: string) => void
onOrgNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onCreateOrganization: () => Promise<void>
isCreatingOrg: boolean
error: string | null
createOrgDialogOpen: boolean
setCreateOrgDialogOpen: (open: boolean) => void
}
export function NoOrganizationView({
hasTeamPlan,
hasEnterprisePlan,
orgName,
orgSlug,
setOrgSlug,
onOrgNameChange,
onCreateOrganization,
isCreatingOrg,
error,
createOrgDialogOpen,
setCreateOrgDialogOpen,
}: NoOrganizationViewProps) {
const { navigateToSettings } = useSettingsNavigation()
if (hasTeamPlan || hasEnterprisePlan) {
return (
<div>
<div className='flex flex-col gap-5'>
{/* Header - matching settings page style */}
<div>
<h4 className='font-medium text-[var(--text-primary)] text-base'>
Create Your Team Workspace
</h4>
<p className='mt-1 text-[var(--text-muted)] text-small'>
You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your
workspace to start collaborating with your team.
</p>
</div>
{/* Form fields - clean layout without card */}
<div className='flex flex-col gap-4.5'>
{/* Hidden decoy field to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
aria-hidden='true'
aria-label='Ignore this field'
/>
<div>
<Label htmlFor='team-name-field' className='font-medium text-small'>
Team Name
</Label>
<ChipInput
id='team-name-field'
value={orgName}
onChange={onOrgNameChange}
placeholder='My Team'
className='mt-1'
name='team_name_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
<div>
<Label htmlFor='orgSlug' className='font-medium text-small'>
Team URL
</Label>
<div className='mt-1 flex items-center'>
<div className='rounded-l-[6px] border border-[var(--border-1)] border-r-0 bg-[var(--surface-4)] px-3 py-1.5 text-[var(--text-muted)] text-small'>
sim.ai/team/
</div>
<ChipInput
id='orgSlug'
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
placeholder='my-team'
className='rounded-l-none'
/>
</div>
</div>
<div className='flex flex-col gap-2'>
{error && (
<p className='text-[var(--text-error)] text-small leading-tight'>{error}</p>
)}
<div className='flex justify-end'>
<Button
variant='primary'
onClick={onCreateOrganization}
disabled={!orgName || !orgSlug || isCreatingOrg}
>
{isCreatingOrg ? 'Creating...' : 'Create Team Workspace'}
</Button>
</div>
</div>
</div>
</div>
<ChipModal
open={createOrgDialogOpen}
onOpenChange={setCreateOrgDialogOpen}
srTitle='Create Team Organization'
>
<ChipModalHeader onClose={() => setCreateOrgDialogOpen(false)}>
Create Team Organization
</ChipModalHeader>
<ChipModalBody>
{/* Hidden decoy field to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
aria-hidden='true'
aria-label='Ignore this field'
/>
<ChipModalField
type='input'
title='Organization Name'
value={orgName}
onChange={(value) =>
onOrgNameChange({ target: { value } } as React.ChangeEvent<HTMLInputElement>)
}
placeholder='Enter organization name'
disabled={isCreatingOrg}
autoComplete='off'
required
/>
<ChipModalField
type='input'
title='Organization Slug'
value={orgSlug}
onChange={setOrgSlug}
placeholder='organization-slug'
disabled={isCreatingOrg}
autoComplete='off'
/>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setCreateOrgDialogOpen(false)}
cancelDisabled={isCreatingOrg}
primaryAction={{
label: isCreatingOrg ? 'Creating...' : 'Create Organization',
onClick: onCreateOrganization,
disabled: isCreatingOrg || !orgName.trim(),
}}
/>
</ChipModal>
</div>
)
}
return (
<div className='flex flex-col gap-5'>
<div className='flex flex-col gap-2'>
<h3 className='font-medium text-[var(--text-primary)] text-base'>No Team Workspace</h3>
<p className='text-[var(--text-secondary)] text-small'>
You don't have a team workspace yet. To collaborate with others, first upgrade to a team
or enterprise plan.
</p>
</div>
<div>
<Button variant='primary' onClick={() => navigateToSettings({ section: 'billing' })}>
Upgrade to Team Plan
</Button>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { OrganizationInviteModal } from './organization-invite-modal'
@@ -0,0 +1,242 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import {
ChipDropdown,
type ChipDropdownOption,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useSession } from '@/lib/auth/auth-client'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useInviteMember } from '@/hooks/queries/organization'
const logger = createLogger('OrganizationInviteModal')
const ROLE_OPTIONS = [
{ value: 'admin', label: 'Admin' },
{ value: 'write', label: 'Write' },
{ value: 'read', label: 'Read' },
] as const
const EMPTY_EMAILS: string[] = []
interface OrganizationInviteModalProps {
open: boolean
onOpenChange: (open: boolean) => void
organizationId: string
/** Workspaces the inviter can grant access to. */
workspaces: Array<{ id: string; name: string }>
/** Emails of external collaborators (rejected — they cannot join the organization). */
externalEmails?: string[]
/**
* Non-member emails with a pending invitation (rejected as duplicates).
* Member emails are always allowed — they receive workspace invitations for
* the selected workspaces they aren't in yet, deduped per workspace by the
* server — so the parent excludes them from this list.
*/
pendingEmails?: string[]
}
/**
* Organization-level invite modal: enter emails, pick one or more workspaces to
* grant access to, choose a role applied to every selected workspace, and send
* through the organization invite path. Emails of existing organization
* members are accepted — the server sends them workspace-only invitations for
* the selected workspaces they don't already have access to.
*/
export function OrganizationInviteModal({
open,
onOpenChange,
organizationId,
workspaces,
externalEmails = EMPTY_EMAILS,
pendingEmails = EMPTY_EMAILS,
}: OrganizationInviteModalProps) {
const [emails, setEmails] = useState<string[]>([])
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([])
const [inviteRole, setInviteRole] = useState<PermissionType>('write')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { data: session } = useSession()
const inviteMember = useInviteMember()
const isSubmitting = inviteMember.isPending
const workspaceOptions = useMemo<ChipDropdownOption[]>(
() => workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name })),
[workspaces]
)
const externalEmailSet = useMemo(
() => new Set(externalEmails.map((email) => email.toLowerCase())),
[externalEmails]
)
const pendingEmailSet = useMemo(
() => new Set(pendingEmails.map((email) => email.toLowerCase())),
[pendingEmails]
)
const validateEmail = useCallback(
(email: string): string | null => {
const formatResult = quickValidateEmail(email)
if (!formatResult.isValid) {
return formatResult.reason ?? 'Invalid email'
}
if (session?.user?.email && session.user.email.toLowerCase() === email) {
return 'You cannot invite yourself'
}
if (externalEmailSet.has(email)) {
return `${email} belongs to another organization and can't be invited. Invite them to individual workspaces from the Teammates tab.`
}
if (pendingEmailSet.has(email)) {
return `${email} already has a pending invitation`
}
return null
},
[session?.user?.email, externalEmailSet, pendingEmailSet]
)
const handleEmailsChange = useCallback((next: string[]) => {
setEmails(next)
setErrorMessage(null)
}, [])
const handleSend = useCallback(() => {
setErrorMessage(null)
if (emails.length === 0 || selectedWorkspaceIds.length === 0 || !organizationId) return
const workspaceInvitations = selectedWorkspaceIds.map((workspaceId) => ({
workspaceId,
permission: inviteRole as 'admin' | 'write' | 'read',
}))
inviteMember.mutate(
{ emails, orgId: organizationId, workspaceInvitations },
{
onSuccess: (result) => {
const summary =
'data' in result && result.data && typeof result.data === 'object'
? (result.data as {
invitationsSent?: number
directlyAddedCount?: number
failedInvitations?: Array<{ email: string; error: string }>
})
: null
const addedCount = summary?.directlyAddedCount ?? 0
const sentCount = summary?.invitationsSent ?? 0
const failed = summary?.failedInvitations ?? []
// Surface partial successes even when some addresses fail.
const parts: string[] = []
if (addedCount > 0) {
parts.push(`${addedCount} member${addedCount === 1 ? '' : 's'} added`)
}
if (sentCount > 0) {
parts.push(`${sentCount} invite${sentCount === 1 ? '' : 's'} sent`)
}
if (parts.length > 0) {
toast.success(parts.join(' · '))
}
if (failed.length > 0) {
// Keep only the failed addresses (workspaces stay selected) for retry.
setEmails(failed.map((entry) => entry.email))
setErrorMessage(
failed.length === 1
? failed[0].error
: `${failed.length} invitations failed. ${failed[0].error}`
)
return
}
setEmails([])
setSelectedWorkspaceIds([])
onOpenChange(false)
},
onError: (error) => {
logger.error('Failed to invite members', { error })
setErrorMessage(error.message || 'An unexpected error occurred. Please try again.')
},
}
)
}, [emails, selectedWorkspaceIds, organizationId, inviteRole, inviteMember, onOpenChange])
const resetState = useCallback(() => {
setEmails([])
setSelectedWorkspaceIds([])
setInviteRole('write')
setErrorMessage(null)
}, [])
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) resetState()
onOpenChange(next)
},
[onOpenChange, resetState]
)
const isSendDisabled =
isSubmitting || emails.length === 0 || selectedWorkspaceIds.length === 0 || !organizationId
return (
<ChipModal
open={open}
onOpenChange={handleOpenChange}
srTitle='Invite teammates to organization'
>
<ChipModalHeader onClose={() => handleOpenChange(false)}>Invite teammates</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='emails'
title='Emails'
value={emails}
onChange={handleEmailsChange}
validate={validateEmail}
error={errorMessage}
placeholder='Enter emails'
disabled={isSubmitting}
/>
<ChipModalField type='custom' title='Workspaces'>
<ChipDropdown
multiple
value={selectedWorkspaceIds}
onChange={setSelectedWorkspaceIds}
options={workspaceOptions}
allLabel='Select workspaces'
showAllOption={false}
searchable
searchPlaceholder='Search workspaces...'
fullWidth
flush
disabled={isSubmitting || workspaceOptions.length === 0}
/>
</ChipModalField>
<ChipModalField
type='dropdown'
title='Invite as'
options={ROLE_OPTIONS}
value={inviteRole}
placeholder='Select role'
align='start'
onChange={(role) => setInviteRole(role as PermissionType)}
/>
</ChipModalBody>
<ChipModalFooter
onCancel={() => handleOpenChange(false)}
primaryAction={{
label: isSubmitting ? 'Sending...' : 'Send invites',
onClick: handleSend,
disabled: isSendDisabled,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export { OrganizationMemberLists } from './organization-member-lists'
@@ -0,0 +1,471 @@
'use client'
import { useMemo, useState } from 'react'
import { ChipDropdown, ChipInput, Search, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import {
type OrgRole,
type PermissionType,
RoleLockTooltip,
workspaceRoleLockReason,
} from '@/components/permissions'
import type {
OrganizationRoster,
RosterMember,
RosterPendingInvitation,
RosterWorkspaceAccess,
} from '@/lib/api/contracts/organization'
import type { Member } from '@/lib/workspaces/organization'
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,
} from '@/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal'
import {
useRemoveWorkspaceMember,
useUpdateWorkspacePermissions,
} from '@/hooks/queries/invitations'
import {
useCancelInvitation,
useResendInvitation,
useUpdateInvitation,
useUpdateOrganizationMemberRole,
} from '@/hooks/queries/organization'
const logger = createLogger('OrganizationMemberLists')
const ORG_ROLE_OPTIONS = [
{ value: 'admin', label: 'Admin' },
{ value: 'member', label: 'Member' },
] as const
const WORKSPACE_ROLE_OPTIONS = [
{ value: 'read', label: 'Read' },
{ value: 'write', label: 'Write' },
{ value: 'admin', label: 'Admin' },
] as const
function capitalize(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
function copyToClipboard(text: string) {
void navigator.clipboard.writeText(text)
}
function buildActionsMenu(actions: RowAction[]) {
return <RowActionsMenu label='Member actions' actions={actions} />
}
interface OrganizationMemberListsProps {
organizationId: string
roster: OrganizationRoster | null | undefined
isLoadingRoster: boolean
currentUserId: string
onRemoveMember: (member: Member) => void
onTransferOwnership?: () => void
}
/**
* Renders the organization roster as Teammates-style sections: an org-level
* "Members" section followed by one section per workspace, each listing that
* workspace's members and pending grants. A single search box filters every
* section; sections with no matches collapse while a search is active.
*/
export function OrganizationMemberLists({
organizationId,
roster,
isLoadingRoster,
currentUserId,
onRemoveMember,
onTransferOwnership,
}: OrganizationMemberListsProps) {
const [query, setQuery] = useState('')
const [creditsTarget, setCreditsTarget] = useState<ManageCreditsTarget | null>(null)
const updateMemberRole = useUpdateOrganizationMemberRole()
const updateInvitation = useUpdateInvitation()
const updatePermissions = useUpdateWorkspacePermissions()
const removeWorkspaceMember = useRemoveWorkspaceMember()
const cancelInvitation = useCancelInvitation()
const resendInvitation = useResendInvitation()
const members = useMemo(() => roster?.members ?? [], [roster])
const pendingInvitations = useMemo(() => roster?.pendingInvitations ?? [], [roster])
const workspaces = useMemo(() => roster?.workspaces ?? [], [roster])
const q = query.trim().toLowerCase()
const matches = (name: string, email: string) =>
!q || name.toLowerCase().includes(q) || email.toLowerCase().includes(q)
const isActiveSearch = q.length > 0
const renderOrgMemberRow = (member: RosterMember) => {
const isSelf = member.userId === currentUserId
const isOwner = member.role === 'owner'
const isExternal = member.role === 'external'
const editable = !isSelf && !isOwner && !isExternal
const canRemove = !isSelf && !isOwner
return (
<MemberRow
key={`org-member-${member.memberId}`}
name={member.name}
email={member.email}
image={member.image}
status={`Joined ${formatDate(new Date(member.createdAt))}`}
roleControl={
editable ? (
<ChipDropdown
value={member.role}
onChange={(role) =>
updateMemberRole
.mutateAsync({
orgId: organizationId,
userId: member.userId,
role: role as OrgRole,
})
.catch((error) => logger.error('Failed to update member role', { error }))
}
options={ORG_ROLE_OPTIONS}
matchTriggerWidth={false}
disabled={updateMemberRole.isPending}
/>
) : (
<ChipDropdown
value={member.role}
options={[{ value: member.role, label: capitalize(member.role) }]}
matchTriggerWidth={false}
disabled
/>
)
}
menu={buildActionsMenu([
{ label: 'Copy email', onSelect: () => copyToClipboard(member.email) },
...(!isOwner
? [
{
label: 'Manage Credits',
onSelect: () =>
setCreditsTarget({
userId: member.userId,
name: member.name,
email: member.email,
}),
},
]
: []),
...(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,
},
}),
},
]
: []),
])}
/>
)
}
const renderInviteRow = (
invitation: RosterPendingInvitation,
keyPrefix: string,
roleControl: React.ReactNode
) => (
<MemberRow
key={`${keyPrefix}-${invitation.id}`}
name={invitation.inviteeName ?? invitation.email}
email={invitation.email}
image={invitation.inviteeImage}
status='Invite pending'
roleControl={roleControl}
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 })),
},
])}
/>
)
const renderOrgInviteRow = (invitation: RosterPendingInvitation) => {
const isExternal = invitation.membershipIntent === 'external'
const roleControl = isExternal ? (
<ChipDropdown
value='external'
options={[{ value: 'external', label: 'External' }]}
matchTriggerWidth={false}
disabled
/>
) : (
<ChipDropdown
value={invitation.role === 'admin' ? 'admin' : 'member'}
onChange={(role) =>
updateInvitation
.mutateAsync({
orgId: organizationId,
invitationId: invitation.id,
role: role as OrgRole,
})
.catch((error) => logger.error('Failed to update invitation role', { error }))
}
options={ORG_ROLE_OPTIONS}
matchTriggerWidth={false}
disabled={updateInvitation.isPending}
/>
)
return renderInviteRow(invitation, 'org-invite', roleControl)
}
const renderWorkspaceMemberRow = (
member: RosterMember,
workspaceId: string,
access: RosterWorkspaceAccess
) => {
const rowUserIsOrgAdmin = isOrgAdminRole(member.role)
const isSelf = member.userId === currentUserId
const wouldDemoteSelf = isSelf && access.permission === 'admin'
const disabled = rowUserIsOrgAdmin || wouldDemoteSelf || updatePermissions.isPending
const lockReason = rowUserIsOrgAdmin ? workspaceRoleLockReason('org-admin') : null
const canRemoveFromWorkspace = !rowUserIsOrgAdmin && !isSelf
return (
<MemberRow
key={`ws-${workspaceId}-member-${member.memberId}`}
name={member.name}
email={member.email}
image={member.image}
status={`Joined ${formatDate(new Date(member.createdAt))}`}
roleControl={
<RoleLockTooltip reason={lockReason}>
<ChipDropdown
value={access.permission}
onChange={(permission) =>
updatePermissions
.mutateAsync({
workspaceId,
organizationId,
updates: [{ userId: member.userId, permissions: permission as PermissionType }],
})
.catch((error) =>
logger.error('Failed to update workspace permission', { error })
)
}
options={WORKSPACE_ROLE_OPTIONS}
matchTriggerWidth={false}
disabled={disabled}
/>
</RoleLockTooltip>
}
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.'),
})
}),
},
]
: []),
])}
/>
)
}
const renderWorkspaceInviteRow = (
invitation: RosterPendingInvitation,
workspaceId: string,
access: RosterWorkspaceAccess
) => {
const roleControl = (
<ChipDropdown
value={access.permission}
onChange={(permission) =>
updateInvitation
.mutateAsync({
orgId: organizationId,
invitationId: invitation.id,
grants: [{ workspaceId, permission: permission as PermissionType }],
})
.catch((error) => logger.error('Failed to update invitation grant', { error }))
}
options={WORKSPACE_ROLE_OPTIONS}
matchTriggerWidth={false}
disabled={updateInvitation.isPending}
/>
)
return renderInviteRow(invitation, `ws-${workspaceId}-invite`, roleControl)
}
const filteredOrgMembers = members.filter((m) => matches(m.name, m.email))
const orgPending = pendingInvitations.filter((inv) => inv.kind === 'organization')
const filteredOrgPending = orgPending.filter((inv) =>
matches(inv.inviteeName ?? inv.email, inv.email)
)
const orgRowCount = members.length + orgPending.length
const hasOrgMatches = filteredOrgMembers.length + filteredOrgPending.length > 0
const showMembersSection = !isActiveSearch || hasOrgMatches
/**
* Group each workspace's members and pending invites once per roster change.
* This is O(workspaces × members) and independent of the search query, so
* hoisting it out of render keeps keystroke filtering cheap on large orgs.
*/
const workspaceGroups = useMemo(
() =>
workspaces.map((workspace) => {
const workspaceMembers = members
.map((member) => ({
member,
access: member.workspaces.find((w) => w.workspaceId === workspace.id),
}))
.filter((entry): entry is { member: RosterMember; access: RosterWorkspaceAccess } =>
Boolean(entry.access)
)
const workspaceInvites = pendingInvitations
.map((invitation) => ({
invitation,
access: invitation.workspaces.find((w) => w.workspaceId === workspace.id),
}))
.filter(
(
entry
): entry is { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess } =>
Boolean(entry.access)
)
return { workspace, workspaceMembers, workspaceInvites }
}),
[workspaces, members, pendingInvitations]
)
return (
<>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search members...'
value={query}
onChange={(e) => setQuery(e.target.value)}
className='flex-1'
/>
</div>
{showMembersSection && (
<MemberSection
label={`Members (${orgRowCount})`}
isEmpty={!isLoadingRoster && filteredOrgMembers.length + filteredOrgPending.length === 0}
emptyText={isActiveSearch ? `No members matching “${query}` : 'No members yet'}
>
{filteredOrgMembers.map(renderOrgMemberRow)}
{filteredOrgPending.map(renderOrgInviteRow)}
</MemberSection>
)}
{workspaceGroups.map(({ workspace, workspaceMembers, workspaceInvites }) => {
const visibleMembers = workspaceMembers.filter(({ member }) =>
matches(member.name, member.email)
)
const visibleInvites = workspaceInvites.filter(({ invitation }) =>
matches(invitation.inviteeName ?? invitation.email, invitation.email)
)
const totalCount = workspaceMembers.length + workspaceInvites.length
const hasMatches = visibleMembers.length + visibleInvites.length > 0
if (isActiveSearch && !hasMatches) return null
return (
<MemberSection
key={`workspace-${workspace.id}`}
label={`${workspace.name} (${totalCount})`}
isEmpty={visibleMembers.length + visibleInvites.length === 0}
emptyText={
isActiveSearch ? `No members matching “${query}` : 'No members in this workspace'
}
>
{visibleMembers.map(({ member, access }) =>
renderWorkspaceMemberRow(member, workspace.id, access)
)}
{visibleInvites.map(({ invitation, access }) =>
renderWorkspaceInviteRow(invitation, workspace.id, access)
)}
</MemberSection>
)
})}
<ManageCreditsModal
key={creditsTarget?.userId ?? 'none'}
open={creditsTarget !== null}
onOpenChange={(open) => {
if (!open) setCreditsTarget(null)
}}
organizationId={organizationId}
member={creditsTarget}
/>
</>
)
}
@@ -0,0 +1 @@
export { RemoveMemberDialog } from './remove-member-dialog'
@@ -0,0 +1,72 @@
import { ChipConfirmModal } from '@sim/emcn'
interface RemoveMemberDialogProps {
open: boolean
memberName: string
isSelfRemoval?: boolean
isExternalRemoval?: boolean
isSubmitting?: boolean
error?: Error | null
onOpenChange: (open: boolean) => void
onConfirmRemove: () => Promise<void>
onCancel: () => void
}
export function RemoveMemberDialog({
open,
memberName,
error,
onOpenChange,
onConfirmRemove,
onCancel,
isSelfRemoval = false,
isExternalRemoval = false,
isSubmitting = false,
}: RemoveMemberDialogProps) {
const title = isSelfRemoval
? 'Leave Organization'
: isExternalRemoval
? 'Remove External Member'
: 'Remove Team Member'
const errorMessage =
error instanceof Error && error.message ? error.message : error ? String(error) : null
return (
<ChipConfirmModal
open={open}
onOpenChange={(next) => {
if (!next) onCancel()
onOpenChange(next)
}}
srTitle={title}
title={title}
text={
isSelfRemoval
? 'Are you sure you want to leave this organization? You will lose access to all team resources. This action cannot be undone.'
: isExternalRemoval
? [
'Are you sure you want to remove ',
{ text: memberName, bold: true },
' from all organization workspaces? Their workspace access and workspace credential access will be revoked. This action cannot be undone.',
]
: [
'Are you sure you want to remove ',
{ text: memberName, bold: true },
' from the team? This action cannot be undone.',
]
}
confirm={{
label: isSelfRemoval ? 'Leave Organization' : 'Remove',
onClick: () => onConfirmRemove(),
pending: isSubmitting,
}}
>
{errorMessage ? (
<p role='alert' className='mt-1 px-2 text-[var(--text-error)] text-caption'>
{errorMessage}
</p>
) : null}
</ChipConfirmModal>
)
}
@@ -0,0 +1 @@
export { TeamSeatsOverview } from './team-seats-overview'
@@ -0,0 +1,148 @@
import { Badge, Chip, cn } from '@sim/emcn'
import { useParams, useRouter } from 'next/navigation'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
type Subscription = {
id: string
plan: string
status: string
referenceId: string
cancelAtPeriodEnd?: boolean
periodEnd?: number | Date
trialEnd?: number | Date
}
interface TeamSeatsOverviewProps {
subscriptionData: Subscription | null
isLoadingSubscription: boolean
totalSeats: number
/** Seats consumed by actual members. Pending invites are not counted here. */
usedSeats: number
/** Outstanding invites that have not been accepted yet (do not consume a seat). */
pendingSeats?: number
}
export function TeamSeatsOverview({
subscriptionData,
isLoadingSubscription,
totalSeats,
usedSeats,
pendingSeats = 0,
}: TeamSeatsOverviewProps) {
const router = useRouter()
const params = useParams<{ workspaceId: string }>()
const workspaceId = params?.workspaceId
if (isLoadingSubscription) {
return null
}
if (!subscriptionData) {
return (
<SettingsSection label='Seats'>
<div className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 flex-col'>
<span className='text-[var(--text-body)] text-small'>No active Team subscription</span>
<span className='text-[var(--text-muted)] text-small'>
Purchase a Team plan to invite teammates to this organization.
</span>
</div>
<Chip
variant='primary'
flush
onClick={() => {
if (workspaceId) {
router.push(`/workspace/${workspaceId}/settings/billing`)
}
}}
disabled={!workspaceId}
>
View plans
</Chip>
</div>
</SettingsSection>
)
}
const isEnterprise = checkEnterprisePlan(subscriptionData)
const isSeatDataPending = !isEnterprise && totalSeats === 0
const isOverLimit = totalSeats > 0 && usedSeats > totalSeats
const pillCount = Math.max(totalSeats, usedSeats, 1)
if (isSeatDataPending) {
return null
}
const pendingBadge =
pendingSeats > 0 ? (
<Badge variant='gray-secondary' size='sm'>
{pendingSeats} pending
</Badge>
) : null
/**
* Team plans have no fixed seat cap — the seat count is reconciled to the
* member count, so a used/total ratio (and its meter) is always 100% and
* carries no information. Show a plain seat count instead, and reserve the
* cap meter for Enterprise, where seats are a fixed allotment.
*/
if (!isEnterprise) {
return (
<SettingsSection label='Seats'>
<div className='flex items-center justify-between gap-2'>
<span className='text-[var(--text-body)] text-small tabular-nums'>
{usedSeats} {usedSeats === 1 ? 'seat' : 'seats'}
</span>
{pendingBadge}
</div>
</SettingsSection>
)
}
return (
<SettingsSection label='Seats'>
<div className='flex flex-col gap-2.5'>
<div className='flex items-center justify-between gap-2'>
<span className='text-[var(--text-body)] text-small tabular-nums'>
{usedSeats} used / {totalSeats} total
</span>
<div className='flex items-center gap-1.5'>
{pendingBadge}
{isOverLimit && (
<Badge variant='amber' size='sm'>
Over limit
</Badge>
)}
</div>
</div>
<div className='flex items-center gap-1'>
{Array.from({ length: pillCount }).map((_, i) => {
const isFilled = i < usedSeats
const isOverage = i >= totalSeats
return (
<div
key={i}
className={cn(
'h-[6px] flex-1 rounded-full transition-colors',
isOverage
? 'bg-[var(--badge-amber-text)]'
: isFilled
? 'bg-[var(--indicator-seat-filled)]'
: 'bg-[var(--border)]'
)}
/>
)
})}
</div>
<p className='text-[var(--text-muted)] text-small'>
{isOverLimit
? 'You have more teammates than seats. Contact support to adjust your enterprise seat count.'
: 'Contact support for enterprise seat changes.'}
</p>
</div>
</SettingsSection>
)
}
@@ -0,0 +1 @@
export { TransferOwnershipDialog } from './transfer-ownership-dialog'
@@ -0,0 +1,216 @@
'use client'
import { useMemo, useState } from 'react'
import {
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Banner,
ChipConfirmModal,
ChipInput,
cn,
Search,
Skeleton,
} from '@sim/emcn'
import { getUserColor } from '@/lib/workspaces/colors'
import type { RosterMember } from '@/hooks/queries/organization'
interface TransferOwnershipDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
members: RosterMember[]
isLoadingMembers: boolean
currentUserId: string
isSubmitting: boolean
error?: Error | null
portalError?: string | null
hasPaidSubscription: boolean
isOpeningBillingPortal: boolean
onConfirm: (newOwnerUserId: string) => Promise<void>
onOpenBillingPortal: () => void
}
export function TransferOwnershipDialog({
open,
onOpenChange,
members,
isLoadingMembers,
currentUserId,
isSubmitting,
error,
portalError,
hasPaidSubscription,
isOpeningBillingPortal,
onConfirm,
onOpenBillingPortal,
}: TransferOwnershipDialogProps) {
const [search, setSearch] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
const candidates = useMemo(() => {
const others = members.filter(
(m) => m.userId !== currentUserId && m.role !== 'owner' && m.role !== 'external'
)
others.sort((a, b) => {
if (a.role === 'admin' && b.role !== 'admin') return -1
if (a.role !== 'admin' && b.role === 'admin') return 1
return a.name.localeCompare(b.name)
})
if (!search.trim()) return others
const q = search.trim().toLowerCase()
return others.filter(
(m) => m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q)
)
}, [members, currentUserId, search])
const hasCandidates = members.some(
(m) => m.userId !== currentUserId && m.role !== 'owner' && m.role !== 'external'
)
const handleClose = (next: boolean) => {
if (!next) {
setSearch('')
setSelectedUserId(null)
}
onOpenChange(next)
}
const handleConfirm = async () => {
if (!selectedUserId) return
await onConfirm(selectedUserId)
}
return (
<ChipConfirmModal
open={open}
onOpenChange={handleClose}
srTitle='Leave organization'
title='Leave organization'
confirm={{
label: 'Transfer & leave',
onClick: handleConfirm,
pending: isSubmitting,
pendingLabel: 'Transferring...',
disabled: !selectedUserId || !hasCandidates || isLoadingMembers,
}}
>
<div className='flex flex-col gap-4'>
{isLoadingMembers ? (
<div className='space-y-3'>
<Skeleton className='h-4 w-3/4' />
<Skeleton className='h-4 w-1/2' />
<div className='space-y-2 pt-2'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-10 w-full' />
<Skeleton className='h-10 w-full' />
</div>
</div>
) : !hasCandidates ? (
<p className='px-2 text-[var(--text-secondary)] text-sm'>
You're the only member of this organization. Invite another admin before leaving.
</p>
) : (
<div className='space-y-3'>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
As the owner, you need to hand off the organization before you can leave. Pick a
member to become the new owner. They'll inherit billing access, seat management, and
all owner-only permissions. You'll lose access to every shared workspace in this
organization.
</p>
{hasPaidSubscription && (
<Banner
variant='default'
className='rounded-md px-3 py-2'
textClassName='text-[var(--text-primary)]'
actionLabel={isOpeningBillingPortal ? 'Opening...' : 'Open Stripe billing portal'}
actionDisabled={isOpeningBillingPortal}
onAction={onOpenBillingPortal}
text={
<>
<span className='block font-medium'>
Your payment method stays on this organization
</span>
<span className='block text-[var(--text-secondary)]'>
Future charges will keep hitting the card you added. Open the Stripe billing
portal to remove it before you leave.
</span>
</>
}
/>
)}
{portalError && <p className='px-2 text-[var(--text-error)] text-sm'>{portalError}</p>}
<ChipInput
icon={Search}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder='Search members...'
/>
<div className='max-h-[280px] overflow-y-auto rounded-md border border-[var(--border-1)]'>
{candidates.length === 0 ? (
<div className='px-3 py-4 text-center text-[var(--text-muted)] text-small'>
No members match "{search}"
</div>
) : (
<ul className='divide-y divide-[var(--border-1)]'>
{candidates.map((m) => {
const isSelected = selectedUserId === m.userId
return (
<li key={m.userId}>
<button
type='button'
onClick={() => setSelectedUserId(m.userId)}
className={cn(
'flex w-full items-center gap-3 px-3 py-2 text-left transition-colors',
isSelected
? 'bg-[var(--surface-active)]'
: 'hover-hover:bg-[var(--surface-hover)]'
)}
>
<Avatar className='size-8 shrink-0'>
{m.image && <AvatarImage src={m.image} alt={m.name} />}
<AvatarFallback
style={{ background: getUserColor(m.userId || m.email) }}
className='border-0 text-white'
>
{m.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
{m.name}
</span>
{m.role === 'admin' && (
<Badge variant='gray-secondary' size='sm'>
Admin
</Badge>
)}
</div>
<div className='truncate text-[var(--text-muted)] text-caption'>
{m.email}
</div>
</div>
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
)}
{error && (
<p className='px-2 text-[var(--text-error)] text-sm'>
{error instanceof Error && error.message ? error.message : String(error)}
</p>
)}
</div>
</ChipConfirmModal>
)
}
@@ -0,0 +1 @@
export { TeamManagement } from './team-management'
@@ -0,0 +1,392 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus } from '@sim/emcn'
import { createLogger } from '@sim/logger'
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,
OrganizationMemberLists,
RemoveMemberDialog,
TeamSeatsOverview,
TransferOwnershipDialog,
} from '@/app/workspace/[workspaceId]/settings/components/team-management/components'
import {
useCreateOrganization,
useOrganization,
useOrganizationBilling,
useOrganizationRoster,
useOrganizations,
useRemoveMember,
useTransferOwnership,
} from '@/hooks/queries/organization'
import { useOpenBillingPortal, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('TeamManagement')
export function TeamManagement() {
const { data: session } = useSession()
const { isInvitationsDisabled } = usePermissionConfig()
const { data: organizationsData } = useOrganizations()
const activeOrganization = organizationsData?.activeOrganization
const { data: userSubscriptionData } = useSubscriptionData()
const subscriptionAccess = getSubscriptionAccessState(userSubscriptionData?.data)
const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
const {
data: organization,
isLoading,
error: orgError,
} = useOrganization(activeOrganization?.id || '')
const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
activeOrganization?.id || ''
)
const { data: roster, isLoading: isLoadingRoster } = useOrganizationRoster(activeOrganization?.id)
const removeMemberMutation = useRemoveMember()
const transferOwnershipMutation = useTransferOwnership()
const openBillingPortal = useOpenBillingPortal()
const createOrgMutation = useCreateOrganization()
const [inviteModalOpen, setInviteModalOpen] = useState(false)
const [createOrgDialogOpen, setCreateOrgDialogOpen] = useState(false)
const [removeMemberDialog, setRemoveMemberDialog] = useState<{
open: boolean
memberId: string
memberName: string
isSelfRemoval?: boolean
isExternalRemoval?: boolean
}>({ open: false, memberId: '', memberName: '' })
const [transferDialogOpen, setTransferDialogOpen] = useState(false)
const [transferPortalError, setTransferPortalError] = useState<string | null>(null)
const [orgName, setOrgName] = useState('')
const [orgSlug, setOrgSlug] = useState('')
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
const totalSeats = organizationBillingData?.data?.totalSeats ?? 0
const usedSeats = organizationBillingData?.data?.members?.length ?? 0
const reservedSeats = organizationBillingData?.data?.usedSeats ?? 0
const pendingSeats = Math.max(0, reservedSeats - usedSeats)
/**
* The org's active subscription, derived from DB-backed organization billing
* (`getOrganizationBillingData` only returns data when an entitled org
* subscription exists). We intentionally do not read this from better-auth's
* `client.subscription.list`, which does not reliably surface org-scoped
* subscriptions.
*/
const orgBilling = organizationBillingData?.data ?? null
const orgSubscription = orgBilling
? {
id: orgBilling.organizationId,
plan: orgBilling.subscriptionPlan,
status: orgBilling.subscriptionStatus ?? 'active',
referenceId: orgBilling.organizationId,
}
: null
const externalEmails = useMemo(() => {
const emails: string[] = []
for (const member of roster?.members ?? []) {
if (member.role === 'external') emails.push(member.email)
}
return emails
}, [roster])
/**
* Pending invitations for emails that already belong to a member are
* excluded: members can always be re-invited to additional workspaces (the
* server dedupes per workspace), so only non-member pending emails are
* blocked in the invite modal.
*/
const pendingEmails = useMemo(() => {
const memberEmailSet = new Set<string>()
for (const member of roster?.members ?? []) {
if (member.role !== 'external') memberEmailSet.add(member.email.toLowerCase())
}
const emails: string[] = []
for (const invitation of roster?.pendingInvitations ?? []) {
if (!memberEmailSet.has(invitation.email.toLowerCase())) emails.push(invitation.email)
}
return emails
}, [roster])
useEffect(() => {
if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
const defaultName = `${session.user.name}'s Team`
setOrgName(defaultName)
setOrgSlug(generateSlug(defaultName))
}
}, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName])
const handleOrgNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value
setOrgName(newName)
setOrgSlug(generateSlug(newName))
}, [])
const handleCreateOrganization = useCallback(async () => {
if (!session?.user || !orgName.trim()) return
try {
await createOrgMutation.mutateAsync({
name: orgName.trim(),
slug: orgSlug.trim(),
})
setCreateOrgDialogOpen(false)
setOrgName('')
setOrgSlug('')
} catch (error) {
logger.error('Failed to create organization', error)
}
}, [orgName, orgSlug, createOrgMutation, session?.user])
const handleRemoveMember = useCallback(
async (member: Member) => {
if (!session?.user || !activeOrganization?.id) return
if (!member.user?.id) {
logger.error('Member object missing user ID', { member })
return
}
const isLeavingSelf = member.user?.email === session.user.email
const displayName = isLeavingSelf
? 'yourself'
: member.user?.name || member.user?.email || 'this member'
setRemoveMemberDialog({
open: true,
memberId: member.user.id,
memberName: displayName,
isSelfRemoval: isLeavingSelf,
isExternalRemoval: member.role === 'external',
})
},
[session?.user, activeOrganization?.id]
)
const confirmRemoveMember = useCallback(async () => {
const { memberId, isSelfRemoval } = removeMemberDialog
if (!session?.user || !activeOrganization?.id || !memberId) return
try {
await removeMemberMutation.mutateAsync({
memberId,
orgId: activeOrganization?.id,
})
setRemoveMemberDialog({
open: false,
memberId: '',
memberName: '',
isExternalRemoval: false,
})
if (isSelfRemoval) {
window.location.href = '/workspace'
}
} catch (error) {
logger.error('Failed to remove member', error)
}
}, [
removeMemberDialog.memberId,
removeMemberDialog.isSelfRemoval,
session?.user?.id,
activeOrganization?.id,
removeMemberMutation,
])
const handleTransferDialogOpenChange = useCallback(
(next: boolean) => {
setTransferDialogOpen(next)
if (!next) {
transferOwnershipMutation.reset()
setTransferPortalError(null)
}
},
[transferOwnershipMutation]
)
const handleOpenTransferDialog = useCallback(() => {
transferOwnershipMutation.reset()
setTransferPortalError(null)
setTransferDialogOpen(true)
}, [transferOwnershipMutation])
const handleConfirmTransfer = useCallback(
async (newOwnerUserId: string) => {
if (!activeOrganization?.id) return
try {
const result = await transferOwnershipMutation.mutateAsync({
orgId: activeOrganization.id,
newOwnerUserId,
alsoLeave: true,
})
setTransferDialogOpen(false)
if (result.left) {
window.location.href = '/workspace'
}
} catch (error) {
logger.error('Failed to transfer ownership', error)
}
},
[activeOrganization?.id, transferOwnershipMutation]
)
const handleOpenTransferBillingPortal = useCallback(() => {
if (!activeOrganization?.id) return
setTransferPortalError(null)
const portalWindow = window.open('', '_blank')
openBillingPortal.mutate(
{
context: 'organization',
organizationId: activeOrganization.id,
returnUrl: `${getBaseUrl()}/workspace`,
},
{
onSuccess: (data) => {
if (portalWindow) {
portalWindow.location.href = data.url
} else {
window.location.href = data.url
}
},
onError: (error) => {
portalWindow?.close()
logger.error('Failed to open billing portal from transfer dialog', { error })
setTransferPortalError(
error instanceof Error
? error.message
: 'Failed to open Stripe billing portal. Please try again.'
)
},
}
)
}, [activeOrganization?.id, openBillingPortal])
const queryError = orgError
const errorMessage = queryError instanceof Error ? queryError.message : null
const displayOrganization = organization || activeOrganization
if (isLoading && !displayOrganization && !(hasTeamPlan || hasEnterprisePlan)) {
return null
}
if (!displayOrganization) {
return (
<NoOrganizationView
hasTeamPlan={hasTeamPlan}
hasEnterprisePlan={hasEnterprisePlan}
orgName={orgName}
orgSlug={orgSlug}
setOrgSlug={setOrgSlug}
onOrgNameChange={handleOrgNameChange}
onCreateOrganization={handleCreateOrganization}
isCreatingOrg={createOrgMutation.isPending}
error={errorMessage}
createOrgDialogOpen={createOrgDialogOpen}
setCreateOrgDialogOpen={setCreateOrgDialogOpen}
/>
)
}
if (!adminOrOwner) {
return null
}
return (
<>
<SettingsPanel
actions={[
{
text: 'Invite',
icon: Plus,
variant: 'primary',
onSelect: () => setInviteModalOpen(true),
disabled: isInvitationsDisabled,
tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined,
},
]}
>
<TeamSeatsOverview
subscriptionData={orgSubscription}
isLoadingSubscription={isOrgBillingLoading}
totalSeats={totalSeats}
usedSeats={usedSeats}
pendingSeats={pendingSeats}
/>
<OrganizationMemberLists
organizationId={displayOrganization.id}
roster={roster ?? null}
isLoadingRoster={isLoadingRoster}
currentUserId={session?.user?.id ?? ''}
onRemoveMember={handleRemoveMember}
onTransferOwnership={handleOpenTransferDialog}
/>
</SettingsPanel>
<OrganizationInviteModal
open={inviteModalOpen}
onOpenChange={setInviteModalOpen}
organizationId={displayOrganization.id}
workspaces={roster?.workspaces ?? []}
externalEmails={externalEmails}
pendingEmails={pendingEmails}
/>
<TransferOwnershipDialog
open={transferDialogOpen}
onOpenChange={handleTransferDialogOpenChange}
members={roster?.members ?? []}
isLoadingMembers={isLoadingRoster}
currentUserId={session?.user?.id ?? ''}
isSubmitting={transferOwnershipMutation.isPending}
error={transferOwnershipMutation.error}
portalError={transferPortalError}
hasPaidSubscription={Boolean(orgSubscription)}
isOpeningBillingPortal={openBillingPortal.isPending}
onConfirm={handleConfirmTransfer}
onOpenBillingPortal={handleOpenTransferBillingPortal}
/>
<RemoveMemberDialog
open={removeMemberDialog.open}
memberName={removeMemberDialog.memberName}
isSelfRemoval={removeMemberDialog.isSelfRemoval}
isExternalRemoval={removeMemberDialog.isExternalRemoval}
isSubmitting={removeMemberMutation.isPending}
error={removeMemberMutation.error}
onOpenChange={(open: boolean) => {
if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false })
}}
onConfirmRemove={confirmRemoveMember}
onCancel={() =>
setRemoveMemberDialog({
open: false,
memberId: '',
memberName: '',
isSelfRemoval: false,
isExternalRemoval: false,
})
}
/>
</>
)
}
@@ -0,0 +1 @@
export { Teammates } from './teammates'
@@ -0,0 +1,18 @@
import { parseAsString } from 'nuqs/server'
/**
* Co-located, typed URL query-param definition for the Teammates view.
*
* `search` is the name/email filter. The input is controlled directly by the
* nuqs value; only its URL write is debounced via `limitUrlUpdates`.
*/
export const teammatesSearchParam = {
key: 'search',
parser: parseAsString.withDefault(''),
} as const
/** Search view-state: clean URLs, no back-stack churn. */
export const teammatesUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1,312 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ChipDropdown, Plus, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryState } from 'nuqs'
import {
RoleLockTooltip,
type WorkspaceRoleSource,
workspaceRoleLockReason,
} from '@/components/permissions'
import type { WorkspacePermission } from '@/lib/api/contracts/workspaces'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
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 {
teammatesSearchParam,
teammatesUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/components/teammates/search-params'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import {
useCancelWorkspaceInvitation,
usePendingInvitations,
useRemoveWorkspaceMember,
useResendWorkspaceInvitation,
useUpdateWorkspacePermissions,
} from '@/hooks/queries/invitations'
import { prefetchUpgradeBillingData } from '@/hooks/queries/subscription'
import {
prefetchWorkspaceSettings,
useWorkspacePermissionsQuery,
useWorkspacesQuery,
} from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const ROLE_OPTIONS = [
{ value: 'read', label: 'Read' },
{ value: 'write', label: 'Write' },
{ value: 'admin', label: 'Admin' },
] as const
interface Teammate {
key: string
email: string
name: string
image: string | null
role: WorkspacePermission
status: string
isPending: boolean
userId?: string
invitationId?: string
token?: string
roleSource?: WorkspaceRoleSource
}
function copyToClipboard(text: string) {
void navigator.clipboard.writeText(text)
}
function buildInviteLink(invitationId: string, token: string) {
return `${window.location.origin}/invite/${invitationId}?token=${token}`
}
export function Teammates() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [searchTerm, setSearchTerm] = useQueryState(teammatesSearchParam.key, {
...teammatesSearchParam.parser,
...teammatesUrlKeys,
limitUrlUpdates: debounce(300),
})
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
const { data: permissions, isPending: permissionsLoading } =
useWorkspacePermissionsQuery(workspaceId)
const { data: invitations } = usePendingInvitations(workspaceId)
const { data: workspaces } = useWorkspacesQuery()
const router = useRouter()
const queryClient = useQueryClient()
const { isInvitationsDisabled: isInvitationsDisabledByConfig } = usePermissionConfig()
const resendInvitation = useResendWorkspaceInvitation()
const cancelInvitation = useCancelWorkspaceInvitation()
const removeMember = useRemoveWorkspaceMember()
const updatePermissions = useUpdateWorkspacePermissions()
const viewer = permissions?.viewer
const canManage = Boolean(viewer?.isAdmin)
const activeWorkspace = workspaces?.find((workspace) => workspace.id === workspaceId)
const inviteDisabledReason = activeWorkspace?.inviteDisabledReason ?? null
const isInvitationsDisabled = isInvitationsDisabledByConfig || inviteDisabledReason !== null
const upgradeHref = buildUpgradeHref(workspaceId, 'seats')
/**
* Warm the Upgrade route bundle and the queries it gates on, so a gated
* invite click lands on cached data instead of a loading state.
*/
const prefetchUpgrade = useCallback(() => {
router.prefetch(upgradeHref)
prefetchUpgradeBillingData(queryClient)
prefetchWorkspaceSettings(queryClient, workspaceId)
}, [router, queryClient, upgradeHref, workspaceId])
const handleInvite = () => {
if (isInvitationsDisabled) {
if (isBillingEnabled) router.push(upgradeHref)
return
}
setIsInviteModalOpen(true)
}
const teammates = useMemo<Teammate[]>(() => {
const members: Teammate[] = (permissions?.users ?? []).map((member) => ({
key: member.userId,
email: member.email,
name: member.name ?? member.email,
image: member.image,
role: member.permissionType,
status: `Joined ${formatDate(new Date(member.joinedAt))}`,
isPending: false,
userId: member.userId,
roleSource: member.roleSource,
}))
const pending: Teammate[] = (invitations ?? []).map((invitation) => ({
key: invitation.invitationId ?? invitation.email,
email: invitation.email,
name: invitation.email,
image: null,
role: invitation.permissionType,
status: 'Invite pending',
isPending: true,
invitationId: invitation.invitationId,
token: invitation.token,
}))
return [...members, ...pending]
}, [permissions, invitations])
const filteredTeammates = useMemo(() => {
const query = searchTerm.trim().toLowerCase()
if (!query) return teammates
return teammates.filter(
(teammate) =>
teammate.email.toLowerCase().includes(query) || teammate.name.toLowerCase().includes(query)
)
}, [teammates, searchTerm])
const showNoResults = !permissionsLoading && filteredTeammates.length === 0
const handleRoleChange = (teammate: Teammate, role: WorkspacePermission) => {
if (!teammate.userId || role === teammate.role) return
updatePermissions.mutate({
workspaceId,
updates: [{ userId: teammate.userId, permissions: role }],
})
}
return (
<>
<SettingsPanel
search={{
value: searchTerm,
onChange: (value) => void setSearchTerm(value),
placeholder: 'Search teammates...',
}}
actions={[
{
text: 'Invite',
icon: Plus,
variant: 'primary',
onSelect: handleInvite,
tooltip: inviteDisabledReason ?? undefined,
onPrefetch: isInvitationsDisabled ? prefetchUpgrade : undefined,
},
]}
>
<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,
})
}
},
},
{
label: 'Copy invite link',
onSelect: () => {
if (teammate.invitationId && teammate.token) {
copyToClipboard(
buildInviteLink(teammate.invitationId, teammate.token)
)
}
},
},
{
label: 'Revoke invite',
destructive: true,
onSelect: () => {
if (teammate.invitationId) {
cancelInvitation.mutate({
invitationId: teammate.invitationId,
workspaceId,
})
}
},
},
]
: []),
...(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}
onOpenChange={setIsInviteModalOpen}
workspaceName={activeWorkspace?.name ?? 'Workspace'}
inviteDisabledReason={inviteDisabledReason}
organizationId={activeWorkspace?.organizationId ?? null}
/>
</>
)
}
@@ -0,0 +1,142 @@
'use client'
import { useCallback, useState } from 'react'
import {
ButtonGroup,
ButtonGroupItem,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
ChipSelect,
type ComboboxOption,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useCreateWorkflowMcpServer } from '@/hooks/queries/workflow-mcp-servers'
const logger = createLogger('CreateWorkflowMcpServerModal')
const INITIAL_FORM_DATA: { name: string; description: string; isPublic: boolean } = {
name: '',
description: '',
isPublic: false,
}
interface CreateWorkflowMcpServerModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
workflowOptions?: ComboboxOption[]
}
export function CreateWorkflowMcpServerModal({
open,
onOpenChange,
workspaceId,
workflowOptions,
}: CreateWorkflowMcpServerModalProps) {
const createServerMutation = useCreateWorkflowMcpServer()
const [formData, setFormData] = useState({ ...INITIAL_FORM_DATA })
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
const isFormValid = formData.name.trim().length > 0
const [prevOpen, setPrevOpen] = useState(false)
if (open && !prevOpen) {
setFormData({ ...INITIAL_FORM_DATA })
setSelectedWorkflowIds([])
}
if (open !== prevOpen) {
setPrevOpen(open)
}
const handleCreateServer = useCallback(async () => {
if (!formData.name.trim()) return
try {
await createServerMutation.mutateAsync({
workspaceId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
isPublic: formData.isPublic,
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
})
onOpenChange(false)
} catch (err) {
logger.error('Failed to create server:', err)
}
}, [formData, selectedWorkflowIds, workspaceId, onOpenChange, createServerMutation.mutateAsync])
const showWorkflows = workflowOptions !== undefined
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Add New MCP Server'>
<ChipModalHeader onClose={() => onOpenChange(false)}>Add New MCP Server</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='input'
title='Server Name'
value={formData.name}
onChange={(value) => setFormData({ ...formData, name: value })}
required
placeholder='e.g., My MCP Server'
/>
<ChipModalField
type='textarea'
title='Description'
value={formData.description}
onChange={(value) => setFormData({ ...formData, description: value })}
placeholder='Describe what this MCP server does (optional)'
/>
{showWorkflows && (
<ChipModalField type='custom' title='Workflows'>
<ChipSelect
options={workflowOptions ?? []}
multiSelect
multiSelectValues={selectedWorkflowIds}
onMultiSelectChange={setSelectedWorkflowIds}
placeholder='Select workflows...'
searchable
searchPlaceholder='Search workflows...'
disabled={createServerMutation.isPending}
fullWidth
dropdownWidth='trigger'
align='start'
displayLabel={
selectedWorkflowIds.length > 0
? `${selectedWorkflowIds.length} workflow${selectedWorkflowIds.length !== 1 ? 's' : ''} selected`
: undefined
}
/>
</ChipModalField>
)}
<ChipModalField type='custom' title='Access'>
<div className='flex items-center gap-3'>
<ButtonGroup
value={formData.isPublic ? 'public' : 'private'}
onValueChange={(value) => setFormData({ ...formData, isPublic: value === 'public' })}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
{formData.isPublic && (
<span className='text-[var(--text-muted)] text-xs'>No authentication required</span>
)}
</div>
</ChipModalField>
<ChipModalError>{createServerMutation.error?.message}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
primaryAction={{
label: createServerMutation.isPending ? 'Adding...' : 'Add Server',
onClick: handleCreateServer,
disabled: !isFormValid || createServerMutation.isPending,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export { CreateWorkflowMcpServerModal } from './create-workflow-mcp-server-modal'
@@ -0,0 +1 @@
export { CreateWorkflowMcpServerModal } from './create-workflow-mcp-server-modal'

Some files were not shown because too many files have changed in this diff Show More