From ce12e3cd6fb37c53387e76c7b91bf44f1700c73a Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 28 Feb 2026 13:06:43 +0100 Subject: [PATCH] fix: use Tauri opener plugin for GitHub OAuth browser launch Replace window.open() with openExternalUrl() which uses @tauri-apps/plugin-opener in native mode. Also show the verification URL in the waiting UI so users can navigate manually, add a copy-code button, and display a retry button on error/expiry. Co-Authored-By: Claude Opus 4.6 --- src/components/SettingsPanel.test.tsx | 60 +++++++++++++++++++-- src/components/SettingsPanel.tsx | 76 ++++++++++++++++++++++----- 2 files changed, 118 insertions(+), 18 deletions(-) diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 036174a2..753af8d9 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -5,6 +5,7 @@ import type { Settings } from '../types' // Mock the tauri/mock-tauri calls used by GitHubSection const mockInvokeFn = vi.fn() +const mockOpenExternalUrl = vi.fn().mockResolvedValue(undefined) vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args: unknown[]) => mockInvokeFn(...args), })) @@ -12,6 +13,9 @@ vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: (...args: unknown[]) => mockInvokeFn(...args), })) +vi.mock('../utils/url', () => ({ + openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args), +})) const emptySettings: Settings = { anthropic_key: null, @@ -272,9 +276,6 @@ describe('SettingsPanel', () => { return null }) - // Mock window.open - const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) - render( ) @@ -286,8 +287,57 @@ describe('SettingsPanel', () => { expect(screen.getByTestId('github-user-code')).toHaveTextContent('TEST-1234') }) - expect(windowOpen).toHaveBeenCalledWith('https://github.com/login/device', '_blank') - windowOpen.mockRestore() + expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://github.com/login/device') + }) + + it('shows verification URL as clickable link in waiting state', async () => { + mockInvokeFn.mockImplementation(async (cmd: string) => { + if (cmd === 'github_device_flow_start') { + return { + device_code: 'test_device_code', + user_code: 'TEST-1234', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + } + } + if (cmd === 'github_device_flow_poll') { + return { status: 'pending', access_token: null, error: 'authorization_pending' } + } + return null + }) + + render( + + ) + + fireEvent.click(screen.getByTestId('github-login')) + + await waitFor(() => { + const urlButton = screen.getByTestId('github-open-url') + expect(urlButton).toBeInTheDocument() + expect(urlButton).toHaveTextContent('https://github.com/login/device') + }) + }) + + it('shows retry button when OAuth flow errors', async () => { + mockInvokeFn.mockImplementation(async (cmd: string) => { + if (cmd === 'github_device_flow_start') { + throw 'Network error' + } + return null + }) + + render( + + ) + + fireEvent.click(screen.getByTestId('github-login')) + + await waitFor(() => { + expect(screen.getByTestId('github-error')).toHaveTextContent('Network error') + expect(screen.getByTestId('github-retry')).toBeInTheDocument() + }) }) it('shows GitHub section description about connecting', () => { diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index e4bd67e9..30021554 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useCallback, useEffect } from 'react' -import { X, Eye, EyeSlash, GithubLogo, SignOut, CircleNotch } from '@phosphor-icons/react' +import { X, Eye, EyeSlash, GithubLogo, SignOut, CircleNotch, ArrowClockwise, Copy, Check } from '@phosphor-icons/react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' +import { openExternalUrl } from '../utils/url' import type { Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types' function tauriCall(cmd: string, args: Record = {}): Promise { @@ -109,6 +110,7 @@ function processPollResult( function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect }: GitHubSectionProps) { const [oauthStatus, setOauthStatus] = useState('idle') const [userCode, setUserCode] = useState(null) + const [verificationUri, setVerificationUri] = useState(null) const [errorMessage, setErrorMessage] = useState(null) const pollingRef = useRef(false) const deviceCodeRef = useRef(null) @@ -132,8 +134,11 @@ function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect try { const flowStart = await tauriCall('github_device_flow_start') setUserCode(flowStart.user_code) + setVerificationUri(flowStart.verification_uri) deviceCodeRef.current = flowStart.device_code - window.open(flowStart.verification_uri, '_blank') + openExternalUrl(flowStart.verification_uri).catch(() => { + // Browser failed to open — URL is shown in UI so user can navigate manually + }) pollingRef.current = true const intervalMs = Math.max(flowStart.interval * 1000, 5000) @@ -184,6 +189,7 @@ function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect stopPolling() setOauthStatus('idle') setUserCode(null) + setVerificationUri(null) setErrorMessage(null) }, [stopPolling]) @@ -192,10 +198,10 @@ function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect } if (oauthStatus === 'waiting' && userCode) { - return + return } - return + return { resetOAuth(); handleLogin() } : undefined} /> } function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) { @@ -224,7 +230,20 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi ) } -function GitHubWaitingView({ userCode, onCancel }: { userCode: string; onCancel: () => void }) { +function GitHubWaitingView({ userCode, verificationUri, onCancel }: { userCode: string; verificationUri: string | null; onCancel: () => void }) { + const [copied, setCopied] = useState(false) + + const handleCopyCode = useCallback(() => { + navigator.clipboard.writeText(userCode).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }).catch(() => {}) + }, [userCode]) + + const handleOpenUrl = useCallback(() => { + if (verificationUri) openExternalUrl(verificationUri).catch(() => {}) + }, [verificationUri]) + return (
Enter this code on GitHub:
-
- {userCode} +
+
+ {userCode} +
+
+ {verificationUri && ( + + )}
Waiting for authorization... @@ -255,7 +294,7 @@ function GitHubWaitingView({ userCode, onCancel }: { userCode: string; onCancel: ) } -function GitHubLoginButton({ onLogin, disabled, errorMessage }: { onLogin: () => void; disabled: boolean; errorMessage: string | null }) { +function GitHubLoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) { return (
{errorMessage && ( -
- {errorMessage} +
+ {errorMessage} + {onRetry && ( + + )}
)}