feat(auth): add copy-to-clipboard for device codes in login instructions

Extract device codes from OAuth login instructions and render them with a copy button so users don't need to manually select and copy.
This commit is contained in:
gsxdsm
2026-04-26 11:44:33 -07:00
parent 845fed1d50
commit dd63ecec91
4 changed files with 117 additions and 15 deletions

View File

@@ -0,0 +1,67 @@
import { useCallback } from "react";
import { Copy, Check } from "lucide-react";
import { useState } from "react";
interface LoginInstructionsProps {
instructions: string;
"data-testid"?: string;
}
/**
* Extract a device code from OAuth login instructions.
* Matches patterns like "GH-2469", "ABCD-1234", "1234-ABCD", etc.
*/
function extractDeviceCode(text: string): string | null {
const match = text.match(/\bcode\s+([A-Z0-9]+(?:-[A-Z0-9]+)+)\b/i);
return match?.[1] ?? null;
}
/**
* Renders OAuth login instructions with a copy-to-clipboard button
* for the device code when one is detected.
*/
export function LoginInstructions({ instructions, "data-testid": testId }: LoginInstructionsProps) {
const [copied, setCopied] = useState(false);
const deviceCode = extractDeviceCode(instructions);
const handleCopy = useCallback(async () => {
const textToCopy = deviceCode ?? instructions;
try {
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Ignore copy failures
}
}, [deviceCode, instructions]);
return (
<p className="auth-login-instructions" data-testid={testId}>
{deviceCode ? (
<>
{instructions.split(deviceCode).map((part, i, arr) => (
<span key={i}>
{part}
{i < arr.length - 1 && (
<span className="device-code-wrapper">
<code className="device-code">{deviceCode}</code>
<button
className="device-code-copy-btn"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy code"}
aria-label={copied ? "Copied to clipboard" : "Copy device code"}
type="button"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
</span>
)}
</span>
))}
</>
) : (
instructions
)}
</p>
);
}

View File

@@ -18,6 +18,7 @@ import type { ToastType } from "../hooks/useToast";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { LoginInstructions } from "./LoginInstructions";
import { appendTokenQuery } from "../auth";
/** Provider-specific API key setup metadata for onboarding form rendering */
@@ -1716,12 +1717,10 @@ export function ModelOnboardingModal({
)}
</div>
{authActionInProgress === provider.id && loginInstructions[provider.id] && (
<p
className="auth-login-instructions"
<LoginInstructions
instructions={loginInstructions[provider.id]}
data-testid={`onboarding-login-instructions-${provider.id}`}
>
{loginInstructions[provider.id]}
</p>
/>
)}
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
<p className="onboarding-helper-text onboarding-inline-feedback">
@@ -2140,12 +2139,10 @@ export function ModelOnboardingModal({
</button>
)}
{authActionInProgress === "github" && loginInstructions.github && (
<p
className="auth-login-instructions"
<LoginInstructions
instructions={loginInstructions.github}
data-testid="onboarding-login-instructions-github"
>
{loginInstructions.github}
</p>
/>
)}
</div>
)}

View File

@@ -617,6 +617,45 @@
line-height: 1.5;
white-space: pre-wrap;
}
.device-code-wrapper {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin: 0 2px;
}
.device-code {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
padding: 2px 6px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
}
.device-code-copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast);
}
.device-code-copy-btn:hover {
color: var(--text);
background: var(--card-hover);
border-color: var(--text-muted);
}
.auth-apikey-section {
display: flex;
flex-direction: column;

View File

@@ -17,6 +17,7 @@ const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m)
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { PluginSlot } from "./PluginSlot";
import { AgentPromptsManager } from "./AgentPromptsManager";
import { LoginInstructions } from "./LoginInstructions";
import { ProviderIcon } from "./ProviderIcon";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
import { appendTokenQuery } from "../auth";
@@ -3969,12 +3970,10 @@ export function SettingsModal({
</button>
)}
{loginInstructions[provider.id] && (
<p
className="auth-login-instructions"
<LoginInstructions
instructions={loginInstructions[provider.id]}
data-testid={`auth-login-instructions-${provider.id}`}
>
{loginInstructions[provider.id]}
</p>
/>
)}
</div>
)}