diff --git a/.changeset/forty-cameras-guess.md b/.changeset/forty-cameras-guess.md new file mode 100644 index 00000000000..d77b4f04913 --- /dev/null +++ b/.changeset/forty-cameras-guess.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': patch +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Add confirmation step for `<__experimental_ConfigureSSO />` diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 92cc7bd8af8..192e5370cfb 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -201,6 +201,33 @@ export const enUS: LocalizationResource = { yearPerUnit: 'Year per {{unitName}}', }, configureSSO: { + confirmation: { + configurationSection: { + certificateLabel: 'Certificate', + configureAgainLink: 'Configure again', + issuerLabel: 'Issuer', + ssoUrlLabel: 'Sign on URL', + title: 'Configuration details', + }, + domainSection: { + title: 'Domain', + }, + enableSection: { + title: 'Enable SSO', + }, + resetSection: { + confirmationFieldLabel: 'Type "{{name}}" to confirm', + submitButton: 'Reset connection', + title: 'Reset connection', + warning: + 'This will permanently remove the SSO configuration. Members will no longer be able to sign in with SSO.', + }, + statusSection: { + activeBadge: 'Active', + inactiveBadge: 'Inactive', + title: 'SSO Status', + }, + }, missingManageEnterpriseConnectionsPermission: { subtitle: 'Contact your organization administrator in order to have permissions to manage enterprise connections.', diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 4d2d9ab72f2..1c8f2e9aa45 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -48,7 +48,13 @@ export type ProfileSectionId = | 'organizationDomains' | 'manageVerifiedDomains' | 'subscriptionsList' - | 'paymentMethods'; + | 'paymentMethods' + | 'ssoStatus' + | 'enableSso' + | 'ssoDomain' + | 'ssoConfiguration' + | 'configureAgain' + | 'resetSso'; export type ProfilePageId = 'account' | 'security' | 'organizationGeneral' | 'organizationMembers' | 'billing'; export type UserPreviewId = 'userButton' | 'personalWorkspace'; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 7b23db716c7..ed0b109d305 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1429,6 +1429,32 @@ export type __internal_LocalizationResource = { }; }; }; + confirmation: { + statusSection: { + title: LocalizationValue; + activeBadge: LocalizationValue; + inactiveBadge: LocalizationValue; + }; + enableSection: { + title: LocalizationValue; + }; + domainSection: { + title: LocalizationValue; + }; + configurationSection: { + title: LocalizationValue; + ssoUrlLabel: LocalizationValue; + issuerLabel: LocalizationValue; + certificateLabel: LocalizationValue; + configureAgainLink: LocalizationValue; + }; + resetSection: { + title: LocalizationValue; + warning: LocalizationValue; + confirmationFieldLabel: LocalizationValue<'name'>; + submitButton: LocalizationValue; + }; + }; }; apiKeys: { formTitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx index e2947e15342..1a9ba7eacbe 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx @@ -1,34 +1,325 @@ -import { descriptors, Flow, Text } from '@/customizables'; +import { __internal_useUserEnterpriseConnections, useReverification } from '@clerk/shared/react'; +import { useState } from 'react'; +import { Badge, Col, descriptors, Flex, Flow, Grid, Link, localizationKeys, Text } from '@/customizables'; +import { Action } from '@/elements/Action'; +import { useActionContext } from '@/elements/Action/ActionRoot'; +import { useCardState, withCardStateProvider } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { FormButtons } from '@/elements/FormButtons'; +import type { FormProps } from '@/elements/FormContainer'; +import { FormContainer } from '@/elements/FormContainer'; +import { ProfileSection } from '@/elements/Section'; +import { Switch } from '@/elements/Switch'; +import { mqu } from '@/styledSystem'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSO } from '../ConfigureSSOContext'; import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard'; export const ConfirmationStep = (): JSX.Element => { - const { goPrev, isFirstStep } = useWizard(); - return ( - + - - - UI goes here - + ({ paddingInline: t.space.$8, paddingBlock: t.space.$4 })}> + + + + + - - goPrev()} - isDisabled={isFirstStep} - /> - + ); }; + +const SsoStatusSection = (): JSX.Element => { + const { enterpriseConnection } = useConfigureSSO(); + const isActive = !!enterpriseConnection?.active; + + return ( + + + + + + ); +}; + +const EnableSsoSection = (): JSX.Element => { + const { enterpriseConnection } = useConfigureSSO(); + const { updateEnterpriseConnection } = __internal_useUserEnterpriseConnections({ enabled: false }); + const card = useCardState(); + + const [isChecked, setIsChecked] = useState(!!enterpriseConnection?.active); + + const updateActive = useReverification((id: string, active: boolean) => updateEnterpriseConnection(id, { active })); + + const onActiveChange = async (active: boolean) => { + if (card.isLoading) { + return; + } + + card.setError(undefined); + card.setLoading(); + setIsChecked(active); + + try { + // Enterprise connection is guaranteed to be set at this point + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const updated = await updateActive(enterpriseConnection!.id, active); + if (updated) { + setIsChecked(updated.active); + } + } catch (err) { + setIsChecked(!active); + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + return ( + ({ border: 0, paddingBlock: t.space.$2 })} + > + void onActiveChange(active)} + aria-label='Enable SSO' + /> + + ); +}; + +const DomainSection = (): JSX.Element | null => { + const { enterpriseConnection } = useConfigureSSO(); + const domain = enterpriseConnection?.domains?.[0]; + + // A type guard only, domains are guaranteed to be set at this point + if (!domain) { + return null; + } + + return ( + + + {domain} + + + ); +}; + +const ConfigurationDetailsSection = (): JSX.Element => { + const { enterpriseConnection } = useConfigureSSO(); + const { goToStep } = useWizard(); + + // This will later be expanded to support OIDC connections as well + const samlConnection = enterpriseConnection?.samlConnection; + + return ( + + + + + + {samlConnection?.idpSsoUrl} + + + + + {samlConnection?.idpEntityId} + + + + + {samlConnection?.idpCertificate} + + + + ({ marginTop: t.space.$2, paddingInlineStart: 0, marginInline: '-10px' })} + > + goToStep('select-provider')} + variant='ghost' + colorScheme='primary' + localizationKey={localizationKeys('configureSSO.confirmation.configurationSection.configureAgainLink')} + /> + + + + ); +}; + +const ResetConnectionForm = withCardStateProvider((props: FormProps) => { + const { onReset, onSuccess } = props; + const card = useCardState(); + const { enterpriseConnection } = useConfigureSSO(); + const { deleteEnterpriseConnection } = __internal_useUserEnterpriseConnections({ enabled: false }); + const { goToStep } = useWizard(); + + const deleteConnection = useReverification((id: string) => deleteEnterpriseConnection(id)); + + const confirmationField = useFormControl('deleteConfirmation', '', { + type: 'text', + label: localizationKeys('configureSSO.confirmation.resetSection.confirmationFieldLabel', { + name: enterpriseConnection?.name ?? '', + }), + isRequired: true, + placeholder: enterpriseConnection?.name, + }); + + const canSubmit = Boolean(enterpriseConnection?.name && confirmationField.value === enterpriseConnection.name); + + const onSubmit = async () => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + try { + await deleteConnection(enterpriseConnection.id); + onSuccess(); + await goToStep('select-provider'); + } catch (err) { + handleError(err as Error, [confirmationField], card.setError); + } + }; + + return ( + ({ gap: t.space.$0x5 })} + > + + + + + + + + + + + ); +}); + +const ResetConnectionScreen = (): JSX.Element => { + const { close } = useActionContext(); + return ( + + ); +}; + +const ResetConnectionSection = (): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/ui/src/elements/Section.tsx b/packages/ui/src/elements/Section.tsx index d5cc3846248..ce6ae5a5372 100644 --- a/packages/ui/src/elements/Section.tsx +++ b/packages/ui/src/elements/Section.tsx @@ -13,12 +13,13 @@ import { Menu, MenuItem, MenuList, MenuTrigger } from './Menu'; type ProfileSectionProps = Omit, 'title'> & { title: LocalizationKey; + titleId?: string; centered?: boolean; id: ProfileSectionId; }; const ProfileSectionRoot = (props: ProfileSectionProps) => { - const { title, centered = true, children, id, sx, ...rest } = props; + const { title, titleId, centered = true, children, id, sx, ...rest } = props; const ref = useRef(null); const [height, setHeight] = useState(0); @@ -87,6 +88,7 @@ const ProfileSectionRoot = (props: ProfileSectionProps) => { > & { localizationKey: LocalizationKey; textElementDescriptor?: ElementDescriptor; textElementId?: ElementId; + textId?: string; }; export const SectionHeader = (props: SectionHeaderProps) => { - const { textElementDescriptor, textElementId, localizationKey, ...rest } = props; + const { textElementDescriptor, textElementId, textId, localizationKey, ...rest } = props; return ( void; isDisabled?: boolean; - label: string | LocalizationKey; + label?: string | LocalizationKey; + 'aria-label'?: string; + 'aria-labelledby'?: string; } export const Switch = forwardRef( - ({ isChecked: controlledChecked, defaultChecked, onChange, isDisabled = false, label }, ref) => { + ( + { + id, + isChecked: controlledChecked, + defaultChecked, + onChange, + isDisabled = false, + label, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + }, + ref, + ) => { const [internalChecked, setInternalChecked] = useState(!!defaultChecked); const isControlled = controlledChecked !== undefined; const checked = isControlled ? controlledChecked : internalChecked; @@ -28,6 +43,8 @@ export const Switch = forwardRef( onChange?.(e.target.checked); }; + const hasInternalLabel = label !== undefined; + return ( ( > {/* The order of the elements is important here for the focus ring to work. The input is visually hidden, so the focus ring is applied to the span. */} ( })} /> - ({ - paddingInlineStart: t.sizes.$2, - cursor: isDisabled ? 'not-allowed' : 'pointer', - userSelect: 'none', - })} - /> + {hasInternalLabel ? ( + ({ + paddingInlineStart: t.sizes.$2, + cursor: isDisabled ? 'not-allowed' : 'pointer', + userSelect: 'none', + })} + /> + ) : null} ); }, diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx index 11379652372..e323252963a 100644 --- a/packages/ui/src/elements/contexts/index.tsx +++ b/packages/ui/src/elements/contexts/index.tsx @@ -140,7 +140,7 @@ export type FlowMetadata = { | 'configureCreateApp' | 'configureMapAttributes' | 'test-sso' - | 'sso-confirmation'; + | 'ssoConfirmation'; }; const [FlowMetadataCtx, useFlowMetadata] = createContextAndHook('FlowMetadata');