|
3 | 3 | import { useState } from 'react' |
4 | 4 | import { |
5 | 5 | Button, |
| 6 | + Chip, |
6 | 7 | ChipCombobox, |
7 | 8 | ChipCopyInput, |
8 | 9 | ChipInput, |
@@ -70,6 +71,17 @@ const SAML_NAMEID_FORMATS = [ |
70 | 71 |
|
71 | 72 | const PROVIDER_ID_SUGGESTIONS = SSO_TRUSTED_PROVIDERS.map((id) => ({ label: id, value: id })) |
72 | 73 |
|
| 74 | +/** Reads the display-only hint the API attaches beside the redacted client secret. */ |
| 75 | +function readClientSecretHint(oidcConfig?: string): string | null { |
| 76 | + if (!oidcConfig) return null |
| 77 | + try { |
| 78 | + const hint = JSON.parse(oidcConfig).clientSecretHint |
| 79 | + return typeof hint === 'string' ? hint : null |
| 80 | + } catch { |
| 81 | + return null |
| 82 | + } |
| 83 | +} |
| 84 | + |
73 | 85 | const DEFAULT_FORM_DATA = { |
74 | 86 | providerType: 'oidc' as 'oidc' | 'saml', |
75 | 87 | providerId: '', |
@@ -145,12 +157,18 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { |
145 | 157 | const [errors, setErrors] = useState<Record<string, string[]>>(DEFAULT_ERRORS) |
146 | 158 | const [showErrors, setShowErrors] = useState(false) |
147 | 159 |
|
| 160 | + const [isReplacingClientSecret, setIsReplacingClientSecret] = useState(false) |
| 161 | + |
148 | 162 | /** |
149 | 163 | * Editing an OIDC provider always means a secret is stored — the contract |
150 | 164 | * requires one to register, and the API returns only its sentinel, never the |
151 | 165 | * value. Leaving the field blank therefore means "keep it", not "clear it". |
152 | 166 | */ |
153 | 167 | const hasStoredClientSecret = isEditing && existingProvider?.providerType === 'oidc' |
| 168 | + /** Last four characters of the saved secret, when the API judged it safe to hint. */ |
| 169 | + const storedClientSecretHint = hasStoredClientSecret |
| 170 | + ? readClientSecretHint(existingProvider?.oidcConfig) |
| 171 | + : null |
154 | 172 |
|
155 | 173 | const hasChanges = (Object.keys(formData) as (keyof typeof formData)[]).some( |
156 | 174 | (k) => formData[k] !== originalFormData[k] |
@@ -263,6 +281,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { |
263 | 281 | setErrors(DEFAULT_ERRORS) |
264 | 282 | setShowErrors(false) |
265 | 283 | setShowAdvanced(false) |
| 284 | + setIsReplacingClientSecret(false) |
266 | 285 | } |
267 | 286 |
|
268 | 287 | const handleSubmit = async (e?: React.FormEvent) => { |
@@ -339,6 +358,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { |
339 | 358 | setShowErrors(false) |
340 | 359 | setIsEditing(false) |
341 | 360 | setShowAdvanced(false) |
| 361 | + setIsReplacingClientSecret(false) |
342 | 362 | } catch (err) { |
343 | 363 | const message = getErrorMessage(err, 'Unknown error occurred') |
344 | 364 | toast.error(message) |
@@ -447,6 +467,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { |
447 | 467 | setIsEditing(true) |
448 | 468 | setShowErrors(false) |
449 | 469 | setShowAdvanced(false) |
| 470 | + setIsReplacingClientSecret(false) |
450 | 471 | setShowMapping(Boolean(snapshot.mapId || snapshot.mapEmail || snapshot.mapName)) |
451 | 472 | } catch (err) { |
452 | 473 | logger.error('Failed to parse provider config', { error: err }) |
@@ -684,57 +705,97 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { |
684 | 705 | <SettingRow |
685 | 706 | label='Client Secret' |
686 | 707 | description={ |
687 | | - hasStoredClientSecret |
688 | | - ? 'Your saved secret is never shown again. Leave this blank to keep it, or type a new one to replace it.' |
689 | | - : undefined |
| 708 | + isReplacingClientSecret ? 'Replaces the saved secret when you save.' : undefined |
690 | 709 | } |
691 | 710 | error={ |
692 | 711 | showErrors && errors.clientSecret.length > 0 |
693 | 712 | ? errors.clientSecret.join(' ') |
694 | 713 | : undefined |
695 | 714 | } |
696 | 715 | > |
697 | | - <ChipInput |
698 | | - id='sso-client-secret' |
699 | | - type='text' |
700 | | - placeholder={hasStoredClientSecret ? '••••••••••••' : 'Enter Client Secret'} |
701 | | - value={formData.clientSecret} |
702 | | - name='sso_client_key' |
703 | | - autoComplete='off' |
704 | | - autoCapitalize='none' |
705 | | - spellCheck={false} |
706 | | - readOnly |
707 | | - onFocus={(e) => { |
708 | | - e.target.removeAttribute('readOnly') |
709 | | - setShowClientSecret(true) |
710 | | - }} |
711 | | - onBlurCapture={() => setShowClientSecret(false)} |
712 | | - onChange={(e) => handleInputChange('clientSecret', e.target.value)} |
713 | | - inputClassName={!showClientSecret ? '[-webkit-text-security:disc]' : undefined} |
714 | | - error={showErrors && errors.clientSecret.length > 0} |
715 | | - endAdornment={ |
716 | | - // Only offer the reveal once there is something to reveal. The |
717 | | - // stored secret is never sent to the browser, so on an untouched |
718 | | - // edit the toggle would be a control that visibly does nothing. |
719 | | - formData.clientSecret ? ( |
720 | | - <Button |
721 | | - type='button' |
722 | | - variant='ghost' |
723 | | - onClick={() => setShowClientSecret((s) => !s)} |
724 | | - className='size-6 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]' |
725 | | - aria-label={ |
726 | | - showClientSecret ? 'Hide client secret' : 'Show client secret' |
727 | | - } |
| 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 | + }} |
728 | 793 | > |
729 | | - {showClientSecret ? ( |
730 | | - <EyeOff className='size-[14px]' /> |
731 | | - ) : ( |
732 | | - <Eye className='size-[14px]' /> |
733 | | - )} |
734 | | - </Button> |
735 | | - ) : undefined |
736 | | - } |
737 | | - /> |
| 794 | + Cancel |
| 795 | + </Chip> |
| 796 | + )} |
| 797 | + </div> |
| 798 | + )} |
738 | 799 | </SettingRow> |
739 | 800 |
|
740 | 801 | <div className='flex flex-col gap-2'> |
|
0 commit comments