Skip to content

Commit e308b6d

Browse files
committed
fix(modal): hold a dialog from the input that starts its work
A confirm that fired a mutation and returned `false` relied on the mutation's `isPending` to hold the dialog, but query-core delivers that on a setTimeout(0). The confirm's own `pending` had already ended by then, so for several milliseconds the dialog read idle: an Escape right behind the click dismissed a running ban, Cancel and Confirm were live, and focus jumped back to the field. Every dialog passing `pending` to a confirm that starts work now returns that work's promise, which holds it from the click's own commit. A refused ban, role change or unban also read as success: better-auth's client resolves with `{ error }` rather than throwing, so the mutation succeeded and closed the dialog. They now throw, as impersonation already did. The e2e check presses Escape right behind the click, past the point Chromium closes natively, and on the refused ban. It replaces the jsdom test, which stubbed showModal and close and so proved nothing about the browser.
1 parent 0eeb6d4 commit e308b6d

17 files changed

Lines changed: 155 additions & 182 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/files/components/share-modal/use-share-dialog.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ export function useShareDialog({
123123
onOpenChange(false)
124124
}
125125

126-
const submitPrimaryAction = () => {
126+
/** Settles with the save, for a dialog to hold on. A failure stays on `upsertShare.error`. */
127+
const submitPrimaryAction = async () => {
127128
if (!shareReadReady || upsertShare.isPending) return
128129

129130
const base = { workspaceId, fileId, token: saved ? undefined : generateShortId() }
@@ -145,21 +146,22 @@ export function useShareDialog({
145146
}
146147
: { ...base, isActive: true as const, authType: 'public' as const }
147148

148-
upsertShare.mutate(vars, {
149-
onSuccess: () => {
150-
toast.success(PRIMARY_ACTION_SUCCESS_MESSAGES[primaryAction])
151-
setUnshareConfirmOpen(false)
152-
resetDraft()
153-
},
154-
})
149+
try {
150+
await upsertShare.mutateAsync(vars)
151+
} catch {
152+
return
153+
}
154+
toast.success(PRIMARY_ACTION_SUCCESS_MESSAGES[primaryAction])
155+
setUnshareConfirmOpen(false)
156+
resetDraft()
155157
}
156158

157-
const handlePrimaryAction = () => {
159+
const handlePrimaryAction = async () => {
158160
if (isUnshareAction) {
159161
setUnshareConfirmOpen(true)
160162
return
161163
}
162-
submitPrimaryAction()
164+
await submitPrimaryAction()
163165
}
164166

165167
const accessHint = (() => {

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,18 @@ export function useConnectorActions({
121121
pending: remove.isPending,
122122
disabled: actionsDisabled,
123123
error: remove.error,
124-
onConfirm: () => {
124+
/** Settles with the removal, for a dialog to hold on. A refusal stays on `removal.error`. */
125+
onConfirm: async () => {
125126
if (!canEdit || actionsDisabled) return
126-
remove.mutate({
127-
knowledgeBaseId,
128-
connectorId: connector.id,
129-
deleteDocuments: requiresDocumentDeletion || deleteDocuments,
130-
})
127+
try {
128+
await remove.mutateAsync({
129+
knowledgeBaseId,
130+
connectorId: connector.id,
131+
deleteDocuments: requiresDocumentDeletion || deleteDocuments,
132+
})
133+
} catch {
134+
return
135+
}
131136
},
132137
},
133138
}

‎apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-admin-console.ts‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -178,24 +178,30 @@ export function useAdminConsole() {
178178
setBanReason('')
179179
}
180180

181-
const handleConfirmBan = () => {
181+
/** Settles with the ban, for a dialog to hold on. A refusal stays on `banUser.error`. */
182+
const handleConfirmBan = async () => {
182183
if (pendingAction?.type !== 'ban') return
183184
const trimmedReason = banReason.trim()
184-
banUser.mutate(
185-
{
185+
try {
186+
await banUser.mutateAsync({
186187
userId: pendingAction.userId,
187188
...(trimmedReason ? { banReason: trimmedReason } : {}),
188-
},
189-
{ onSuccess: closePendingAction }
190-
)
189+
})
190+
closePendingAction()
191+
} catch {
192+
return
193+
}
191194
}
192195

193-
const handleConfirmRoleChange = () => {
196+
/** Settles with the change, for a dialog to hold on. A refusal stays on `setUserRole.error`. */
197+
const handleConfirmRoleChange = async () => {
194198
if (pendingAction?.type !== 'role') return
195-
setUserRole.mutate(
196-
{ userId: pendingAction.userId, role: pendingAction.nextRole },
197-
{ onSuccess: closePendingAction }
198-
)
199+
try {
200+
await setUserRole.mutateAsync({ userId: pendingAction.userId, role: pendingAction.nextRole })
201+
closePendingAction()
202+
} catch {
203+
return
204+
}
199205
}
200206

201207
const pendingUserIds = new Set<string>()

‎apps/sim/components/integrations/use-slack-search-setup.ts‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,35 +57,41 @@ export function useSlackSearchSetup({
5757
(!configuredAppId || configuredAppId === prepare.data.sharedAppId)
5858
)
5959

60+
/**
61+
* Settles with the start of consent, for a dialog to hold on. On desktop consent runs in the
62+
* user's browser and they may close the dialog meanwhile, so there is nothing to hold on.
63+
*/
6064
function installShared() {
6165
const controller = new AbortController()
6266
nativeAbort.current = controller
63-
oauth.mutate(
64-
{
67+
const started = oauth
68+
.mutateAsync({
6569
organizationId,
6670
installationId,
6771
name,
6872
description,
6973
mode: 'shared',
7074
signal: controller.signal,
71-
},
72-
{
73-
onSuccess: (result) => {
75+
})
76+
.then(
77+
(result) => {
7478
if (result) window.location.assign(result.authorizationUrl)
7579
else onClose()
7680
},
77-
}
78-
)
81+
() => undefined
82+
)
83+
return isDesktopApp() ? undefined : started
7984
}
8085

81-
function advance() {
86+
/** A step forward is immediate; the last one settles with the connection, for a dialog to hold on. */
87+
async function advance() {
8288
if (step === 'manifest') {
8389
setStep('token')
8490
} else if (step === 'token') {
8591
setStep('credentials')
8692
} else {
87-
connect.mutate(
88-
{
93+
try {
94+
await connect.mutateAsync({
8995
organizationId,
9096
installationId,
9197
name,
@@ -94,11 +100,11 @@ export function useSlackSearchSetup({
94100
...(clientId.trim() ? { clientId: clientId.trim() } : {}),
95101
...(clientSecret.trim() ? { clientSecret: clientSecret.trim() } : {}),
96102
...(signingSecret.trim() ? { signingSecret: signingSecret.trim() } : {}),
97-
},
98-
{
99-
onSuccess: onClose,
100-
}
101-
)
103+
})
104+
} catch {
105+
return
106+
}
107+
onClose()
102108
}
103109
}
104110

‎apps/sim/hooks/queries/admin-users.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export function useSetUserRole() {
219219
return useMutation({
220220
mutationFn: async ({ userId, role }: { userId: string; role: 'user' | 'admin' }) => {
221221
const result = await client.admin.setRole({ userId, role })
222+
if (result.error) throw new Error(result.error.message ?? 'Failed to change the user role')
222223
return result
223224
},
224225
onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }),
@@ -236,6 +237,7 @@ export function useBanUser() {
236237
userId,
237238
...(banReason ? { banReason } : {}),
238239
})
240+
if (result.error) throw new Error(result.error.message ?? 'Failed to ban user')
239241
return result
240242
},
241243
onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }),
@@ -250,6 +252,7 @@ export function useUnbanUser() {
250252
return useMutation({
251253
mutationFn: async ({ userId }: { userId: string }) => {
252254
const result = await client.admin.unbanUser({ userId })
255+
if (result.error) throw new Error(result.error.message ?? 'Failed to unban user')
253256
return result
254257
},
255258
onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }),

‎apps/sim/platform/app/(shell)/_components/conversation/share-dialog.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,8 @@ export function ShareDialog(props: ShareDialogProps) {
176176
confirmLabel={dialog.primaryLabel}
177177
confirmDisabled={dialog.primaryDisabled}
178178
destructive={dialog.isUnshareAction}
179-
onConfirm={() => {
180-
dialog.runPrimaryAction()
179+
onConfirm={async () => {
180+
await dialog.runPrimaryAction()
181181
return false
182182
}}
183183
>
@@ -255,8 +255,8 @@ export function ShareDialog(props: ShareDialogProps) {
255255
confirmLabel={pending ? 'Unsharing...' : 'Unshare'}
256256
confirmDisabled={pending}
257257
pending={pending}
258-
onConfirm={() => {
259-
dialog.confirmUnshare()
258+
onConfirm={async () => {
259+
await dialog.confirmUnshare()
260260
return false
261261
}}
262262
>

‎apps/sim/platform/app/(shell)/settings/_components/sections/admin/admin-section.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,8 +302,8 @@ export default function AdminSection() {
302302
confirmLabel='Ban'
303303
pendingLabel='Banning…'
304304
pending={admin.banUser.isPending}
305-
onConfirm={() => {
306-
admin.handleConfirmBan()
305+
onConfirm={async () => {
306+
await admin.handleConfirmBan()
307307
return false
308308
}}
309309
>
@@ -331,8 +331,8 @@ export default function AdminSection() {
331331
confirmLabel={admin.isDemotion ? 'Demote' : 'Promote'}
332332
pendingLabel={admin.isDemotion ? 'Demoting…' : 'Promoting…'}
333333
pending={admin.setUserRole.isPending}
334-
onConfirm={() => {
335-
admin.handleConfirmRoleChange()
334+
onConfirm={async () => {
335+
await admin.handleConfirmRoleChange()
336336
return false
337337
}}
338338
>

‎apps/sim/platform/app/(shell)/settings/_components/sections/connected-accounts/group-access.tsx‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,16 @@ function WorkspaceAccessList({
134134
throw new Error(
135135
`Workspace access cannot exceed ${ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT} workspaces`
136136
)
137-
void save([...access.grants, grant], editor.revision)
138-
} else {
139-
if (grant.workspaceId !== editor.grant.workspaceId)
140-
throw new Error('Cannot change the workspace of an existing grant')
141-
void save(
142-
access.grants.map((existing) =>
143-
existing.workspaceId === grant.workspaceId ? grant : existing
144-
),
145-
editor.revision
146-
)
137+
return save([...access.grants, grant], editor.revision)
147138
}
139+
if (grant.workspaceId !== editor.grant.workspaceId)
140+
throw new Error('Cannot change the workspace of an existing grant')
141+
return save(
142+
access.grants.map((existing) =>
143+
existing.workspaceId === grant.workspaceId ? grant : existing
144+
),
145+
editor.revision
146+
)
148147
}
149148

150149
return (

‎apps/sim/platform/app/(shell)/settings/_components/sections/connected-accounts/workspace-grant-modal.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ interface WorkspaceGrantModalBaseProps {
1818
credentialTypes: OrganizationAccountWorkspaceAccess['credentialTypes']
1919
disabled: boolean
2020
error?: string
21-
onSave: (grant: Grant) => void
21+
/** Settles with the save, for the dialog to hold on. */
22+
onSave: (grant: Grant) => Promise<void>
2223
onClose: () => void
2324
}
2425

@@ -38,7 +39,7 @@ export function WorkspaceGrantModal(props: WorkspaceGrantModalProps) {
3839
props.mode === 'edit' ? props.grant.access : { mode: 'selected', credentialTypes: [] }
3940
)
4041

41-
function save() {
42+
async function save() {
4243
if (!workspaceId) throw new Error('Select a workspace before granting access')
4344
if (
4445
props.mode === 'create' &&
@@ -47,7 +48,7 @@ export function WorkspaceGrantModal(props: WorkspaceGrantModalProps) {
4748
throw new Error('Selected workspace is unavailable')
4849
if (access.mode === 'selected' && !access.credentialTypes.length)
4950
throw new Error('Select at least one integration')
50-
onSave({ workspaceId, access })
51+
await onSave({ workspaceId, access })
5152
return false
5253
}
5354

‎apps/sim/platform/app/(shell)/settings/_components/sections/integrations/policy-modal.test.tsx‎

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ const mocks = vi.hoisted(() => ({ save: vi.fn() }))
1515
vi.mock('next/navigation', () => nextNavigationMock)
1616
vi.mock('@/hooks/queries/kb/connectors', () => kbConnectorsQueriesMock)
1717
vi.mock('@/hooks/queries/search-integrations', () => ({
18-
useUpdateSearchIntegration: () => ({ mutate: mocks.save }),
18+
useUpdateSearchIntegration: () => ({ mutateAsync: mocks.save }),
1919
}))
2020
const mockPush = nextNavigationMockFns.router.push
2121
const mockSources = kbConnectorsQueriesMockFns.mockUseSearchSources
2222
let root: Root
2323
let container: HTMLDivElement
2424
beforeEach(() => {
25+
mocks.save.mockReset()
26+
mocks.save.mockReturnValue(new Promise(() => {}))
2527
mockSources.mockReturnValue({ data: [], hasNextPage: false })
2628
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
2729
Element.prototype.checkVisibility = () => true
@@ -80,15 +82,12 @@ describe('live search policy source preservation', () => {
8082
document.querySelector<HTMLInputElement>('input[type="radio"][value="member"]')?.click()
8183
)
8284
await act(async () => button('Save settings').click())
83-
expect(mocks.save).toHaveBeenCalledWith(
84-
{
85-
organizationId: 'org',
86-
connectorType: 'gmail',
87-
approved: true,
88-
policy: defaultLiveSearchPolicy(),
89-
},
90-
expect.any(Object)
91-
)
85+
expect(mocks.save).toHaveBeenCalledWith({
86+
organizationId: 'org',
87+
connectorType: 'gmail',
88+
approved: true,
89+
policy: defaultLiveSearchPolicy(),
90+
})
9291
})
9392
it('can add another connection without clearing the currently configured source', async () => {
9493
mockSources.mockReturnValue({
@@ -120,15 +119,20 @@ describe('live search policy source preservation', () => {
120119
/>
121120
)
122121
)
122+
let saved = () => {}
123+
mocks.save.mockReturnValue(
124+
new Promise<void>((resolve) => {
125+
saved = resolve
126+
})
127+
)
123128
await act(async () => button('Add connection').click())
124129
expect(mocks.save).toHaveBeenCalledWith(
125130
expect.objectContaining({
126131
policy: expect.objectContaining({ sourceId: 'current-source' }),
127-
}),
128-
expect.any(Object)
132+
})
129133
)
130134
expect(mockPush).not.toHaveBeenCalled()
131-
await act(async () => mocks.save.mock.calls.at(-1)?.[1].onSuccess())
135+
await act(async () => saved())
132136
expect(mockPush).toHaveBeenCalledWith(
133137
'/o/org/settings/integrations/providers/gmail?addConnector=gmail'
134138
)
@@ -159,8 +163,7 @@ describe('live search policy source preservation', () => {
159163
accessMode: 'service_account',
160164
sourceId: 'configured-source',
161165
}),
162-
}),
163-
expect.any(Object)
166+
})
164167
)
165168
})
166169
})

0 commit comments

Comments
 (0)