Skip to content

Commit 0e23d50

Browse files
committed
fix(identity): reuse settings controls and tighten SCIM projections
1 parent 4ea7b39 commit 0e23d50

20 files changed

Lines changed: 350 additions & 158 deletions

File tree

‎apps/docs/content/docs/platform/enterprise/scim.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ Mapping a permission group to a directory group switches that permission group t
137137

138138
</Steps>
139139

140-
<Image src="/static/enterprise/scim-provisioning.png" alt="Provisioning tab with an active SCIM connection, tokens, provisioning rules, group mappings, and recent activity" width={832} height={1314} />
140+
<Image src="/static/enterprise/scim-provisioning.png" alt="Provisioning tab with an active SCIM connection, tokens, provisioning rules, group mappings, and recent activity" width={832} height={1284} />
141141

142142
## How access is withdrawn
143143

@@ -149,7 +149,7 @@ With locking off, access held before the directory mapping is preserved. If some
149149

150150
## Provisioning and SSO together
151151

152-
A member the directory created can sign in with SSO immediately; the two resolve to the same account through your verified domain.
152+
An active provisioned member can sign in once SSO is configured for their verified domain. Provisioning and SSO resolve to the same account.
153153

154154
To stop SSO from creating organization memberships, open **Provisioning rules → Manage rules** and enable **Disable just-in-time provisioning**. While the SCIM connection is active and entitled, this overrides **Sign-in → First sign-in → Automatic**. Existing organization members can still sign in, including members added by invitation; the setting does not require every existing member to have been provisioned by SCIM.
155155

‎apps/docs/content/docs/platform/enterprise/sso.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Use **Domains** to verify ownership, then return to **Sign-in** to configure you
7979

8080
### 4. Copy the callback URL
8181

82-
Copy **Callback URL** for OIDC or **ACS URL (Reply URL)** for SAML. This is the endpoint that receives your identity provider's authentication response. Register it in your IdP before saving.
82+
Copy **Callback URL** for OIDC or **ACS URL (Reply URL)** for SAML. This is the endpoint that receives your identity provider's authentication response. Register it in your IdP before saving. If you set a SAML **Callback URL override** under Advanced options, the copyable ACS URL uses that override.
8383

8484
**OIDC providers** (Okta, Microsoft Entra ID, Google Workspace, Auth0):
8585
```

‎apps/docs/content/docs/platform/enterprise/verified-domains.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Verified Domains let organization owners and admins on Enterprise plans prove th
1919

2020
Go to **Settings → Organization → Single sign-on → Domains**. The **Verified domains** section is shared by sign-in and directory provisioning.
2121

22-
<Image src="/static/enterprise/sso-domains.png" loading="eager" alt="Domains tab showing a verified domain and the DNS record for a pending domain" width={832} height={659} />
22+
<Image src="/static/enterprise/sso-domains.png" loading="eager" alt="Domains tab showing a verified domain and the DNS record for a pending domain" width={832} height={663} />
2323

2424
1. Enter the domain, for example `acme.com`, and click **Add domain**.
2525
2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`).
-1.36 KB
Loading
-102 Bytes
Loading
-1.09 KB
Loading

‎apps/sim/ee/scim/components/options.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,18 @@ export const SETTING_TOGGLES = [
2222
key: 'lockManualMembership',
2323
label: 'Lock managed membership',
2424
description:
25-
'Refuse invitations, role changes, and manual grants for members the directory provisions. The next sync would revert them anyway.',
25+
'Prevent manual invitations, role changes, and access grants for provisioned members.',
2626
},
2727
{
2828
key: 'disableJit',
2929
label: 'Disable just-in-time provisioning',
3030
description:
31-
'Prevent SSO from creating new organization memberships. Existing members can still sign in, including members added by invitation.',
31+
'Prevent SSO from adding new organization members. Existing members can still sign in.',
3232
},
3333
{
3434
key: 'autoMapPermissionGroupsByName',
3535
label: 'Match permission groups by name',
36-
description:
37-
'When a pushed group has the same name as one of your permission groups, map them automatically. Nothing is created.',
36+
description: 'Map directory groups to existing permission groups with the same name.',
3837
},
3938
] as const
4039

‎apps/sim/ee/scim/components/scim-section.tsx‎

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ChipTag,
1515
Expandable,
1616
ExpandableContent,
17+
Label,
1718
Switch,
1819
toast,
1920
} from '@sim/emcn'
@@ -32,7 +33,10 @@ import {
3233
SettingsEmptyState,
3334
SettingsQueryErrorState,
3435
} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
35-
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
36+
import {
37+
RESOURCE_LIST_STACK,
38+
SettingsResourceRow,
39+
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
3640
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
3741
import {
3842
useOrganizationWorkspaces,
@@ -107,8 +111,7 @@ function CredentialRow({ credential, onRevoke }: CredentialRowProps) {
107111
: 'no expiry'
108112
return (
109113
<SettingsResourceRow
110-
icon={<Key />}
111-
title={<span className='font-mono'>{credential.tokenPrefix}…</span>}
114+
title={`${credential.tokenPrefix}…`}
112115
description={`Last used ${formatRelative(credential.lastUsedAt)} · ${expiry}`}
113116
trailing={
114117
<RowActionsMenu
@@ -268,7 +271,7 @@ function GroupMappings({ organizationId, active }: GroupMappingsProps) {
268271
title={group.displayName}
269272
description={`${group.memberCount} member${group.memberCount === 1 ? '' : 's'}`}
270273
/>
271-
<div className='flex flex-col gap-3 pl-[46px]'>
274+
<div className='flex flex-col gap-3'>
272275
{group.mappings.length > 0 && (
273276
<div className='flex flex-wrap gap-1.5'>
274277
{group.mappings.map((mapping) => (
@@ -341,7 +344,7 @@ function ActivityList({ organizationId, active }: ActivityListProps) {
341344
<ChipTag variant={failed ? 'gray' : 'mono'} invalid={failed}>
342345
{entry.status}
343346
</ChipTag>
344-
<span className='truncate font-mono text-[var(--text-primary)]'>
347+
<span className='truncate text-[var(--text-body)]'>
345348
{entry.method} {entry.path}
346349
</span>
347350
<span className='ml-auto shrink-0 text-[var(--text-muted)]'>
@@ -439,7 +442,6 @@ function ConnectionDetails({ organizationId, connection, active }: ConnectionDet
439442
id='scim-base-url'
440443
value={connection.baseUrl}
441444
copyLabel='Copy base URL'
442-
inputClassName='font-mono'
443445
/>
444446
</SettingRow>
445447

@@ -463,13 +465,15 @@ function ConnectionDetails({ organizationId, connection, active }: ConnectionDet
463465
{connection.credentials.length === 0 ? (
464466
<SettingsEmptyState variant='inline'>No tokens yet.</SettingsEmptyState>
465467
) : (
466-
connection.credentials.map((credential) => (
467-
<CredentialRow
468-
key={credential.id}
469-
credential={credential}
470-
onRevoke={(credential) => setPendingRevokeId(credential.id)}
471-
/>
472-
))
468+
<div className={RESOURCE_LIST_STACK}>
469+
{connection.credentials.map((credential) => (
470+
<CredentialRow
471+
key={credential.id}
472+
credential={credential}
473+
onRevoke={(credential) => setPendingRevokeId(credential.id)}
474+
/>
475+
))}
476+
</div>
473477
)}
474478
<div className='flex flex-wrap items-center gap-2'>
475479
<ChipSelect
@@ -510,19 +514,18 @@ function ConnectionDetails({ organizationId, connection, active }: ConnectionDet
510514
<ExpandableContent id='scim-rules'>
511515
<div className='flex flex-col gap-4.5 pt-4'>
512516
{SETTING_TOGGLES.map((toggle) => (
513-
<SettingRow
514-
key={toggle.key}
515-
label={toggle.label}
516-
description={toggle.description}
517-
htmlFor={`scim-${toggle.key}`}
518-
>
517+
<div key={toggle.key} className='flex items-center justify-between gap-4'>
518+
<div className='flex flex-col gap-1'>
519+
<Label htmlFor={`scim-${toggle.key}`}>{toggle.label}</Label>
520+
<p className='text-[var(--text-muted)] text-caption'>{toggle.description}</p>
521+
</div>
519522
<Switch
520523
id={`scim-${toggle.key}`}
521524
checked={connection.settings[toggle.key] ?? false}
522525
onCheckedChange={(checked) => void handleToggleSetting(toggle.key, checked)}
523526
disabled={configure.isPending}
524527
/>
525-
</SettingRow>
528+
</div>
526529
))}
527530
</div>
528531
</ExpandableContent>
@@ -563,17 +566,12 @@ function ConnectionDetails({ organizationId, connection, active }: ConnectionDet
563566
</ChipModalHeader>
564567
<ChipModalBody>
565568
<ChipModalField
566-
type='custom'
569+
type='copy'
567570
title='Token'
571+
value={issuedSecret ?? ''}
572+
copyLabel='Copy token'
568573
hint='Copy it into your identity provider now. Sim stores only a digest and cannot show it again.'
569-
>
570-
<ChipCopyInput
571-
aria-label='Token'
572-
value={issuedSecret ?? ''}
573-
copyLabel='Copy token'
574-
inputClassName='font-mono'
575-
/>
576-
</ChipModalField>
574+
/>
577575
</ChipModalBody>
578576
<ChipModalFooter
579577
onCancel={() => setIssuedSecret(null)}
@@ -646,18 +644,20 @@ export function ScimSection({ organizationId, onOpenDomains, active }: ScimSecti
646644
<div className='flex flex-col gap-7'>
647645
<SettingsSection label='Directory provisioning'>
648646
<div className='flex flex-col gap-4.5'>
649-
<SettingRow
650-
label='Enable directory provisioning'
651-
description='Create, update, and deactivate members from your identity provider with SCIM 2.0.'
652-
htmlFor='scim-enabled'
653-
>
647+
<div className='flex items-center justify-between gap-4'>
648+
<div className='flex flex-col gap-1'>
649+
<Label htmlFor='scim-enabled'>Enable directory provisioning</Label>
650+
<p className='text-[var(--text-muted)] text-caption'>
651+
Create, update, and deactivate members from your identity provider with SCIM 2.0.
652+
</p>
653+
</div>
654654
<Switch
655655
id='scim-enabled'
656656
checked={enabled}
657657
onCheckedChange={(checked) => void handleToggleEnabled(checked)}
658658
disabled={isLoading || configure.isPending}
659659
/>
660-
</SettingRow>
660+
</div>
661661

662662
{!enabled && (
663663
<div className='flex flex-wrap items-center justify-between gap-2'>

‎apps/sim/ee/scim/lib/protocol/resources.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,71 @@ describe('attribute projection', () => {
169169
expect(scimUserResourceSchema.safeParse(projected).success).toBe(true)
170170
})
171171

172+
it.each([
173+
'userName.foo',
174+
'active.foo',
175+
'name.givenName.foo',
176+
'emails.value.foo',
177+
'groups.value.foo',
178+
'urn:ietf:params:scim:schemas:core:2.0:User:USERNAME.foo',
179+
'urn:okta:sim:2.0:user:custom:costCenter.foo',
180+
'urn:okta:sim:2.0:user:custom:tags.foo',
181+
])('does not return scalar values for a nonexistent descendant %s', (attributes) => {
182+
const base = userRow()
183+
const resource = toUserResource(
184+
{
185+
...base,
186+
attributes: {
187+
...base.attributes,
188+
extra: { 'urn:okta:sim:2.0:user:custom': { costCenter: 'R&D', tags: ['staff'] } },
189+
},
190+
},
191+
BASE_URL
192+
)
193+
const projected = projectResource(resource, parseAttributeProjection({ attributes }))
194+
expect(Object.keys(projected).sort()).toEqual(['id', 'meta', 'schemas'])
195+
expect(scimUserResourceSchema.safeParse(projected).success).toBe(true)
196+
})
197+
198+
it('omits arrays with no matching sub-attributes while retaining valid selections', () => {
199+
const projected = projectResource(
200+
toUserResource(userRow(), BASE_URL),
201+
parseAttributeProjection({ attributes: 'emails.unknown,name.givenName.foo,name.familyName' })
202+
)
203+
expect(projected).not.toHaveProperty('emails')
204+
expect(projected.name).toEqual({ familyName: 'Lovelace' })
205+
})
206+
207+
it('keeps explicitly selected parents even when nonexistent descendants are also requested', () => {
208+
const resource = toUserResource(userRow(), BASE_URL)
209+
const projected = projectResource(
210+
resource,
211+
parseAttributeProjection({
212+
attributes: 'userName,userName.foo,name,name.givenName.foo,emails,emails.value.foo',
213+
})
214+
)
215+
expect(projected.userName).toBe(resource.userName)
216+
expect(projected.name).toEqual(resource.name)
217+
expect(projected.emails).toEqual(resource.emails)
218+
})
219+
220+
it('ignores exclusions of nonexistent scalar descendants', () => {
221+
const resource = toUserResource(userRow(), BASE_URL)
222+
const projected = projectResource(
223+
resource,
224+
parseAttributeProjection({
225+
excludedAttributes: 'userName.foo,name.givenName.foo,emails.value.foo',
226+
})
227+
)
228+
expect(projected).toEqual(resource)
229+
})
230+
231+
it('keeps an explicitly selected empty multi-valued attribute', () => {
232+
const resource = toUserResource({ ...userRow(), groups: [] }, BASE_URL)
233+
const projected = projectResource(resource, parseAttributeProjection({ attributes: 'groups' }))
234+
expect(projected.groups).toEqual([])
235+
})
236+
172237
it('projects each multi-valued entry and still loads requested group sub-attributes', () => {
173238
const projection = parseAttributeProjection({ attributes: 'emails.value,groups.value' })
174239
const projected = projectResource(toUserResource(userRow(), BASE_URL), projection)

‎apps/sim/ee/scim/lib/protocol/resources.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,11 +286,12 @@ function projectAttribute(
286286
return undefined
287287
}
288288
if (Array.isArray(value)) {
289-
return value
289+
const projected = value
290290
.map((entry) => projectAttribute(entry, path, projection, included))
291291
.filter((entry) => entry !== undefined)
292+
return included || projected.length > 0 ? projected : undefined
292293
}
293-
if (!isRecord(value)) return value
294+
if (!isRecord(value)) return included ? value : undefined
294295
const projected: Record<string, unknown> = {}
295296
for (const [key, nested] of Object.entries(value)) {
296297
const selected = projectAttribute(

0 commit comments

Comments
 (0)