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