feat(FN-4554): complete Step 4 — surface copilot device code in UI

Fusion-Task-Id: FN-4554
Fusion-Task-Lineage: 12d517c5-1954-4b27-8c63-c7f0dde93d3f
This commit is contained in:
Fusion
2026-05-14 21:21:36 -07:00
committed by gsxdsm
parent 90dcc0cd2d
commit 6d072f47f6
5 changed files with 186 additions and 6 deletions

View File

@@ -1449,3 +1449,38 @@
gap: var(--space-sm);
color: var(--text-muted);
}
.auth-device-code-panel {
margin-top: var(--space-sm);
padding: var(--space-sm);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--surface) 85%, transparent);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.auth-device-code-pill {
font-family: var(--font-mono);
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
letter-spacing: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-pill);
border: var(--btn-border-width) solid var(--border);
background: var(--card);
color: var(--text);
display: inline-flex;
align-self: flex-start;
}
@media (max-width: 768px) {
.auth-device-code-panel {
width: 100%;
}
.auth-device-code-pill {
max-width: 100%;
overflow-wrap: anywhere;
}
}

View File

@@ -2,7 +2,7 @@ import "./ModelOnboardingModal.css";
import { useState, useEffect, useCallback, useRef } from "react";
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus } from "lucide-react";
import { getErrorMessage, type Task } from "@fusion/core";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, CustomProvider, CustomProviderConfig } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, CustomProvider, CustomProviderConfig, OAuthDeviceCodeInfo } from "../api";
import {
fetchAuthStatus,
fetchGlobalSettings,
@@ -588,6 +588,7 @@ export function ModelOnboardingModal({
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
const [manualCodeConfigs, setManualCodeConfigs] = useState<Record<string, ManualOAuthCodeInfo>>({});
const [deviceCodes, setDeviceCodes] = useState<Record<string, OAuthDeviceCodeInfo>>({});
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
@@ -1107,6 +1108,14 @@ export function ModelOnboardingModal({
delete next[providerId];
return next;
});
setDeviceCodes((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
};
clearAuthLoginUiState();
@@ -1117,14 +1126,17 @@ export function ModelOnboardingModal({
pollCountRef.current = 0;
try {
const { url, instructions, manualCode } = await loginProvider(providerId);
const { url, instructions, manualCode, deviceCode } = await loginProvider(providerId);
if (instructions?.trim()) {
setLoginInstructions((prev) => ({ ...prev, [providerId]: instructions }));
}
if (manualCode) {
setManualCodeConfigs((prev) => ({ ...prev, [providerId]: manualCode }));
}
window.open(appendTokenQuery(url), "_blank");
if (deviceCode && providerId === "github-copilot") {
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
}
window.open(appendTokenQuery(deviceCode?.verificationUri ?? url), "_blank");
// Poll for auth completion
pollIntervalRef.current = setInterval(async () => {
@@ -1278,6 +1290,14 @@ export function ModelOnboardingModal({
return next;
});
setManualCodeSubmitInProgress((prev) => prev === providerId ? null : prev);
setDeviceCodes((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
}
}, [addToast, loadAuthStatus]);
@@ -1966,6 +1986,20 @@ export function ModelOnboardingModal({
</button>
)}
</div>
{(authActionInProgress === provider.id || showRemoteLoginInProgress) && provider.id === "github-copilot" && deviceCodes[provider.id] && (
<div className="auth-device-code-panel" data-testid={`onboarding-device-code-${provider.id}`}>
<strong>Enter this code on GitHub</strong>
<div className="auth-device-code-pill">{deviceCodes[provider.id].userCode}</div>
<div className="auth-provider-actions-row">
<button className="btn btn-sm" onClick={() => { void navigator.clipboard?.writeText(deviceCodes[provider.id].userCode); }}>
Copy code
</button>
<button className="btn btn-sm" onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}>
Open GitHub
</button>
</div>
</div>
)}
{(authActionInProgress === provider.id || showRemoteLoginInProgress) && loginInstructions[provider.id] && (
<LoginInstructions
instructions={loginInstructions[provider.id]}

View File

@@ -1507,6 +1507,30 @@
gap: var(--space-sm);
}
.auth-device-code-panel {
margin-top: var(--space-sm);
padding: var(--space-sm);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--surface) 85%, transparent);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.auth-device-code-pill {
font-family: var(--font-mono);
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
letter-spacing: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-pill);
border: var(--btn-border-width) solid var(--border);
background: var(--card);
color: var(--text);
display: inline-flex;
align-self: flex-start;
}
/* === Key Hint === */
.auth-key-hint {
font-family: var(--font-mono);
@@ -1860,6 +1884,15 @@
justify-content: flex-end;
}
.auth-device-code-panel {
width: 100%;
}
.auth-device-code-pill {
max-width: 100%;
overflow-wrap: anywhere;
}
.auth-provider-info {
width: 100%;
flex-basis: 100%;

View File

@@ -13,7 +13,7 @@ import {
} from "@fusion/core";
import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type { ToastType } from "../hooks/useToast";
@@ -519,6 +519,7 @@ export function SettingsModal({
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
const [manualCodeConfigs, setManualCodeConfigs] = useState<Record<string, ManualOAuthCodeInfo>>({});
const [deviceCodes, setDeviceCodes] = useState<Record<string, OAuthDeviceCodeInfo>>({});
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState<string | null>(null);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
@@ -995,6 +996,14 @@ export function SettingsModal({
delete next[providerId];
return next;
});
setDeviceCodes((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
}, []);
const handleLogin = useCallback(async (providerId: string) => {
@@ -1016,14 +1025,17 @@ export function SettingsModal({
clearAuthLoginUiState(providerId);
try {
const { url, instructions, manualCode } = await loginProvider(providerId);
const { url, instructions, manualCode, deviceCode } = await loginProvider(providerId);
if (instructions?.trim()) {
setLoginInstructions((prev) => ({ ...prev, [providerId]: instructions }));
}
if (manualCode) {
setManualCodeConfigs((prev) => ({ ...prev, [providerId]: manualCode }));
}
window.open(appendTokenQuery(url), "_blank");
if (deviceCode && providerId === "github-copilot") {
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
}
window.open(appendTokenQuery(deviceCode?.verificationUri ?? url), "_blank");
// Poll for auth completion every 2 seconds
pollIntervalRef.current = setInterval(async () => {
@@ -6229,6 +6241,28 @@ export function SettingsModal({
Login
</button>
)}
{provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<div className="auth-device-code-panel" data-testid={`auth-device-code-${provider.id}`}>
<strong>Enter this code on GitHub</strong>
<div className="auth-device-code-pill">{deviceCodes[provider.id].userCode}</div>
<div className="auth-provider-actions-row">
<button
className="btn btn-sm"
onClick={() => {
void navigator.clipboard?.writeText(deviceCodes[provider.id].userCode);
}}
>
Copy code
</button>
<button
className="btn btn-sm"
onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}
>
Open GitHub
</button>
</div>
</div>
)}
{loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<LoginInstructions
instructions={loginInstructions[provider.id]}

View File

@@ -1623,6 +1623,50 @@ describe("SettingsModal", () => {
});
});
it("renders github copilot device code panel and handles copy/open actions", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetchAuthStatus
.mockResolvedValueOnce({
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth" }],
})
.mockResolvedValueOnce({
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: true }],
})
.mockResolvedValueOnce({
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth" }],
});
mockLoginProvider.mockResolvedValueOnce({
url: "https://auth.example.com/login",
deviceCode: {
userCode: "ABCD-1234",
verificationUri: "https://github.com/login/device",
},
});
renderModal();
await waitForSettingsModalReady();
const copilotCard = screen.getByTestId("auth-provider-icon-github-copilot").closest(".auth-provider-card") as HTMLElement;
await userEvent.click(within(copilotCard).getByRole("button", { name: "Login" }));
expect(await within(copilotCard).findByText("ABCD-1234")).toBeInTheDocument();
await userEvent.click(within(copilotCard).getByRole("button", { name: "Copy code" }));
expect(writeText).toHaveBeenCalledWith("ABCD-1234");
await userEvent.click(within(copilotCard).getByRole("button", { name: "Open GitHub" }));
expect(openSpy).toHaveBeenCalledWith("https://github.com/login/device", "_blank");
await waitFor(() => {
expect(within(copilotCard).queryByText("ABCD-1234")).not.toBeInTheDocument();
}, { timeout: 5000 });
});
it("scrolls settings content to top after API key save succeeds", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }],