fix(web): snapshot pending before reload so expired-auth cancel actually fires (#1972)

reloadConnectorStatuses commits its prune setState (and runs the connectorAuthorizationPendingRef mirror effect) between the two awaits in onFocus, so cancelStaleAuthorizations was reading a post-prune ref and never identified anyone as stuck. Capture the snapshot inside onFocus before reloadConnectorStatuses runs, and pass it explicitly so the expired-auth daemon cancel actually fires.

Refs #1909 #1354
This commit is contained in:
Ethan Guo 2026-05-17 23:13:53 +08:00 committed by GitHub
parent d36ceacf3a
commit 0d2c87242f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 90 additions and 4 deletions

View file

@ -582,13 +582,13 @@ export function ConnectorsBrowser({
}, [connectorAuthorizationPending]);
const cancelStaleAuthorizations = useCallback(async (
pendingBeforeReload: ConnectorAuthorizationPendingState,
statuses: ConnectorStatusResponse['statuses'],
nowMs = Date.now(),
) => {
const pending = connectorAuthorizationPendingRef.current;
const stuck = Object.keys(pending).filter((connectorId) => {
const stuck = Object.keys(pendingBeforeReload).filter((connectorId) => {
if (statuses[connectorId]?.status === 'connected') return false;
const expiresAt = pending[connectorId]?.expiresAt;
const expiresAt = pendingBeforeReload[connectorId]?.expiresAt;
if (!expiresAt) return false;
const expiresAtMs = Date.parse(expiresAt);
return Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs;
@ -705,8 +705,9 @@ export function ConnectorsBrowser({
// card recovers to its default state instead of staying stuck loading.
useEffect(() => {
async function onFocus() {
const pendingBeforeReload = connectorAuthorizationPendingRef.current;
const statuses = await reloadConnectorStatuses();
await cancelStaleAuthorizations(statuses);
await cancelStaleAuthorizations(pendingBeforeReload, statuses);
}
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);

View file

@ -552,6 +552,91 @@ describe('ConnectorsBrowser', () => {
).toHaveProperty('github');
});
it('auto-cancels stuck pending authorization on focus once the daemon authorization window has expired', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const startMs = new Date('2026-05-17T10:00:00.000Z').getTime();
vi.setSystemTime(startMs);
const availableConnector: ConnectorDetail = {
...configuredComposioConnector,
status: 'available',
auth: { provider: 'composio', configured: true },
};
vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]);
vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]);
vi.mocked(fetchConnectorStatuses).mockResolvedValue({
github: { status: 'available' },
});
vi.mocked(connectConnector).mockResolvedValue({
connector: availableConnector,
auth: {
kind: 'redirect_required',
redirectUrl: 'https://example.com/oauth',
expiresAt: new Date(startMs + 5 * 60 * 1000).toISOString(),
},
});
vi.mocked(cancelConnectorAuthorization).mockResolvedValue(availableConnector);
render(<ConnectorsBrowser composioConfigured />);
await screen.findByText('GitHub');
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
await screen.findByRole('button', { name: 'Cancel' });
vi.setSystemTime(startMs + 10 * 60 * 1000);
fireEvent(window, new Event('focus'));
await waitFor(() => expect(cancelConnectorAuthorization).toHaveBeenCalledWith('github'));
await screen.findByRole('button', { name: 'Connect' });
expect(
JSON.parse(window.sessionStorage.getItem('od-connectors-authorization-pending') ?? '{}'),
).not.toHaveProperty('github');
vi.useRealTimers();
});
it('marks the auto-cancel as failed when the daemon /authorization/cancel returns null after the authorization expires', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const startMs = new Date('2026-05-17T10:00:00.000Z').getTime();
vi.setSystemTime(startMs);
const availableConnector: ConnectorDetail = {
...configuredComposioConnector,
status: 'available',
auth: { provider: 'composio', configured: true },
};
vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]);
vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]);
vi.mocked(fetchConnectorStatuses).mockResolvedValue({
github: { status: 'available' },
});
vi.mocked(connectConnector).mockResolvedValue({
connector: availableConnector,
auth: {
kind: 'redirect_required',
redirectUrl: 'https://example.com/oauth',
expiresAt: new Date(startMs + 5 * 60 * 1000).toISOString(),
},
});
vi.mocked(cancelConnectorAuthorization).mockResolvedValue(null);
render(<ConnectorsBrowser composioConfigured />);
await screen.findByText('GitHub');
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
await screen.findByRole('button', { name: 'Cancel' });
vi.setSystemTime(startMs + 10 * 60 * 1000);
fireEvent(window, new Event('focus'));
await waitFor(() => expect(cancelConnectorAuthorization).toHaveBeenCalledWith('github'));
expect(await screen.findByText("Couldn't cancel authorization. Try again.")).toBeTruthy();
vi.useRealTimers();
});
it('does not auto-cancel pending authorization on focus when the daemon already reports the connector as connected', async () => {
const availableConnector: ConnectorDetail = {
...configuredComposioConnector,