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