mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
cd7a56581c
commit
7bd83dd872
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user