fix(welcome): re-enable OAuth buttons with focus/timeout recovery (#1049) (#1069)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
oxoxDev
2026-05-01 17:33:40 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent cd7a56581c
commit 7bd83dd872
4 changed files with 233 additions and 11 deletions
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { getBackendUrl } from '../../services/backendUrl';
import { getDeepLinkAuthState } from '../../store/deepLinkAuthState';
import type { OAuthProviderConfig } from '../../types/oauth';
import { IS_DEV } from '../../utils/config';
import { openUrl } from '../../utils/openUrl';
@@ -13,6 +14,11 @@ interface OAuthProviderButtonProps {
onClickOverride?: () => void;
}
// Reset the loading state if the OAuth round-trip never completes — covers
// the case where the user cancels in the system browser, or the backend
// redirect fails so the `openhuman://` deep link never fires.
const OAUTH_LOADING_TIMEOUT_MS = 90_000;
const OAuthProviderButton = ({
provider,
className = '',
@@ -21,6 +27,55 @@ const OAuthProviderButton = ({
}: OAuthProviderButtonProps) => {
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!isLoading) return;
const reset = () => setIsLoading(false);
// Skip reset when a deep-link auth round-trip is already in flight — the
// OAuth callback flips `isProcessing=true` AFTER the OS focus event fires,
// and resetting first would briefly re-enable the button mid-redirect.
const skipDuringDeepLink = (label: string) => {
if (getDeepLinkAuthState().isProcessing) {
console.debug(`[oauth-button][${provider.id}] ${label} — skip (deep-link processing)`);
return true;
}
return false;
};
// Fast path: window focus fires when the user returns from the system
// browser. On most platforms this lifts the loading state immediately.
const handleFocus = () => {
if (skipDuringDeepLink('focus')) return;
console.debug(`[oauth-button][${provider.id}] window focus → reset isLoading`);
reset();
};
// Backup path: macOS Spaces / virtual desktops sometimes restore window
// focus without firing a `focus` event. `visibilitychange` is the more
// reliable signal there.
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') return;
if (skipDuringDeepLink('visibilitychange')) return;
console.debug(`[oauth-button][${provider.id}] visibilitychange visible → reset isLoading`);
reset();
};
const timer = window.setTimeout(() => {
console.debug(`[oauth-button][${provider.id}] timeout → reset isLoading`);
reset();
}, OAUTH_LOADING_TIMEOUT_MS);
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.clearTimeout(timer);
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [isLoading, provider.id]);
const handleOAuthLogin = async () => {
if (onClickOverride) {
onClickOverride();
@@ -29,7 +84,7 @@ const OAuthProviderButton = ({
if (externalDisabled || isLoading) return;
console.log(`Starting ${provider.name} OAuth login`, isTauri());
console.debug(`[oauth-button][${provider.id}] starting OAuth login (isTauri=${isTauri()})`);
setIsLoading(true);
@@ -0,0 +1,173 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getBackendUrl } from '../../../services/backendUrl';
import { getDeepLinkAuthState } from '../../../store/deepLinkAuthState';
import { openUrl } from '../../../utils/openUrl';
import { isTauri } from '../../../utils/tauriCommands';
import OAuthProviderButton from '../OAuthProviderButton';
vi.mock('../../../services/backendUrl', () => ({ getBackendUrl: vi.fn() }));
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
vi.mock('../../../utils/tauriCommands', () => ({ isTauri: vi.fn() }));
vi.mock('../../../store/deepLinkAuthState', () => ({ getDeepLinkAuthState: vi.fn() }));
const stubProvider = {
id: 'google' as const,
name: 'Google',
icon: ({ className }: { className?: string }) => (
<span aria-hidden="true" className={className} />
),
color: '',
hoverColor: '',
textColor: '',
showOnWelcome: true,
};
describe('OAuthProviderButton', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(getBackendUrl).mockResolvedValue('https://backend.test');
vi.mocked(openUrl).mockResolvedValue(undefined);
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(getDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null });
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('opens the backend OAuth URL on click and shows Connecting...', async () => {
render(<OAuthProviderButton provider={stubProvider} />);
const button = screen.getByRole('button', { name: 'Google' });
fireEvent.click(button);
// Drain the microtasks queued by the async click handler so openUrl resolves.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(getBackendUrl).toHaveBeenCalledTimes(1);
expect(openUrl).toHaveBeenCalledWith(
expect.stringMatching(/^https:\/\/backend\.test\/auth\/google\/login(\?.*)?$/)
);
expect(screen.getByRole('button', { name: /Connecting/ })).toBeDisabled();
});
it('resets isLoading when the window regains focus', async () => {
render(<OAuthProviderButton provider={stubProvider} />);
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('Connecting...')).toBeInTheDocument();
await act(async () => {
window.dispatchEvent(new FocusEvent('focus'));
});
expect(screen.queryByText('Connecting...')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled();
});
it('does NOT reset isLoading on focus when a deep-link auth round-trip is processing', async () => {
vi.mocked(getDeepLinkAuthState).mockReturnValue({ isProcessing: true, errorMessage: null });
render(<OAuthProviderButton provider={stubProvider} />);
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('Connecting...')).toBeInTheDocument();
await act(async () => {
window.dispatchEvent(new FocusEvent('focus'));
});
expect(screen.getByText('Connecting...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Connecting/ })).toBeDisabled();
});
it('resets isLoading on visibilitychange to visible', async () => {
render(<OAuthProviderButton provider={stubProvider} />);
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('Connecting...')).toBeInTheDocument();
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'visible',
});
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
});
expect(screen.queryByText('Connecting...')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled();
});
it('resets isLoading after the 90s safety timeout', async () => {
render(<OAuthProviderButton provider={stubProvider} />);
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('Connecting...')).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(90_000);
});
expect(screen.queryByText('Connecting...')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled();
});
it('honors onClickOverride and skips the OAuth flow', () => {
const override = vi.fn();
render(<OAuthProviderButton provider={stubProvider} onClickOverride={override} />);
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
expect(override).toHaveBeenCalledTimes(1);
expect(getBackendUrl).not.toHaveBeenCalled();
expect(openUrl).not.toHaveBeenCalled();
expect(screen.queryByText('Connecting...')).not.toBeInTheDocument();
});
it('ignores rapid double-clicks while a request is in flight', async () => {
render(<OAuthProviderButton provider={stubProvider} />);
const button = screen.getByRole('button', { name: 'Google' });
fireEvent.click(button);
fireEvent.click(button);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(getBackendUrl).toHaveBeenCalledTimes(1);
expect(openUrl).toHaveBeenCalledTimes(1);
});
});
+1 -3
View File
@@ -16,7 +16,6 @@ import {
const Welcome = () => {
const { isProcessing, errorMessage } = useDeepLinkAuthState();
const handleDisabledOAuthClick = () => undefined;
const [showAdvanced, setShowAdvanced] = useState(false);
const [rpcUrl, setRpcUrl] = useState(getStoredRpcUrl());
@@ -191,7 +190,7 @@ const Welcome = () => {
</div>
) : (
<>
{/* OAuth buttons intentionally inert until auth flow is re-enabled. */}
{/* Real OAuth: click → system browser → backend → deep link back to app. */}
<div className="flex items-center justify-center gap-3">
{oauthProviderConfigs
.filter(provider => provider.showOnWelcome)
@@ -199,7 +198,6 @@ const Welcome = () => {
<OAuthProviderButton
key={provider.id}
provider={provider}
onClickOverride={handleDisabledOAuthClick}
className="!rounded-full !px-4 !py-2"
/>
))}
+2 -6
View File
@@ -62,7 +62,7 @@ describe('Welcome auth entrypoint', () => {
expect(screen.queryByRole('button', { name: 'discord' })).not.toBeInTheDocument();
});
it('keeps OAuth buttons as blank clicks on the welcome screen', () => {
it('delegates OAuth clicks to OAuthProviderButton without an override', () => {
render(<Welcome />);
fireEvent.click(screen.getByRole('button', { name: 'google' }));
@@ -72,11 +72,7 @@ describe('Welcome auth entrypoint', () => {
expect(oauthButtonSpy).toHaveBeenNthCalledWith(1, 'google');
expect(oauthButtonSpy).toHaveBeenNthCalledWith(2, 'github');
expect(oauthButtonSpy).toHaveBeenNthCalledWith(3, 'twitter');
expect(oauthOverrideSpy).toHaveBeenNthCalledWith(1, 'google');
expect(oauthOverrideSpy).toHaveBeenNthCalledWith(2, 'github');
expect(oauthOverrideSpy).toHaveBeenNthCalledWith(3, 'twitter');
expect(screen.queryByText('Connecting...')).not.toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(oauthOverrideSpy).not.toHaveBeenCalled();
});
it('shows the deep-link processing state when auth is already in progress', () => {