diff --git a/spire-ui/docs/WIDGETS.md b/spire-ui/docs/WIDGETS.md index 18ead739..2d073797 100644 --- a/spire-ui/docs/WIDGETS.md +++ b/spire-ui/docs/WIDGETS.md @@ -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) | diff --git a/spire-ui/src/components/HarnessSignInCode.test.tsx b/spire-ui/src/components/HarnessSignInCode.test.tsx new file mode 100644 index 00000000..e34a02f1 --- /dev/null +++ b/spire-ui/src/components/HarnessSignInCode.test.tsx @@ -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((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(); + +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(); + + 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(); + + 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(); + + 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.'); +}); diff --git a/spire-ui/src/components/HarnessSignInCode.tsx b/spire-ui/src/components/HarnessSignInCode.tsx new file mode 100644 index 00000000..d74600df --- /dev/null +++ b/spire-ui/src/components/HarnessSignInCode.tsx @@ -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). + * + *

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. + * + *

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). + * + *

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(null); + const timer = useRef | 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

+
    +
  1. + Open the sign-in page +
    + {safeUri + ? {safeUri} + : {verificationUri}} + {safeUri && + + } +
    +
  2. +
  3. + Sign in with the account the factory will use, then enter this code +
    + {userCode} + + + +
    +
  4. +
+

+

+
; +} + +/** 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 { + 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; } +} diff --git a/spire-ui/src/components/HarnessSubscriptionSignIn.test.tsx b/spire-ui/src/components/HarnessSubscriptionSignIn.test.tsx index 63a5e52b..be8647b6 100644 --- a/spire-ui/src/components/HarnessSubscriptionSignIn.test.tsx +++ b/spire-ui/src/components/HarnessSubscriptionSignIn.test.tsx @@ -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')); }); diff --git a/spire-ui/src/components/HarnessSubscriptionSignIn.tsx b/spire-ui/src/components/HarnessSubscriptionSignIn.tsx index 94bcc2a7..8453d143 100644 --- a/spire-ui/src/components/HarnessSubscriptionSignIn.tsx +++ b/spire-ui/src/components/HarnessSubscriptionSignIn.tsx @@ -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. */ @@ -20,11 +21,12 @@ const REASONS: Record = { 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 { @@ -49,7 +51,8 @@ export default function HarnessSubscriptionSignIn({ harness, done }: Props) { const [label, setLabel] = useState(''); const [signIn, setSignIn] = useState(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 @@ -123,12 +126,9 @@ export default function HarnessSubscriptionSignIn({ harness, done }: Props) { if (watching && signIn) { return <> {signIn.state === 'PENDING' &&

Starting the sign-in. This takes a few seconds.

} - {signIn.state === 'PROMPTED' && signIn.verificationUri && signIn.userCode && <> -

Open this link on any device, sign in, and type the code.

-

{signIn.verificationUri}

-

{signIn.userCode}

-

{remaining(signIn.expiresAt)}

- } + {signIn.state === 'PROMPTED' && signIn.verificationUri && signIn.userCode && + } {error &&

{error}

}
diff --git a/spire-ui/src/index.css b/spire-ui/src/index.css index ae1f7f3d..c4b81585 100644 --- a/spire-ui/src/index.css +++ b/spire-ui/src/index.css @@ -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. */