Skip to content

Commit f0ca0fd

Browse files
committed
refactor(sso): extract the client secret field and give it its own reveal state
1 parent cfc8f27 commit f0ca0fd

1 file changed

Lines changed: 110 additions & 84 deletions

File tree

apps/sim/ee/sso/components/sso-settings.tsx

Lines changed: 110 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,102 @@ const SAML_NAMEID_FORMATS = [
7171

7272
const 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. */
75171
function 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

Comments
 (0)