feat(FN-2667): improve login instruction device code handling

- Broaden device-code extraction to support contextual, enter/use, dashed, and short alphanumeric formats
- Auto-copy the device code only when it first appears and keep manual copy feedback behavior
- Move login instruction styles into a dedicated LoginInstructions.css file and remove duplicate SettingsModal styles
- Add LoginInstructions component tests for extraction formats, auto-copy behavior, and copy button state
This commit is contained in:
Fusion
2026-04-27 02:24:21 -07:00
committed by gsxdsm
parent 7806724cf2
commit ed208452a7
4 changed files with 199 additions and 58 deletions

View File

@@ -0,0 +1,60 @@
.auth-login-instructions {
margin-top: var(--space-sm);
margin-bottom: 0;
padding: var(--space-sm) var(--space-md);
border: 1px solid color-mix(in srgb, var(--color-info) 35%, var(--border));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-info) 8%, transparent);
color: var(--text);
line-height: 1.5;
white-space: pre-wrap;
}
.device-code-wrapper {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin: 0 var(--space-xs);
}
.device-code {
font-family: var(--font-mono);
font-size: calc(var(--space-sm) + var(--space-xs));
font-weight: 600;
padding: var(--space-xs) var(--space-sm);
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;
inline-size: calc(var(--space-md) * 3);
block-size: calc(var(--space-md) * 3);
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),
box-shadow var(--transition-fast);
}
.device-code-copy-btn:hover {
color: var(--text);
background: var(--card-hover);
border-color: var(--text-muted);
}
.device-code-copy-btn:focus-visible {
outline: none;
border-color: var(--todo);
box-shadow: var(--focus-ring-strong);
}

View File

@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Copy, Check } from "lucide-react";
import { useState } from "react";
import "./LoginInstructions.css";
interface LoginInstructionsProps {
instructions: string;
@@ -9,11 +9,30 @@ interface LoginInstructionsProps {
/**
* Extract a device code from OAuth login instructions.
* Matches patterns like "GH-2469", "ABCD-1234", "1234-ABCD", etc.
* Matches patterns like "GH-2469", "ABCD-1234", "1234-ABCD", and short alphanumeric codes.
*/
function extractDeviceCode(text: string): string | null {
const match = text.match(/\bcode\s+([A-Z0-9]+(?:-[A-Z0-9]+)+)\b/i);
return match?.[1] ?? null;
const codePattern = "([A-Z0-9]{2,}(?:-[A-Z0-9]{2,})+|[A-Z0-9]{6,9})";
const contextualPattern = new RegExp(`\\b(?:device\\s+code|your\\s+code|code)\\s*(?:is|:)?\\s*${codePattern}\\b`, "i");
const enterPattern = new RegExp(`\\b(?:enter|use)\\s+${codePattern}\\b`, "i");
const standaloneDashedPattern = /\b([A-Z0-9]{2,}(?:-[A-Z0-9]{2,})+)\b/;
const contextualMatch = text.match(contextualPattern);
if (contextualMatch?.[1]) {
return contextualMatch[1];
}
const enterMatch = text.match(enterPattern);
if (enterMatch?.[1]) {
return enterMatch[1];
}
const dashedMatch = text.match(standaloneDashedPattern);
if (dashedMatch?.[1]) {
return dashedMatch[1];
}
return null;
}
/**
@@ -23,17 +42,42 @@ function extractDeviceCode(text: string): string | null {
export function LoginInstructions({ instructions, "data-testid": testId }: LoginInstructionsProps) {
const [copied, setCopied] = useState(false);
const deviceCode = extractDeviceCode(instructions);
const previousDeviceCodeRef = useRef<string | null>(null);
const markCopied = useCallback(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, []);
const handleCopy = useCallback(async () => {
const textToCopy = deviceCode ?? instructions;
try {
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
markCopied();
} catch {
// Ignore copy failures
}
}, [deviceCode, instructions]);
}, [deviceCode, instructions, markCopied]);
useEffect(() => {
const shouldAutoCopy = previousDeviceCodeRef.current === null && deviceCode !== null;
previousDeviceCodeRef.current = deviceCode;
if (!shouldAutoCopy) {
return;
}
const autoCopy = async () => {
try {
await navigator.clipboard.writeText(deviceCode);
markCopied();
} catch {
// Ignore copy failures
}
};
void autoCopy();
}, [deviceCode, markCopied]);
return (
<p className="auth-login-instructions" data-testid={testId}>

View File

@@ -659,56 +659,6 @@
border-top: 1px solid var(--border);
margin-top: 8px;
}
.auth-login-instructions {
margin-top: var(--space-sm);
margin-bottom: 0;
padding: var(--space-sm) var(--space-md);
border: 1px solid color-mix(in srgb, var(--color-info) 35%, var(--border));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-info) 8%, transparent);
color: var(--text);
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

@@ -0,0 +1,87 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { LoginInstructions } from "../LoginInstructions";
describe("LoginInstructions", () => {
let writeTextMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(window.navigator, "clipboard", {
value: { writeText: writeTextMock },
configurable: true,
});
});
it("renders plain instructions without a device code", () => {
render(<LoginInstructions instructions="Open in browser and continue." data-testid="instructions" />);
const instructions = screen.getByTestId("instructions");
expect(instructions).toHaveClass("auth-login-instructions");
expect(instructions).toHaveTextContent("Open in browser and continue.");
expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument();
expect(instructions.querySelector("code")).toBeNull();
});
it("extracts and highlights device code with copy button", () => {
render(<LoginInstructions instructions="Enter device code GH-2469 on github.com/login/device." />);
expect(screen.getByText("GH-2469")).toBeInTheDocument();
expect(screen.getByText("GH-2469").tagName.toLowerCase()).toBe("code");
expect(screen.getByRole("button", { name: /copy device code/i })).toBeInTheDocument();
});
it("copy button copies the device code and updates button state", async () => {
render(<LoginInstructions instructions="Enter device code GH-2469 on github.com/login/device." />);
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("GH-2469");
});
writeTextMock.mockClear();
fireEvent.click(screen.getByRole("button", { name: /cop(y|ied)/i }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("GH-2469");
expect(screen.getByRole("button", { name: "Copied to clipboard" })).toBeInTheDocument();
});
});
it("auto-copies the device code on appearance", async () => {
render(<LoginInstructions instructions="To authenticate, visit https://github.com/login/device and enter code: ABCD-1234" />);
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("ABCD-1234");
});
});
it("does not auto-copy when no device code exists", async () => {
render(<LoginInstructions instructions="Open in browser" />);
await waitFor(() => {
expect(writeTextMock).not.toHaveBeenCalled();
});
});
it.each([
["Enter device code ABCD-EFGH at github.com/login/device", "ABCD-EFGH"],
["Your code: XXXX-1234", "XXXX-1234"],
["Code: ABC123-XY at https://github.com/login/device", "ABC123-XY"],
["Open in browser", null],
])("matches expected device code format for '%s'", async (instructions, expectedCode) => {
render(<LoginInstructions instructions={instructions} />);
if (expectedCode) {
expect(screen.getByText(expectedCode)).toBeInTheDocument();
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(expectedCode);
});
return;
}
expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument();
await waitFor(() => {
expect(writeTextMock).not.toHaveBeenCalled();
});
});
});