From 8d4e523df8b16b5afa8d9f927b0657472a704f64 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Fri, 19 Jun 2026 15:35:22 -0300 Subject: [PATCH 1/8] docs(ui): document the non-atomic provider change window Changing an SSO provider deletes the existing enterprise connection before creating the new one. Document that this is intentionally non-atomic for the MVP: a failed create briefly leaves the org without a connection, and recovery is by design since the next render revalidates the deleted connection away so a retry becomes a plain create. --- .../hooks/useOrganizationEnterpriseConnection.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts index 024c35a15c9..c9cdd7090fb 100644 --- a/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts @@ -228,8 +228,11 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise }; const changeProvider: EnterpriseConnectionMutations['changeProvider'] = async provider => { - // Currently it's not possible to change the provider of an existing connection, - // so we need to delete the existing connection and create a new one. + // FAPI can't switch an existing connection's provider in place, so for the MVP + // we delete the old connection and create a new one. This is intentionally + // non-atomic: if the create fails, the org is briefly left without a connection + // until the user retries. Recovery is by design — the next render revalidates + // the now-deleted connection away, so a retry is just a plain create. if (enterpriseConnection) { await deleteEnterpriseConnection(enterpriseConnection.id); } From e4693cec5af0d8c68e26ceaa809158d47a0759f9 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Fri, 19 Jun 2026 15:35:30 -0300 Subject: [PATCH 2/8] fix(ui): keep change-provider dialog open on failure and make it accessible A failed provider change now surfaces the error inline inside the dialog via the shared card state, keeping the dialog open so the user can retry or cancel instead of dropping the error behind a dismissed modal. The dialog also gains an accessible name (aria-labelledby wired to its heading) and restores Escape-to-close (ignored while a change is in progress). Threads aria-labelledby/aria-label and onKeyDown through the Modal primitive onto the role=dialog node. --- .../ConfigureSSO/ChangeProviderDialog.tsx | 163 +++++++++++------- packages/ui/src/elements/Modal.tsx | 24 ++- 2 files changed, 124 insertions(+), 63 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx b/packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx index 44623de0505..9a064a014c9 100644 --- a/packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx +++ b/packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx @@ -1,14 +1,18 @@ +import { useId, useState } from 'react'; + import type { LocalizationKey } from '@/customizables'; import { Button, Col, descriptors, Flex, Heading, localizationKeys, Text, useLocalizations } from '@/customizables'; import { Card } from '@/elements/Card'; -import { withCardStateProvider } from '@/elements/contexts'; +import { useCardState, withCardStateProvider } from '@/elements/contexts'; import { Modal } from '@/elements/Modal'; +import { Alert } from '@/ui/elements/Alert'; +import { handleError } from '@/utils/errorHandler'; type ChangeProviderDialogProps = { isOpen: boolean; onClose: () => void; - onConfirm: () => void; - isSubmitting?: boolean; + /** Performs the provider change. Rejecting keeps the dialog open with the error shown inline. */ + onConfirm: () => Promise; nextProviderLabel: LocalizationKey; currentProviderLabel: LocalizationKey; contentRef: React.RefObject; @@ -19,11 +23,52 @@ export const ChangeProviderDialog = (props: ChangeProviderDialogProps): JSX.Elem return null; } + return ; +}; + +const ChangeProviderDialogContent = withCardStateProvider((props: ChangeProviderDialogProps) => { + const { onClose, onConfirm, nextProviderLabel, currentProviderLabel, contentRef } = props; + const { t } = useLocalizations(); + const card = useCardState(); + + const [isSubmitting, setIsSubmitting] = useState(false); + const titleId = useId(); + + const nextProvider = t(nextProviderLabel); + const currentProvider = t(currentProviderLabel); + + const handleConfirm = async () => { + card.setError(undefined); + setIsSubmitting(true); + + try { + // On success the wizard advances and unmounts this dialog; on failure we + // surface the error inline and keep the dialog open so the user can retry. + await onConfirm(); + } catch (err) { + handleError(err as Error, [], card.setError); + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (isSubmitting) { + return; + } + onClose(); + }; + return ( { + if (event.key === 'Escape') { + handleClose(); + } + }} containerSx={t => ({ alignItems: 'center', position: 'absolute', @@ -34,64 +79,60 @@ export const ChangeProviderDialog = (props: ChangeProviderDialogProps): JSX.Elem backdropFilter: `blur(${t.sizes.$2})`, })} > - - - ); -}; - -const ChangeProviderDialogContent = withCardStateProvider((props: ChangeProviderDialogProps) => { - const { onClose, onConfirm, isSubmitting, nextProviderLabel, currentProviderLabel } = props; - const { t } = useLocalizations(); + ({ borderRadius: t.radii.$md })} + > + ({ textAlign: 'start', padding: t.sizes.$5 })}> + ({ gap: t.space.$4 })}> + ({ gap: t.space.$2 })}> + ({ fontSize: t.fontSizes.$md })} + /> + + - const nextProvider = t(nextProviderLabel); - const currentProvider = t(currentProviderLabel); + {card.error && ( + + )} - return ( - ({ borderRadius: t.radii.$md })} - > - ({ textAlign: 'start', padding: t.sizes.$5 })}> - ({ gap: t.space.$4 })}> - ({ gap: t.space.$2 })}> - ({ fontSize: t.fontSizes.$md })} - /> - + ({ gap: t.space.$3 })} + > +