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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user