mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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=…`).
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
() => ({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
+72
-78
@@ -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}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user