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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
+247
@@ -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
|
||||
+151
@@ -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...',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+197
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -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>
|
||||
)
|
||||
}
|
||||
+38
@@ -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>
|
||||
)
|
||||
}
|
||||
+142
@@ -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>
|
||||
)
|
||||
}
|
||||
+63
@@ -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...',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+83
@@ -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'
|
||||
+197
@@ -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'
|
||||
+131
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { InboxEnableToggle } from './inbox-enable-toggle'
|
||||
+316
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { InboxSettingsTab } from './inbox-settings-tab'
|
||||
+231
@@ -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
|
||||
}
|
||||
}
|
||||
+1
@@ -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'
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type McpServerFormConfig,
|
||||
McpServerFormModal,
|
||||
type McpServerFormModalProps,
|
||||
} from './mcp-server-form-modal'
|
||||
+816
@@ -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 "Add server" above to get started</SettingsEmptyState>
|
||||
) : (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{filteredServers.map((server) => {
|
||||
if (!server?.id) return null
|
||||
const tools = toolsByServer[server.id] || []
|
||||
const 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 "{searchTerm}"
|
||||
</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'
|
||||
+508
@@ -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>
|
||||
)
|
||||
}
|
||||
+49
@@ -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'
|
||||
+76
@@ -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>
|
||||
)
|
||||
}
|
||||
+33
@@ -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'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SecretValueField } from './secret-value-field'
|
||||
+93
@@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SecretsManager } from './secrets-manager'
|
||||
+1062
File diff suppressed because it is too large
Load Diff
+101
@@ -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'
|
||||
+30
@@ -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>
|
||||
)
|
||||
}
|
||||
+219
@@ -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'
|
||||
+93
@@ -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}</>
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SettingsResourceRow } from './settings-resource-row'
|
||||
+57
@@ -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>
|
||||
)
|
||||
}
|
||||
+33
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
@@ -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'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ManageCreditsModal, type ManageCreditsTarget } from './manage-credits-modal'
|
||||
+142
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { NoOrganizationView } from './no-organization-view'
|
||||
+199
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { OrganizationInviteModal } from './organization-invite-modal'
|
||||
+242
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { OrganizationMemberLists } from './organization-member-lists'
|
||||
+471
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { RemoveMemberDialog } from './remove-member-dialog'
|
||||
+72
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TeamSeatsOverview } from './team-seats-overview'
|
||||
+148
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TransferOwnershipDialog } from './transfer-ownership-dialog'
|
||||
+216
@@ -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'
|
||||
+392
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+142
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { CreateWorkflowMcpServerModal } from './create-workflow-mcp-server-modal'
|
||||
+1
@@ -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
Reference in New Issue
Block a user