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:
60
packages/dashboard/app/components/LoginInstructions.css
Normal file
60
packages/dashboard/app/components/LoginInstructions.css
Normal 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);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user