Merge pull request #36 from refactoringhq/task/github-oauth-login

fix: surface actual error messages in GitHub OAuth login flow
This commit is contained in:
Luca Rossi
2026-02-24 12:12:36 +01:00
committed by GitHub
3 changed files with 86 additions and 5 deletions

View File

@@ -2,8 +2,10 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
/// GitHub OAuth App client ID. Replace with your registered GitHub App's client_id.
const GITHUB_CLIENT_ID: &str = "Ov23liCuBz7Z5hKk6T8c";
/// GitHub App client ID for OAuth device flow.
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GithubRepo {
@@ -175,14 +177,23 @@ async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlow
let response = client
.post(format!("{}/login/device/code", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
.send()
.await
.map_err(|e| format!("Device flow request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Device flow start failed: {}", body));
if status.as_u16() == 404 {
return Err(
"GitHub device flow not available. Ensure a GitHub App is registered with \
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
.to_string(),
);
}
return Err(format!("Device flow start failed ({}): {}", status, body));
}
response
@@ -204,6 +215,7 @@ async fn github_device_flow_poll_with_base(
let response = client
.post(format!("{}/login/oauth/access_token", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[
("client_id", GITHUB_CLIENT_ID),
("device_code", device_code),
@@ -794,6 +806,24 @@ mod tests {
assert!(result.unwrap_err().contains("Device flow start failed"));
}
#[tokio::test]
async fn test_github_device_flow_start_404_gives_clear_message() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/login/device/code")
.with_status(404)
.with_body(r#"{"error":"Not Found"}"#)
.create_async()
.await;
let result = github_device_flow_start_with_base(&server.url()).await;
mock.assert_async().await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("device flow not available"));
assert!(err.contains("Device authorization flow"));
}
#[tokio::test]
async fn test_github_device_flow_poll_complete() {
let mut server = mockito::Server::new_async().await;

View File

@@ -291,5 +291,56 @@ describe('SettingsPanel', () => {
)
expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument()
})
it('displays the actual backend error string when device flow start fails', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'github_device_flow_start') {
// Tauri invoke rejects with a plain string, not an Error instance
throw 'GitHub device flow not available. Ensure a GitHub App is registered.'
}
return null
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('github-login'))
await waitFor(() => {
expect(screen.getByTestId('github-error')).toHaveTextContent(
'GitHub device flow not available. Ensure a GitHub App is registered.'
)
})
})
it('prevents double-click by disabling button during login flow', async () => {
let resolveStart: ((v: unknown) => void) | null = null
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'github_device_flow_start') {
return new Promise(r => { resolveStart = r })
}
return null
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement
fireEvent.click(loginBtn)
// Button should be disabled while waiting
await waitFor(() => {
expect(loginBtn.disabled).toBe(true)
})
// Clean up
resolveStart?.({
device_code: 'dc', user_code: 'UC-1234',
verification_uri: 'https://github.com/login/device',
expires_in: 900, interval: 5,
})
})
})
})

View File

@@ -172,11 +172,11 @@ function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect
pollLoop().catch(err => {
stopPolling()
setOauthStatus('error')
setErrorMessage(err instanceof Error ? err.message : 'Polling failed.')
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
})
} catch (err) {
setOauthStatus('error')
setErrorMessage(err instanceof Error ? err.message : 'Failed to start login.')
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
}
}, [onConnected, stopPolling])