@@ -48,6 +48,18 @@ interface SSOProvider {
4848 providerType : 'oidc' | 'saml'
4949}
5050
51+ /**
52+ * Claim/attribute names each protocol uses out of the box. Kept as the fallback
53+ * rather than seeded into form state so switching protocol needs no reset logic
54+ * and the inputs can show them as placeholders.
55+ */
56+ const OIDC_DEFAULT_MAPPING = { id : 'sub' , email : 'email' , name : 'name' , image : 'picture' } as const
57+ const SAML_DEFAULT_MAPPING = {
58+ id : 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' ,
59+ email : 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' ,
60+ name : 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' ,
61+ } as const
62+
5163const DEFAULT_FORM_DATA = {
5264 providerType : 'oidc' as 'oidc' | 'saml' ,
5365 providerId : '' ,
@@ -62,6 +74,9 @@ const DEFAULT_FORM_DATA = {
6274 audience : '' ,
6375 wantAssertionsSigned : true ,
6476 idpMetadata : '' ,
77+ mapId : '' ,
78+ mapEmail : '' ,
79+ mapName : '' ,
6580}
6681
6782const DEFAULT_ERRORS = {
@@ -109,6 +124,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
109124 const [ showClientSecret , setShowClientSecret ] = useState ( false )
110125 const [ isEditing , setIsEditing ] = useState ( false )
111126 const [ showAdvanced , setShowAdvanced ] = useState ( false )
127+ const [ showMapping , setShowMapping ] = useState ( false )
112128
113129 const [ formData , setFormData ] = useState ( DEFAULT_FORM_DATA )
114130 const [ originalFormData , setOriginalFormData ] = useState ( DEFAULT_FORM_DATA )
@@ -271,10 +287,10 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
271287 domain : formData . domain ,
272288 orgId : organizationId ,
273289 mapping : {
274- id : 'sub' ,
275- email : ' email' ,
276- name : ' name' ,
277- image : 'picture' ,
290+ id : formData . mapId . trim ( ) || OIDC_DEFAULT_MAPPING . id ,
291+ email : formData . mapEmail . trim ( ) || OIDC_DEFAULT_MAPPING . email ,
292+ name : formData . mapName . trim ( ) || OIDC_DEFAULT_MAPPING . name ,
293+ image : OIDC_DEFAULT_MAPPING . image ,
278294 } ,
279295 clientId : formData . clientId ,
280296 clientSecret : formData . clientSecret ,
@@ -287,9 +303,9 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
287303 domain : formData . domain ,
288304 orgId : organizationId ,
289305 mapping : {
290- id : 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' ,
291- email : 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' ,
292- name : 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/ name' ,
306+ id : formData . mapId . trim ( ) || SAML_DEFAULT_MAPPING . id ,
307+ email : formData . mapEmail . trim ( ) || SAML_DEFAULT_MAPPING . email ,
308+ name : formData . mapName . trim ( ) || SAML_DEFAULT_MAPPING . name ,
293309 } ,
294310 entryPoint : formData . entryPoint ,
295311 cert : formData . cert ,
@@ -328,6 +344,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
328344 }
329345
330346 const isSaml = formData . providerType === 'saml'
347+ const mappingDefaults = isSaml ? SAML_DEFAULT_MAPPING : OIDC_DEFAULT_MAPPING
331348 const callbackUrl = `${ getBaseUrl ( ) } /api/auth/${ isSaml ? 'sso/saml2/callback' : 'sso/callback' } /${ formData . providerId || existingProvider ?. providerId || 'provider-id' } `
332349
333350 const handleEdit = ( ) => {
@@ -343,12 +360,17 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
343360 let audience = ''
344361 let wantAssertionsSigned = true
345362 let idpMetadata = ''
363+ // Blank means "use the protocol default", so only carry over a stored value
364+ // that actually differs — otherwise editing would rewrite a default as an
365+ // explicit override, and a stored custom mapping must never silently reset.
366+ let mapping : { id ?: string ; email ?: string ; name ?: string } = { }
346367
347368 if ( existingProvider . providerType === 'oidc' && existingProvider . oidcConfig ) {
348369 const config = JSON . parse ( existingProvider . oidcConfig )
349370 clientId = config . clientId || ''
350371 clientSecret = config . clientSecret || ''
351372 scopes = config . scopes ?. join ( ',' ) || 'openid,profile,email'
373+ mapping = config . mapping ?? { }
352374 } else if ( existingProvider . providerType === 'saml' && existingProvider . samlConfig ) {
353375 const config = JSON . parse ( existingProvider . samlConfig )
354376 entryPoint = config . entryPoint || ''
@@ -357,8 +379,14 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
357379 audience = config . audience || ''
358380 wantAssertionsSigned = config . wantAssertionsSigned ?? true
359381 idpMetadata = config . idpMetadata ?. metadata || config . idpMetadata || ''
382+ mapping = config . mapping ?? { }
360383 }
361384
385+ const defaults =
386+ existingProvider . providerType === 'saml' ? SAML_DEFAULT_MAPPING : OIDC_DEFAULT_MAPPING
387+ const overrideOf = ( value : string | undefined , fallback : string ) =>
388+ value && value !== fallback ? value : ''
389+
362390 const snapshot = {
363391 providerType : existingProvider . providerType ,
364392 providerId : existingProvider . providerId ,
@@ -373,12 +401,16 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
373401 audience,
374402 wantAssertionsSigned,
375403 idpMetadata,
404+ mapId : overrideOf ( mapping . id , defaults . id ) ,
405+ mapEmail : overrideOf ( mapping . email , defaults . email ) ,
406+ mapName : overrideOf ( mapping . name , defaults . name ) ,
376407 }
377408 setFormData ( snapshot )
378409 setOriginalFormData ( snapshot )
379410 setIsEditing ( true )
380411 setShowErrors ( false )
381412 setShowAdvanced ( false )
413+ setShowMapping ( Boolean ( snapshot . mapId || snapshot . mapEmail || snapshot . mapName ) )
382414 } catch ( err ) {
383415 logger . error ( 'Failed to parse provider config' , { error : err } )
384416 toast . error ( 'Failed to load provider configuration' )
@@ -775,6 +807,72 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
775807 Configure this in your identity provider
776808 </ p >
777809 </ SettingRow >
810+
811+ { /* Identity providers vary in which claim carries each value — Entra,
812+ for instance, can send the address as `upn` rather than `email`.
813+ Leaving a field blank uses the protocol default shown as its
814+ placeholder, so the common case needs no input at all. */ }
815+ < div className = 'flex flex-col gap-2' >
816+ < Button
817+ type = 'button'
818+ variant = 'ghost'
819+ onClick = { ( ) => setShowMapping ( ( v ) => ! v ) }
820+ className = 'w-fit gap-1.5 px-0 text-[var(--text-muted)] hover:bg-transparent hover:text-[var(--text-primary)]'
821+ >
822+ < ChevronDown
823+ className = { cn ( 'size-[14px] transition-transform' , showMapping && 'rotate-180' ) }
824+ />
825+ Attribute mapping
826+ </ Button >
827+
828+ < Expandable expanded = { showMapping } >
829+ < ExpandableContent >
830+ < div className = 'flex flex-col gap-4.5 pt-2' >
831+ < SettingRow label = 'Email attribute' optional >
832+ < ChipInput
833+ type = 'text'
834+ placeholder = { mappingDefaults . email }
835+ value = { formData . mapEmail }
836+ autoComplete = 'off'
837+ autoCapitalize = 'none'
838+ spellCheck = { false }
839+ inputClassName = 'font-mono'
840+ onChange = { ( e ) => handleInputChange ( 'mapEmail' , e . target . value ) }
841+ />
842+ </ SettingRow >
843+
844+ < SettingRow label = 'Name attribute' optional >
845+ < ChipInput
846+ type = 'text'
847+ placeholder = { mappingDefaults . name }
848+ value = { formData . mapName }
849+ autoComplete = 'off'
850+ autoCapitalize = 'none'
851+ spellCheck = { false }
852+ inputClassName = 'font-mono'
853+ onChange = { ( e ) => handleInputChange ( 'mapName' , e . target . value ) }
854+ />
855+ </ SettingRow >
856+
857+ < SettingRow label = 'User ID attribute' optional >
858+ < ChipInput
859+ type = 'text'
860+ placeholder = { mappingDefaults . id }
861+ value = { formData . mapId }
862+ autoComplete = 'off'
863+ autoCapitalize = 'none'
864+ spellCheck = { false }
865+ inputClassName = 'font-mono'
866+ onChange = { ( e ) => handleInputChange ( 'mapId' , e . target . value ) }
867+ />
868+ < p className = 'text-[var(--text-muted)] text-small' >
869+ Must be stable and unique per user — changing it later re-links accounts.
870+ </ p >
871+ </ SettingRow >
872+ </ div >
873+ </ ExpandableContent >
874+ </ Expandable >
875+ </ div >
778876 </ div >
779877 </ SettingsSection >
780878 </ SettingsPanel >
0 commit comments