From 919a98d00ff31daf564e2c6519526c6bd6abbfe1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 19:11:34 -0700 Subject: [PATCH] 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 | 697 ++++++++---------- ...tings.tsx => verified-domains-section.tsx} | 150 ++-- 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, 678 insertions(+), 607 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 fe91e17e83..5c7464b609 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 23f872c507..0c78d76f21 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 924ecc95ab..6303933ab1 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 94aae59169..10d4b15793 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 5d2a80a52e..cf2bf5caf1 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 2996eb3c23..fc2e914762 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 15cd1c21ed..3693f88fd4 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 c8eab37aea..24721ece3f 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 d2a3e0d632..66ae7a1efc 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 813b2d9df8..6d2cb78d2e 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 04f5c08bdb..7685f83b3f 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 4d854cd908..2b6eb0826f 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 f60a85b95c..d3a591c084 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 5a1bed9c96..5526a57aaa 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 f1e85a890a..04219abecc 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 9ce0f36f06..2a400aaf8f 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 df9e1a586f..959581bd9c 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.providerId}

+
- -

{existingProvider.domain}

-
+ +

+ {existingProvider.providerType.toUpperCase()} +

+
- -

- {existingProvider.issuer} -

-
+ +

{existingProvider.domain}

+
- - 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.issuer} +

+
+ + + +

+ Configure this in your identity provider +

+
+
+
) } @@ -520,312 +476,303 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { }), ]} > -
- - - handleInputChange('providerType', value as 'oidc' | 'saml') + + + +
+ + + 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 } - 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.)'} -

- + > + handleInputChange('providerId', value)} + options={SSO_TRUSTED_PROVIDERS.map((id) => ({ + label: id, + value: id, + }))} + placeholder='Select or enter a provider ID' + editable + /> +
- 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.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) +

+
- 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 - } - > - { - 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={ - + {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 04f10e6a8d..e069fd16d5 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 }]} - /> -
-
+
+ } + title={domain.domain} + description={isVerified ? 'Ownership verified' : 'Awaiting DNS verification'} + trailing={ +
+ + {isVerified ? '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. -

- + - + 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 2b048031af..deecd10d1c 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 7b258f0641..38df0a0cea 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 90380a9bff..8d9284c953 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