refactor(settings): fold verified domains into SSO, move group-detail state to nuqs, design-system cleanup (#5950)

* 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
This commit is contained in:
Waleed
2026-07-24 19:11:34 -07:00
committed by GitHub
parent f43b52c569
commit 919a98d00f
26 changed files with 678 additions and 607 deletions
@@ -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=…`).
+1 -1
View File
@@ -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 }
@@ -36,6 +36,8 @@ const SECTION_ALIASES: Readonly<Record<string, SettingsSection>> = {
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<Record<string, (workspaceId: string) => string>> = {
@@ -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
@@ -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 && <SSO organizationId={organizationId} />}
{effectiveSection === 'domains' && organizationId && (
<DomainSettings key={organizationId} organizationId={organizationId} />
)}
{effectiveSection === 'sessions' && organizationId && (
<SessionPolicySettings key={organizationId} organizationId={organizationId} />
)}
@@ -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' },
@@ -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',
+6 -24
View File
@@ -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<Record<string, OrganizationSettingsSection>>
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,
@@ -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 <AuditLogs organizationId={organizationId} />
if (section === 'sso') return <SSO organizationId={organizationId} />
if (section === 'domains') {
return <DomainSettings key={organizationId} organizationId={organizationId} />
}
if (section === 'sessions') {
return <SessionPolicySettings key={organizationId} organizationId={organizationId} />
}
@@ -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
<button
key={group.id}
type='button'
onClick={() => void setSelectedGroupId(group.id)}
onClick={() => openGroupDetail(group.id)}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 flex-1 flex-col'>
@@ -27,10 +27,19 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { ChevronDown, Plus } from 'lucide-react'
import { useQueryState } from 'nuqs'
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import {
groupSearchParam,
groupSearchUrlKeys,
groupStatusParam,
groupStatusUrlKeys,
groupTabParam,
groupTabUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import {
MemberAvatar,
MemberRow,
@@ -58,6 +67,7 @@ import { SettingRow } from '@/ee/components/setting-row'
import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers'
import { useOrganizationRoster } from '@/hooks/queries/organization'
import { useProviderModels } from '@/hooks/queries/providers'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import {
DYNAMIC_MODEL_PROVIDERS,
getProviderModels,
@@ -72,6 +82,9 @@ const logger = createLogger('AccessControlGroupDetail')
type ConfigTab = 'general' | 'providers' | 'blocks' | 'platform'
/** Hoisted: rebuilding this per comparison allocated once per sort step. */
const BLOCK_CATEGORY_ORDER: Record<string, number> = { triggers: 0, blocks: 1, tools: 2 }
/** Public-file-share auth modes an admin can allow/disallow. `null` config = all allowed. */
const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [
{ value: 'public', label: 'Anyone with link' },
@@ -651,7 +664,7 @@ function ProviderRow({
onCheckedChange={() => onToggleProvider()}
/>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
{ProviderIcon && <ProviderIcon className='!h-[16px] !w-[16px]' />}
{ProviderIcon && <ProviderIcon className='!size-[16px]' />}
</div>
<button
type='button'
@@ -842,13 +855,33 @@ export function GroupDetail({
*/
const scopeWriteSeqRef = useRef(0)
const [configTab, setConfigTab] = useState<ConfigTab>('general')
const [providerSearchTerm, setProviderSearchTerm] = useState('')
const [integrationSearchTerm, setIntegrationSearchTerm] = useState('')
const [platformSearchTerm, setPlatformSearchTerm] = useState('')
const [providerStatusFilter, setProviderStatusFilter] = useState<StatusFilter>('all')
const [blockStatusFilter, setBlockStatusFilter] = useState<StatusFilter>('all')
const [platformStatusFilter, setPlatformStatusFilter] = useState<StatusFilter>('all')
// Tab, search, and status filter are shareable detail-view state, so they live
// in the URL (see .claude/rules/sim-url-state.md). The three tabs never render
// together, so search and status share one param each rather than carrying
// three mutually-exclusive keys; switching tabs resets both.
const [configTab, setConfigTab] = useQueryState(groupTabParam.key, {
...groupTabParam.parser,
...groupTabUrlKeys,
})
const [searchTerm, setSearchTermParam] = useQueryState(groupSearchParam.key, {
...groupSearchParam.parser,
...groupSearchUrlKeys,
})
const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam)
const [statusFilter, setStatusFilter] = useQueryState(groupStatusParam.key, {
...groupStatusParam.parser,
...groupStatusUrlKeys,
})
const handleTabChange = useCallback(
(value: string) => {
void setConfigTab(value as ConfigTab)
// Don't carry a provider query or an enabled-only filter into another tab.
setSearchTerm('')
void setStatusFilter(null)
},
[setConfigTab, setSearchTerm, setStatusFilter]
)
const [showAddMembersModal, setShowAddMembersModal] = useState(false)
const [addMembersError, setAddMembersError] = useState<string | null>(null)
@@ -878,9 +911,8 @@ export function GroupDetail({
const allBlocks = useMemo(() => {
const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type))
return blocks.sort((a, b) => {
const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
const catA = categoryOrder[a.category] ?? 3
const catB = categoryOrder[b.category] ?? 3
const catA = BLOCK_CATEGORY_ORDER[a.category] ?? 3
const catB = BLOCK_CATEGORY_ORDER[b.category] ?? 3
if (catA !== catB) return catA - catB
return a.name.localeCompare(b.name)
})
@@ -913,20 +945,20 @@ export function GroupDetail({
}, [allBlocks])
const searchedPlatformFeatures = useMemo(() => {
const search = platformSearchTerm.trim().toLowerCase()
const search = searchTerm.trim().toLowerCase()
if (!search) return PLATFORM_FEATURES
return PLATFORM_FEATURES.filter(
(f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search)
)
}, [platformSearchTerm])
}, [searchTerm])
/** Split from the search pass for the same reason as the provider and block lists. */
const filteredPlatformFeatures = useMemo(() => {
if (platformStatusFilter === 'all') return searchedPlatformFeatures
if (statusFilter === 'all') return searchedPlatformFeatures
return searchedPlatformFeatures.filter((f) =>
matchesStatusFilter(platformStatusFilter, !editingConfig[f.configKey])
matchesStatusFilter(statusFilter, !editingConfig[f.configKey])
)
}, [searchedPlatformFeatures, platformStatusFilter, editingConfig])
}, [searchedPlatformFeatures, statusFilter, editingConfig])
const platformCategories = useMemo(() => {
const categories: Record<string, typeof PLATFORM_FEATURES> = {}
@@ -999,10 +1031,10 @@ export function GroupDetail({
)
const searchedProviders = useMemo(() => {
const query = providerSearchTerm.trim().toLowerCase()
const query = searchTerm.trim().toLowerCase()
if (!query) return allProviderIds
return allProviderIds.filter((id) => id.toLowerCase().includes(query))
}, [allProviderIds, providerSearchTerm])
}, [allProviderIds, searchTerm])
/**
* Split from the search pass so the common `all` case returns the searched
@@ -1010,24 +1042,24 @@ export function GroupDetail({
* checkbox toggle no longer invalidates downstream consumers.
*/
const filteredProviders = useMemo(() => {
if (providerStatusFilter === 'all') return searchedProviders
if (statusFilter === 'all') return searchedProviders
return searchedProviders.filter((id) =>
matchesStatusFilter(providerStatusFilter, isProviderAllowed(id))
matchesStatusFilter(statusFilter, isProviderAllowed(id))
)
}, [searchedProviders, providerStatusFilter, isProviderAllowed])
}, [searchedProviders, statusFilter, isProviderAllowed])
const searchedBlocks = useMemo(() => {
const query = integrationSearchTerm.trim().toLowerCase()
const query = searchTerm.trim().toLowerCase()
if (!query) return visibleBlocks
return visibleBlocks.filter((b) => b.name.toLowerCase().includes(query))
}, [visibleBlocks, integrationSearchTerm])
}, [visibleBlocks, searchTerm])
const filteredBlocks = useMemo(() => {
if (blockStatusFilter === 'all') return searchedBlocks
if (statusFilter === 'all') return searchedBlocks
return searchedBlocks.filter((b) =>
matchesStatusFilter(blockStatusFilter, isIntegrationAllowed(b.type))
matchesStatusFilter(statusFilter, isIntegrationAllowed(b.type))
)
}, [searchedBlocks, blockStatusFilter, isIntegrationAllowed])
}, [searchedBlocks, statusFilter, isIntegrationAllowed])
const filteredCoreBlocks = useMemo(
() => filteredBlocks.filter((block) => block.category === 'blocks'),
@@ -1519,18 +1551,14 @@ export function GroupDetail({
]}
>
<div className='sticky top-0 z-10 bg-[var(--bg)]'>
<ChipModalTabs
tabs={tabs}
value={configTab}
onChange={(value) => setConfigTab(value as ConfigTab)}
/>
<ChipModalTabs tabs={tabs} value={configTab} onChange={handleTabChange} />
</div>
{configTab === 'general' && (
<>
<SettingsSection label='Details'>
<div className='flex flex-col gap-4'>
<SettingRow label='Name'>
<SettingRow label='Name' error={!trimmedName ? 'Name is required.' : undefined}>
<ChipInput
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
@@ -1538,9 +1566,6 @@ export function GroupDetail({
maxLength={100}
error={!trimmedName}
/>
{!trimmedName && (
<p className='text-[var(--text-error)] text-caption'>Name is required.</p>
)}
</SettingRow>
<SettingRow label='Description'>
<ChipInput
@@ -1676,11 +1701,14 @@ export function GroupDetail({
<ChipInput
icon={Search}
placeholder='Search providers...'
value={providerSearchTerm}
onChange={(e) => setProviderSearchTerm(e.target.value)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='min-w-0 flex-1'
/>
<StatusFilterChip value={providerStatusFilter} onChange={setProviderStatusFilter} />
<StatusFilterChip
value={statusFilter}
onChange={(next) => void setStatusFilter(next)}
/>
<Chip
flush
onClick={() => setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)}
@@ -1719,11 +1747,15 @@ export function GroupDetail({
<ChipInput
icon={Search}
placeholder='Search blocks...'
value={integrationSearchTerm}
onChange={(e) => setIntegrationSearchTerm(e.target.value)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='min-w-0 flex-1'
/>
<StatusFilterChip value={blockStatusFilter} onChange={setBlockStatusFilter} flush />
<StatusFilterChip
value={statusFilter}
onChange={(next) => void setStatusFilter(next)}
flush
/>
</div>
{filteredCoreBlocks.length === 0 && filteredToolBlocks.length === 0 && (
<SettingsEmptyState variant='inline'>
@@ -1822,11 +1854,14 @@ export function GroupDetail({
<ChipInput
icon={Search}
placeholder='Search features...'
value={platformSearchTerm}
onChange={(e) => setPlatformSearchTerm(e.target.value)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='min-w-0 flex-1'
/>
<StatusFilterChip value={platformStatusFilter} onChange={setPlatformStatusFilter} />
<StatusFilterChip
value={statusFilter}
onChange={(next) => void setStatusFilter(next)}
/>
<Chip
onClick={() =>
setEditingConfig((prev) => ({
@@ -19,6 +19,9 @@ import {
} from '@/lib/api/contracts'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
export const PERMISSION_GROUP_MEMBERS_STALE_TIME = 30 * 1000
export const PERMISSION_GROUPS_STALE_TIME = 60 * 1000
export type {
PermissionGroup,
PermissionGroupMember,
@@ -54,7 +57,7 @@ export function usePermissionGroups(organizationId?: string, enabled = true) {
return data.permissionGroups ?? []
},
enabled: Boolean(organizationId) && enabled,
staleTime: 60 * 1000,
staleTime: PERMISSION_GROUPS_STALE_TIME,
})
}
@@ -70,7 +73,7 @@ export function usePermissionGroupMembers(organizationId?: string, permissionGro
return data.members ?? []
},
enabled: Boolean(organizationId) && Boolean(permissionGroupId),
staleTime: 30 * 1000,
staleTime: PERMISSION_GROUP_MEMBERS_STALE_TIME,
})
}
@@ -86,7 +89,7 @@ export function useOrganizationWorkspaces(organizationId?: string, enabled = tru
return data.workspaces
},
enabled: Boolean(organizationId) && enabled,
staleTime: 60 * 1000,
staleTime: PERMISSION_GROUPS_STALE_TIME,
})
}
@@ -101,7 +104,7 @@ export function useUserPermissionConfig(workspaceId?: string) {
return data
},
enabled: Boolean(workspaceId),
staleTime: 60 * 1000,
staleTime: PERMISSION_GROUPS_STALE_TIME,
})
}
@@ -450,7 +450,12 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
</PopoverContent>
</Popover>
</div>
<Button variant='ghost' onClick={handleRefresh} disabled={isVisuallyRefreshing}>
<Button
variant='ghost'
onClick={handleRefresh}
disabled={isVisuallyRefreshing}
aria-label='Refresh audit logs'
>
<RefreshCw animate={isVisuallyRefreshing} className='size-[14px]' />
</Button>
</div>
+3 -1
View File
@@ -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,
})
}
+32 -12
View File
@@ -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 (
<div className='flex flex-col gap-1.5'>
<div className='flex items-center gap-1.5'>
<Label className='text-[var(--text-primary)] text-small'>{label}</Label>
<Label htmlFor={htmlFor}>
{label}
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
</Label>
{labelTooltip && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='size-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
{labelTooltip}
</Tooltip.Content>
</Tooltip.Root>
<Info side='bottom' align='start'>
{labelTooltip}
</Info>
)}
</div>
{description && <p className='text-[var(--text-muted)] text-caption'>{description}</p>}
{children}
{error ? (
<p role='alert' className='text-[var(--text-error)] text-caption'>
{error}
</p>
) : null}
</div>
)
}
@@ -224,7 +224,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
[deployedLoaded, availableFields, overrideById, inputs]
)
const [expandedInputs, setExpandedInputs] = useState<ReadonlySet<string>>(new Set())
const [expandedInputs, setExpandedInputs] = useState<ReadonlySet<string>>(() => 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'}
</Button>
@@ -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)]'
>
<X className='size-[14px]' />
</Button>
+5 -2
View File
@@ -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,
})
}
@@ -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,
})
}
@@ -21,6 +21,7 @@ vi.mock('@sim/emcn', () => ({
</button>
),
ChipCombobox: () => <div />,
ChipCopyInput: ({ value }: { value?: string }) => <input readOnly value={value ?? ''} />,
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: () => <div />,
}))
vi.mock(
'@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions',
() => ({
+322 -375
View File
@@ -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 (
<div className='flex flex-col gap-[9px]'>
<Label className='font-normal text-[var(--text-muted)]'>
{label}
{optional ? <span className='ml-1'>(optional)</span> : null}
</Label>
{children}
{error ? <p className='text-[var(--text-error)] text-caption'>{error}</p> : null}
</div>
)
}
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 (
<SettingsPanel actions={[{ text: 'Edit', variant: 'primary', onSelect: handleEdit }]}>
<div className='flex flex-col gap-4.5'>
<FormField label='Provider ID'>
<p className='text-[var(--text-primary)] text-small'>{existingProvider.providerId}</p>
</FormField>
<VerifiedDomainsSection organizationId={organizationId} />
<FormField label='Provider Type'>
<p className='text-[var(--text-primary)] text-small'>
{existingProvider.providerType.toUpperCase()}
</p>
</FormField>
<SettingsSection label='Identity provider'>
<div className='flex flex-col gap-4.5'>
<SettingRow label='Provider ID'>
<p className='text-[var(--text-primary)] text-small'>{existingProvider.providerId}</p>
</SettingRow>
<FormField label='Domain'>
<p className='text-[var(--text-primary)] text-small'>{existingProvider.domain}</p>
</FormField>
<SettingRow label='Provider Type'>
<p className='text-[var(--text-primary)] text-small'>
{existingProvider.providerType.toUpperCase()}
</p>
</SettingRow>
<FormField label='Issuer URL'>
<p className='break-all font-mono text-[var(--text-primary)] text-small leading-relaxed'>
{existingProvider.issuer}
</p>
</FormField>
<SettingRow label='Domain'>
<p className='text-[var(--text-primary)] text-small'>{existingProvider.domain}</p>
</SettingRow>
<FormField label='Callback URL'>
<ChipInput
value={providerCallbackUrl}
readOnly
endAdornment={
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(providerCallbackUrl)}
className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]'
aria-label='Copy callback URL'
>
{copied ? (
<Check className='size-[14px]' />
) : (
<Clipboard className='size-[14px]' />
)}
</Button>
}
/>
<p className='text-[var(--text-muted)] text-small'>
Configure this in your identity provider
</p>
</FormField>
</div>
<SettingRow label='Issuer URL'>
<p className='break-all text-[var(--text-primary)] text-small'>
{existingProvider.issuer}
</p>
</SettingRow>
<SettingRow label='Callback URL'>
<ChipCopyInput value={providerCallbackUrl} copyLabel='Copy callback URL' />
<p className='text-[var(--text-muted)] text-small'>
Configure this in your identity provider
</p>
</SettingRow>
</div>
</SettingsSection>
</SettingsPanel>
)
}
@@ -520,312 +476,303 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
}),
]}
>
<div className='flex flex-col gap-4.5'>
<FormField label='Provider Type'>
<ChipSelect
align='start'
value={formData.providerType}
onChange={(value: string) =>
handleInputChange('providerType', value as 'oidc' | 'saml')
<VerifiedDomainsSection organizationId={organizationId} />
<SettingsSection label='Identity provider'>
<div className='flex flex-col gap-4.5'>
<SettingRow label='Provider Type'>
<ChipSelect
align='start'
value={formData.providerType}
onChange={(value: string) =>
handleInputChange('providerType', value as 'oidc' | 'saml')
}
options={[
{ label: 'OIDC', value: 'oidc' },
{ label: 'SAML', value: 'saml' },
]}
placeholder='Select provider type'
/>
<p className='text-[var(--text-muted)] text-small'>
{formData.providerType === 'oidc'
? 'OpenID Connect (Okta, Azure AD, Auth0, etc.)'
: 'Security Assertion Markup Language (ADFS, Shibboleth, etc.)'}
</p>
</SettingRow>
<SettingRow
label='Provider ID'
error={
showErrors && errors.providerId.length > 0 ? errors.providerId.join(' ') : undefined
}
options={[
{ label: 'OIDC', value: 'oidc' },
{ label: 'SAML', value: 'saml' },
]}
placeholder='Select provider type'
/>
<p className='text-[var(--text-muted)] text-small'>
{formData.providerType === 'oidc'
? 'OpenID Connect (Okta, Azure AD, Auth0, etc.)'
: 'Security Assertion Markup Language (ADFS, Shibboleth, etc.)'}
</p>
</FormField>
>
<ChipCombobox
value={formData.providerId}
onChange={(value: string) => handleInputChange('providerId', value)}
options={SSO_TRUSTED_PROVIDERS.map((id) => ({
label: id,
value: id,
}))}
placeholder='Select or enter a provider ID'
editable
/>
</SettingRow>
<FormField
label='Provider ID'
error={
showErrors && errors.providerId.length > 0 ? errors.providerId.join(' ') : undefined
}
>
<ChipCombobox
value={formData.providerId}
onChange={(value: string) => handleInputChange('providerId', value)}
options={SSO_TRUSTED_PROVIDERS.map((id) => ({
label: id,
value: id,
}))}
placeholder='Select or enter a provider ID'
editable
/>
</FormField>
<SettingRow
label='Issuer URL'
error={
showErrors && errors.issuerUrl.length > 0 ? errors.issuerUrl.join(' ') : undefined
}
>
<ChipInput
id='sso-issuer'
type='url'
placeholder='https://your-identity-provider.com/oauth2/default'
value={formData.issuerUrl}
name='sso_issuer_endpoint'
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
onChange={(e) => handleInputChange('issuerUrl', e.target.value)}
error={showErrors && errors.issuerUrl.length > 0}
/>
</SettingRow>
<FormField
label='Issuer URL'
error={
showErrors && errors.issuerUrl.length > 0 ? errors.issuerUrl.join(' ') : undefined
}
>
<ChipInput
id='sso-issuer'
type='url'
placeholder='https://your-identity-provider.com/oauth2/default'
value={formData.issuerUrl}
name='sso_issuer_endpoint'
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
onChange={(e) => handleInputChange('issuerUrl', e.target.value)}
error={showErrors && errors.issuerUrl.length > 0}
/>
</FormField>
<SettingRow
label='Domain'
error={showErrors && errors.domain.length > 0 ? errors.domain.join(' ') : undefined}
>
<ChipInput
id='sso-domain'
type='text'
placeholder='company.com'
value={formData.domain}
name='sso_identity_domain'
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
onChange={(e) => handleInputChange('domain', e.target.value)}
error={showErrors && errors.domain.length > 0}
/>
<p className='text-[var(--text-muted)] text-small'>
The email domain users sign in with (e.g. company.com)
</p>
</SettingRow>
<FormField
label='Domain'
error={showErrors && errors.domain.length > 0 ? errors.domain.join(' ') : undefined}
>
<ChipInput
id='sso-domain'
type='text'
placeholder='company.com'
value={formData.domain}
name='sso_identity_domain'
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
onChange={(e) => handleInputChange('domain', e.target.value)}
error={showErrors && errors.domain.length > 0}
/>
<p className='text-[var(--text-muted)] text-small'>
The email domain users sign in with (e.g. company.com)
</p>
</FormField>
{formData.providerType === 'oidc' ? (
<>
<FormField
label='Client ID'
error={
showErrors && errors.clientId.length > 0 ? errors.clientId.join(' ') : undefined
}
>
<ChipInput
id='sso-client-id'
type='text'
placeholder='Enter Client ID'
value={formData.clientId}
name='sso_client_identifier'
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
onChange={(e) => handleInputChange('clientId', e.target.value)}
error={showErrors && errors.clientId.length > 0}
/>
</FormField>
<FormField
label='Client Secret'
error={
showErrors && errors.clientSecret.length > 0
? errors.clientSecret.join(' ')
: undefined
}
>
<ChipInput
id='sso-client-secret'
type='text'
placeholder='Enter Client Secret'
value={formData.clientSecret}
name='sso_client_key'
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
readOnly
onFocus={(e) => {
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={
<Button
type='button'
variant='ghost'
onClick={() => setShowClientSecret((s) => !s)}
className='size-6 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
aria-label={showClientSecret ? 'Hide client secret' : 'Show client secret'}
>
{showClientSecret ? (
<EyeOff className='size-[14px]' />
) : (
<Eye className='size-[14px]' />
)}
</Button>
{formData.providerType === 'oidc' ? (
<>
<SettingRow
label='Client ID'
error={
showErrors && errors.clientId.length > 0 ? errors.clientId.join(' ') : undefined
}
/>
</FormField>
<FormField
label='Scopes'
error={showErrors && errors.scopes.length > 0 ? errors.scopes.join(' ') : undefined}
>
<ChipInput
id='sso-scopes'
type='text'
placeholder='openid,profile,email'
value={formData.scopes}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('scopes', e.target.value)}
error={showErrors && errors.scopes.length > 0}
/>
<p className='text-[var(--text-muted)] text-small'>
Comma-separated list of OIDC scopes to request
</p>
</FormField>
</>
) : (
<>
<FormField
label='Entry Point URL'
error={
showErrors && errors.entryPoint.length > 0
? errors.entryPoint.join(' ')
: undefined
}
>
<ChipInput
id='sso-entry-point'
type='url'
placeholder='https://idp.example.com/sso/saml'
value={formData.entryPoint}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('entryPoint', e.target.value)}
error={showErrors && errors.entryPoint.length > 0}
/>
</FormField>
<FormField
label='Identity Provider Certificate'
error={showErrors && errors.cert.length > 0 ? errors.cert.join(' ') : undefined}
>
<ChipTextarea
id='sso-cert'
placeholder={'-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'}
value={formData.cert}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('cert', e.target.value)}
className='min-h-[80px] font-mono'
error={showErrors && errors.cert.length > 0}
rows={3}
/>
</FormField>
<div className='flex flex-col gap-2'>
<Button
type='button'
variant='ghost'
onClick={() => setShowAdvanced((v) => !v)}
className='w-fit gap-1.5 px-0 text-[var(--text-muted)] hover:bg-transparent hover:text-[var(--text-primary)]'
>
<ChevronDown
className={cn('size-[14px] transition-transform', showAdvanced && 'rotate-180')}
<ChipInput
id='sso-client-id'
type='text'
placeholder='Enter Client ID'
value={formData.clientId}
name='sso_client_identifier'
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
onChange={(e) => handleInputChange('clientId', e.target.value)}
error={showErrors && errors.clientId.length > 0}
/>
Advanced Options
</Button>
</SettingRow>
<Expandable expanded={showAdvanced}>
<ExpandableContent>
<div className='flex flex-col gap-4.5 pt-2'>
<FormField label='Audience (Entity ID)' optional>
<ChipInput
type='text'
placeholder='Enter Audience'
value={formData.audience}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('audience', e.target.value)}
/>
</FormField>
<FormField label='Callback URL Override' optional>
<ChipInput
type='url'
placeholder={`${getBaseUrl()}/api/auth/sso/saml2/callback/provider-id`}
value={formData.callbackUrl}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('callbackUrl', e.target.value)}
/>
</FormField>
<FormField label='Require signed SAML assertions'>
<Switch
checked={formData.wantAssertionsSigned}
onCheckedChange={(checked) =>
handleInputChange('wantAssertionsSigned', checked)
}
/>
</FormField>
<FormField label='IDP Metadata XML' optional>
<ChipTextarea
placeholder='Paste IDP metadata XML here'
value={formData.idpMetadata}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('idpMetadata', e.target.value)}
className='min-h-[60px] font-mono'
rows={2}
/>
</FormField>
</div>
</ExpandableContent>
</Expandable>
</div>
</>
)}
<FormField label='Callback URL'>
<ChipInput
value={callbackUrl}
readOnly
endAdornment={
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(callbackUrl)}
className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]'
aria-label='Copy callback URL'
<SettingRow
label='Client Secret'
error={
showErrors && errors.clientSecret.length > 0
? errors.clientSecret.join(' ')
: undefined
}
>
{copied ? (
<Check className='size-[14px]' />
) : (
<Clipboard className='size-[14px]' />
)}
</Button>
}
/>
<p className='text-[var(--text-muted)] text-small'>
Configure this in your identity provider
</p>
</FormField>
</div>
<ChipInput
id='sso-client-secret'
type='text'
placeholder='Enter Client Secret'
value={formData.clientSecret}
name='sso_client_key'
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
readOnly
onFocus={(e) => {
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={
<Button
type='button'
variant='ghost'
onClick={() => setShowClientSecret((s) => !s)}
className='size-6 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
aria-label={showClientSecret ? 'Hide client secret' : 'Show client secret'}
>
{showClientSecret ? (
<EyeOff className='size-[14px]' />
) : (
<Eye className='size-[14px]' />
)}
</Button>
}
/>
</SettingRow>
<SettingRow
label='Scopes'
error={
showErrors && errors.scopes.length > 0 ? errors.scopes.join(' ') : undefined
}
>
<ChipInput
id='sso-scopes'
type='text'
placeholder='openid,profile,email'
value={formData.scopes}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('scopes', e.target.value)}
error={showErrors && errors.scopes.length > 0}
/>
<p className='text-[var(--text-muted)] text-small'>
Comma-separated list of OIDC scopes to request
</p>
</SettingRow>
</>
) : (
<>
<SettingRow
label='Entry Point URL'
error={
showErrors && errors.entryPoint.length > 0
? errors.entryPoint.join(' ')
: undefined
}
>
<ChipInput
id='sso-entry-point'
type='url'
placeholder='https://idp.example.com/sso/saml'
value={formData.entryPoint}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('entryPoint', e.target.value)}
error={showErrors && errors.entryPoint.length > 0}
/>
</SettingRow>
<SettingRow
label='Identity Provider Certificate'
error={showErrors && errors.cert.length > 0 ? errors.cert.join(' ') : undefined}
>
<ChipTextarea
id='sso-cert'
placeholder={'-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'}
value={formData.cert}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('cert', e.target.value)}
className='min-h-[80px] font-mono'
error={showErrors && errors.cert.length > 0}
rows={3}
/>
</SettingRow>
<div className='flex flex-col gap-2'>
<Button
type='button'
variant='ghost'
onClick={() => setShowAdvanced((v) => !v)}
className='w-fit gap-1.5 px-0 text-[var(--text-muted)] hover:bg-transparent hover:text-[var(--text-primary)]'
>
<ChevronDown
className={cn(
'size-[14px] transition-transform',
showAdvanced && 'rotate-180'
)}
/>
Advanced Options
</Button>
<Expandable expanded={showAdvanced}>
<ExpandableContent>
<div className='flex flex-col gap-4.5 pt-2'>
<SettingRow label='Audience (Entity ID)' optional>
<ChipInput
type='text'
placeholder='Enter Audience'
value={formData.audience}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('audience', e.target.value)}
/>
</SettingRow>
<SettingRow label='Callback URL Override' optional>
<ChipInput
type='url'
placeholder={`${getBaseUrl()}/api/auth/sso/saml2/callback/provider-id`}
value={formData.callbackUrl}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('callbackUrl', e.target.value)}
/>
</SettingRow>
<SettingRow label='Require signed SAML assertions'>
<Switch
checked={formData.wantAssertionsSigned}
onCheckedChange={(checked) =>
handleInputChange('wantAssertionsSigned', checked)
}
/>
</SettingRow>
<SettingRow label='IDP Metadata XML' optional>
<ChipTextarea
placeholder='Paste IDP metadata XML here'
value={formData.idpMetadata}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
onChange={(e) => handleInputChange('idpMetadata', e.target.value)}
className='min-h-[60px] font-mono'
rows={2}
/>
</SettingRow>
</div>
</ExpandableContent>
</Expandable>
</div>
</>
)}
<SettingRow label='Callback URL'>
<ChipCopyInput value={callbackUrl} copyLabel='Copy callback URL' />
<p className='text-[var(--text-muted)] text-small'>
Configure this in your identity provider
</p>
</SettingRow>
</div>
</SettingsSection>
</SettingsPanel>
</form>
)
@@ -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 (
<div className='flex flex-col gap-1'>
<span className='text-[var(--text-muted)] text-caption'>{label}</span>
<ChipCopyInput value={value} copyLabel={`Copy ${label}`} inputClassName='font-mono' />
{hint ? <span className='text-[var(--text-muted)] text-caption'>{hint}</span> : null}
</div>
)
}
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 (
<div className='flex flex-col gap-3 rounded-lg border border-[var(--border-1)] p-3'>
<div className='flex items-center justify-between gap-2'>
<span className='truncate text-[var(--text-body)] text-sm'>{domain.domain}</span>
<div className='flex items-center gap-2'>
<ChipTag variant={domain.status === 'verified' ? 'mono' : 'gray'}>
{domain.status === 'verified' ? 'Verified' : 'Pending'}
</ChipTag>
<RowActionsMenu
label={`${domain.domain} actions`}
actions={[{ label: 'Remove', onSelect: () => onRemove(domain), destructive: true }]}
/>
</div>
</div>
<div className='flex flex-col gap-3'>
<SettingsResourceRow
icon={<Link />}
title={domain.domain}
description={isVerified ? 'Ownership verified' : 'Awaiting DNS verification'}
trailing={
<div className='flex items-center gap-2'>
<ChipTag variant={isVerified ? 'mono' : 'gray'}>
{isVerified ? 'Verified' : 'Pending'}
</ChipTag>
<RowActionsMenu
label={`${domain.domain} actions`}
actions={[{ label: 'Remove', onSelect: () => onRemove(domain), destructive: true }]}
/>
</div>
}
/>
{domain.status === 'pending' && domain.txtRecordValue && (
<div className='flex flex-col gap-3'>
<p className='text-[var(--text-muted)] text-caption'>
Add this TXT record at your DNS provider, then verify. DNS changes can take up to 48
hours to propagate.
</p>
<CopyField
{/* pl-[46px] indents past SettingsResourceRow's icon gutter (size-9 tile + gap-2.5). */}
{!isVerified && domain.txtRecordValue && (
<div className='flex flex-col gap-3 pl-[46px]'>
<SettingRow
label='Host / name'
value={domain.challengeHost}
hint='Some DNS providers append your zone automatically. If yours does, enter this host with the trailing zone removed.'
/>
<CopyField label='Value' value={domain.txtRecordValue} />
description='Some DNS providers append your zone automatically. If yours does, enter this host with the trailing zone removed.'
>
<ChipCopyInput
value={domain.challengeHost}
copyLabel='Copy host'
inputClassName='font-mono'
/>
</SettingRow>
<SettingRow label='Value'>
<ChipCopyInput
value={domain.txtRecordValue}
copyLabel='Copy value'
inputClassName='font-mono'
/>
</SettingRow>
<div>
<Button size='sm' onClick={handleVerify} disabled={verifyDomain.isPending}>
<Chip onClick={handleVerify} disabled={verifyDomain.isPending}>
{verifyDomain.isPending ? 'Checking...' : 'Verify'}
</Button>
</Chip>
</div>
</div>
)}
@@ -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<OrganizationDomain | null>(null)
if (isLoading) {
return (
<SettingsPanel>
<SettingsEmptyState>Loading domains...</SettingsEmptyState>
</SettingsPanel>
)
}
if (data && !data.isEnterprise) {
return (
<SettingsPanel>
<SettingsEmptyState>
Domain verification is available on Enterprise plans only.
</SettingsEmptyState>
</SettingsPanel>
)
}
async function handleAdd() {
const value = newDomain.trim()
if (!value) return
@@ -143,13 +132,12 @@ export function DomainSettings({ organizationId }: DomainSettingsProps) {
return (
<>
<SettingsPanel>
<div className='flex flex-col gap-7'>
<div className='flex flex-col gap-[9px]'>
<p className='text-[var(--text-muted)] text-caption'>
Verify domains your organization owns. A domain must be verified before you can
configure SSO for it.
</p>
<SettingsSection label='Verified domains'>
<div className='flex flex-col gap-4.5'>
<SettingRow
label='Add a domain'
description='Verify a domain your organization owns before configuring SSO for it. Verifying proves you control the domain, so no one else can point it at their identity provider.'
>
<div className='flex items-center gap-2'>
<ChipInput
value={newDomain}
@@ -157,16 +145,22 @@ export function DomainSettings({ organizationId }: DomainSettingsProps) {
placeholder='acme.com'
className='min-w-0 flex-1'
/>
<Button onClick={handleAdd} disabled={addDomain.isPending || !newDomain.trim()}>
<Chip
variant='primary'
onClick={handleAdd}
disabled={addDomain.isPending || !newDomain.trim()}
>
{addDomain.isPending ? 'Adding...' : 'Add domain'}
</Button>
</Chip>
</div>
</div>
</SettingRow>
{domains.length === 0 ? (
{isLoading ? (
<SettingsEmptyState variant='inline'>Loading domains...</SettingsEmptyState>
) : domains.length === 0 ? (
<SettingsEmptyState variant='inline'>No domains yet.</SettingsEmptyState>
) : (
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-4'>
{domains.map((domain) => (
<DomainRow
key={domain.id}
@@ -178,7 +172,7 @@ export function DomainSettings({ organizationId }: DomainSettingsProps) {
</div>
)}
</div>
</SettingsPanel>
</SettingsSection>
<ChipConfirmModal
open={pendingRemoval !== null}
+3 -1
View File
@@ -9,6 +9,8 @@ import {
} from '@/lib/api/contracts/auth'
import { organizationKeys } from '@/hooks/queries/organization'
export const SSO_PROVIDERS_STALE_TIME = 5 * 60 * 1000
/**
* Query key factories for SSO-related queries
*/
@@ -41,7 +43,7 @@ export function useSSOProviders({ enabled = true, organizationId }: UseSSOProvid
return useQuery({
queryKey: ssoKeys.providerList(organizationId),
queryFn: ({ signal }) => fetchSSOProviders(signal, organizationId),
staleTime: 5 * 60 * 1000,
staleTime: SSO_PROVIDERS_STALE_TIME,
enabled,
})
}
@@ -79,7 +79,7 @@ function ColorInput({ label, value, onChange, placeholder = '#000000' }: ColorIn
return (
<div className='flex flex-col gap-1.5'>
<Label className='text-[var(--text-primary)] text-small'>{label}</Label>
<Label>{label}</Label>
<div className={cn(CHIP_FIELD_SHELL, !isValidHex && 'border-[var(--text-error)]')}>
<div
className={cn(
@@ -336,7 +336,9 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe
type='button'
onClick={logoUpload.handleThumbnailClick}
disabled={logoUpload.isUploading}
className='group relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
aria-label={logoUpload.previewUrl ? 'Change logo' : 'Upload logo'}
title={logoUpload.previewUrl ? 'Change logo' : 'Upload logo'}
className='group relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{logoUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
@@ -353,27 +355,17 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe
)}
</button>
</DropZone>
<div className='flex gap-2'>
{logoUpload.previewUrl && (
<Button
variant='outline'
variant='ghost'
size='sm'
onClick={logoUpload.handleThumbnailClick}
disabled={logoUpload.isUploading}
className='text-small'
onClick={logoUpload.handleRemove}
aria-label='Remove logo'
className='text-[var(--text-muted)] text-small hover:text-[var(--text-primary)]'
>
{logoUpload.previewUrl ? 'Change' : 'Upload'}
<X className='size-[14px]' />
</Button>
{logoUpload.previewUrl && (
<Button
variant='ghost'
size='sm'
onClick={logoUpload.handleRemove}
className='text-[var(--text-muted)] text-small hover:text-[var(--text-primary)]'
>
<X className='size-[14px]' />
</Button>
)}
</div>
)}
<input
ref={logoUpload.fileInputRef}
type='file'
@@ -394,7 +386,9 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe
type='button'
onClick={wordmarkUpload.handleThumbnailClick}
disabled={wordmarkUpload.isUploading}
className='group relative flex h-16 w-full items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
aria-label={wordmarkUpload.previewUrl ? 'Change wordmark' : 'Upload wordmark'}
title={wordmarkUpload.previewUrl ? 'Change wordmark' : 'Upload wordmark'}
className='group relative flex h-16 w-full items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{wordmarkUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
@@ -411,27 +405,17 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe
)}
</button>
</DropZone>
<div className='flex gap-2'>
{wordmarkUpload.previewUrl && (
<Button
variant='outline'
variant='ghost'
size='sm'
onClick={wordmarkUpload.handleThumbnailClick}
disabled={wordmarkUpload.isUploading}
className='text-small'
onClick={wordmarkUpload.handleRemove}
aria-label='Remove wordmark'
className='text-[var(--text-muted)] text-small hover:text-[var(--text-primary)]'
>
{wordmarkUpload.previewUrl ? 'Change' : 'Upload'}
<X className='size-[14px]' />
</Button>
{wordmarkUpload.previewUrl && (
<Button
variant='ghost'
size='sm'
onClick={wordmarkUpload.handleRemove}
className='text-[var(--text-muted)] text-small hover:text-[var(--text-primary)]'
>
<X className='size-[14px]' />
</Button>
)}
</div>
)}
<input
ref={wordmarkUpload.fileInputRef}
type='file'
@@ -9,6 +9,8 @@ import {
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
import { organizationKeys } from '@/hooks/queries/organization'
export const WHITELABEL_STALE_TIME = 60 * 1000
/** PUT payload — string fields accept null to clear a previously-set value. */
export type WhitelabelSettingsPayload = {
[K in keyof OrganizationWhitelabelSettings]: OrganizationWhitelabelSettings[K] extends
@@ -46,7 +48,7 @@ export function useWhitelabelSettings(orgId: string | undefined) {
queryKey: whitelabelKeys.settings(orgId ?? ''),
queryFn: ({ signal }) => fetchWhitelabelSettings(orgId as string, signal),
enabled: Boolean(orgId),
staleTime: 60 * 1000,
staleTime: WHITELABEL_STALE_TIME,
})
}
@@ -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' ? (
@@ -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