@@ -71,6 +71,102 @@ const SAML_NAMEID_FORMATS = [
7171
7272const PROVIDER_ID_SUGGESTIONS = SSO_TRUSTED_PROVIDERS . map ( ( id ) => ( { label : id , value : id } ) )
7373
74+ const CLIENT_SECRET_FIELD_ID = 'sso-client-secret'
75+ /** Fixed width, so the mask never leaks how long the stored secret is. */
76+ const CLIENT_SECRET_MASK = '••••••••••••'
77+
78+ interface ClientSecretFieldProps {
79+ /** A secret is already saved, so the field opens as a masked fact rather than an input. */
80+ hasStoredSecret : boolean
81+ /** Last four characters of the saved secret, when the API judged it safe to hint. */
82+ storedHint : string | null
83+ isReplacing : boolean
84+ onReplace : ( ) => void
85+ onCancelReplace : ( ) => void
86+ value : string
87+ onChange : ( value : string ) => void
88+ hasError : boolean
89+ }
90+
91+ /**
92+ * A saved client secret is a fact, not an editable value — the browser never
93+ * receives it. Rendering it as a static masked row with an explicit Replace
94+ * action avoids the "will blank clear it?" ambiguity an empty input invites, and
95+ * keeps a stray keystroke from arming a replacement.
96+ */
97+ function ClientSecretField ( {
98+ hasStoredSecret,
99+ storedHint,
100+ isReplacing,
101+ onReplace,
102+ onCancelReplace,
103+ value,
104+ onChange,
105+ hasError,
106+ } : ClientSecretFieldProps ) {
107+ const [ isRevealed , setIsRevealed ] = useState ( false )
108+
109+ if ( hasStoredSecret && ! isReplacing ) {
110+ return (
111+ < div className = 'flex items-center gap-2' >
112+ < ChipInput
113+ id = { CLIENT_SECRET_FIELD_ID }
114+ readOnly
115+ value = { storedHint ? `${ CLIENT_SECRET_MASK } ${ storedHint } ` : CLIENT_SECRET_MASK }
116+ inputClassName = 'cursor-default font-mono'
117+ className = 'min-w-0 flex-1'
118+ aria-label = {
119+ storedHint ? `Saved client secret ending ${ storedHint } ` : 'Saved client secret'
120+ }
121+ />
122+ < Chip onClick = { onReplace } > Replace</ Chip >
123+ </ div >
124+ )
125+ }
126+
127+ return (
128+ < div className = 'flex items-center gap-2' >
129+ < ChipInput
130+ id = { CLIENT_SECRET_FIELD_ID }
131+ type = 'text'
132+ placeholder = 'Enter Client Secret'
133+ className = 'min-w-0 flex-1'
134+ value = { value }
135+ name = 'sso_client_key'
136+ autoComplete = 'off'
137+ autoCapitalize = 'none'
138+ spellCheck = { false }
139+ readOnly
140+ // Kept from the original field: opening read-only and dropping the
141+ // attribute on focus is what stops password managers autofilling here.
142+ onFocus = { ( e ) => {
143+ e . target . removeAttribute ( 'readOnly' )
144+ setIsRevealed ( true )
145+ } }
146+ onBlurCapture = { ( ) => setIsRevealed ( false ) }
147+ onChange = { ( e ) => onChange ( e . target . value ) }
148+ inputClassName = { ! isRevealed ? '[-webkit-text-security:disc]' : undefined }
149+ error = { hasError }
150+ endAdornment = {
151+ // Only offer the reveal once there is something to reveal.
152+ value ? (
153+ < Button
154+ type = 'button'
155+ variant = 'ghost'
156+ onClick = { ( ) => setIsRevealed ( ( s ) => ! s ) }
157+ className = 'size-6 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
158+ aria-label = { isRevealed ? 'Hide client secret' : 'Show client secret' }
159+ >
160+ { isRevealed ? < EyeOff className = 'size-[14px]' /> : < Eye className = 'size-[14px]' /> }
161+ </ Button >
162+ ) : undefined
163+ }
164+ />
165+ { hasStoredSecret && < Chip onClick = { onCancelReplace } > Cancel</ Chip > }
166+ </ div >
167+ )
168+ }
169+
74170/** Reads the display-only hint the API attaches beside the redacted client secret. */
75171function readClientSecretHint ( oidcConfig ?: string ) : string | null {
76172 if ( ! oidcConfig ) return null
@@ -147,7 +243,6 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
147243
148244 const configureSSOMutation = useConfigureSSO ( )
149245
150- const [ showClientSecret , setShowClientSecret ] = useState ( false )
151246 const [ isEditing , setIsEditing ] = useState ( false )
152247 const [ showAdvanced , setShowAdvanced ] = useState ( false )
153248 const [ showMapping , setShowMapping ] = useState ( false )
@@ -704,6 +799,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
704799
705800 < SettingRow
706801 label = 'Client Secret'
802+ htmlFor = { CLIENT_SECRET_FIELD_ID }
707803 description = {
708804 isReplacingClientSecret ? 'Replaces the saved secret when you save.' : undefined
709805 }
@@ -713,89 +809,19 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
713809 : undefined
714810 }
715811 >
716- { hasStoredClientSecret && ! isReplacingClientSecret ? (
717- // A saved secret is a fact, not an editable value — the browser
718- // never receives it. Showing it as a static row with an explicit
719- // Replace action removes the "is blank going to clear it?" question
720- // an empty input invites, and stops a stray keystroke from arming
721- // a replacement.
722- < div className = 'flex items-center gap-2' >
723- < ChipInput
724- id = 'sso-client-secret'
725- readOnly
726- value = {
727- storedClientSecretHint
728- ? `••••••••••••${ storedClientSecretHint } `
729- : '••••••••••••'
730- }
731- inputClassName = 'cursor-default font-mono'
732- className = 'min-w-0 flex-1'
733- aria-label = {
734- storedClientSecretHint
735- ? `Saved client secret ending ${ storedClientSecretHint } `
736- : 'Saved client secret'
737- }
738- />
739- < Chip onClick = { ( ) => setIsReplacingClientSecret ( true ) } > Replace</ Chip >
740- </ div >
741- ) : (
742- < div className = 'flex items-center gap-2' >
743- < ChipInput
744- id = 'sso-client-secret'
745- type = 'text'
746- placeholder = 'Enter Client Secret'
747- className = 'min-w-0 flex-1'
748- value = { formData . clientSecret }
749- name = 'sso_client_key'
750- autoComplete = 'off'
751- autoCapitalize = 'none'
752- spellCheck = { false }
753- readOnly
754- onFocus = { ( e ) => {
755- e . target . removeAttribute ( 'readOnly' )
756- setShowClientSecret ( true )
757- } }
758- onBlurCapture = { ( ) => setShowClientSecret ( false ) }
759- onChange = { ( e ) => handleInputChange ( 'clientSecret' , e . target . value ) }
760- inputClassName = {
761- ! showClientSecret ? '[-webkit-text-security:disc]' : undefined
762- }
763- error = { showErrors && errors . clientSecret . length > 0 }
764- endAdornment = {
765- // Only offer the reveal once there is something to reveal. The
766- // stored secret is never sent to the browser, so on an untouched
767- // edit the toggle would be a control that visibly does nothing.
768- formData . clientSecret ? (
769- < Button
770- type = 'button'
771- variant = 'ghost'
772- onClick = { ( ) => setShowClientSecret ( ( s ) => ! s ) }
773- className = 'size-6 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
774- aria-label = {
775- showClientSecret ? 'Hide client secret' : 'Show client secret'
776- }
777- >
778- { showClientSecret ? (
779- < EyeOff className = 'size-[14px]' />
780- ) : (
781- < Eye className = 'size-[14px]' />
782- ) }
783- </ Button >
784- ) : undefined
785- }
786- />
787- { hasStoredClientSecret && (
788- < Chip
789- onClick = { ( ) => {
790- setIsReplacingClientSecret ( false )
791- handleInputChange ( 'clientSecret' , '' )
792- } }
793- >
794- Cancel
795- </ Chip >
796- ) }
797- </ div >
798- ) }
812+ < ClientSecretField
813+ hasStoredSecret = { hasStoredClientSecret }
814+ storedHint = { storedClientSecretHint }
815+ isReplacing = { isReplacingClientSecret }
816+ onReplace = { ( ) => setIsReplacingClientSecret ( true ) }
817+ onCancelReplace = { ( ) => {
818+ setIsReplacingClientSecret ( false )
819+ handleInputChange ( 'clientSecret' , '' )
820+ } }
821+ value = { formData . clientSecret }
822+ onChange = { ( next ) => handleInputChange ( 'clientSecret' , next ) }
823+ hasError = { showErrors && errors . clientSecret . length > 0 }
824+ />
799825 </ SettingRow >
800826
801827 < div className = 'flex flex-col gap-2' >
0 commit comments