diff --git a/design/github-oauth-fix.pen b/design/github-oauth-fix.pen index b27f0f31..7dbabdc3 100644 --- a/design/github-oauth-fix.pen +++ b/design/github-oauth-fix.pen @@ -2,302 +2,68 @@ "children": [ { "type": "frame", - "id": "gh_oauth_frame", - "name": "GitHub OAuth — Device Code Waiting", + "id": "gofix01", "x": 0, "y": 0, - "width": 520, - "height": "fit_content(600)", - "fill": "#ffffff", + "name": "GitHub OAuth Fix — Modal with inline device flow", + "clip": true, + "width": 560, + "height": 420, + "fill": "$--background", + "cornerRadius": 12, "layout": "vertical", - "gap": 0, - "cornerRadius": [8, 8, 8, 8], - "stroke": "#e5e5e5", - "strokeWidth": 1, - "theme": { - "Mode": "Light" - }, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, "children": [ { "type": "frame", - "id": "gh_header", + "id": "gofix01-hdr", "name": "Header", "width": "fill_container", "height": 56, - "layout": "horizontal", + "fill": "$--background", "padding": [0, 24], - "gap": 0, "alignItems": "center", "justifyContent": "space-between", + "stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" }, "children": [ - { - "type": "text", - "id": "gh_title", - "content": "Settings", - "fontFamily": "Inter", - "fontSize": 16, - "fontWeight": "600", - "fill": "#1a1a1a" - }, - { - "type": "text", - "id": "gh_close", - "content": "✕", - "fontFamily": "Inter", - "fontSize": 14, - "fill": "#888888" - } + { "type": "text", "id": "gofix01-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" }, + { "type": "frame", "id": "gofix01-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix01-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] } ] }, { - "type": "rectangle", - "id": "gh_div1", + "type": "frame", + "id": "gofix01-desc", + "name": "Description", "width": "fill_container", - "height": 1, - "fill": "#e5e5e5" + "padding": [16, 24], + "children": [ + { "type": "text", "id": "gofix01-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 } + ] }, { "type": "frame", - "id": "gh_body", - "name": "Body", + "id": "gofix01-body", + "name": "DeviceFlowLogin", "width": "fill_container", "layout": "vertical", - "padding": 24, - "gap": 20, + "gap": 16, + "padding": [24, 24], + "alignItems": "center", "children": [ { "type": "frame", - "id": "gh_section_header", - "name": "GitHub Section Header", - "layout": "vertical", - "width": "fill_container", - "gap": 4, - "children": [ - { - "type": "text", - "id": "gh_section_title", - "content": "GitHub", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "600", - "fill": "#1a1a1a" - }, - { - "type": "text", - "id": "gh_section_desc", - "content": "Connect your GitHub account to clone and sync vaults.", - "fontFamily": "Inter", - "fontSize": 12, - "fill": "#888888", - "lineHeight": 1.5 - } - ] - }, - { - "type": "frame", - "id": "gh_waiting_card", - "name": "Device Code Card", - "width": "fill_container", - "layout": "vertical", - "padding": [16, 20], - "gap": 10, - "alignItems": "center", - "cornerRadius": [6, 6, 6, 6], - "stroke": "#e5e5e5", - "strokeWidth": 1, - "children": [ - { - "type": "text", - "id": "gh_prompt_text", - "content": "Enter this code on GitHub:", - "fontFamily": "Inter", - "fontSize": 12, - "fill": "#888888" - }, - { - "type": "frame", - "id": "gh_code_row", - "name": "Code + Copy", - "layout": "horizontal", - "gap": 8, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "gh_user_code", - "content": "ABCD-1234", - "fontFamily": "JetBrains Mono, monospace", - "fontSize": 24, - "fontWeight": "700", - "letterSpacing": 4, - "fill": "#1a1a1a" - }, - { - "type": "frame", - "id": "gh_copy_btn", - "name": "Copy Button", - "width": 24, - "height": 24, - "alignItems": "center", - "justifyContent": "center", - "children": [ - { - "type": "text", - "id": "gh_copy_icon", - "content": "⧉", - "fontFamily": "Inter", - "fontSize": 16, - "fill": "#888888" - } - ] - } - ] - }, - { - "type": "text", - "id": "gh_url_link", - "content": "https://github.com/login/device", - "fontFamily": "Inter", - "fontSize": 12, - "fill": "#888888", - "textDecoration": "underline" - }, - { - "type": "frame", - "id": "gh_spinner_row", - "name": "Waiting Spinner", - "layout": "horizontal", - "gap": 6, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "gh_spinner_icon", - "content": "◌", - "fontFamily": "Inter", - "fontSize": 14, - "fill": "#888888" - }, - { - "type": "text", - "id": "gh_spinner_text", - "content": "Waiting for authorization...", - "fontFamily": "Inter", - "fontSize": 12, - "fill": "#888888" - } - ] - } - ] - }, - { - "type": "frame", - "id": "gh_cancel_row", - "name": "Cancel Row", - "width": "fill_container", + "id": "gofix01-login-btn", + "name": "LoginButton", + "width": 200, + "height": 36, + "fill": "$--foreground", + "cornerRadius": 6, "alignItems": "center", "justifyContent": "center", - "children": [ - { - "type": "frame", - "id": "gh_cancel_btn", - "name": "Cancel Button", - "layout": "horizontal", - "padding": [6, 12], - "cornerRadius": [4, 4, 4, 4], - "stroke": "#e5e5e5", - "strokeWidth": 1, - "alignItems": "center", - "justifyContent": "center", - "children": [ - { - "type": "text", - "id": "gh_cancel_text", - "content": "Cancel", - "fontFamily": "Inter", - "fontSize": 12, - "fill": "#888888" - } - ] - } - ] - } - ] - }, - { - "type": "rectangle", - "id": "gh_div2", - "width": "fill_container", - "height": 1, - "fill": "#e5e5e5" - }, - { - "type": "frame", - "id": "gh_footer", - "name": "Footer", - "width": "fill_container", - "height": 56, - "layout": "horizontal", - "padding": [0, 24], - "alignItems": "center", - "justifyContent": "space-between", - "children": [ - { - "type": "text", - "id": "gh_shortcut", - "content": "⌘, to open settings", - "fontFamily": "Inter", - "fontSize": 11, - "fill": "#888888" - }, - { - "type": "frame", - "id": "gh_footer_buttons", - "name": "Footer Buttons", - "layout": "horizontal", "gap": 8, "children": [ - { - "type": "frame", - "id": "gh_cancel_footer", - "name": "Cancel", - "padding": [6, 16], - "cornerRadius": [4, 4, 4, 4], - "stroke": "#e5e5e5", - "strokeWidth": 1, - "alignItems": "center", - "justifyContent": "center", - "children": [ - { - "type": "text", - "id": "gh_cancel_f_text", - "content": "Cancel", - "fontFamily": "Inter", - "fontSize": 13, - "fill": "#1a1a1a" - } - ] - }, - { - "type": "frame", - "id": "gh_save_btn", - "name": "Save", - "padding": [6, 16], - "cornerRadius": [4, 4, 4, 4], - "fill": "#3b82f6", - "alignItems": "center", - "justifyContent": "center", - "children": [ - { - "type": "text", - "id": "gh_save_text", - "content": "Save", - "fontFamily": "Inter", - "fontSize": 13, - "fill": "#ffffff" - } - ] - } + { "type": "text", "id": "gofix01-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" }, + { "type": "text", "id": "gofix01-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" } ] } ] @@ -306,122 +72,181 @@ }, { "type": "frame", - "id": "gh_error_frame", - "name": "GitHub OAuth — Error with Retry", - "x": 620, + "id": "gofix02", + "x": 660, "y": 0, - "width": 520, - "height": "fit_content(300)", - "fill": "#ffffff", + "name": "GitHub OAuth Fix — Device code waiting state", + "clip": true, + "width": 560, + "height": 420, + "fill": "$--background", + "cornerRadius": 12, "layout": "vertical", - "padding": 24, - "gap": 16, - "cornerRadius": [8, 8, 8, 8], - "stroke": "#e5e5e5", - "strokeWidth": 1, - "theme": { - "Mode": "Light" - }, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, "children": [ { "type": "frame", - "id": "gh_err_header", + "id": "gofix02-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" }, + "children": [ + { "type": "text", "id": "gofix02-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" }, + { "type": "frame", "id": "gofix02-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix02-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] } + ] + }, + { + "type": "frame", + "id": "gofix02-desc", + "name": "Description", + "width": "fill_container", + "padding": [16, 24], + "children": [ + { "type": "text", "id": "gofix02-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 } + ] + }, + { + "type": "frame", + "id": "gofix02-body", + "name": "DeviceCodeCard", + "width": "fill_container", "layout": "vertical", - "width": "fill_container", - "gap": 4, - "children": [ - { - "type": "text", - "id": "gh_err_title", - "content": "GitHub", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "600", - "fill": "#1a1a1a" - }, - { - "type": "text", - "id": "gh_err_desc", - "content": "Connect your GitHub account to clone and sync vaults.", - "fontFamily": "Inter", - "fontSize": 12, - "fill": "#888888", - "lineHeight": 1.5 - } - ] - }, - { - "type": "frame", - "id": "gh_login_btn", - "name": "Login Button", - "width": "fill_container", - "height": 36, - "fill": "#1a1a1a", - "cornerRadius": [6, 6, 6, 6], - "layout": "horizontal", - "gap": 8, - "alignItems": "center", - "justifyContent": "center", - "children": [ - { - "type": "text", - "id": "gh_login_icon", - "content": "⬤", - "fontFamily": "Inter", - "fontSize": 14, - "fill": "#ffffff" - }, - { - "type": "text", - "id": "gh_login_text", - "content": "Login with GitHub", - "fontFamily": "Inter", - "fontSize": 13, - "fontWeight": "500", - "fill": "#ffffff" - } - ] - }, - { - "type": "frame", - "id": "gh_error_row", - "name": "Error + Retry", - "layout": "horizontal", - "gap": 8, + "gap": 12, + "padding": [24, 24], "alignItems": "center", "children": [ { - "type": "text", - "id": "gh_error_msg", - "content": "Authorization expired. Please try again.", - "fontFamily": "Inter", - "fontSize": 12, - "fill": "#e03e3e" + "type": "frame", + "id": "gofix02-card", + "name": "CodeCard", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [16, 24], + "cornerRadius": 8, + "alignItems": "center", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "children": [ + { "type": "text", "id": "gofix02-hint", "content": "Enter this code on GitHub:", "fontSize": 12, "fill": "$--muted-foreground" }, + { + "type": "frame", + "id": "gofix02-code-row", + "gap": 8, + "alignItems": "center", + "children": [ + { "type": "text", "id": "gofix02-code", "content": "ABCD-1234", "fontSize": 24, "fontWeight": 700, "letterSpacing": 4, "fontFamily": "monospace", "fill": "$--foreground" }, + { "type": "text", "id": "gofix02-copy", "content": "[copy]", "fontSize": 12, "fill": "$--muted-foreground" } + ] + }, + { "type": "text", "id": "gofix02-url", "content": "https://github.com/login/device", "fontSize": 12, "fill": "$--muted-foreground", "textDecoration": "underline" }, + { + "type": "frame", + "id": "gofix02-spinner-row", + "gap": 6, + "alignItems": "center", + "children": [ + { "type": "text", "id": "gofix02-waiting", "content": "Waiting for authorization...", "fontSize": 12, "fill": "$--muted-foreground" } + ] + } + ] }, { "type": "frame", - "id": "gh_retry_btn", - "name": "Retry Button", - "layout": "horizontal", - "gap": 4, + "id": "gofix02-cancel-btn", + "name": "CancelButton", + "width": 80, + "height": 32, + "cornerRadius": 6, + "alignItems": "center", + "justifyContent": "center", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "children": [ + { "type": "text", "id": "gofix02-cancel-label", "content": "Cancel", "fontSize": 12, "fill": "$--muted-foreground" } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "gofix03", + "x": 1320, + "y": 0, + "name": "GitHub OAuth Fix — Expired/Error state with retry", + "clip": true, + "width": 560, + "height": 420, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "children": [ + { + "type": "frame", + "id": "gofix03-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" }, + "children": [ + { "type": "text", "id": "gofix03-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" }, + { "type": "frame", "id": "gofix03-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix03-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] } + ] + }, + { + "type": "frame", + "id": "gofix03-desc", + "name": "Description", + "width": "fill_container", + "padding": [16, 24], + "children": [ + { "type": "text", "id": "gofix03-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 } + ] + }, + { + "type": "frame", + "id": "gofix03-body", + "name": "ErrorState", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [24, 24], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gofix03-login-btn", + "name": "LoginButton", + "width": 200, + "height": 36, + "fill": "$--foreground", + "cornerRadius": 6, + "alignItems": "center", + "justifyContent": "center", + "gap": 8, + "children": [ + { "type": "text", "id": "gofix03-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" }, + { "type": "text", "id": "gofix03-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" } + ] + }, + { + "type": "frame", + "id": "gofix03-error-row", + "gap": 8, "alignItems": "center", "children": [ - { - "type": "text", - "id": "gh_retry_icon", - "content": "↻", - "fontFamily": "Inter", - "fontSize": 12, - "fill": "#e03e3e" - }, - { - "type": "text", - "id": "gh_retry_text", - "content": "Retry", - "fontFamily": "Inter", - "fontSize": 12, - "fill": "#e03e3e" - } + { "type": "text", "id": "gofix03-error-msg", "content": "Authorization expired. Please try again.", "fontSize": 12, "fill": "$--destructive" }, + { "type": "text", "id": "gofix03-retry-label", "content": "Retry", "fontSize": 12, "fill": "$--destructive" } ] } ] @@ -430,4 +255,4 @@ } ], "variables": {} -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 33be115e..74887052 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -389,6 +389,7 @@ function App() { onClose={dialogs.closeGitHubVault} onVaultCloned={vaultSwitcher.handleVaultCloned} onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }} + onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })} /> ) diff --git a/src/components/GitHubDeviceFlow.tsx b/src/components/GitHubDeviceFlow.tsx new file mode 100644 index 00000000..9bc13813 --- /dev/null +++ b/src/components/GitHubDeviceFlow.tsx @@ -0,0 +1,226 @@ +import { useState, useRef, useCallback, useEffect } from 'react' +import { GithubLogo, 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 { DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types' + +function tauriCall(cmd: string, args: Record = {}): Promise { + return isTauri() ? invoke(cmd, args) : mockInvoke(cmd, args) +} + +type OAuthStatus = 'idle' | 'waiting' | 'error' + +/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */ +function processPollResult( + result: DeviceFlowPollResult, + callbacks: { + onComplete: (token: string) => Promise + onExpired: () => void + onError: (msg: string) => void + }, +): 'done' | 'continue' { + if (result.status === 'complete' && result.access_token) { + callbacks.onComplete(result.access_token) + return 'done' + } + if (result.status === 'expired') { + callbacks.onExpired() + return 'done' + } + if (result.status === 'error') { + callbacks.onError(result.error ?? 'Authorization failed.') + return 'done' + } + return 'continue' +} + +interface GitHubDeviceFlowProps { + onConnected: (token: string, username: string) => void +} + +export function GitHubDeviceFlow({ onConnected }: GitHubDeviceFlowProps) { + 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) + + const stopPolling = useCallback(() => { + pollingRef.current = false + deviceCodeRef.current = null + }, []) + + useEffect(() => { + return () => { pollingRef.current = false } + }, []) + + const handleLogin = useCallback(async () => { + setOauthStatus('waiting') + setErrorMessage(null) + setUserCode(null) + + try { + const flowStart = await tauriCall('github_device_flow_start') + setUserCode(flowStart.user_code) + setVerificationUri(flowStart.verification_uri) + deviceCodeRef.current = flowStart.device_code + openExternalUrl(flowStart.verification_uri).catch(() => {}) + + pollingRef.current = true + const intervalMs = Math.max(flowStart.interval * 1000, 5000) + + const pollLoop = async () => { + while (pollingRef.current && deviceCodeRef.current) { + await new Promise(r => setTimeout(r, intervalMs)) + if (!pollingRef.current) break + + const result = await tauriCall('github_device_flow_poll', { + deviceCode: deviceCodeRef.current, + }) + const outcome = processPollResult(result, { + onComplete: async (token) => { + const user = await tauriCall('github_get_user', { token }) + stopPolling() + setOauthStatus('idle') + setUserCode(null) + onConnected(token, user.login) + }, + onExpired: () => { + stopPolling() + setOauthStatus('error') + setErrorMessage('Authorization expired. Please try again.') + }, + onError: (msg) => { + stopPolling() + setOauthStatus('error') + setErrorMessage(msg) + }, + }) + if (outcome === 'done') return + } + } + + pollLoop().catch(err => { + stopPolling() + setOauthStatus('error') + setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.') + }) + } catch (err) { + setOauthStatus('error') + setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.') + } + }, [onConnected, stopPolling]) + + const resetOAuth = useCallback(() => { + stopPolling() + setOauthStatus('idle') + setUserCode(null) + setVerificationUri(null) + setErrorMessage(null) + }, [stopPolling]) + + if (oauthStatus === 'waiting' && userCode) { + return + } + + return { resetOAuth(); handleLogin() } : undefined} /> +} + +function DeviceCodeView({ 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} +
+ +
+ {verificationUri && ( + + )} +
+ + Waiting for authorization... +
+
+ +
+ ) +} + +function LoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) { + return ( +
+ + {errorMessage && ( +
+ {errorMessage} + {onRetry && ( + + )} +
+ )} +
+ ) +} diff --git a/src/components/GitHubVaultModal.test.tsx b/src/components/GitHubVaultModal.test.tsx index 5d9da6e7..a904a70a 100644 --- a/src/components/GitHubVaultModal.test.tsx +++ b/src/components/GitHubVaultModal.test.tsx @@ -7,6 +7,9 @@ vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: vi.fn(), })) +vi.mock('../utils/url', () => ({ + openExternalUrl: vi.fn().mockResolvedValue(undefined), +})) import { mockInvoke } from '../mock-tauri' const mockInvokeFn = vi.mocked(mockInvoke) @@ -20,6 +23,7 @@ describe('GitHubVaultModal', () => { const onClose = vi.fn() const onVaultCloned = vi.fn() const onOpenSettings = vi.fn() + const onGitHubConnected = vi.fn() beforeEach(() => { vi.clearAllMocks() @@ -38,7 +42,7 @@ describe('GitHubVaultModal', () => { expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument() }) - it('shows connect prompt when no GitHub token', () => { + it('shows settings fallback when no token and no onGitHubConnected', () => { render( ) @@ -54,6 +58,37 @@ describe('GitHubVaultModal', () => { expect(onOpenSettings).toHaveBeenCalled() }) + it('shows inline device flow when no token and onGitHubConnected is provided', () => { + render( + + ) + expect(screen.getByTestId('github-login')).toBeInTheDocument() + expect(screen.getByText('Login with GitHub')).toBeInTheDocument() + expect(screen.queryByTestId('github-open-settings')).not.toBeInTheDocument() + }) + + it('shows device code after starting inline OAuth flow', async () => { + mockInvokeFn.mockImplementation(async (cmd: string) => { + if (cmd === 'github_device_flow_start') { + return { device_code: 'dc_modal', user_code: 'MODAL-5678', 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(() => { + expect(screen.getByTestId('github-user-code')).toHaveTextContent('MODAL-5678') + }) + }) + it('shows clone and create tabs when token is present', async () => { render( diff --git a/src/components/GitHubVaultModal.tsx b/src/components/GitHubVaultModal.tsx index 9ebb2330..187ad836 100644 --- a/src/components/GitHubVaultModal.tsx +++ b/src/components/GitHubVaultModal.tsx @@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' +import { GitHubDeviceFlow } from './GitHubDeviceFlow' import type { GithubRepo } from '../types' function tauriCall(cmd: string, args: Record): Promise { @@ -18,6 +19,7 @@ interface GitHubVaultModalProps { onClose: () => void onVaultCloned: (path: string, label: string) => void onOpenSettings: () => void + onGitHubConnected?: (token: string, username: string) => void } type CloneStatus = 'idle' | 'cloning' | 'success' | 'error' @@ -244,7 +246,7 @@ function CloningProgress({ repoName }: { repoName: string }) { ) } -export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings }: GitHubVaultModalProps) { +export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings, onGitHubConnected }: GitHubVaultModalProps) { const [tab, setTab] = useState('clone') const [repos, setRepos] = useState([]) const [loading, setLoading] = useState(false) @@ -365,15 +367,21 @@ export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, on Connect your GitHub account to clone or create vaults backed by GitHub repos.
-

- You need to connect your GitHub account first. Add your GitHub token in Settings. -

- + {onGitHubConnected ? ( + + ) : ( + <> +

+ You need to connect your GitHub account first. Add your GitHub token in Settings. +

+ + + )}
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 30021554..1fcdd002 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,13 +1,7 @@ -import { useState, useRef, useCallback, useEffect } from '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 { - return isTauri() ? invoke(cmd, args) : mockInvoke(cmd, args) -} +import { useState, useRef, useCallback } from 'react' +import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react' +import { GitHubDeviceFlow } from './GitHubDeviceFlow' +import type { Settings } from '../types' interface SettingsPanelProps { open: boolean @@ -74,8 +68,6 @@ function KeyField({ label, placeholder, value, onChange, onClear }: KeyFieldProp // --- GitHub OAuth Section --- -type OAuthStatus = 'idle' | 'waiting' | 'error' - interface GitHubSectionProps { githubUsername: string | null githubToken: string | null @@ -83,125 +75,14 @@ interface GitHubSectionProps { onDisconnect: () => void } -/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */ -function processPollResult( - result: DeviceFlowPollResult, - callbacks: { - onComplete: (token: string) => Promise - onExpired: () => void - onError: (msg: string) => void - }, -): 'done' | 'continue' { - if (result.status === 'complete' && result.access_token) { - callbacks.onComplete(result.access_token) - return 'done' - } - if (result.status === 'expired') { - callbacks.onExpired() - return 'done' - } - if (result.status === 'error') { - callbacks.onError(result.error ?? 'Authorization failed.') - return 'done' - } - return 'continue' // status === 'pending' -} - 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) - const isConnected = !!githubToken && !!githubUsername - const stopPolling = useCallback(() => { - pollingRef.current = false - deviceCodeRef.current = null - }, []) - - useEffect(() => { - return () => { pollingRef.current = false } - }, []) - - const handleLogin = useCallback(async () => { - setOauthStatus('waiting') - setErrorMessage(null) - setUserCode(null) - - try { - const flowStart = await tauriCall('github_device_flow_start') - setUserCode(flowStart.user_code) - setVerificationUri(flowStart.verification_uri) - deviceCodeRef.current = flowStart.device_code - 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) - - const pollLoop = async () => { - while (pollingRef.current && deviceCodeRef.current) { - await new Promise(r => setTimeout(r, intervalMs)) - if (!pollingRef.current) break - - const result = await tauriCall('github_device_flow_poll', { - deviceCode: deviceCodeRef.current, - }) - const outcome = processPollResult(result, { - onComplete: async (token) => { - const user = await tauriCall('github_get_user', { token }) - stopPolling() - setOauthStatus('idle') - setUserCode(null) - onConnected(token, user.login) - }, - onExpired: () => { - stopPolling() - setOauthStatus('error') - setErrorMessage('Authorization expired. Please try again.') - }, - onError: (msg) => { - stopPolling() - setOauthStatus('error') - setErrorMessage(msg) - }, - }) - if (outcome === 'done') return - } - } - - pollLoop().catch(err => { - stopPolling() - setOauthStatus('error') - setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.') - }) - } catch (err) { - setOauthStatus('error') - setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.') - } - }, [onConnected, stopPolling]) - - const resetOAuth = useCallback(() => { - stopPolling() - setOauthStatus('idle') - setUserCode(null) - setVerificationUri(null) - setErrorMessage(null) - }, [stopPolling]) - - if (isConnected && oauthStatus === 'idle') { - return { resetOAuth(); onDisconnect() }} /> + if (isConnected) { + return } - if (oauthStatus === 'waiting' && userCode) { - return - } - - return { resetOAuth(); handleLogin() } : undefined} /> + return } function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) { @@ -230,103 +111,6 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi ) } -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} -
- -
- {verificationUri && ( - - )} -
- - Waiting for authorization... -
-
- -
- ) -} - -function GitHubLoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) { - return ( -
- - {errorMessage && ( -
- {errorMessage} - {onRetry && ( - - )} -
- )} -
- ) -} - // --- Settings Panel --- export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) { diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 6048dcfd..6e05bc6b 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -77,8 +77,8 @@ let mockSettings: Settings = { anthropic_key: null, openai_key: null, google_key: null, - github_token: 'gho_mock_token_for_testing', - github_username: 'lucaong', + github_token: null, + github_username: null, auto_pull_interval_minutes: 5, }