From 919a98d00ff31daf564e2c6519526c6bd6abbfe1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 19:11:34 -0700 Subject: [PATCH 01/15] refactor(settings): fold verified domains into SSO, move group-detail state to nuqs, design-system cleanup (#5950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(settings): fold verified domains into SSO and use shared primitives Verified domains only gates SSO, so managing it on a separate page meant discovering the requirement after filling out the whole IdP form and then navigating away mid-setup. Move it into the SSO page as a section above the provider config and drop the standalone page, its nav entry, and both route branches. Align the surfaces with the shared settings primitives rather than bespoke chrome, matching whitelabeling/custom-blocks/access-control: - SSO's local FormField (muted labels) is replaced by the shared SettingRow, so its fields read like every other settings page. SettingRow gains optional `optional` and `error` props to absorb what FormField did — additive, so existing consumers are untouched. - The domains section is built from SettingsSection, SettingRow, SettingsResourceRow, and SettingsEmptyState instead of hand-rolled cards. Also drop the redundant Upload/Change buttons in whitelabeling: the logo and wordmark thumbnails were already clickable, so the button was a second control for the same action. Remove still appears once an image is set. * fix(settings): move group-detail view state to nuqs and clean up design-system drift Access control's group detail kept its tab, three search boxes, and three status filters in useState, so a `?group-id=` link always landed on General and a filter was lost on reload — the parent already puts the group id in the URL. The three tabs never render together, so search and status share one param each rather than carrying three mutually-exclusive keys, and switching tabs resets both. Closing the detail clears all three alongside group-id in one batched write, so nothing lingers on the list URL. Design-system fixes from a cleanup pass over the surfaces this branch touched: - Restore accessible names lost when the whitelabeling Upload buttons were removed. The thumbnail is now the only click target, and it contained just an icon, so it announced as an unlabeled button; the icon-only Remove had the same problem. Both now carry aria-labels reflecting their state. - Use Chip, not the legacy Button, for the domain actions — Button is ~26px against the 30px ChipInput beside it, so "Add domain" sat visibly short. - SettingRow now uses the emcn Info component; a bare svg as Tooltip.Trigger was neither focusable nor nameable. It also stops re-specifying Label's own default styling. - Use the new SettingRow error prop for the group name instead of a hand-rolled error paragraph, which is what the prop was added for. - Hoist the block-category lookup out of a sort comparator, size-* over h/w, name the staleTime constants the rules require, and import RowActionsMenu from its barrel. * fix(settings): alias the old /settings/domains path to SSO Folding verified domains into the SSO page dropped /settings/domains, so bookmarks and shared links 404'd instead of landing where domains now live. Both alias maps already exist for exactly this (organization/'members', subscription/'billing'); add domains -> sso to each. * chore(settings): adopt ChipCopyInput, named staleTime constants, and a11y labels * fix(settings): reset group detail params on open and drop issuer mono styling --- .../platform/enterprise/verified-domains.mdx | 2 +- apps/sim/app/api/auth/sso/register/route.ts | 2 +- .../[workspaceId]/settings/[section]/page.tsx | 2 + .../settings/[section]/search-params.ts | 51 ++ .../settings/[section]/settings.tsx | 6 - .../[workspaceId]/settings/navigation.test.ts | 1 - .../components/settings/navigation.test.ts | 3 - apps/sim/components/settings/navigation.ts | 30 +- .../organization-settings-renderer.tsx | 6 - .../components/access-control.tsx | 51 +- .../components/group-detail.tsx | 123 +-- .../access-control/hooks/permission-groups.ts | 11 +- .../ee/audit-logs/components/audit-logs.tsx | 7 +- apps/sim/ee/audit-logs/hooks/audit-logs.ts | 4 +- apps/sim/ee/components/setting-row.tsx | 44 +- .../components/custom-block-detail.tsx | 7 +- apps/sim/ee/data-drains/hooks/data-drains.ts | 7 +- .../ee/data-retention/hooks/data-retention.ts | 4 +- .../ee/sso/components/sso-settings.test.tsx | 7 + apps/sim/ee/sso/components/sso-settings.tsx | 713 ++++++++---------- ...tings.tsx => verified-domains-section.tsx} | 152 ++-- apps/sim/ee/sso/hooks/sso.ts | 4 +- .../components/whitelabeling-settings.tsx | 58 +- apps/sim/ee/whitelabeling/hooks/whitelabel.ts | 4 +- .../ee/workspace-forking/components/forks.tsx | 2 +- .../hooks/use-forking-available.ts | 2 +- 26 files changed, 687 insertions(+), 616 deletions(-) rename apps/sim/ee/sso/components/{domain-settings.tsx => verified-domains-section.tsx} (54%) diff --git a/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx index fe91e17e83e..5c7464b6090 100644 --- a/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx @@ -16,7 +16,7 @@ Verified Domains let organization owners and admins on Enterprise plans prove th ## Verify a domain -Go to **Settings → Security → Verified domains** in your organization settings. +Go to **Settings → Security → Single sign-on** in your organization settings. Domains are managed in the **Verified domains** section at the top of that page, directly above the identity provider configuration. 1. Enter the domain, for example `acme.com`, and click **Add domain**. 2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`). diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index 23f872c5077..0c78d76f216 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -148,7 +148,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const domainNotVerifiedResponse = () => NextResponse.json( { - error: `Verify ownership of ${domain} under Settings → Verified domains before configuring SSO for it.`, + error: `Verify ownership of ${domain} under Verified domains above before configuring SSO for it.`, code: 'SSO_DOMAIN_NOT_VERIFIED', }, { status: 403 } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 924ecc95aba..6303933ab1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -36,6 +36,8 @@ const SECTION_ALIASES: Readonly> = { subscription: 'billing', team: 'organization', 'api-keys': 'apikeys', + // Verified domains moved into the SSO page; keep old links working. + domains: 'sso', } const TOP_LEVEL_REDIRECTS: Readonly string>> = { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index 94aae591694..10d4b15793f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -80,6 +80,57 @@ export const groupIdUrlKeys = { clearOnDefault: true, } as const +/** + * `group-tab` is the active tab inside the deep-linked permission-group detail + * view, so a shared `group-id` link can land on the same tab (mirrors + * `server-tab` on the workflow MCP server detail). + */ +export const groupTabParam = { + key: 'group-tab', + parser: parseAsStringLiteral(['general', 'providers', 'blocks', 'platform'] as const).withDefault( + 'general' + ), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const groupTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** + * `group-search` is the search box inside the permission-group detail view. The + * provider/block/platform tabs never render together, so they share one param + * rather than carrying three mutually-exclusive keys; the tab handler clears it + * so a query cannot bleed across tabs. Distinct from the list's shared + * `?search=` (`useSettingsSearch`), which belongs to the group list behind it. + */ +export const groupSearchParam = { + key: 'group-search', + parser: parseAsString.withDefault(''), +} as const + +/** Search view-state: clean URLs, no back-stack churn. */ +export const groupSearchUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** + * `group-status` filters the permission-group detail's toggle lists by enabled + * state. Shared across the tabs for the same reason as `group-search`. + */ +export const groupStatusParam = { + key: 'group-status', + parser: parseAsStringLiteral(['all', 'enabled', 'disabled'] as const).withDefault('all'), +} as const + +/** Filter view-state: clean URLs, no back-stack churn. */ +export const groupStatusUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + /** * `custom-block-id` deep-links the Custom Blocks settings tab to a specific * block's detail sub-view. The "create new" flow stays in local state — only diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 5d2a80a52e6..cf2bf5caf18 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -81,9 +81,6 @@ 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 DomainSettings = dynamic(() => - import('@/ee/sso/components/domain-settings').then((m) => m.DomainSettings) -) const SessionPolicySettings = dynamic(() => import('@/ee/session-policy/components/session-policy-settings').then( (m) => m.SessionPolicySettings @@ -166,9 +163,6 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'sso' && organizationId && } - {effectiveSection === 'domains' && organizationId && ( - - )} {effectiveSection === 'sessions' && organizationId && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 2996eb3c238..fc2e914762b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -39,7 +39,6 @@ describe('unified settings navigation', () => { { id: 'inbox', label: 'Sim mailer', section: 'system' }, { id: 'recently-deleted', label: 'Recently deleted', section: 'system' }, { id: 'sso', label: 'Single sign-on', section: 'enterprise' }, - { id: 'domains', label: 'Verified domains', section: 'enterprise' }, { id: 'sessions', label: 'Session policies', section: 'enterprise' }, { id: 'data-retention', label: 'Data retention', section: 'enterprise' }, { id: 'data-drains', label: 'Data drains', section: 'enterprise' }, diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 15cd1c21ed4..3693f88fd48 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -42,7 +42,6 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'sso', - 'domains', 'sessions', 'data-retention', 'data-drains', @@ -65,7 +64,6 @@ describe('settings navigation boundaries', () => { 'access-control', 'audit-logs', 'sso', - 'domains', 'sessions', 'data-retention', 'data-drains', @@ -121,7 +119,6 @@ describe('settings navigation boundaries', () => { 'billing', 'data-drains', 'data-retention', - 'domains', 'organization', 'sessions', 'sso', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index c8eab37aea1..24721ece3fc 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -6,7 +6,6 @@ import { HexSimple, Key, KeySquare, - Link, Lock, LogIn, Palette, @@ -43,7 +42,6 @@ export type OrganizationSettingsSection = | 'access-control' | 'audit-logs' | 'sso' - | 'domains' | 'sessions' | 'data-retention' | 'data-drains' @@ -90,7 +88,6 @@ export type UnifiedSettingsSection = | 'teammates' | 'organization' | 'sso' - | 'domains' | 'whitelabeling' | 'copilot' | 'forks' @@ -223,6 +220,8 @@ export const ACCOUNT_SETTINGS_PATH_ALIASES = { export const ORGANIZATION_SETTINGS_PATH_ALIASES = { organization: 'members', + // Verified domains moved into the SSO page; keep old links working. + domains: 'sso', } as const satisfies Readonly> export const WORKSPACE_SETTINGS_PATH_ALIASES = { @@ -544,22 +543,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] organization: { id: 'sso', group: 'security', order: 4 }, }, }, - { - label: 'Verified domains', - icon: Link, - docsLink: 'https://docs.sim.ai/platform/enterprise/verified-domains', - unified: { - id: 'domains', - description: 'Prove ownership of your email domains before configuring SSO.', - group: 'enterprise', - requiresHosted: true, - requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, - }, - planes: { - organization: { id: 'domains', group: 'security', order: 5 }, - }, - }, { label: 'Session policies', icon: Clock, @@ -573,7 +556,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, }, planes: { - organization: { id: 'sessions', group: 'security', order: 6 }, + organization: { id: 'sessions', group: 'security', order: 5 }, }, }, { @@ -590,7 +573,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, }, planes: { - organization: { id: 'data-retention', group: 'enterprise', order: 7 }, + organization: { id: 'data-retention', group: 'enterprise', order: 6 }, }, }, { @@ -606,7 +589,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, }, planes: { - organization: { id: 'data-drains', group: 'enterprise', order: 8 }, + organization: { id: 'data-drains', group: 'enterprise', order: 7 }, }, }, { @@ -622,7 +605,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, }, planes: { - organization: { id: 'whitelabeling', group: 'enterprise', order: 9 }, + organization: { id: 'whitelabeling', group: 'enterprise', order: 8 }, }, }, { @@ -758,7 +741,6 @@ export function getOrganizationSettingsFeatures( 'access-control': SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, 'audit-logs': SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, sso: SETTINGS_SELF_HOSTED_OVERRIDES.sso, - domains: SETTINGS_SELF_HOSTED_OVERRIDES.sso, sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, 'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, 'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx index d2a3e0d6324..66ae7a1efc3 100644 --- a/apps/sim/components/settings/organization-settings-renderer.tsx +++ b/apps/sim/components/settings/organization-settings-renderer.tsx @@ -23,9 +23,6 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO)) -const DomainSettings = dynamic(() => - import('@/ee/sso/components/domain-settings').then((module) => module.DomainSettings) -) const SessionPolicySettings = dynamic(() => import('@/ee/session-policy/components/session-policy-settings').then( (module) => module.SessionPolicySettings @@ -71,9 +68,6 @@ export function OrganizationSettingsRenderer({ } if (section === 'audit-logs') return if (section === 'sso') return - if (section === 'domains') { - return - } if (section === 'sessions') { return } diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 813b2d9df80..6d2cb78d2e3 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -22,6 +22,12 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' import { groupIdParam, groupIdUrlKeys, + groupSearchParam, + groupSearchUrlKeys, + groupStatusParam, + groupStatusUrlKeys, + groupTabParam, + groupTabUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -85,6 +91,45 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon ...groupIdParam.parser, ...groupIdUrlKeys, }) + + // Params scoped to the detail sub-view are cleared alongside the group id, so + // a tab/search/filter can't linger on the list URL after going back. nuqs + // batches these same-tick writes into a single URL update. + const [, setGroupTab] = useQueryState(groupTabParam.key, { + ...groupTabParam.parser, + ...groupTabUrlKeys, + }) + const [, setGroupSearch] = useQueryState(groupSearchParam.key, { + ...groupSearchParam.parser, + ...groupSearchUrlKeys, + }) + const [, setGroupStatus] = useQueryState(groupStatusParam.key, { + ...groupStatusParam.parser, + ...groupStatusUrlKeys, + }) + + /** + * The detail view's tab/search/status params are scoped to one group, so both + * transitions reset them — otherwise a stale `group-id` that never resolves + * leaves them in the URL and the next group opens on the previous group's tab + * and filters. nuqs batches these same-tick writes into one URL update. + */ + const openGroupDetail = useCallback( + (groupId: string) => { + void setSelectedGroupId(groupId) + void setGroupTab(null) + void setGroupSearch(null) + void setGroupStatus(null) + }, + [setSelectedGroupId, setGroupTab, setGroupSearch, setGroupStatus] + ) + + const closeGroupDetail = useCallback(() => { + void setSelectedGroupId(null, { history: 'replace' }) + void setGroupTab(null) + void setGroupSearch(null) + void setGroupStatus(null) + }, [setSelectedGroupId, setGroupTab, setGroupSearch, setGroupStatus]) const [showCreateModal, setShowCreateModal] = useState(false) const [newGroupName, setNewGroupName] = useState('') const [newGroupDescription, setNewGroupDescription] = useState('') @@ -169,8 +214,8 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon workspaceOptions={workspaceOptions} organizationWorkspaces={organizationWorkspaces} workspacesLoading={workspacesLoading} - onBack={() => void setSelectedGroupId(null, { history: 'replace' })} - onDeleted={() => void setSelectedGroupId(null, { history: 'replace' })} + onBack={closeGroupDetail} + onDeleted={closeGroupDetail} /> ) } @@ -207,7 +252,7 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon diff --git a/apps/sim/ee/audit-logs/hooks/audit-logs.ts b/apps/sim/ee/audit-logs/hooks/audit-logs.ts index 04f5c08bdb6..7685f83b3ff 100644 --- a/apps/sim/ee/audit-logs/hooks/audit-logs.ts +++ b/apps/sim/ee/audit-logs/hooks/audit-logs.ts @@ -2,6 +2,8 @@ import { useInfiniteQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type AuditLogPage, listAuditLogsContract } from '@/lib/api/contracts/audit-logs' +export const AUDIT_LOG_LIST_STALE_TIME = 30 * 1000 + export const auditLogKeys = { all: ['audit-logs'] as const, lists: () => [...auditLogKeys.all, 'list'] as const, @@ -47,6 +49,6 @@ export function useAuditLogs(organizationId: string, filters: AuditLogFilters, e initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, enabled: Boolean(organizationId) && enabled, - staleTime: 30 * 1000, + staleTime: AUDIT_LOG_LIST_STALE_TIME, }) } diff --git a/apps/sim/ee/components/setting-row.tsx b/apps/sim/ee/components/setting-row.tsx index 4d854cd908a..2b6eb0826f6 100644 --- a/apps/sim/ee/components/setting-row.tsx +++ b/apps/sim/ee/components/setting-row.tsx @@ -1,32 +1,52 @@ -import { Label, Tooltip } from '@sim/emcn' -import { Info } from 'lucide-react' +import { Info, Label } from '@sim/emcn' interface SettingRowProps { label: string description?: string /** Optional supplementary guidance shown in a tooltip on an info icon beside the label. */ labelTooltip?: string + /** Marks the field as not required, rendered as a muted suffix on the label. */ + optional?: boolean + /** Validation message rendered beneath the control. */ + error?: React.ReactNode + /** + * Id of the control this row labels. Wires the label to the control so + * clicking it focuses the field, and points the control at the error text via + * `aria-describedby` — pass the same id to the child input. + */ + htmlFor?: string children: React.ReactNode } -export function SettingRow({ label, description, labelTooltip, children }: SettingRowProps) { +export function SettingRow({ + label, + description, + labelTooltip, + optional = false, + error, + htmlFor, + children, +}: SettingRowProps) { return (
- + {labelTooltip && ( - - - - - - {labelTooltip} - - + + {labelTooltip} + )}
{description &&

{description}

} {children} + {error ? ( +

+ {error} +

+ ) : null}
) } diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index f60a85b95ce..d3a591c0841 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -224,7 +224,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD [deployedLoaded, availableFields, overrideById, inputs] ) - const [expandedInputs, setExpandedInputs] = useState>(new Set()) + const [expandedInputs, setExpandedInputs] = useState>(() => new Set()) const toggleInput = (id: string) => setExpandedInputs((prev) => { const next = new Set(prev) @@ -537,7 +537,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD size='sm' onClick={iconUpload.handleThumbnailClick} disabled={iconUpload.isUploading || !canManageBlock} - className='text-[13px]' + className='text-small' > {iconUrl ? 'Change' : 'Upload'} @@ -547,8 +547,9 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD variant='ghost' size='sm' onClick={iconUpload.handleRemove} + aria-label='Remove icon' disabled={iconUpload.isUploading || !canManageBlock} - className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]' + className='text-[var(--text-muted)] text-small hover:text-[var(--text-primary)]' > diff --git a/apps/sim/ee/data-drains/hooks/data-drains.ts b/apps/sim/ee/data-drains/hooks/data-drains.ts index 5a1bed9c966..5526a57aaac 100644 --- a/apps/sim/ee/data-drains/hooks/data-drains.ts +++ b/apps/sim/ee/data-drains/hooks/data-drains.ts @@ -15,6 +15,9 @@ import { updateDataDrainContract, } from '@/lib/api/contracts/data-drains' +export const DATA_DRAIN_LIST_STALE_TIME = 60 * 1000 +export const DATA_DRAIN_RUNS_STALE_TIME = 30 * 1000 + const logger = createLogger('DataDrainsQueries') export const dataDrainKeys = { @@ -54,7 +57,7 @@ export function useDataDrains(organizationId?: string) { queryKey: dataDrainKeys.list(organizationId), queryFn: ({ signal }) => fetchDataDrains(organizationId as string, signal), enabled: Boolean(organizationId), - staleTime: 60 * 1000, + staleTime: DATA_DRAIN_LIST_STALE_TIME, }) } @@ -64,7 +67,7 @@ export function useDataDrainRuns(organizationId?: string, drainId?: string, limi queryFn: ({ signal }) => fetchDataDrainRuns(organizationId as string, drainId as string, limit, signal), enabled: Boolean(organizationId && drainId), - staleTime: 30 * 1000, + staleTime: DATA_DRAIN_RUNS_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/ee/data-retention/hooks/data-retention.ts b/apps/sim/ee/data-retention/hooks/data-retention.ts index f1e85a890a2..04219abeccc 100644 --- a/apps/sim/ee/data-retention/hooks/data-retention.ts +++ b/apps/sim/ee/data-retention/hooks/data-retention.ts @@ -10,6 +10,8 @@ import { updateOrganizationDataRetentionContract, } from '@/lib/api/contracts/organization' +export const DATA_RETENTION_STALE_TIME = 60 * 1000 + export type RetentionValues = OrganizationRetentionValues export type DataRetentionResponse = OrganizationDataRetention @@ -34,7 +36,7 @@ export function useOrganizationRetention(orgId: string | undefined) { queryKey: dataRetentionKeys.settings(orgId ?? ''), queryFn: ({ signal }) => fetchDataRetention(orgId as string, signal), enabled: Boolean(orgId), - staleTime: 60 * 1000, + staleTime: DATA_RETENTION_STALE_TIME, }) } diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx index 9ce0f36f06b..2a400aaf8f0 100644 --- a/apps/sim/ee/sso/components/sso-settings.test.tsx +++ b/apps/sim/ee/sso/components/sso-settings.test.tsx @@ -21,6 +21,7 @@ vi.mock('@sim/emcn', () => ({ ), ChipCombobox: () =>
, + ChipCopyInput: ({ value }: { value?: string }) => , ChipInput: ({ value, onChange, @@ -51,6 +52,12 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: mockUseSession, })) +// Domain management is covered by its own tests and needs a QueryClient; this +// suite only exercises the provider form's org-transition behavior. +vi.mock('@/ee/sso/components/verified-domains-section', () => ({ + VerifiedDomainsSection: () =>
, +})) + vi.mock( '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions', () => ({ diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index df9e1a586f8..959581bd9c8 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -1,22 +1,22 @@ 'use client' -import { type ReactNode, useState } from 'react' +import { useState } from 'react' import { Button, ChipCombobox, + ChipCopyInput, ChipInput, ChipSelect, ChipTextarea, cn, Expandable, ExpandableContent, - Label, Switch, toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react' +import { ChevronDown, Eye, EyeOff } from 'lucide-react' import type { SsoRegistrationBody } from '@/lib/api/contracts/auth' import { useSession } from '@/lib/auth/auth-client' import { isEnterprise } from '@/lib/billing/plan-helpers' @@ -26,37 +26,16 @@ import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/compo 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 { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import { SettingRow } from '@/ee/components/setting-row' +import { VerifiedDomainsSection } from '@/ee/sso/components/verified-domains-section' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso' import { useOrganizationBilling } from '@/hooks/queries/organization' const logger = createLogger('SSO') -interface FormFieldProps { - label: ReactNode - children: ReactNode - optional?: boolean - error?: ReactNode -} - -/** - * Page-level labeled-field row for the SSO settings form, matching the - * standalone-field rhythm: muted label, control, then a caption-sized error. - */ -function FormField({ label, children, optional = false, error }: FormFieldProps) { - return ( -
- - {children} - {error ?

{error}

: null} -
- ) -} - interface SSOProvider { id: string providerId: string @@ -128,7 +107,6 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { const configureSSOMutation = useConfigureSSO() const [showClientSecret, setShowClientSecret] = useState(false) - const [copied, setCopied] = useState(false) const [isEditing, setIsEditing] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) @@ -352,14 +330,6 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { const isSaml = formData.providerType === 'saml' const callbackUrl = `${getBaseUrl()}/api/auth/${isSaml ? 'sso/saml2/callback' : 'sso/callback'}/${formData.providerId || existingProvider?.providerId || 'provider-id'}` - const copyToClipboard = async (url: string) => { - try { - await navigator.clipboard.writeText(url) - setCopied(true) - setTimeout(() => setCopied(false), 1500) - } catch {} - } - const handleEdit = () => { if (!existingProvider) return @@ -420,52 +390,38 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { return ( -
- -

{existingProvider.providerId}

-
- - -

- {existingProvider.providerType.toUpperCase()} -

-
- - -

{existingProvider.domain}

-
- - -

- {existingProvider.issuer} -

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

- Configure this in your identity provider -

-
-
+ + + +
+ +

{existingProvider.providerId}

+
+ + +

+ {existingProvider.providerType.toUpperCase()} +

+
+ + +

{existingProvider.domain}

+
+ + +

+ {existingProvider.issuer} +

+
+ + + +

+ Configure this in your identity provider +

+
+
+
) } @@ -520,312 +476,303 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { }), ]} > -
- - - handleInputChange('providerType', value as 'oidc' | 'saml') - } - options={[ - { label: 'OIDC', value: 'oidc' }, - { label: 'SAML', value: 'saml' }, - ]} - placeholder='Select provider type' - /> -

- {formData.providerType === 'oidc' - ? 'OpenID Connect (Okta, Azure AD, Auth0, etc.)' - : 'Security Assertion Markup Language (ADFS, Shibboleth, etc.)'} -

-
- - 0 ? errors.providerId.join(' ') : undefined - } - > - handleInputChange('providerId', value)} - options={SSO_TRUSTED_PROVIDERS.map((id) => ({ - label: id, - value: id, - }))} - placeholder='Select or enter a provider ID' - editable - /> - - - 0 ? errors.issuerUrl.join(' ') : undefined - } - > - e.target.removeAttribute('readOnly')} - onChange={(e) => handleInputChange('issuerUrl', e.target.value)} - error={showErrors && errors.issuerUrl.length > 0} - /> - - - 0 ? errors.domain.join(' ') : undefined} - > - e.target.removeAttribute('readOnly')} - onChange={(e) => handleInputChange('domain', e.target.value)} - error={showErrors && errors.domain.length > 0} - /> -

- The email domain users sign in with (e.g. company.com) -

-
- - {formData.providerType === 'oidc' ? ( - <> - 0 ? errors.clientId.join(' ') : undefined - } - > - e.target.removeAttribute('readOnly')} - onChange={(e) => handleInputChange('clientId', e.target.value)} - error={showErrors && errors.clientId.length > 0} - /> - - - 0 - ? errors.clientSecret.join(' ') - : undefined + + + +
+ + + handleInputChange('providerType', value as 'oidc' | 'saml') } - > - { - e.target.removeAttribute('readOnly') - setShowClientSecret(true) - }} - onBlurCapture={() => setShowClientSecret(false)} - onChange={(e) => handleInputChange('clientSecret', e.target.value)} - inputClassName={!showClientSecret ? '[-webkit-text-security:disc]' : undefined} - error={showErrors && errors.clientSecret.length > 0} - endAdornment={ - + options={[ + { label: 'OIDC', value: 'oidc' }, + { label: 'SAML', value: 'saml' }, + ]} + placeholder='Select provider type' + /> +

+ {formData.providerType === 'oidc' + ? 'OpenID Connect (Okta, Azure AD, Auth0, etc.)' + : 'Security Assertion Markup Language (ADFS, Shibboleth, etc.)'} +

+
+ + 0 ? errors.providerId.join(' ') : undefined + } + > + handleInputChange('providerId', value)} + options={SSO_TRUSTED_PROVIDERS.map((id) => ({ + label: id, + value: id, + }))} + placeholder='Select or enter a provider ID' + editable + /> + + + 0 ? errors.issuerUrl.join(' ') : undefined + } + > + e.target.removeAttribute('readOnly')} + onChange={(e) => handleInputChange('issuerUrl', e.target.value)} + error={showErrors && errors.issuerUrl.length > 0} + /> + + + 0 ? errors.domain.join(' ') : undefined} + > + e.target.removeAttribute('readOnly')} + onChange={(e) => handleInputChange('domain', e.target.value)} + error={showErrors && errors.domain.length > 0} + /> +

+ The email domain users sign in with (e.g. company.com) +

+
+ + {formData.providerType === 'oidc' ? ( + <> + 0 ? errors.clientId.join(' ') : undefined } - /> - - - 0 ? errors.scopes.join(' ') : undefined} - > - handleInputChange('scopes', e.target.value)} - error={showErrors && errors.scopes.length > 0} - /> -

- Comma-separated list of OIDC scopes to request -

-
- - ) : ( - <> - 0 - ? errors.entryPoint.join(' ') - : undefined - } - > - handleInputChange('entryPoint', e.target.value)} - error={showErrors && errors.entryPoint.length > 0} - /> - - - 0 ? errors.cert.join(' ') : undefined} - > - handleInputChange('cert', e.target.value)} - className='min-h-[80px] font-mono' - error={showErrors && errors.cert.length > 0} - rows={3} - /> - - -
- - - - -
- - handleInputChange('audience', e.target.value)} - /> - - - - handleInputChange('callbackUrl', e.target.value)} - /> - - - - - handleInputChange('wantAssertionsSigned', checked) - } - /> - - - - handleInputChange('idpMetadata', e.target.value)} - className='min-h-[60px] font-mono' - rows={2} - /> - -
-
-
-
- - )} - - - copyToClipboard(callbackUrl)} - className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]' - aria-label='Copy callback URL' +
+ + 0 + ? errors.clientSecret.join(' ') + : undefined + } > - {copied ? ( - - ) : ( - - )} - - } - /> -

- Configure this in your identity provider -

- -
+ { + e.target.removeAttribute('readOnly') + setShowClientSecret(true) + }} + onBlurCapture={() => setShowClientSecret(false)} + onChange={(e) => handleInputChange('clientSecret', e.target.value)} + inputClassName={!showClientSecret ? '[-webkit-text-security:disc]' : undefined} + error={showErrors && errors.clientSecret.length > 0} + endAdornment={ + + } + /> + + + 0 ? errors.scopes.join(' ') : undefined + } + > + handleInputChange('scopes', e.target.value)} + error={showErrors && errors.scopes.length > 0} + /> +

+ Comma-separated list of OIDC scopes to request +

+
+ + ) : ( + <> + 0 + ? errors.entryPoint.join(' ') + : undefined + } + > + handleInputChange('entryPoint', e.target.value)} + error={showErrors && errors.entryPoint.length > 0} + /> + + + 0 ? errors.cert.join(' ') : undefined} + > + handleInputChange('cert', e.target.value)} + className='min-h-[80px] font-mono' + error={showErrors && errors.cert.length > 0} + rows={3} + /> + + +
+ + + + +
+ + handleInputChange('audience', e.target.value)} + /> + + + + handleInputChange('callbackUrl', e.target.value)} + /> + + + + + handleInputChange('wantAssertionsSigned', checked) + } + /> + + + + handleInputChange('idpMetadata', e.target.value)} + className='min-h-[60px] font-mono' + rows={2} + /> + +
+
+
+
+ + )} + + + +

+ Configure this in your identity provider +

+
+
+ ) diff --git a/apps/sim/ee/sso/components/domain-settings.tsx b/apps/sim/ee/sso/components/verified-domains-section.tsx similarity index 54% rename from apps/sim/ee/sso/components/domain-settings.tsx rename to apps/sim/ee/sso/components/verified-domains-section.tsx index 04f10e6a8d3..e069fd16d53 100644 --- a/apps/sim/ee/sso/components/domain-settings.tsx +++ b/apps/sim/ee/sso/components/verified-domains-section.tsx @@ -1,12 +1,15 @@ 'use client' import { useState } from 'react' -import { Button, ChipConfirmModal, ChipCopyInput, ChipInput, ChipTag, toast } from '@sim/emcn' +import { Chip, ChipConfirmModal, ChipCopyInput, ChipInput, ChipTag, toast } from '@sim/emcn' +import { Link } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import type { OrganizationDomain } from '@/lib/api/contracts/organization' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu' +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 { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { SettingRow } from '@/ee/components/setting-row' import { useAddOrganizationDomain, useOrganizationDomains, @@ -14,26 +17,10 @@ import { useVerifyOrganizationDomain, } from '@/ee/sso/hooks/domains' -interface DomainSettingsProps { +interface VerifiedDomainsSectionProps { organizationId: string } -interface CopyFieldProps { - label: string - value: string - hint?: string -} - -function CopyField({ label, value, hint }: CopyFieldProps) { - return ( -
- {label} - - {hint ? {hint} : null} -
- ) -} - interface DomainRowProps { organizationId: string domain: OrganizationDomain @@ -42,6 +29,7 @@ interface DomainRowProps { function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) { const verifyDomain = useVerifyOrganizationDomain() + const isVerified = domain.status === 'verified' async function handleVerify() { try { @@ -53,36 +41,50 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) { } return ( -
-
- {domain.domain} -
- - {domain.status === 'verified' ? 'Verified' : 'Pending'} - - onRemove(domain), destructive: true }]} - /> -
-
- - {domain.status === 'pending' && domain.txtRecordValue && ( -
-

- Add this TXT record at your DNS provider, then verify. DNS changes can take up to 48 - hours to propagate. -

- + } + title={domain.domain} + description={isVerified ? 'Ownership verified' : 'Awaiting DNS verification'} + trailing={ +
+ + {isVerified ? 'Verified' : 'Pending'} + + onRemove(domain), destructive: true }]} + /> +
+ } + /> + + {/* pl-[46px] indents past SettingsResourceRow's icon gutter (size-9 tile + gap-2.5). */} + {!isVerified && domain.txtRecordValue && ( +
+ - + description='Some DNS providers append your zone automatically. If yours does, enter this host with the trailing zone removed.' + > + + + + + + +
- +
)} @@ -90,7 +92,12 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) { ) } -export function DomainSettings({ organizationId }: DomainSettingsProps) { +/** + * Domain-ownership management, rendered as a section of the SSO settings page. + * A domain must be verified here before SSO can be configured for it, so the two + * live together rather than sending the admin to a separate page mid-setup. + */ +export function VerifiedDomainsSection({ organizationId }: VerifiedDomainsSectionProps) { const { data, isLoading } = useOrganizationDomains(organizationId) const addDomain = useAddOrganizationDomain() const removeDomain = useRemoveOrganizationDomain() @@ -98,24 +105,6 @@ export function DomainSettings({ organizationId }: DomainSettingsProps) { const [newDomain, setNewDomain] = useState('') const [pendingRemoval, setPendingRemoval] = useState(null) - if (isLoading) { - return ( - - Loading domains... - - ) - } - - if (data && !data.isEnterprise) { - return ( - - - Domain verification is available on Enterprise plans only. - - - ) - } - async function handleAdd() { const value = newDomain.trim() if (!value) return @@ -143,13 +132,12 @@ export function DomainSettings({ organizationId }: DomainSettingsProps) { return ( <> - -
-
-

- Verify domains your organization owns. A domain must be verified before you can - configure SSO for it. -

+ +
+
- +
-
+ - {domains.length === 0 ? ( + {isLoading ? ( + Loading domains... + ) : domains.length === 0 ? ( No domains yet. ) : ( -
+
{domains.map((domain) => ( )}
- + fetchSSOProviders(signal, organizationId), - staleTime: 5 * 60 * 1000, + staleTime: SSO_PROVIDERS_STALE_TIME, enabled, }) } diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index 2b048031afb..deecd10d1ce 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -79,7 +79,7 @@ function ColorInput({ label, value, onChange, placeholder = '#000000' }: ColorIn return (
- +
{logoUpload.isUploading ? ( @@ -353,27 +355,17 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe )} -
+ {logoUpload.previewUrl && ( - {logoUpload.previewUrl && ( - - )} -
+ )} {wordmarkUpload.isUploading ? ( @@ -411,27 +405,17 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe )} -
+ {wordmarkUpload.previewUrl && ( - {wordmarkUpload.previewUrl && ( - - )} -
+ )} fetchWhitelabelSettings(orgId as string, signal), enabled: Boolean(orgId), - staleTime: 60 * 1000, + staleTime: WHITELABEL_STALE_TIME, }) } diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx index 7b258f06415..38df0a0cead 100644 --- a/apps/sim/ee/workspace-forking/components/forks.tsx +++ b/apps/sim/ee/workspace-forking/components/forks.tsx @@ -404,7 +404,7 @@ export function Forks() { workspaceId={workspaceId} otherWorkspaceId={parent.id} otherWorkspaceName={parent.name} - onBack={() => setSelectedForkId(null, { history: 'replace' })} + onBack={() => void setSelectedForkId(null, { history: 'replace' })} actions={parentHeaderActions} /> ) : forkView === 'activity' ? ( diff --git a/apps/sim/ee/workspace-forking/hooks/use-forking-available.ts b/apps/sim/ee/workspace-forking/hooks/use-forking-available.ts index 90380a9bff7..8d9284c9533 100644 --- a/apps/sim/ee/workspace-forking/hooks/use-forking-available.ts +++ b/apps/sim/ee/workspace-forking/hooks/use-forking-available.ts @@ -9,7 +9,7 @@ export const forkAvailabilityKeys = { } /** Availability flips only on plan changes or flag rollouts - cache generously. */ -const FORK_AVAILABILITY_STALE_TIME = 5 * 60 * 1000 +export const FORK_AVAILABILITY_STALE_TIME = 5 * 60 * 1000 interface ForkingAvailability { available: boolean From 2272d4c0f06d8544a0841d664d1085470e066417 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 19:20:11 -0700 Subject: [PATCH 02/15] fix(skills): show the new skill after creating it (#5949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(skills): show the new skill after creating it The create page navigated to the new skill's detail route, but the unsaved-changes guard immediately undid it: `isDirty` was derived from `createSkill.isSuccess`, so on success the guard's effect fired `history.back()` to pop its sentinel entry and cancelled the navigation that had just run. The header stayed on "New skill" with the fields intact, and nothing confirmed the save. The guard now exposes `release()` to retire itself for the rest of the mount, and the create page calls it before navigating with `replace` so the sentinel entry is consumed rather than stacked. Also from a cleanup pass over the same surfaces: - `useCreateSkill` resolves the created row, so the page reads `created.id` instead of re-deriving it from the response list - seed the list cache with the upsert's authoritative list verbatim; the id-merge kept the previous ordering and appended the new skill last - treat placeholder data as loading in the detail page, which could otherwise flash "Skill not found." on a workspace switch - seed credential-detail drafts on id change rather than in a value-keyed effect, so a background refetch can't clobber an in-progress edit - toast on save and on delete failure, which were both silent * improvement(skills): align the custom tools row and harden the guard lifecycle Follow-ups from an audit of the skills and custom tools surfaces. Custom tools row, now matching the skills row exactly: - the trailing arrow could be squeezed by a long description; the shared row owns `flex-shrink-0` for its trailing slot, so the five callers that hand-rolled it (and the two that forgot) all get it - drop a redundant `cursor-pointer` — Tailwind v3's preflight already sets it on `button` - the tile chrome was defined twice, in ResourceTile and again in SettingsResourceRow, despite ResourceTile's doc claiming to be the single source. Both now share RESOURCE_TILE_BASE/RESOURCE_TILE_FILL, and ResourceTile's redundant wrapper div is gone - skills' row uses the text-sm/text-caption tokens instead of literal pixels; the added gap-[1px] offsets the 21px->20px line-height so the row stays 39px Guard and cache correctness: - the delete path released the guard after the await, but the delete is optimistic — the row leaves the cache first, so the form went clean mid-flight and popped the sentinel before the release landed. Release up front and rearm if the request fails - hold the loading frame across the whole optimistic delete instead of flashing "Skill not found." on the way out - custom tools had the same placeholder-data bug just fixed in skill detail, and worse: a deep-linked id could resolve against the workspace just left - keep cached rows the create response omits, so a concurrent create isn't dropped until the refetch lands * fix(skills): retire the in-app back guard on release too release() suppressed the unload warning and the browser Back trap, but the in-app back link still keyed only on isDirty — so the Skills chip could open the unsaved-changes modal after a successful create, while the drafts were still populated and the navigation was already in flight. * fix(skills): track a sentinel consumed while the guard is released release() intentionally leaves the seeded history entry in place, but it also drops the popstate listener — so Back during the released window (an optimistic delete's round-trip) consumed that entry with nothing to record it. hasSentinelRef stayed true, and a failed delete's rearm() then skipped re-seeding, leaving the surface with no Back confirm despite unsaved edits. The released branch now keeps a bookkeeping-only popstate listener that clears the ref, so rearm() seeds a fresh entry when the old one is gone. --- .../hooks/use-unsaved-changes-guard.ts | 41 +++++++++++++++++-- .../components/resource-tile/index.ts | 6 ++- .../resource-tile/resource-tile.tsx | 22 +++++++--- .../components/byok/byok-key-manager.tsx | 4 +- .../components/custom-tools/custom-tools.tsx | 7 +++- .../recently-deleted/recently-deleted.tsx | 10 ++--- .../settings-resource-row.tsx | 18 ++++---- .../skills/[skillId]/skill-detail.tsx | 21 ++++++++-- .../[workspaceId]/skills/new/skill-create.tsx | 18 ++++---- .../workspace/[workspaceId]/skills/skills.tsx | 6 +-- .../components/custom-blocks.tsx | 2 +- apps/sim/hooks/queries/custom-tools.ts | 2 +- apps/sim/hooks/queries/skills.ts | 37 ++++++++++------- 13 files changed, 131 insertions(+), 63 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts index 7e3ac184867..1dd0bb241bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts @@ -26,9 +26,22 @@ interface UseUnsavedChangesGuardParams { export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesGuardParams) { const router = useRouter() const [showUnsavedAlert, setShowUnsavedAlert] = useState(false) + const [isReleased, setIsReleased] = useState(false) const hasSentinelRef = useRef(false) useEffect(() => { + // The caller is navigating away — popping the seeded entry would cancel it. But + // Back during that window consumes the entry with no listener left to re-push + // it, so track that: a later rearm() must seed a fresh one rather than trust a + // stale ref and leave the surface unguarded. + if (isReleased) { + if (!hasSentinelRef.current) return + const handleSentinelConsumed = () => { + hasSentinelRef.current = false + } + window.addEventListener('popstate', handleSentinelConsumed) + return () => window.removeEventListener('popstate', handleSentinelConsumed) + } if (!isDirty) { // Clean again while still mounted (saved/reverted): pop the seeded entry so // it can't pile up across edit/save cycles. This runs in the effect body, @@ -58,16 +71,16 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG window.removeEventListener('beforeunload', handleBeforeUnload) window.removeEventListener('popstate', handlePopState) } - }, [isDirty]) + }, [isDirty, isReleased]) const handleBackClick = useCallback( (event: MouseEvent) => { - if (isDirty) { + if (isDirty && !isReleased) { event.preventDefault() setShowUnsavedAlert(true) } }, - [isDirty] + [isDirty, isReleased] ) const confirmDiscard = useCallback(() => { @@ -75,5 +88,25 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG router.push(backHref) }, [router, backHref]) - return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard } + /** + * Retires the guard: no unload warning, no Back trap (browser or the in-app back + * link), and no pop of the seeded entry when the form goes clean. Call it before + * navigating away on a successful save, and navigate with `router.replace` so the + * seeded entry is the one consumed. An operation that goes clean before it + * resolves (an optimistic delete) must release up front and {@link rearm} if it + * fails. + */ + const release = useCallback(() => setIsReleased(true), []) + + /** Restores guarding after a released operation failed and the surface stays. */ + const rearm = useCallback(() => setIsReleased(false), []) + + return { + showUnsavedAlert, + setShowUnsavedAlert, + handleBackClick, + confirmDiscard, + release, + rearm, + } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts index 507fe07a2f8..035cc143cdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts @@ -1 +1,5 @@ -export { ResourceTile } from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile' +export { + RESOURCE_TILE_BASE, + RESOURCE_TILE_FILL, + ResourceTile, +} from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx index 90907001430..91340e06517 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx @@ -1,20 +1,30 @@ import type { ComponentType } from 'react' +import { cn } from '@sim/emcn' interface ResourceTileProps { icon: ComponentType<{ className?: string }> } +/** + * Geometry and border of the square resource tile — the single source for that + * chrome, shared by {@link ResourceTile} and `SettingsResourceRow` so the skills, + * custom tools, and settings surfaces cannot drift apart. Pair with a fill. Sizing + * the glyph is the tile's job: the descendant rule outranks an icon's own class. + */ +export const RESOURCE_TILE_BASE = + 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5' + +/** Filled treatment worn by the skills and custom tools resource tiles. */ +export const RESOURCE_TILE_FILL = 'bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' + /** * Square glyph tile identifying a workspace resource — the leading visual on a - * resource's row and on its detail heading. Single source for that chrome so - * the skills and custom tools surfaces cannot drift apart. + * resource's row and on its detail heading. */ export function ResourceTile({ icon: Icon }: ResourceTileProps) { return ( -
-
- -
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index ba957ffde7e..51d3bd630a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -270,7 +270,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { if (props.multiKey) { const keyCount = getProviderKeys(provider.id).length return ( -
+
{keyCount} {keyCount === 1 ? 'key' : 'keys'} @@ -283,7 +283,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { if (readOnly) return null return ( -
+
openEditModal(provider.id)}>Update openDeleteConfirm(provider.id)}>Delete
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index 95ce91a808f..1253c36e865 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -26,7 +26,10 @@ export function CustomTools() { const workspacePermissions = useUserPermissionsContext() const canEdit = canMutateWorkspaceSettingsSection('custom-tools', workspacePermissions) - const { data: tools = [], isLoading, error } = useCustomTools(workspaceId) + const { data: tools = [], isPending, isPlaceholderData, error } = useCustomTools(workspaceId) + // Placeholder data is another workspace's tools reading as success — treat it as + // loading, or a deep-linked id resolves against the workspace the user just left. + const isLoading = isPending || isPlaceholderData const [searchTerm, setSearchTerm] = useSettingsSearch() const [selectedToolId, setSelectedToolId] = useQueryState(customToolIdParam.key, { @@ -119,7 +122,7 @@ export function CustomTools() { key={tool.id} type='button' onClick={() => void setSelectedToolId(tool.id)} - className='w-full cursor-pointer rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' + className='w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' > } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 134f008f92e..f9c5a9a1914 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -464,22 +464,18 @@ export function RecentlyDeleted() { } trailing={ !canRestore ? null : isRestoring ? ( - + Restoring... ) : isRestored ? ( -
+
Restored handleView(resource)}> View
) : ( - void handleRestore(resource)} - className='shrink-0' - > + void handleRestore(resource)}> Restore ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index 7e31036010d..f5e9ea6c73b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -1,5 +1,9 @@ import type { ReactNode } from 'react' import { cn } from '@sim/emcn' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_FILL, +} from '@/app/workspace/[workspaceId]/components/resource-tile' /** * The canonical settings "resource row": a rounded-bordered icon tile, a @@ -29,13 +33,13 @@ interface SettingsResourceRowProps { title: ReactNode /** Secondary muted line — truncates. */ description?: ReactNode - /** Trailing element pinned to the row's end (chips, actions menu, status). */ + /** + * Trailing element pinned to the row's end (chips, actions menu, status). The row + * keeps it at its natural size — callers never need their own `flex-shrink-0`. + */ trailing?: ReactNode } -const TILE_BASE = - 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5' - export function SettingsResourceRow({ icon, iconFill = false, @@ -49,8 +53,8 @@ export function SettingsResourceRow({
@@ -63,7 +67,7 @@ export function SettingsResourceRow({ )}
- {trailing} + {trailing ?
{trailing}
: null}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx index c3189d4d50a..68597185bd4 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx @@ -44,7 +44,11 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { const router = useRouter() const skillsHref = `/workspace/${workspaceId}/skills` - const { data: skills = [], isPending: skillsLoading } = useSkills(workspaceId) + const { data: skills = [], isPending, isPlaceholderData } = useSkills(workspaceId) + // `keepPreviousData` carries the old cache entry across a workspace-id change, + // so another workspace's list can read as success with no match — treat that as + // loading so the detail never flashes "Skill not found." + const skillsLoading = isPending || isPlaceholderData const updateSkill = useUpdateSkill() const deleteSkill = useDeleteSkill() const skill = skills.find((s) => s.id === skillId) ?? null @@ -112,6 +116,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { }, }) setErrors({}) + toast.success(`Saved "${nameDraft}"`) } catch (error) { if (isSkillNameConflictError(error)) { setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') }) @@ -127,10 +132,17 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { const handleConfirmDelete = async () => { if (!skill) return setShowDeleteConfirm(false) + // Optimistic: the skill leaves the list cache before the request resolves, so + // this surface goes clean mid-flight — release up front, rearm if it fails. + guard.release() try { await deleteSkill.mutateAsync({ workspaceId, skillId: skill.id }) - router.push(skillsHref) + router.replace(skillsHref) } catch (error) { + guard.rearm() + toast.error("Couldn't delete skill", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) logger.error('Failed to delete skill', error) } } @@ -172,7 +184,10 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { ) : null - if (skillsLoading && !skill) { + // A delete is optimistic and settles before `router.replace` commits, so the row + // is gone for a few frames while this surface is still mounted — hold the loading + // frame through both phases instead of flashing "Skill not found." on the way out. + if ((skillsLoading || deleteSkill.isPending || deleteSkill.isSuccess) && !skill) { return (

Loading…

diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx index 928862b063b..5eaddfeee45 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx @@ -50,11 +50,7 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) { const [contentSeed, setContentSeed] = useState(0) const [errors, setErrors] = useState({}) - // Drops on success so the guard pops its history sentinel before we navigate — - // otherwise Back from the new skill lands on a stale, empty create form. - const isDirty = - !createSkill.isSuccess && - (!!nameDraft.trim() || !!descriptionDraft.trim() || !!contentDraft.trim()) + const isDirty = !!nameDraft.trim() || !!descriptionDraft.trim() || !!contentDraft.trim() const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref }) @@ -72,16 +68,16 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) { } try { - const created = await createSkill.mutateAsync({ + const { created } = await createSkill.mutateAsync({ workspaceId, skill: { name: nameDraft, description: descriptionDraft, content: contentDraft }, }) setErrors({}) - // The upsert responds with the caller's whole skill list (built-ins - // included), not just the new row — match by name, which is unique per - // workspace, rather than trusting the first element. - const createdId = created.find((skill) => skill.name === nameDraft)?.id - router.push(createdId ? `${skillsHref}/${createdId}` : skillsHref) + toast.success(`Created "${nameDraft}"`) + // Detach the guard so its Back trap can't fire mid-navigation; `replace` then + // consumes the seeded entry rather than stacking another. + guard.release() + router.replace(created ? `${skillsHref}/${created.id}` : skillsHref) } catch (error) { if (isSkillNameConflictError(error)) { setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') }) diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index 7004a6c3ebf..2f2dd9ad2ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -34,10 +34,10 @@ function SkillItem({ name, description, onClick }: SkillItemProps) { className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' > -
- {name} +
+ {name} {description && ( - {description} + {description} )}
diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx index 58301485c88..50d0b7ebceb 100644 --- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx @@ -143,7 +143,7 @@ export function CustomBlocks() { title={cb.name} description={cb.description || undefined} trailing={ -
+
{!cb.enabled && Disabled} {canAdmin && }
diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index 5af4a5166a3..dfcc734731c 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -172,7 +172,7 @@ export function useCustomTools(workspaceId: string) { queryKey: customToolsKeys.list(workspaceId), queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal), enabled: !!workspaceId, - staleTime: CUSTOM_TOOL_LIST_STALE_TIME, // 1 minute - tools don't change frequently + staleTime: CUSTOM_TOOL_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/skills.ts b/apps/sim/hooks/queries/skills.ts index 6817f6b6249..f5e3c4e41c2 100644 --- a/apps/sim/hooks/queries/skills.ts +++ b/apps/sim/hooks/queries/skills.ts @@ -26,7 +26,8 @@ export const skillsKeys = { all: ['skills'] as const, lists: () => [...skillsKeys.all, 'list'] as const, list: (workspaceId: string) => [...skillsKeys.lists(), workspaceId] as const, - members: (skillId?: string) => [...skillsKeys.all, 'members', skillId ?? ''] as const, + memberLists: () => [...skillsKeys.all, 'members'] as const, + members: (skillId?: string) => [...skillsKeys.memberLists(), skillId ?? ''] as const, } /** @@ -53,11 +54,6 @@ export function useSkills(workspaceId: string) { }) } -/** - * Create skill mutation. On success the created skill is merged into the list - * cache so consumers (e.g. the integration detail page's "Added" state) reflect - * it immediately, before the invalidation refetch lands. - */ interface CreateSkillParams { workspaceId: string skill: { @@ -67,6 +63,11 @@ interface CreateSkillParams { } } +/** + * Create skill mutation. Resolves to the caller's full skill list plus the newly + * created row, and seeds the list cache so consumers (e.g. the integration detail + * page's "Added" state) reflect it before the invalidation refetch lands. + */ export function useCreateSkill() { const queryClient = useQueryClient() @@ -88,15 +89,23 @@ export function useCreateSkill() { }) logger.info(`Created skill: ${s.name}`) - return data + // The upsert responds with the caller's whole skill list (built-ins + // included), not just the new row. Match by name — unique per workspace, + // and a same-named built-in is filtered out of the response. + return { skills: data, created: data.find((skill) => skill.name === s.name) ?? null } }, - onSuccess: (data, variables) => { + onSuccess: ({ skills }, variables) => { + // The response is the same authoritative list GET /api/skills returns for this + // caller, so its ordering wins. Cached rows absent from it (a concurrent create, + // or a delete this response post-dates) are kept rather than dropped — the two + // are indistinguishable here; the refetch settles both. queryClient.setQueryData( skillsKeys.list(variables.workspaceId), (prev) => { - const byId = new Map((prev ?? []).map((skill) => [skill.id, skill])) - for (const skill of data) byId.set(skill.id, skill) - return Array.from(byId.values()) + if (!prev) return skills + const responded = new Set(skills.map((skill) => skill.id)) + const missing = prev.filter((skill) => !responded.has(skill.id)) + return missing.length > 0 ? [...skills, ...missing] : skills } ) }, @@ -106,9 +115,6 @@ export function useCreateSkill() { }) } -/** - * Update skill mutation - */ interface UpdateSkillParams { workspaceId: string skillId: string @@ -169,8 +175,9 @@ export function useUpdateSkill() { } }, onSettled: (_data, _error, variables) => { + // Only name/description/content go over the wire here, none of which can + // change the editor roster — no need to invalidate it. queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ queryKey: skillsKeys.members(variables.skillId) }) }, }) } From e39045d94ec6c8ecb836c06f38eb45b016bf5a5f Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 19:49:16 -0700 Subject: [PATCH 03/15] improvement(data-drains): move drains to the fullscreen list/detail pattern (#5954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(data-drains): move drains to the fullscreen list/detail pattern Data drains was the last settings surface still on a Table plus a create modal. It now matches Skills and Custom Tools: - the list is SettingsResourceRow rows in a clickable button, with the source, destination, cadence, and last run on the row and a Disabled tag when a drain is paused - clicking a row opens a detail sub-view: the drain's actions (Run now / Test connection / Delete), its enabled toggle, resolved destination config, and recent run history — replacing the expanding table row - creating a drain is a fullscreen view instead of a modal, reusing the destination form registry so the two never fork - a drain's detail is deep-linkable via `data-drain-id`, pushed on open and replaced on close, matching custom-tool-id and custom-block-id The detail is read-only apart from the enabled toggle: destination credentials are never returned by the API, so changing one means recreating the drain. Alignment work the migration surfaced: - the destination registry rendered its 36 fields with ChipModalField, whose label is byte-identical to a SettingsSection header — on a page that made section titles and field labels indistinguishable. All of them, and the create view's own fields, now use SettingRow like every other page-level detail view - the shared source/destination/cadence label maps moved to labels.ts, now that three files need them - run status uses Badge with a dot rather than hand-coloured text, and byte counts use formatFileSize — the old /1024 math showed a 5 GB export as "5242880.0 KB" - copy follows the sibling surfaces: Create drain, Disabled, Run queued, Connection test passed, and "No drains found matching …" - seed the list cache on create so landing on the new drain's detail doesn't depend on the invalidation refetch succeeding * fix(data-drains): wire destination field labels to their controls The ChipModalField -> SettingRow migration dropped the label/control association that ChipModalField generated for free, so screen readers announced the destination fields unnamed and clicking a label focused nothing. All 35 id-capable controls now carry an id with a matching htmlFor on their row; the one ChipSelect has no id prop, matching how the existing SettingRow + combobox rows render. Also pass includeBytes to formatFileSize — without it any run writing under 1 KB reported '0 Bytes'. * fix(data-drains): name the select fields for assistive tech ChipSelect takes an aria-label that lands on its trigger button, so the four select fields no longer announce as unnamed buttons — the Datadog site plus the create view's source, cadence, and destination type. The visible SettingRow label stays; this only gives the trigger an accessible name, since ChipSelect exposes no id for htmlFor to point at. --- .../settings/[section]/search-params.ts | 16 + .../components/data-drain-create.tsx | 179 ++++++ .../components/data-drain-detail.tsx | 262 +++++++++ .../components/data-drains-settings.tsx | 540 ++++-------------- .../ee/data-drains/destinations/registry.tsx | 204 ++++--- apps/sim/ee/data-drains/hooks/data-drains.ts | 6 + apps/sim/ee/data-drains/labels.ts | 31 + 7 files changed, 743 insertions(+), 495 deletions(-) create mode 100644 apps/sim/ee/data-drains/components/data-drain-create.tsx create mode 100644 apps/sim/ee/data-drains/components/data-drain-detail.tsx create mode 100644 apps/sim/ee/data-drains/labels.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index 10d4b15793f..bde90b3f029 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -163,6 +163,22 @@ export const customToolIdUrlKeys = { clearOnDefault: true, } as const +/** + * `data-drain-id` deep-links the Data Drains settings tab to a specific drain's + * detail sub-view. The "create new" flow stays in local state — only existing + * entities are deep-linkable. + */ +export const dataDrainIdParam = { + key: 'data-drain-id', + parser: parseAsString, +} as const + +/** Opening a drain's detail is a destination → push to history; clear on close. */ +export const dataDrainIdUrlKeys = { + 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. diff --git a/apps/sim/ee/data-drains/components/data-drain-create.tsx b/apps/sim/ee/data-drains/components/data-drain-create.tsx new file mode 100644 index 00000000000..9db2befb798 --- /dev/null +++ b/apps/sim/ee/data-drains/components/data-drain-create.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useState } from 'react' +import { ChipInput, ChipSelect, toast } from '@sim/emcn' +import { ArrowLeft, Database } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { CreateDataDrainBody } from '@/lib/api/contracts/data-drains' +import type { CADENCE_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types' +import { DESTINATION_TYPES } from '@/lib/data-drains/types' +import { ResourceTile } from '@/app/workspace/[workspaceId]/components' +import { + CredentialDetailHeading, + UnsavedChangesModal, +} from '@/app/workspace/[workspaceId]/components/credential-detail' +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 { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import { SettingRow } from '@/ee/components/setting-row' +import { DESTINATION_FORM_REGISTRY } from '@/ee/data-drains/destinations/registry' +import { useCreateDataDrain } from '@/ee/data-drains/hooks/data-drains' +import { + CADENCE_OPTIONS, + DESTINATION_LABELS, + DESTINATION_OPTIONS, + SOURCE_OPTIONS, +} from '@/ee/data-drains/labels' + +const logger = createLogger('DataDrainCreate') + +interface DataDrainCreateProps { + organizationId: string + onBack: () => void + /** Lands the caller on the drain it just created, matching the skills create flow. */ + onCreated: (drainId: string) => void +} + +/** + * Full-page data drain creation rendered as a settings detail sub-view: a back + * chip, a Create action, and the common fields plus the selected destination's + * own fields from {@link DESTINATION_FORM_REGISTRY}. + */ +export function DataDrainCreate({ organizationId, onBack, onCreated }: DataDrainCreateProps) { + const createDrain = useCreateDataDrain() + + const [name, setName] = useState('') + const [source, setSource] = useState<(typeof SOURCE_TYPES)[number]>('workflow_logs') + const [cadence, setCadence] = useState<(typeof CADENCE_TYPES)[number]>('daily') + const [destinationType, setDestinationType] = useState<(typeof DESTINATION_TYPES)[number]>( + DESTINATION_TYPES[0] + ) + const [destState, setDestState] = useState( + () => DESTINATION_FORM_REGISTRY[DESTINATION_TYPES[0]].initialState + ) + + const spec = DESTINATION_FORM_REGISTRY[destinationType] + const canSubmit = name.trim().length > 0 && spec.isComplete(destState) + + const submitError = createDrain.error ? toError(createDrain.error).message : null + const isDirty = + name.trim().length > 0 || + JSON.stringify(destState) !== JSON.stringify(spec.initialState) || + source !== 'workflow_logs' || + cadence !== 'daily' || + destinationType !== DESTINATION_TYPES[0] + + const guard = useSettingsUnsavedGuard({ isDirty }) + + const handleDestinationChange = (next: (typeof DESTINATION_TYPES)[number]) => { + setDestinationType(next) + setDestState(DESTINATION_FORM_REGISTRY[next].initialState) + } + + const handleSubmit = async () => { + if (!canSubmit || createDrain.isPending) return + const body = { + name: name.trim(), + source, + scheduleCadence: cadence, + ...spec.toDestinationBranch(destState), + } as CreateDataDrainBody + try { + const drain = await createDrain.mutateAsync({ organizationId, body }) + toast.success('Drain created') + onCreated(drain.id) + } catch (error) { + const message = toError(error).message + toast.error("Couldn't create drain", { description: message }) + logger.error('Failed to create data drain', { error: message }) + } + } + + const actions: SettingsAction[] = [ + { + text: createDrain.isPending ? 'Creating...' : 'Create', + variant: 'primary', + onSelect: handleSubmit, + disabled: !canSubmit || createDrain.isPending, + tooltip: canSubmit ? undefined : 'Name the drain and complete its destination', + }, + ] + + return ( + <> + guard.guardBack(onBack) }} + title='New drain' + actions={actions} + > +
+ } + title='New drain' + subtitle='Export logs, chats, and runs to your own storage or observability stack on a schedule.' + /> + + +
+ + setName(e.target.value)} + placeholder='Workflow logs export' + /> + + + setSource(v as (typeof SOURCE_TYPES)[number])} + options={SOURCE_OPTIONS} + align='start' + /> + + + setCadence(v as (typeof CADENCE_TYPES)[number])} + options={CADENCE_OPTIONS} + align='start' + /> + +
+
+ + +
+ + handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])} + options={DESTINATION_OPTIONS} + displayLabel={DESTINATION_LABELS[destinationType]} + align='start' + /> + + + {submitError && ( +

+ {submitError} +

+ )} +
+
+
+
+ + + + ) +} diff --git a/apps/sim/ee/data-drains/components/data-drain-detail.tsx b/apps/sim/ee/data-drains/components/data-drain-detail.tsx new file mode 100644 index 00000000000..5ebbcdbe17b --- /dev/null +++ b/apps/sim/ee/data-drains/components/data-drain-detail.tsx @@ -0,0 +1,262 @@ +'use client' + +import { useState } from 'react' +import { Badge, ChipConfirmModal, Label, Switch, toast } from '@sim/emcn' +import { ArrowLeft, Database } from '@sim/emcn/icons' +import { toError } from '@sim/utils/errors' +import type { DataDrain, DataDrainRun } from '@/lib/api/contracts/data-drains' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { ResourceTile } from '@/app/workspace/[workspaceId]/components' +import { CredentialDetailHeading } from '@/app/workspace/[workspaceId]/components/credential-detail' +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 { + useDataDrainRuns, + useDeleteDataDrain, + useRunDataDrainNow, + useTestDataDrain, + useUpdateDataDrain, +} from '@/ee/data-drains/hooks/data-drains' +import { CADENCE_LABELS, DESTINATION_LABELS, SOURCE_LABELS } from '@/ee/data-drains/labels' + +const RECENT_RUN_LIMIT = 10 + +/** Rendered generically so a new destination's config needs no formatter. */ +function formatConfigValue(value: unknown): string { + if (typeof value === 'boolean') return value ? 'Yes' : 'No' + if (value === null || value === undefined || value === '') return '—' + return String(value) +} + +/** `forcePathStyle` → `Force path style`, so a new destination field needs no map. */ +function humanizeConfigKey(key: string): string { + const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ') + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} + +interface DetailRowProps { + label: string + children: React.ReactNode +} + +function DetailRow({ label, children }: DetailRowProps) { + return ( +
+ {label} + + {children} + +
+ ) +} + +interface DataDrainDetailProps { + organizationId: string + drain: DataDrain + onBack: () => void +} + +/** + * Full-page data drain detail rendered as a settings detail sub-view: a back + * chip, the drain's actions (Run now / Test connection / Delete), its resolved + * configuration, and recent run history. Only the enabled toggle is editable — + * destination credentials are never returned, so changing one means recreating + * the drain. + */ +export function DataDrainDetail({ organizationId, drain, onBack }: DataDrainDetailProps) { + const updateDrain = useUpdateDataDrain() + const deleteDrain = useDeleteDataDrain() + const runDrain = useRunDataDrainNow() + const testDrain = useTestDataDrain() + const { + data: runs, + isPending: runsPending, + isPlaceholderData: runsArePlaceholder, + } = useDataDrainRuns(organizationId, drain.id, RECENT_RUN_LIMIT) + // Defensive: the runs query keeps previous data, which would read as another + // drain's history if this view ever renders without a per-drain key. + const runsLoading = runsPending || runsArePlaceholder + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const handleToggleEnabled = async () => { + try { + await updateDrain.mutateAsync({ + organizationId, + drainId: drain.id, + body: { enabled: !drain.enabled }, + }) + toast.success(drain.enabled ? 'Drain disabled' : 'Drain enabled') + } catch (error) { + toast.error(toError(error).message) + } + } + + const handleRunNow = async () => { + try { + await runDrain.mutateAsync({ organizationId, drainId: drain.id }) + toast.success('Run queued') + } catch (error) { + toast.error(toError(error).message) + } + } + + const handleTest = async () => { + try { + await testDrain.mutateAsync({ organizationId, drainId: drain.id }) + toast.success('Connection test passed') + } catch (error) { + toast.error(toError(error).message) + } + } + + const handleConfirmDelete = async () => { + setShowDeleteConfirm(false) + try { + await deleteDrain.mutateAsync({ organizationId, drainId: drain.id }) + onBack() + toast.success('Drain deleted') + } catch (error) { + toast.error(toError(error).message) + } + } + + const actions: SettingsAction[] = [ + { + text: 'Run now', + onSelect: handleRunNow, + disabled: !drain.enabled || runDrain.isPending, + tooltip: drain.enabled ? undefined : 'Enable the drain to run it', + }, + { text: 'Test connection', onSelect: handleTest, disabled: testDrain.isPending }, + { + text: 'Delete', + variant: 'destructive', + onSelect: () => setShowDeleteConfirm(true), + disabled: deleteDrain.isPending, + }, + ] + + return ( + <> + +
+ } + title={drain.name} + subtitle={`${SOURCE_LABELS[drain.source]} → ${DESTINATION_LABELS[drain.destinationType]} · ${CADENCE_LABELS[drain.scheduleCadence]}`} + /> + + +
+
+ +

+ Disabled drains keep their cursor and resume where they left off. +

+
+ +
+
+ + +
+ {SOURCE_LABELS[drain.source]} + {CADENCE_LABELS[drain.scheduleCadence]} + + + {drain.lastRunAt ? new Date(drain.lastRunAt).toLocaleString() : 'Never'} + + + + + {drain.lastSuccessAt ? new Date(drain.lastSuccessAt).toLocaleString() : 'Never'} + + +
+
+ + +
+ {DESTINATION_LABELS[drain.destinationType]} + {Object.entries(drain.destinationConfig).map(([key, value]) => ( + + {formatConfigValue(value)} + + ))} +
+
+ + + {runsLoading ? ( + Loading runs... + ) : runs && runs.length > 0 ? ( +
+ {runs.map((run) => ( + + ))} +
+ ) : ( + No runs yet + )} +
+
+
+ + + + ) +} + +const RUN_STATUS_VARIANT = { + success: 'green', + failed: 'red', + running: 'blue', +} as const + +function RunRow({ run }: { run: DataDrainRun }) { + return ( +
+
+
+ + {run.status} + + {run.trigger} + + {new Date(run.startedAt).toLocaleString()} + +
+ {run.error &&
{run.error}
} +
+
+
{run.rowsExported.toLocaleString()} rows
+
+ {formatFileSize(run.bytesWritten, { includeBytes: true })} +
+
+
+ ) +} diff --git a/apps/sim/ee/data-drains/components/data-drains-settings.tsx b/apps/sim/ee/data-drains/components/data-drains-settings.tsx index d83b67a929e..659c758ffb3 100644 --- a/apps/sim/ee/data-drains/components/data-drains-settings.tsx +++ b/apps/sim/ee/data-drains/components/data-drains-settings.tsx @@ -1,93 +1,45 @@ 'use client' import { useState } from 'react' +import { ChipTag } from '@sim/emcn' +import { ArrowRight, Database, Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' import { - Badge, - ChipConfirmModal, - ChipModal, - ChipModalBody, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - ChipSelect, - cn, - Switch, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - toast, -} from '@sim/emcn' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { ChevronDown, Plus } from 'lucide-react' -import type { CreateDataDrainBody, DataDrain, DataDrainRun } from '@/lib/api/contracts/data-drains' -import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' + dataDrainIdParam, + dataDrainIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' 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 { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -import { DESTINATION_FORM_REGISTRY } from '@/ee/data-drains/destinations/registry' -import { - useCreateDataDrain, - useDataDrainRuns, - useDataDrains, - useDeleteDataDrain, - useRunDataDrainNow, - useTestDataDrain, - useUpdateDataDrain, -} from '@/ee/data-drains/hooks/data-drains' - -const logger = createLogger('DataDrainsSettings') - -const SOURCE_LABELS: Record<(typeof SOURCE_TYPES)[number], string> = { - workflow_logs: 'Workflow logs', - job_logs: 'Job logs', - audit_logs: 'Audit logs', - copilot_chats: 'Chats', - copilot_runs: 'Chat runs', -} - -const DESTINATION_LABELS: Record<(typeof DESTINATION_TYPES)[number], string> = { - s3: 'Amazon S3', - gcs: 'Google Cloud Storage', - azure_blob: 'Azure Blob Storage', - datadog: 'Datadog', - bigquery: 'Google BigQuery', - snowflake: 'Snowflake', - webhook: 'HTTPS webhook', -} - -const CADENCE_LABELS: Record<(typeof CADENCE_TYPES)[number], string> = { - hourly: 'Every hour', - daily: 'Every day', -} - -const SOURCE_OPTIONS = SOURCE_TYPES.map((t) => ({ value: t, label: SOURCE_LABELS[t] })) -const CADENCE_OPTIONS = CADENCE_TYPES.map((t) => ({ value: t, label: CADENCE_LABELS[t] })) - -const DESTINATION_OPTIONS = DESTINATION_TYPES.map((t) => ({ - value: t, - label: DESTINATION_LABELS[t], -})) +import { DataDrainCreate } from '@/ee/data-drains/components/data-drain-create' +import { DataDrainDetail } from '@/ee/data-drains/components/data-drain-detail' +import { useDataDrains } from '@/ee/data-drains/hooks/data-drains' +import { CADENCE_LABELS, DESTINATION_LABELS, SOURCE_LABELS } from '@/ee/data-drains/labels' interface DataDrainsSettingsProps { organizationId: string } export function DataDrainsSettings({ organizationId }: DataDrainsSettingsProps) { - const { - data: drains, - isLoading: drainsLoading, - error: drainsError, - } = useDataDrains(organizationId) + const { data: drains, isPending, error } = useDataDrains(organizationId) - const [createOpen, setCreateOpen] = useState(false) - const [expandedDrainId, setExpandedDrainId] = useState(null) const [searchTerm, setSearchTerm] = useSettingsSearch() + const [selectedDrainId, setSelectedDrainId] = useQueryState(dataDrainIdParam.key, { + ...dataDrainIdParam.parser, + ...dataDrainIdUrlKeys, + }) + /** The create flow has no entity id and is not deep-linkable — stays local. */ + const [isCreating, setIsCreating] = useState(false) + + const selectedDrain = selectedDrainId ? drains?.find((d) => d.id === selectedDrainId) : undefined + + const closeDetail = () => { + setIsCreating(false) + void setSelectedDrainId(null, { history: 'replace' }) + } const query = searchTerm.trim().toLowerCase() const filteredDrains = !query @@ -101,360 +53,104 @@ export function DataDrainsSettings({ organizationId }: DataDrainsSettingsProps) ].some((value) => value.toLowerCase().includes(query)) ) - if (drainsLoading) return null - - return ( - <> - setCreateOpen(true), - }, - ]} - search={{ - value: searchTerm, - onChange: setSearchTerm, - placeholder: 'Search data drains...', + const actions: SettingsAction[] = [ + { + text: 'Create drain', + icon: Plus, + variant: 'primary', + onSelect: () => setIsCreating(true), + disabled: isPending, + }, + ] + + /** + * Hold the first paint while a deep-linked id could still resolve, so a valid + * link never flashes the list before jumping to it. A dead id still falls back + * to the list. + */ + if (selectedDrainId !== null && isPending) return null + + if (isCreating) { + return ( + setIsCreating(false)} + onCreated={(drainId) => { + setIsCreating(false) + void setSelectedDrainId(drainId) }} - > -
-
- {drainsError ? ( -
-

- Failed to load data drains: {toError(drainsError).message} -

-
- ) : drains && drains.length > 0 ? ( - filteredDrains.length > 0 ? ( - - - - Name - Source - Destination - Cadence - Last run - Enabled - - - - - {filteredDrains.map((drain) => ( - - setExpandedDrainId(expandedDrainId === drain.id ? null : drain.id) - } - /> - ))} - -
- ) : ( - - No results for "{searchTerm.trim()}" - - ) - ) : ( - Click "New drain" above to get started - )} -
-
-
- - {createOpen && ( - setCreateOpen(false)} /> - )} - - ) -} - -interface DrainRowProps { - drain: DataDrain - organizationId: string - expanded: boolean - onToggleExpand: () => void -} - -function DrainRow({ drain, organizationId, expanded, onToggleExpand }: DrainRowProps) { - const updateMutation = useUpdateDataDrain() - const deleteMutation = useDeleteDataDrain() - const runMutation = useRunDataDrainNow() - const testMutation = useTestDataDrain() - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - - async function handleToggleEnabled() { - try { - await updateMutation.mutateAsync({ - organizationId, - drainId: drain.id, - body: { enabled: !drain.enabled }, - }) - toast.success(drain.enabled ? 'Drain disabled' : 'Drain enabled') - } catch (error) { - toast.error(toError(error).message) - } - } - - async function handleRunNow() { - try { - await runMutation.mutateAsync({ organizationId, drainId: drain.id }) - toast.success('Drain run enqueued') - } catch (error) { - toast.error(toError(error).message) - } - } - - async function handleTest() { - try { - await testMutation.mutateAsync({ organizationId, drainId: drain.id }) - toast.success('Connection test succeeded') - } catch (error) { - toast.error(toError(error).message) - } - } - - function handleDelete() { - setShowDeleteConfirm(true) - } - - async function handleConfirmDelete() { - try { - setShowDeleteConfirm(false) - await deleteMutation.mutateAsync({ organizationId, drainId: drain.id }) - toast.success('Drain deleted') - } catch (error) { - toast.error(toError(error).message) - } + /> + ) } - return ( - <> - - -
- - {drain.name} -
-
- - {SOURCE_LABELS[drain.source]} - - - {DESTINATION_LABELS[drain.destinationType]} - - {CADENCE_LABELS[drain.scheduleCadence]} - - {drain.lastRunAt ? new Date(drain.lastRunAt).toLocaleString() : 'Never'} - - e.stopPropagation()}> - - - e.stopPropagation()}> - - -
- {expanded && ( - - - - - - )} - - - ) -} - -interface DrainRunsPanelProps { - organizationId: string - drainId: string -} - -function DrainRunsPanel({ organizationId, drainId }: DrainRunsPanelProps) { - const { data: runs, isLoading } = useDataDrainRuns(organizationId, drainId, 10) - - if (isLoading) { - return
Loading runs...
- } - if (!runs || runs.length === 0) { - return
No runs yet.
+ ) } return ( -
-
Recent runs
- {runs.map((run) => ( - - ))} -
- ) -} - -function RunRow({ run }: { run: DataDrainRun }) { - const statusColor = - run.status === 'success' - ? 'text-[var(--text-success)]' - : run.status === 'failed' - ? 'text-[var(--text-error)]' - : 'text-[var(--text-muted)]' - return ( -
-
-
- {run.status} - {run.trigger} - - {new Date(run.startedAt).toLocaleString()} - + + {error ? ( +
+

+ {getErrorMessage(error, "Couldn't load data drains")} +

- {run.error &&
{run.error}
} -
-
-
{run.rowsExported.toLocaleString()} rows
-
{(run.bytesWritten / 1024).toFixed(1)} KB
-
-
- ) -} - -interface CreateDrainModalProps { - organizationId: string - onClose: () => void -} - -function CreateDrainModal({ organizationId, onClose }: CreateDrainModalProps) { - const createMutation = useCreateDataDrain() - - const [name, setName] = useState('') - const [source, setSource] = useState<(typeof SOURCE_TYPES)[number]>('workflow_logs') - const [cadence, setCadence] = useState<(typeof CADENCE_TYPES)[number]>('daily') - const [destinationType, setDestinationType] = useState<(typeof DESTINATION_TYPES)[number]>( - DESTINATION_TYPES[0] - ) - const [destState, setDestState] = useState( - () => DESTINATION_FORM_REGISTRY[DESTINATION_TYPES[0]].initialState - ) - const [submitError, setSubmitError] = useState(null) - - const spec = DESTINATION_FORM_REGISTRY[destinationType] - const canSubmit = name.trim().length > 0 && spec.isComplete(destState) - - function handleDestinationChange(next: (typeof DESTINATION_TYPES)[number]) { - setDestinationType(next) - setDestState(DESTINATION_FORM_REGISTRY[next].initialState) - } - - async function handleSubmit() { - if (!canSubmit) return - setSubmitError(null) - try { - const body = { - name: name.trim(), - source, - scheduleCadence: cadence, - ...spec.toDestinationBranch(destState), - } as CreateDataDrainBody - await createMutation.mutateAsync({ organizationId, body }) - toast.success('Drain created') - onClose() - } catch (error) { - const msg = toError(error).message - logger.error('Failed to create data drain', { error: msg }) - setSubmitError(msg) - } - } - - return ( - !open && onClose()} srTitle='New data drain' size='md'> - onClose()}>New data drain - - - - setSource(v as (typeof SOURCE_TYPES)[number])} - options={SOURCE_OPTIONS} - align='start' - /> - - - setCadence(v as (typeof CADENCE_TYPES)[number])} - options={CADENCE_OPTIONS} - align='start' - /> - - - handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])} - options={DESTINATION_OPTIONS} - displayLabel={DESTINATION_LABELS[destinationType]} - align='start' - /> - - -
- -
- {submitError} -
- -
+ ) : isPending ? null : drains && drains.length > 0 ? ( +
+ {filteredDrains.map((drain) => ( + + ))} + {filteredDrains.length === 0 && ( + + No drains found matching "{searchTerm}" + + )} +
+ ) : ( + Click "Create drain" above to get started + )} + ) } diff --git a/apps/sim/ee/data-drains/destinations/registry.tsx b/apps/sim/ee/data-drains/destinations/registry.tsx index b2d70c06f35..fb1371a1552 100644 --- a/apps/sim/ee/data-drains/destinations/registry.tsx +++ b/apps/sim/ee/data-drains/destinations/registry.tsx @@ -1,9 +1,10 @@ 'use client' import type { ComponentType } from 'react' -import { ChipInput, ChipModalField, ChipSelect, ChipTextarea, SecretInput, Switch } from '@sim/emcn' +import { ChipInput, ChipSelect, ChipTextarea, SecretInput, Switch } from '@sim/emcn' import type { CreateDataDrainBody } from '@/lib/api/contracts/data-drains' import type { DestinationType } from '@/lib/data-drains/types' +import { SettingRow } from '@/ee/components/setting-row' type DestinationBranch = Pick< CreateDataDrainBody, @@ -44,54 +45,67 @@ const s3FormSpec: DestinationFormSpec = { }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, bucket: e.target.value })} placeholder='my-logs-bucket' /> - - + + setState({ ...state, region: e.target.value })} placeholder='us-east-1' /> - - + + setState({ ...state, prefix: e.target.value })} placeholder='exports/sim' /> - - + + setState({ ...state, endpoint: e.target.value })} placeholder='https://s3.example.com' /> - - + + setState({ ...state, forcePathStyle: v })} /> - - + + setState({ ...state, accessKeyId: v })} placeholder='AKIA...' /> - - + + setState({ ...state, secretAccessKey: v })} placeholder='Paste your secret access key' /> - + ), isComplete: (s) => @@ -126,28 +140,31 @@ const gcsFormSpec: DestinationFormSpec = { initialState: { bucket: '', prefix: '', serviceAccountJson: '' }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, bucket: e.target.value })} placeholder='my-logs-bucket' /> - - + + setState({ ...state, prefix: e.target.value })} placeholder='exports/sim' /> - - + + setState({ ...state, serviceAccountJson: e.target.value })} placeholder='{ "type": "service_account", ... }' rows={6} /> - + ), isComplete: (s) => s.bucket.length >= 3 && s.serviceAccountJson.length > 0, @@ -177,41 +194,49 @@ const azureBlobFormSpec: DestinationFormSpec = { }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, accountName: e.target.value })} placeholder='mystorageaccount' /> - - + + setState({ ...state, containerName: e.target.value })} placeholder='sim-exports' /> - - + + setState({ ...state, prefix: e.target.value })} placeholder='exports/sim' /> - - + + setState({ ...state, endpointSuffix: e.target.value })} placeholder='blob.core.windows.net' /> - - + + setState({ ...state, accountKey: v })} placeholder='Paste your storage account key' /> - + ), isComplete: (s) => @@ -250,35 +275,42 @@ const datadogFormSpec: DestinationFormSpec = { initialState: { site: 'us1', service: '', tags: '', apiKey: '' }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, site: v as DatadogState['site'] })} options={DATADOG_SITE_OPTIONS} align='start' /> - - + + setState({ ...state, service: e.target.value })} placeholder='sim' /> - - + + setState({ ...state, tags: e.target.value })} placeholder='env:prod,team:platform' /> - - + + setState({ ...state, apiKey: v })} placeholder='Paste your Datadog API key' /> - + ), isComplete: (s) => s.apiKey.length > 0, @@ -305,35 +337,42 @@ const bigqueryFormSpec: DestinationFormSpec = { initialState: { projectId: '', datasetId: '', tableId: '', serviceAccountJson: '' }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, projectId: e.target.value })} placeholder='my-gcp-project' /> - - + + setState({ ...state, datasetId: e.target.value })} placeholder='sim_drains' /> - - + + setState({ ...state, tableId: e.target.value })} placeholder='workflow_logs' /> - - + + setState({ ...state, serviceAccountJson: e.target.value })} placeholder='{ "type": "service_account", ... }' rows={6} /> - + ), isComplete: (s) => @@ -375,70 +414,82 @@ const snowflakeFormSpec: DestinationFormSpec = { }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, account: e.target.value })} placeholder='orgname-accountname' /> - - + + setState({ ...state, user: e.target.value })} placeholder='SIM_DRAIN_USER' /> - - + + setState({ ...state, warehouse: e.target.value })} placeholder='COMPUTE_WH' /> - - + + setState({ ...state, database: e.target.value })} placeholder='SIM' /> - - + + setState({ ...state, schema: e.target.value })} placeholder='PUBLIC' /> - - + + setState({ ...state, table: e.target.value })} placeholder='WORKFLOW_LOGS' /> - - + + setState({ ...state, column: e.target.value })} placeholder='DATA' /> - - + + setState({ ...state, role: e.target.value })} placeholder='SIM_DRAIN_ROLE' /> - - + + setState({ ...state, privateKey: e.target.value })} placeholder='-----BEGIN PRIVATE KEY-----' rows={6} /> - + ), isComplete: (s) => @@ -477,34 +528,41 @@ const webhookFormSpec: DestinationFormSpec = { initialState: { url: '', signatureHeader: '', signingSecret: '', bearerToken: '' }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, url: e.target.value })} placeholder='https://example.com/sim-drain' /> - - + + setState({ ...state, signatureHeader: e.target.value })} placeholder='X-Sim-Signature' /> - - + + setState({ ...state, signingSecret: v })} placeholder='At least 32 characters' /> - - + + setState({ ...state, bearerToken: v })} placeholder='Paste your bearer token' /> - + ), isComplete: (s) => s.url.length > 0 && s.signingSecret.length >= 32, diff --git a/apps/sim/ee/data-drains/hooks/data-drains.ts b/apps/sim/ee/data-drains/hooks/data-drains.ts index 5526a57aaac..8a12af69ca0 100644 --- a/apps/sim/ee/data-drains/hooks/data-drains.ts +++ b/apps/sim/ee/data-drains/hooks/data-drains.ts @@ -88,6 +88,12 @@ export function useCreateDataDrain() { logger.info('Created data drain', { drainId: drain.id, organizationId }) return drain }, + // Seed the list so the caller can navigate straight to the new drain's detail + // without that handoff depending on the invalidation refetch succeeding. + onSuccess: (drain, variables) => + queryClient.setQueryData(dataDrainKeys.list(variables.organizationId), (prev) => + prev?.some((d) => d.id === drain.id) ? prev : [...(prev ?? []), drain] + ), onSettled: (_drain, _error, variables) => queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) }), }) diff --git a/apps/sim/ee/data-drains/labels.ts b/apps/sim/ee/data-drains/labels.ts new file mode 100644 index 00000000000..0ab0c7d4c87 --- /dev/null +++ b/apps/sim/ee/data-drains/labels.ts @@ -0,0 +1,31 @@ +import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types' + +export const SOURCE_LABELS: Record<(typeof SOURCE_TYPES)[number], string> = { + workflow_logs: 'Workflow logs', + job_logs: 'Job logs', + audit_logs: 'Audit logs', + copilot_chats: 'Chats', + copilot_runs: 'Chat runs', +} + +export const DESTINATION_LABELS: Record<(typeof DESTINATION_TYPES)[number], string> = { + s3: 'Amazon S3', + gcs: 'Google Cloud Storage', + azure_blob: 'Azure Blob Storage', + datadog: 'Datadog', + bigquery: 'Google BigQuery', + snowflake: 'Snowflake', + webhook: 'HTTPS webhook', +} + +export const CADENCE_LABELS: Record<(typeof CADENCE_TYPES)[number], string> = { + hourly: 'Every hour', + daily: 'Every day', +} + +export const SOURCE_OPTIONS = SOURCE_TYPES.map((t) => ({ value: t, label: SOURCE_LABELS[t] })) +export const CADENCE_OPTIONS = CADENCE_TYPES.map((t) => ({ value: t, label: CADENCE_LABELS[t] })) +export const DESTINATION_OPTIONS = DESTINATION_TYPES.map((t) => ({ + value: t, + label: DESTINATION_LABELS[t], +})) From 49bf3f2c6864310e10396010840444e287336074 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 19:50:46 -0700 Subject: [PATCH 04/15] fix(settings): a11y labels, URL-backed member search, and design-system cleanup (#5955) * fix(settings): a11y labels, URL-backed member search, and design-system cleanup * fix(secrets): use useId for autofill salt so it survives hydration --- .../components/activity-log/activity-log.tsx | 1 + .../settings/components/api-keys/api-keys.tsx | 9 +- .../components/billing/billing.test.tsx | 3 + .../settings/components/billing/billing.tsx | 18 ++-- .../settings/components/general/general.tsx | 22 ++++- .../inbox-settings-tab/inbox-settings-tab.tsx | 13 ++- .../inbox-task-list/inbox-task-list.tsx | 2 +- .../settings/components/inbox/inbox.tsx | 44 +++++---- .../components/mothership/mothership.tsx | 24 +++-- .../recently-deleted/recently-deleted.tsx | 8 +- .../recently-deleted/search-params.ts | 8 +- .../secrets-manager/secrets-manager.tsx | 42 +++++++-- .../no-organization-view.tsx | 18 ++-- .../organization-invite-modal.tsx | 2 +- .../organization-member-lists.tsx | 89 ++++++++++--------- .../remove-member-dialog.tsx | 4 +- .../transfer-ownership-dialog.tsx | 3 +- .../team-management/team-management.tsx | 8 ++ .../workflow-mcp-servers.tsx | 11 ++- apps/sim/hooks/queries/mothership-admin.ts | 4 + 20 files changed, 198 insertions(+), 135 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx index 940969b0ced..12a3adccf4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx @@ -52,6 +52,7 @@ function ActivityLogRow({ >
{isLoadingSettings ? null : ( { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index 58c041482a2..05da1f82dbe 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -43,6 +43,9 @@ vi.mock('@sim/emcn', () => ({ {children} ), Credit: () => , + Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => ( + + ), Switch: ({ checked, disabled, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 56de071b7e5..851bc668d9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -7,6 +7,7 @@ import { Credit, chipVariants, cn, + Label, Switch, Tooltip, toast, @@ -494,14 +495,17 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps {showOnDemand && (
- - Allow usage to go past included usage - + {onDemandLockedOn ? ( - + @@ -514,6 +518,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps ) : (
- - Email me when I reach 80% usage - + { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 0c0aa46005c..f68afab6b72 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -403,9 +403,10 @@ export function General() {
- +
Auto-connect on drop - +

Automatically connect blocks when dropped near each other

@@ -466,7 +473,13 @@ export function General() { - +

Show error popups on blocks when a workflow run fails

@@ -485,9 +498,10 @@ export function General() {
- +
(null) const [removeSenderError, setRemoveSenderError] = useState(null) - const [copiedAddress, setCopiedAddress] = useState(false) + const { copied: copiedAddress, copy } = useCopyToClipboard() const handleCopyAddress = useCallback(() => { - if (config?.address) { - navigator.clipboard.writeText(config.address) - setCopiedAddress(true) - setTimeout(() => setCopiedAddress(false), 2000) - } - }, [config?.address]) + if (config?.address) void copy(config.address) + }, [config?.address, copy]) const handleEditAddress = useCallback(async () => { if (!newUsername.trim()) return @@ -172,7 +169,7 @@ export function InboxSettingsTab() { >
{member.email} - + member
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index 5d00c3afa5c..df0a8c8dc1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -171,7 +171,7 @@ export function InboxTaskList() { {formatRelativeTime(task.createdAt)} - + {task.status === 'processing' && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx index 7e995a26fff..b980b83c048 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx @@ -37,32 +37,28 @@ export function Inbox() { ) } return ( -
-
-
-
-
-

- Sim Mailer requires an active Max plan -

-

- Upgrade to Max and ensure billing is active to receive tasks via email and let Sim - work on your behalf. -

-
- {canAdmin && ( - navigateToSettings({ section: 'billing' })} - > - Upgrade to Max - - )} -
+ +
+
+

+ Sim Mailer requires an active Max plan +

+

+ Upgrade to Max and ensure billing is active to receive tasks via email and let Sim + work on your behalf. +

+ {canAdmin && ( + navigateToSettings({ section: 'billing' })} + > + Upgrade to Max + + )}
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx index b61f8353a24..86ba575ca2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx @@ -1,7 +1,16 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { Badge, Button, ChipInput, ChipModalTabs, ChipSelect, Label, Skeleton } from '@sim/emcn' +import { + Badge, + Button, + ChipCopyInput, + ChipInput, + ChipModalTabs, + ChipSelect, + Label, + Skeleton, +} from '@sim/emcn' import { formatDateTime } from '@sim/utils/formatting' import { useQueryStates } from 'nuqs' import { AnthropicIcon, OpenAIIcon } from '@/components/icons' @@ -377,7 +386,7 @@ function OverviewTab({ } function LicensesTab({ environment }: { environment: MothershipEnv }) { - const { data, isLoading, refetch } = useMothershipLicenses(environment) + const { data, isLoading } = useMothershipLicenses(environment) const generateLicense = useGenerateLicense(environment) const [newName, setNewName] = useState('') const [newExpiry, setNewExpiry] = useState('') @@ -395,11 +404,10 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) { setGeneratedKey(result.license_key) setNewName('') setNewExpiry('') - refetch() }, } ) - }, [newName, newExpiry, generateLicense, refetch]) + }, [newName, newExpiry, generateLicense.mutate]) return (
@@ -437,13 +445,11 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
{generatedKey && ( -
-

+

+

License key (only shown once):

- - {generatedKey} - +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index f9c5a9a1914..0f1c50b4695 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -21,6 +21,7 @@ import { 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 { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useFolders, useRestoreFolder } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useMothershipChats, useRestoreMothershipChat } from '@/hooks/queries/mothership-chats' @@ -31,7 +32,6 @@ import { useWorkspaceFileFolders, } from '@/hooks/queries/workspace-file-folders' import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files' -import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useUrlSort } from '@/hooks/use-url-sort' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' @@ -142,7 +142,7 @@ export function RecentlyDeleted() { const workspaceId = params?.workspaceId as string const workspacePermissions = useUserPermissionsContext() const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions) - const [{ tab: activeTab, search: urlSearchTerm }, setRecentlyDeletedFilters] = useQueryStates( + const [{ tab: activeTab }, setRecentlyDeletedFilters] = useQueryStates( recentlyDeletedParsers, recentlyDeletedUrlKeys ) @@ -160,9 +160,7 @@ export function RecentlyDeleted() { * write is debounced. Filtering below is cheap in-memory over a small list, so * it reads the instant value too. */ - const setSearchTerm = useDebouncedSearchSetter((value, options) => - setRecentlyDeletedFilters({ search: value }, options) - ) + const [urlSearchTerm, setSearchTerm] = useSettingsSearch() const [restoringIds, setRestoringIds] = useState>(new Set()) const [restoredItems, setRestoredItems] = useState>(new Map()) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts index a7e8435eed9..6379d8f145e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts @@ -1,4 +1,4 @@ -import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { parseAsStringLiteral } from 'nuqs/server' import { createSortParams } from '@/lib/url-state' /** Selectable resource-type tabs in the Recently Deleted view. */ @@ -34,12 +34,12 @@ export const recentlyDeletedSortParams = createSortParams(RECENTLY_DELETED_SORT_ * - `tab` is the active resource-type filter. * - `sort` / `dir` live in {@link recentlyDeletedSortParams} (shared sort * convention). - * - `search` is the name filter. The input is controlled directly by the nuqs - * value; only its URL write is debounced via `useDebouncedSearchSetter`. + * - The name filter is the settings-wide `?search=` key, owned by + * `settingsSearchParam` and consumed through `useSettingsSearch` — it is + * deliberately not redeclared here (two definitions of one wire key drift). */ export const recentlyDeletedParsers = { tab: parseAsStringLiteral(RECENTLY_DELETED_TABS).withDefault('all'), - search: parseAsString.withDefault(''), } as const /** Tab/filter/sort view-state: clean URLs, no back-stack churn. */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 3e18f61ff1e..3566336a680 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -1,9 +1,8 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { ChipInput, cn, toast } from '@sim/emcn' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' @@ -217,6 +216,15 @@ function WorkspaceVariableRow({ onDelete, onViewDetails, }: WorkspaceVariableRowProps) { + /** + * Salts the generated `name` attributes so password managers can't match them + * against a known field. `useId` is stable across SSR and hydration and across + * re-renders — a fresh `generateShortId()` per render would patch six DOM + * attributes on every keystroke, and a module-scope one would differ between + * the server and browser bundles. + */ + const autofillSalt = useId() + return (
onRenameEnd(envKey, value)} - name={`workspace_env_key_${envKey}_${generateShortId()}`} + name={`workspace_env_key_${envKey}_${autofillSalt}`} autoComplete='off' autoCapitalize='off' spellCheck='false' @@ -241,7 +249,7 @@ function WorkspaceVariableRow({ value={value} onChange={(next) => onValueChange(envKey, next)} canEdit={canEdit} - name={`workspace_env_value_${envKey}_${generateShortId()}`} + name={`workspace_env_value_${envKey}_${autofillSalt}`} /> copyName(envKey)} @@ -265,6 +273,15 @@ function NewWorkspaceVariableRow({ onUpdate, onPaste, }: NewWorkspaceVariableRowProps) { + /** + * Salts the generated `name` attributes so password managers can't match them + * against a known field. `useId` is stable across SSR and hydration and across + * re-renders — a fresh `generateShortId()` per render would patch six DOM + * attributes on every keystroke, and a module-scope one would differ between + * the server and browser bundles. + */ + const autofillSalt = useId() + const keyError = validateEnvVarKey(envVar.key) const hasContent = Boolean(envVar.key || envVar.value) @@ -277,7 +294,7 @@ function NewWorkspaceVariableRow({ onChange={(e) => onUpdate(index, 'key', e.target.value)} onPaste={onPaste ? (e) => onPaste(e, index) : undefined} placeholder='API_KEY' - name={`new_workspace_key_${envVar.id || index}_${generateShortId()}`} + name={`new_workspace_key_${envVar.id || index}_${autofillSalt}`} autoComplete='off' autoCapitalize='off' spellCheck='false' @@ -291,7 +308,7 @@ function NewWorkspaceVariableRow({ onChange={(next) => onUpdate(index, 'value', next)} onPaste={onPaste ? (e) => onPaste(e, index) : undefined} placeholder='Enter value' - name={`new_workspace_value_${envVar.id || index}_${generateShortId()}`} + name={`new_workspace_value_${envVar.id || index}_${autofillSalt}`} className='ml-0' /> {hasContent ? ( @@ -320,6 +337,15 @@ function NewWorkspaceVariableRow({ } export function SecretsManager() { + /** + * Salts the generated `name` attributes so password managers can't match them + * against a known field. `useId` is stable across SSR and hydration and across + * re-renders — a fresh `generateShortId()` per render would patch six DOM + * attributes on every keystroke, and a module-scope one would differ between + * the server and browser bundles. + */ + const autofillSalt = useId() + const params = useParams() const router = useRouter() const workspaceId = (params?.workspaceId as string) || '' @@ -865,7 +891,7 @@ export function SecretsManager() { onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)} onPaste={(e) => handlePaste(e, originalIndex)} placeholder='API_KEY' - name={`env_variable_name_${envVar.id || originalIndex}_${generateShortId()}`} + name={`env_variable_name_${envVar.id || originalIndex}_${autofillSalt}`} autoComplete='off' autoCapitalize='off' spellCheck='false' @@ -881,7 +907,7 @@ export function SecretsManager() { unmasked={isConflicted} readOnly={isConflicted} placeholder={isConflicted ? 'Workspace override active' : 'Enter value'} - name={`env_variable_value_${envVar.id || originalIndex}_${generateShortId()}`} + name={`env_variable_value_${envVar.id || originalIndex}_${autofillSalt}`} className={cn(isConflicted && 'cursor-not-allowed opacity-50')} /> {hasContent ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index 5b91277a2aa..ace340514fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -44,7 +44,6 @@ export function NoOrganizationView({ return (
- {/* Header - matching settings page style */}

Create Your Team Workspace @@ -55,23 +54,20 @@ export function NoOrganizationView({

- {/* Form fields - clean layout without card */}
- {/* Hidden decoy field to prevent browser autofill */} + {/* Decoy field: absorbs browser autofill so it can't target the real inputs. */}
- +
- +
sim.ai/team/ @@ -131,12 +125,12 @@ export function NoOrganizationView({ Create Team Organization - {/* Hidden decoy field to prevent browser autofill */} + {/* Decoy field: absorbs browser autofill so it can't target the real inputs. */} { setEmails([]) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index 3d81108fbe2..5a435d644f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo, useState } from 'react' -import { ChipDropdown, ChipInput, Search, toast } from '@sim/emcn' +import { ChipDropdown, toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { getErrorMessage } from '@sim/utils/errors' @@ -73,6 +73,12 @@ interface OrganizationMemberListsProps { roster: OrganizationRoster | null | undefined isLoadingRoster: boolean currentUserId: string + /** + * The roster filter, owned by the page so it can live in the URL — this + * component renders the shared `SettingsPanel` search box's results, it does + * not own the box. + */ + query: string onRemoveMember: (member: Member) => void onTransferOwnership?: () => void } @@ -89,10 +95,10 @@ export function OrganizationMemberLists({ roster, isLoadingRoster, currentUserId, + query, onRemoveMember, onTransferOwnership, }: OrganizationMemberListsProps) { - const [query, setQuery] = useState('') const [creditsTarget, setCreditsTarget] = useState(null) const updateMemberRole = useUpdateOrganizationMemberRole() @@ -380,48 +386,51 @@ export function OrganizationMemberLists({ /** * 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. + * Indexed by a single pass over the roster rather than a `.find` per + * workspace × member — that inner scan made this O(workspaces × members × + * access-entries). Members are appended in roster order, so each group keeps + * the same ordering the per-workspace scan produced. */ - 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] - ) + const workspaceGroups = useMemo(() => { + const membersByWorkspace = new Map< + string, + { member: RosterMember; access: RosterWorkspaceAccess }[] + >() + for (const member of members) { + const seen = new Set() + for (const access of member.workspaces) { + if (seen.has(access.workspaceId)) continue + seen.add(access.workspaceId) + const entries = membersByWorkspace.get(access.workspaceId) + if (entries) entries.push({ member, access }) + else membersByWorkspace.set(access.workspaceId, [{ member, access }]) + } + } + + const invitesByWorkspace = new Map< + string, + { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess }[] + >() + for (const invitation of pendingInvitations) { + const seen = new Set() + for (const access of invitation.workspaces) { + if (seen.has(access.workspaceId)) continue + seen.add(access.workspaceId) + const entries = invitesByWorkspace.get(access.workspaceId) + if (entries) entries.push({ invitation, access }) + else invitesByWorkspace.set(access.workspaceId, [{ invitation, access }]) + } + } + + return workspaces.map((workspace) => ({ + workspace, + workspaceMembers: membersByWorkspace.get(workspace.id) ?? [], + workspaceInvites: invitesByWorkspace.get(workspace.id) ?? [], + })) + }, [workspaces, members, pendingInvitations]) return ( <> -
- setQuery(e.target.value)} - className='flex-1' - /> -
- {showMembersSection && ( - {error instanceof Error && error.message ? error.message : String(error)} + {getErrorMessage(error) || 'Failed to transfer ownership'}

)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 1f43f1cc594..3a80877faab 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -16,6 +16,7 @@ import { TeamSeatsOverview, TransferOwnershipDialog, } from '@/app/workspace/[workspaceId]/settings/components/team-management/components' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useCreateOrganization, useOrganization, @@ -40,6 +41,7 @@ export function TeamManagement({ }: TeamManagementProps) { const { data: session } = useSession() const { isInvitationsDisabled } = usePermissionConfig() + const [memberQuery, setMemberQuery] = useSettingsSearch() const { data: userSubscriptionData } = useSubscriptionData() const subscriptionAccess = getSubscriptionAccessState(userSubscriptionData?.data) @@ -307,6 +309,11 @@ export function TeamManagement({ return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index ebbfa9883cd..69fed161b7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -19,6 +19,7 @@ import { Code, type ComboboxOption, Label, + useCopyToClipboard, } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -97,7 +98,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe } }, []) - const [copiedConfig, setCopiedConfig] = useState(false) + const { copied: copiedConfig, copy: copyConfig } = useCopyToClipboard() const [activeConfigTab, setActiveConfigTab] = useState('cursor') const [toolToDelete, setToolToDelete] = useState(null) const [toolToView, setToolToView] = useState(null) @@ -308,12 +309,9 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe const handleCopyConfig = useCallback( (isPublic: boolean, serverName: string) => { - const snippet = getConfigSnippet(activeConfigTab, isPublic, serverName) - navigator.clipboard.writeText(snippet) - setCopiedConfig(true) - setTimeout(() => setCopiedConfig(false), 2000) + void copyConfig(getConfigSnippet(activeConfigTab, isPublic, serverName)) }, - [activeConfigTab, getConfigSnippet] + [activeConfigTab, getConfigSnippet, copyConfig] ) const handleOpenEditServer = useCallback(() => { @@ -579,6 +577,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe