Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spire-ui/docs/WIDGETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ as well as its appearance: labels, empty states, pending-save lock-out and succe
| Person identity | `ActorPicker`, `actorLabel`; people are stable provider IDs with display metadata, distinct from credential accounts | [ActorPicker](../src/components/ActorPicker.tsx) |
| Select menu | Shared `Select` provides keyboard navigation, type-ahead and a portalled listbox. Native selects also use `field`; explain missing prerequisites and empty options | [Select](../src/components/Select.tsx), [SettingsContextProviders](../src/components/SettingsContextProviders.tsx) |
| Copyable values | `CopyField`, `CopyableValue`; avoid full URLs as button labels | [CopyField](../src/components/CopyField.tsx), [RepositoryDetail](../src/components/repositories/RepositoryDetail.tsx) |
| A code typed on another device | `HarnessSignInCode`: numbered steps in the order they are used, each with its one icon action — copy the code; copy the code and open the https page, copying first because a browser refuses a clipboard write once the new tab has focus. The status line says what was copied or how long is left | [HarnessSignInCode](../src/components/HarnessSignInCode.tsx) |
| Confirmation | `ConfirmDialog`; retain pending state and explicit action names | [ConfirmDialog](../src/components/ConfirmDialog.tsx) |
| Navigation | A visible icon with `ic`, size 16, alongside the page name — asserted for every rail entry, because Approvals shipped without one and read as unfinished | [App](../src/App.tsx) |
| **Create, edit or inspect one row** | `SidePanel`; a screen lists at full width, a panel beside it changes. Its form is a `fieldset` so `disabled` locks every control during a save. `tabs` splits a panel that does more than one job; `wide` suits a panel that carries a table | [SidePanel](../src/components/SidePanel.tsx), [RepositoryRegistryPage](../src/components/repositories/RepositoryRegistryPage.tsx) |
Expand Down
122 changes: 122 additions & 0 deletions spire-ui/src/components/HarnessSignInCode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { StrictMode } from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import HarnessSignInCode from './HarnessSignInCode';

// Placeholder link and code: this is about the buttons, not the vendor's real values.
const LINK = 'https://auth.example.test/device';
const CODE = 'TEST-12345';

let order: string[];
/** Settles the pending clipboard write, so a test decides when — and whether — the browser confirms. */
let settle: (ok: boolean) => void;

function clipboardThatWaits() {
Object.assign(navigator, {
clipboard: {
writeText: vi.fn((text: string) => {
order.push(`copy:${text}`);
return new Promise<void>((resolve, reject) => { settle = ok => (ok ? resolve() : reject(new Error('TEST denied'))); });
}),
},
});
}

beforeEach(() => {
order = [];
clipboardThatWaits();
vi.spyOn(window, 'open').mockImplementation((url?: string | URL) => { order.push(`open:${String(url)}`); return null; });
});
afterEach(cleanup);

const view = (remaining: string | null = '13m 30s') =>
render(<HarnessSignInCode verificationUri={LINK} userCode={CODE} remaining={remaining} />);

it('says the code was copied only once the browser confirms it', async () => {
view();

fireEvent.click(screen.getByRole('button', { name: 'Copy the code' }));
expect(order).toEqual([`copy:${CODE}`]);
expect(screen.getByRole('status')).not.toHaveTextContent('copied');

await act(async () => settle(true));
expect(screen.getByRole('status')).toHaveTextContent('Code copied.');
});

// Saying "copied" when it was not would send the operator to paste whatever was on the clipboard before.
it('says so when the browser refuses the copy', async () => {
view();

fireEvent.click(screen.getByRole('button', { name: 'Copy the code' }));
await act(async () => settle(false));

expect(screen.getByRole('status')).toHaveTextContent('The code could not be copied. Type it from here.');
});

it('says so when there is no clipboard to copy to', async () => {
Object.assign(navigator, { clipboard: undefined });
view();

await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Copy the code' })); });

expect(screen.getByRole('status')).toHaveTextContent('could not be copied');
});

// The copy is STARTED before the page opens (a browser refuses it once the new tab has focus), and the
// page opens in the same click (a window opened after waiting is blocked as a pop-up).
it('starts the copy, opens the page in the same click, and reports the copy when it lands', async () => {
view();

fireEvent.click(screen.getByRole('button', { name: 'Copy the code and open the sign-in page' }));
expect(order).toEqual([`copy:${CODE}`, `open:${LINK}`]);
expect(window.open).toHaveBeenCalledWith(LINK, '_blank', 'noopener,noreferrer');

await act(async () => settle(true));
expect(screen.getByRole('status')).toHaveTextContent('Paste it on the page that just opened');
});

it('says the page opened without the code when that copy fails', async () => {
view();

fireEvent.click(screen.getByRole('button', { name: 'Copy the code and open the sign-in page' }));
await act(async () => settle(false));

expect(screen.getByRole('status')).toHaveTextContent('The page opened, but the code could not be copied');
});

// The worker accepts only the vendor's own host; this is the second lock, where a click would follow it.
it('offers no link and no open button for an address that is not https', () => {
render(<HarnessSignInCode verificationUri="http://auth.example.test/device" userCode={CODE} remaining="13m 30s" />);

expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Copy the code and open the sign-in page' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy the code' })).toBeInTheDocument();
});

// The dev build runs under StrictMode, which mounts, unmounts and mounts again. The result of a copy
// must still reach the screen afterwards (review of PR #177).
it('still reports a copy after StrictMode mounts the panel twice', async () => {
render(<StrictMode><HarnessSignInCode verificationUri={LINK} userCode={CODE} remaining="13m 30s" /></StrictMode>);

fireEvent.click(screen.getByRole('button', { name: 'Copy the code' }));
await act(async () => settle(true));

expect(screen.getByRole('status')).toHaveTextContent('Code copied.');
});

// Asking the operator to type a code that has already run out is worse than saying it ran out.
it('says the code expired even after a copy failed', async () => {
const { rerender } = view();
fireEvent.click(screen.getByRole('button', { name: 'Copy the code' }));
await act(async () => settle(false));

rerender(<HarnessSignInCode verificationUri={LINK} userCode={CODE} remaining={null} />);

expect(screen.getByRole('status')).toHaveTextContent('The code has expired.');
});

it('says when the code has run out', () => {
view(null);

expect(screen.getByRole('status')).toHaveTextContent('The code has expired.');
});
123 changes: 123 additions & 0 deletions spire-ui/src/components/HarnessSignInCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react';
import { Check, Clock, Copy, ExternalLink } from 'lucide-react';
import Tooltip from './Tooltip';

interface Props {
verificationUri: string;
userCode: string;
/** "13m 30s", or null once the code has run out. Recomputed by the parent on every poll. */
remaining: string | null;
}

/** What the last click achieved. A copy is reported only once the browser has confirmed it. */
type Outcome = 'copied' | 'copy-failed' | 'opened-copied' | 'opened-copy-failed' | null;

/**
* What the operator does with a device code: open one page, type one code (M3.5 part F).
*
* <p>Two numbered steps rather than a link and a code in a column, because that is the order they are
* used in, and each carries the one action it needs.
*
* <p>The open action starts the copy, then opens the page in the same click. The copy is STARTED first
* because a browser refuses a clipboard write once focus has moved to the new tab; the page is opened
* without waiting for it because a window opened after an await is no longer part of the click, and a
* browser blocks it as a pop-up. So the result of the copy arrives after the page is open, and is
* reported then — "copied" only when the browser said so, never assumed (review of PR #177).
*
* <p>The link is offered only when it is https. The worker already accepts only the vendor's own host,
* so this is the second lock, placed where a click would follow it.
*/
export default function HarnessSignInCode({ verificationUri, userCode, remaining }: Props) {
const [outcome, setOutcome] = useState<Outcome>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const mounted = useRef(true);
// Set on every mount, not only at creation: React's development StrictMode mounts, unmounts and
// mounts again, and a flag cleared by the first unmount would silence every copy result after it
// (review of PR #177).
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; clearTimeout(timer.current); };
}, []);
const safeUri = isHttps(verificationUri) ? verificationUri : null;

function report(next: Outcome) {
if (!mounted.current) return;
setOutcome(next);
clearTimeout(timer.current);
// A failure stays until the next click: the operator has to act on it. A success fades.
if (next === 'copied' || next === 'opened-copied') timer.current = setTimeout(() => setOutcome(null), 2000);
}

function copyCode() {
void copy(userCode).then(done => report(done ? 'copied' : 'copy-failed'));
}

function copyAndOpen() {
if (!safeUri) return;
const copying = copy(userCode);
window.open(safeUri, '_blank', 'noopener,noreferrer');
void copying.then(done => report(done ? 'opened-copied' : 'opened-copy-failed'));
}

const failed = remaining !== null && (outcome === 'copy-failed' || outcome === 'opened-copy-failed');
return <div className="signin-code">
<ol className="signin-steps">
<li>
<span className="signin-step-title">Open the sign-in page</span>
<div className="reveal-value">
{safeUri
? <a className="mono signin-link" href={safeUri} target="_blank" rel="noopener noreferrer">{safeUri}</a>
: <span className="mono">{verificationUri}</span>}
{safeUri && <Tooltip label="Copy the code, then open this page">
<button type="button" className="icon-btn" aria-label="Copy the code and open the sign-in page"
onClick={copyAndOpen}>
{outcome === 'opened-copied' ? <Check size={16} /> : <ExternalLink size={16} />}
</button>
</Tooltip>}
</div>
</li>
<li>
<span className="signin-step-title">Sign in with the account the factory will use, then enter this code</span>
<div className="reveal-value">
<span className="mono signin-user-code" aria-label="One-time code">{userCode}</span>
<Tooltip label="Copy the code">
<button type="button" className="icon-btn" aria-label="Copy the code" onClick={copyCode}>
{outcome === 'copied' ? <Check size={16} /> : <Copy size={16} />}
</button>
</Tooltip>
</div>
</li>
</ol>
<p className={`signin-expiry${failed ? ' signin-copy-failed' : ''}`} role="status">
<Clock size={14} aria-hidden="true" />
{statusLine(outcome, remaining)}
</p>
</div>;
}

/** True only when the browser confirmed the write. A missing API or a refusal is a failure, said as one. */
function copy(text: string): Promise<boolean> {
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
if (!clipboard?.writeText) return Promise.resolve(false);
try {
return clipboard.writeText(text).then(() => true, () => false);
} catch {
return Promise.resolve(false);
}
}

/** How long the code has left, unless the last click has something to say about a code still usable. */
function statusLine(outcome: Outcome, remaining: string | null): string {
// Expiry wins: a copy failure asks the operator to type the code, which is pointless once it is gone.
if (!remaining) return 'The code has expired.';
if (outcome === 'opened-copied') return 'Code copied. Paste it on the page that just opened.';
if (outcome === 'opened-copy-failed') return 'The page opened, but the code could not be copied. Type it from here.';
if (outcome === 'copied') return 'Code copied.';
if (outcome === 'copy-failed') return 'The code could not be copied. Type it from here.';
return `The code expires in ${remaining}. This panel closes by itself once you approve.`;
}

function isHttps(uri: string): boolean {
try { return new URL(uri).protocol === 'https:'; }
catch { return false; }
}
2 changes: 1 addition & 1 deletion spire-ui/src/components/HarnessSubscriptionSignIn.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ it('shows the link and the code the operator must use', async () => {
// A new tab, and no referrer or opener handed to whatever is on the other side.
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
expect(screen.getByLabelText('One-time code')).toHaveTextContent('ABCD-12345');
expect(screen.getByText(/left$/)).toBeInTheDocument();
expect(screen.getByText(/^The code expires in \d+m \d{2}s\./)).toBeInTheDocument();
await waitFor(() => expect(api.startHarnessSignIn).toHaveBeenCalledWith('TEST-factory-seat', 'codex'));
});

Expand Down
22 changes: 11 additions & 11 deletions spire-ui/src/components/HarnessSubscriptionSignIn.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { cancelHarnessSignIn, fetchHarnessSignIn, fetchHarnessSignInProgress, startHarnessSignIn, type HarnessSignInView } from '../api';
import HarnessSignInCode from './HarnessSignInCode';
import SettingField from './SettingField';

/** How often the screen asks. The operator is walking to another device; this is not a race. */
Expand All @@ -20,11 +21,12 @@ const REASONS: Record<string, string> = {
const sentence = (reason: string | null) =>
(reason && REASONS[reason]) || 'The sign-in did not finish, and nothing was stored.';

function remaining(expiresAt: string | null): string {
if (!expiresAt) return '';
/** "13m 30s" until the code runs out, then null. */
function remaining(expiresAt: string | null): string | null {
if (!expiresAt) return null;
const seconds = Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000);
if (seconds <= 0) return 'expired';
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s left`;
if (seconds <= 0) return null;
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;
}

interface Props {
Expand All @@ -49,7 +51,8 @@ export default function HarnessSubscriptionSignIn({ harness, done }: Props) {
const [label, setLabel] = useState('');
const [signIn, setSignIn] = useState<HarnessSignInView | null>(null);
const [error, setError] = useState(''), [busy, setBusy] = useState(false);
const [tick, setTick] = useState(0);
// Only re-renders the countdown between polls; the value itself is never read.
const [, setTick] = useState(0);
const active = useRef(true);
/**
* Which sign-in the screen is on. Every start, cancel and unmount moves it on, and a poll that
Expand Down Expand Up @@ -123,12 +126,9 @@ export default function HarnessSubscriptionSignIn({ harness, done }: Props) {
if (watching && signIn) {
return <>
{signIn.state === 'PENDING' && <p className="prov-note" role="status">Starting the sign-in. This takes a few seconds.</p>}
{signIn.state === 'PROMPTED' && signIn.verificationUri && signIn.userCode && <>
<p className="prov-note">Open this link on any device, sign in, and type the code.</p>
<p><a href={signIn.verificationUri} target="_blank" rel="noopener noreferrer">{signIn.verificationUri}</a></p>
<p className="mono" aria-label="One-time code">{signIn.userCode}</p>
<p className="prov-sub" role="status" data-tick={tick}>{remaining(signIn.expiresAt)}</p>
</>}
{signIn.state === 'PROMPTED' && signIn.verificationUri && signIn.userCode &&
<HarnessSignInCode verificationUri={signIn.verificationUri} userCode={signIn.userCode}
remaining={remaining(signIn.expiresAt)} />}
{error && <p className="prov-error" role="alert">{error}</p>}
<div className="prov-actions">
<button className="btn-ghost sm" type="button" disabled={busy} onClick={() => void stop()}>Cancel sign-in</button>
Expand Down
22 changes: 22 additions & 0 deletions spire-ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,28 @@
.field-pr { flex: 1 1 100%; min-width: 0; }
}

/* ---- signing a harness in with a subscription: the device code (M3.5 part F) ---- */
@layer components {
.signin-code { display: flex; flex-direction: column; gap: 14px; }
/* Numbered because they are used in this order; the number is real order, never decoration. */
.signin-steps { list-style: none; counter-reset: signin; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
.signin-steps li { counter-increment: signin; display: grid; grid-template-columns: 22px 1fr; column-gap: 10px; row-gap: 7px; }
.signin-steps li::before {
content: counter(signin); grid-row: 1 / span 2;
width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
font-size: 11.5px; font-weight: 600; color: var(--iris); background: var(--iris-soft);
}
.signin-step-title { font-size: 12.5px; font-weight: 550; color: var(--text); line-height: 22px; }
.signin-steps .reveal-value { padding: 6px 6px 6px 12px; }
.signin-link { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--iris); text-decoration: none; }
.signin-link:hover { text-decoration: underline; }
/* The one thing typed by hand on another device: large, spaced, and easy to read aloud. */
.signin-steps .reveal-value .signin-user-code { font-size: 22px; font-weight: 600; letter-spacing: 0.12em; }
.signin-expiry { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 12px; color: var(--text-2); }
.signin-expiry svg { flex: none; color: var(--text-3); }
.signin-copy-failed, .signin-copy-failed svg { color: var(--warn); }
}

/* ---- proving an SCM account by signing in (FR-11) ---- */
@layer components {
/* One platform's sign-in application: a summary row that opens into its form. */
Expand Down
Loading