diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index b560b863a..db47617ff 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -705,6 +705,14 @@ .authcard .alt { text-align: center; font-size: 13.5px; color: var(--se); } .authcard a { text-decoration: none; } .authcard .alt a { color: var(--c-text); font-weight: 600; } +.authcard .field.code { font: 600 18px/1 var(--f-mono); letter-spacing: 0.08em; } +.authcard .facts { margin: 0; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--d); } +.authcard .facts div { display: flex; justify-content: space-between; gap: 16px; padding: 10px 14px; border-top: 1px solid var(--d); font-size: 13px; } +.authcard .facts div:first-child { border-top: 0; } +.authcard .facts dt { color: var(--se); } +.authcard .facts dd { margin: 0; font: 500 12.5px/1.4 var(--f-mono); color: var(--ink); text-align: right; overflow-wrap: anywhere; } +.authcard h1 .host { white-space: nowrap; } +.authcard .keys2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .authcard .sub { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--se); } .authcard .hint { margin: 0; font-size: 13px; line-height: 1.5; color: var(--se); } .authcard .hint a, .authcard .legal a { color: var(--c-text); font-weight: 600; } @@ -1303,6 +1311,12 @@

Same look. Less said.

Choose a new password

/v2/reset-password?token=…
New password · 1440
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
Updated · 1440
commonly

Choose a new password

Password updated. You can sign in now.
New password · 390
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
Mismatch · 390
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••
Link expired · 390request a new one inline
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
No token · 390page opened without the email link
commonly

Choose a new password

This page needs the reset link from your email. Request a new one below.

+
+

CLI sign-in

/cli/authorize?code=…
+
  • The sign-in card again. commonly login opens this page from the terminal, so it carries the same brand row, card, field and keys as sign-in. The lettered “C” tile and the all-caps eyebrow are gone; the mark is the product mark.
  • The code is the field. Mono, 18px, spaced, so it reads against the terminal at a glance. Signed out, it shows read-only above Sign in to continue, and comes back filled.
  • Approving is the one decision. What is asking (client, instance, when) sits in a bordered list; the caution is the notice row with the info glyph, not a warning colour; Deny and Authorize share the row, Authorize in ink.

Each state, one line

Enter codeDrawn. Signed in, from a link without a code or by hand.
Signed outNot drawn. The field is read-only and the key is Sign in to continue.
ApproveDrawn. After Continue finds a pending request.
AuthorizedDrawn. Title and a status row.
Denied, expired, used, errorNot drawn. Same as Authorized: the title says which, and one row says what to do in the terminal.
Enter code · 1440signed in
commonly

Authorize Commonly CLI

Enter the code shown in your terminal.

Device code
WDJB-MJHT
+
Enter code · 390
commonly

Authorize Commonly CLI

Enter the code shown in your terminal.

Device code
WDJB-MJHT
Approve · 390a pending request
commonly

Authorize alex-mbp as @alex?

This device is asking to use your Commonly account.

Client
commonly-cli 0.1.84
Instance
api.commonly.me
Requested
just now
Only approve a code you requested from your own terminal.
Authorized · 390
commonly

Device authorized

You can return to your terminal.
+
+

Dark mode

one dark value per token
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index f7b32a4ae..83a1c3d3d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -15,11 +15,12 @@ if (process.env.REACT_APP_SENTRY_DSN) { // CssBaseline's palette.background.default #0b1220 — injected at render), // and a class added in V2App's useEffect wins only AFTER first paint, so // every v2 load still flashed dark for a frame (Sam, 2026-08-24). '/' and -// '/v2*' are the v2 front door; V2App's effect keeps the class in sync for +// '/v2*' are the v2 front door (and /cli/authorize, a v2 page mounted outside +// V2App that keeps the class itself); V2App's effect keeps the class in sync for // SPA navigation after boot. Legacy deep links that redirect into /v2 may // still flash once — the exception, not the entry path. const bootPath = window.location.pathname; -if (bootPath === '/' || bootPath.startsWith('/v2')) { +if (bootPath === '/' || bootPath.startsWith('/v2') || /^\/cli\/authorize\/?$/.test(bootPath)) { document.body.classList.add('v2-canvas'); } if (bootPath === '/guides' || bootPath.startsWith('/guides/')) { diff --git a/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx b/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx index ab83dace7..4834cb649 100644 --- a/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx +++ b/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import axios from 'axios'; import { AuthContext } from '../../context/AuthContext'; import V2CliAuthorize from '../components/V2CliAuthorize'; @@ -28,35 +28,114 @@ const renderPage = (path = '/cli/authorize?code=ABCD-EFGH', value = auth) => ren , ); +const LoginProbe = () => { + const location = useLocation(); + return

{location.pathname + location.search}

; +}; + +const renderWithLogin = (path, value) => render( + + + + } /> + } /> + + + , +); + +const signedOut = { ...auth, currentUser: null, user: null, token: null, isAuthenticated: false }; + +// The d215 auth card: the product mark's brand row, never the old lettered +// tile or the all-caps eyebrow. +const expectAuthCard = (container) => { + expect(container.querySelector('main.v2-root.v2-cli-authorize .v2-login .v2-login__card.v2-login__card--stack')).not.toBeNull(); + expect(container.querySelector('.v2-login__brand')).not.toBeNull(); + expect(container.querySelector('.v2-cli-authorize__mark')).toBeNull(); + expect(screen.queryByText('CLI SIGN-IN')).toBeNull(); + expect(screen.queryByText('C', { exact: true })).toBeNull(); +}; + afterEach(() => jest.clearAllMocks()); +test('claims the v2 page canvas while mounted, and gives it back on unmount', () => { + // Outside V2App, body.v2-canvas is what switches off App.css's legacy + // input, button and form rules (all gated on body.modern-ui:not(.v2-canvas)). + document.body.classList.remove('v2-canvas'); + const { unmount } = renderPage(); + expect(document.body).toHaveClass('v2-canvas'); + unmount(); + expect(document.body).not.toHaveClass('v2-canvas'); +}); + describe('V2CliAuthorize', () => { test('prefills a terminal code, confirms the device facts, then completes approval', async () => { axios.post .mockResolvedValueOnce({ data: { status: 'pending', request: { hostname: 'sam-laptop', clientName: 'commonly-cli', clientVersion: '0.1.26', createdAt: '2026-08-31T00:00:00.000Z' } } }) .mockResolvedValueOnce({ data: { status: 'authorized' } }); - renderPage(); + const { container } = renderPage(); - expect(screen.getByLabelText('Device code')).toHaveValue('ABCD-EFGH'); + expectAuthCard(container); + expect(screen.getByRole('heading', { level: 1, name: 'Authorize Commonly CLI' })).toHaveClass('v2-login__title'); + expect(screen.getByText('Enter the code shown in your terminal.')).toHaveClass('v2-login__subtitle'); + const input = screen.getByLabelText('Device code'); + expect(input).toHaveValue('ABCD-EFGH'); + expect(input).toHaveClass('v2-login__input', 'v2-cli-authorize__code'); + expect(input.closest('label')).toHaveClass('v2-login__field'); + expect(screen.getByRole('button', { name: 'Continue' })).toHaveClass('v2-login__submit'); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/auth/device/authorize', { userCode: 'ABCD-EFGH' })); - expect(await screen.findByText('Authorize sam-laptop as @lily?')).toBeInTheDocument(); - expect(screen.getByText('CLI SIGN-IN')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Authorize' })); + const title = await screen.findByRole('heading', { level: 1, name: 'Authorize sam-laptop as @lily?' }); + expectAuthCard(container); + expect(title.querySelector('.v2-cli-authorize__host')).toHaveTextContent('sam-laptop'); + const facts = container.querySelector('dl.v2-cli-authorize__facts'); + expect(facts).not.toBeNull(); + expect(Array.from(facts.querySelectorAll('dt')).map((dt) => dt.textContent)).toEqual(['Client', 'Instance', 'Requested']); + expect(screen.getByText('commonly-cli 0.1.26').tagName).toBe('DD'); + const caution = screen.getByRole('note'); + expect(caution).toHaveClass('v2-login__notice'); + expect(caution).toHaveTextContent('Only approve a code you requested from your own terminal.'); + expect(caution.querySelector('svg')).not.toBeNull(); + const deny = screen.getByRole('button', { name: 'Deny' }); + const authorize = screen.getByRole('button', { name: 'Authorize' }); + expect(deny).toHaveClass('v2-login__submit', 'v2-login__submit--ghost'); + expect(authorize).toHaveClass('v2-login__submit'); + expect(authorize).not.toHaveClass('v2-login__submit--ghost'); + expect(deny.parentElement).toHaveClass('v2-cli-authorize__keys'); + expect(authorize.parentElement).toBe(deny.parentElement); + + fireEvent.click(authorize); await waitFor(() => expect(axios.post).toHaveBeenLastCalledWith('/api/auth/device/authorize', { userCode: 'ABCD-EFGH', decision: 'authorize', })); - expect(await screen.findByText('Device authorized')).toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 1, name: 'Device authorized' })).toBeInTheDocument(); + const status = screen.getByRole('status'); + expect(status).toHaveClass('v2-login__notice'); + await waitFor(() => expect(status).toHaveTextContent('You can return to your terminal.')); }); test('signed-out users get an explicit return path after sign-in', () => { - renderPage('/cli/authorize?code=ABCD-EFGH', { ...auth, currentUser: null, user: null, token: null, isAuthenticated: false }); - expect(screen.getByRole('link', { name: 'Sign in to continue' })).toHaveAttribute( - 'href', - expect.stringContaining('next=%2Fcli%2Fauthorize%3Fcode%3DABCD-EFGH'), - ); + const { container } = renderWithLogin('/cli/authorize?code=ABCD-EFGH', signedOut); + expectAuthCard(container); + expect(screen.getByRole('heading', { level: 1, name: 'Authorize Commonly CLI' })).toBeInTheDocument(); expect(screen.getByLabelText('Device code')).toBeDisabled(); + expect(screen.getByLabelText('Device code')).toHaveValue('ABCD-EFGH'); + expect(screen.queryByRole('link', { name: 'Sign in to continue' })).toBeNull(); + const key = screen.getByRole('button', { name: 'Sign in to continue' }); + expect(key).toHaveClass('v2-login__submit'); + + fireEvent.click(key); + expect(screen.getByTestId('login-probe')).toHaveTextContent( + '/v2/login?next=%2Fcli%2Fauthorize%3Fcode%3DABCD-EFGH', + ); + }); + + test('the session check is the card with one status line', () => { + const { container } = renderPage('/cli/authorize?code=ABCD-EFGH', { ...signedOut, loading: true }); + expectAuthCard(container); + expect(screen.getByRole('status')).toHaveTextContent('Checking your session…'); + expect(screen.queryByRole('heading', { level: 1 })).toBeNull(); }); test('shows the request instance and the expired state for signed-in users', async () => { @@ -69,7 +148,10 @@ describe('V2CliAuthorize', () => { fireEvent.click(screen.getByRole('button', { name: 'Continue' })); expect(await screen.findByText('self-hosted.example')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Authorize' })); - expect(await screen.findByText('Code expired')).toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 1, name: 'Code expired' })).toBeInTheDocument(); + const status = screen.getByRole('status'); + await waitFor(() => expect(status).toHaveTextContent('Return to your terminal and run commonly login again.')); + expect(status.querySelector('code')).toHaveTextContent('commonly login'); axios.defaults.baseURL = ''; }); @@ -78,6 +160,25 @@ describe('V2CliAuthorize', () => { renderPage(); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); - expect(await screen.findByText('Code already used')).toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 1, name: 'Code already used' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('This code was already approved or completed.')); + }); + + test('a denied request says no token was issued', async () => { + axios.post.mockResolvedValue({ data: { status: 'denied' } }); + renderPage(); + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByRole('heading', { level: 1, name: 'Authorization denied' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('No token was issued for this device.')); + }); + + test('a failed lookup is an alert row', async () => { + axios.post.mockRejectedValue({ response: { status: 500 } }); + renderPage(); + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByRole('heading', { level: 1, name: 'Couldn’t authorize this device' })).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Check the code and try again from your terminal.'); }); }); diff --git a/frontend/src/v2/__tests__/signalAccent.test.ts b/frontend/src/v2/__tests__/signalAccent.test.ts index f68489943..1b0cbc609 100644 --- a/frontend/src/v2/__tests__/signalAccent.test.ts +++ b/frontend/src/v2/__tests__/signalAccent.test.ts @@ -108,27 +108,25 @@ describe('Signal accent on standalone and bridged surfaces', () => { test('the CLI authorize surface mounts in .v2-root and reads tokens only', () => { // TASK-682: wrapped in .v2-root so the tokens resolve and the dark theme reaches it. + // d215: it sits on the shared auth card, so the page CSS keeps only its own pieces. const tsx = read('src/v2/components/V2CliAuthorize.tsx'); expect(tsx).toContain('
'); + expect(tsx).toContain('
{ describe('v2 layout invariants (CSS rule presence)', () => { test('/cli/authorize scrolls inside the .v2-root shell on a short viewport (TASK-682)', () => { - // .v2-root is height 100vh + overflow hidden; the page must override it or - // the lower controls (Deny / Authorize) are unreachable on a short screen. - const cli = fs.readFileSync(path.join(__dirname, '../components/V2CliAuthorize.css'), 'utf8'); - const root = /^\.v2-root\.v2-cli-authorize \{([^}]*)\}/m.exec(cli)?.[1] ?? ''; - expect(root).toContain('overflow-y: auto'); - expect(root).toContain('display: grid'); - expect(root).not.toContain('place-items: center'); - expect(/^\.v2-cli-authorize__card \{([^}]*)\}/m.exec(cli)?.[1]).toContain('margin: auto'); - // The legacy V1 form rule (0,2,2) would paint the form #111827. - expect(cli).toContain('.v2-root.v2-cli-authorize .v2-cli-authorize__card form {'); + // .v2-root is height 100vh + overflow hidden; the page must sit in a + // scroller or the lower controls (Deny / Authorize) are unreachable on a + // short screen. Since d215 that scroller is the auth family's .v2-login. + const tsx = fs.readFileSync(path.join(__dirname, '../components/V2CliAuthorize.tsx'), 'utf8'); + expect(tsx).toMatch(/
\s*
/); + const login = /^\.v2-login \{([^}]*)\}/m.exec(readV2Css())?.[1] ?? ''; + expect(login).toContain('overflow-y: auto'); + expect(login).toContain('min-height: 0'); + expect(login).not.toContain('place-items: center'); + expect(/^\.v2-login__card \{([^}]*)\}/m.exec(readV2Css())?.[1]).toContain('margin: auto'); + // The legacy V1 form rule (0,2,2) would paint the form #111827: the code + // form is display: contents inside a
card, so it draws no box. + expect(tsx).not.toContain('
{ const V2CliAuthorize: React.FC = () => { const { isAuthenticated, loading, user } = useAuth(); const location = useLocation(); + const navigate = useNavigate(); const queryCode = useMemo(() => normalizeCode(new URLSearchParams(location.search).get('code') || ''), [location.search]); const [code, setCode] = useState(queryCode); const [screen, setScreen] = useState('code'); @@ -48,6 +52,16 @@ const V2CliAuthorize: React.FC = () => { useEffect(() => setCode(queryCode), [queryCode]); + // The page mounts outside V2App, so it claims the v2 page canvas itself + // while mounted (index.tsx stamps it at boot for this path, too). Without + // it App.css's legacy V1 input, button and form rules reach the card. + useEffect(() => { + document.body.classList.add('v2-canvas'); + return () => { + document.body.classList.remove('v2-canvas'); + }; + }, []); + const lookup = async (event?: React.FormEvent) => { event?.preventDefault(); if (code.replace('-', '').length !== 8) return; @@ -92,69 +106,114 @@ const V2CliAuthorize: React.FC = () => { const next = `/cli/authorize${code ? `?code=${encodeURIComponent(code)}` : ''}`; - return ( + // Layout: the d215 s-cli-authorize frames. The sign-in card family: brand + // row, title, one prose line, the code as the field, one key per state. + // The page mounts outside V2App, so it carries its own .v2-root. + const card = (children: React.ReactNode) => (
-
- -

CLI SIGN-IN

- {loading &&

Checking your session…

} - {!loading && !isAuthenticated && ( - <> -

Authorize Commonly CLI

-

Sign in to approve this device. The code stays tied to this browser tab.

- - Sign in to continue - - )} - {!loading && isAuthenticated && screen === 'code' && ( - -

Authorize Commonly CLI

-

Enter the code shown in your terminal.

- - - - )} - {!loading && isAuthenticated && screen === 'confirm' && request && ( - <> -

Authorize {request.hostname} as @{user?.username || 'you'}?

-

This device is asking to use your Commonly account.

-
-
Client
{request.clientName}{request.clientVersion ? ` ${request.clientVersion}` : ''}
-
Instance
{currentInstance()}
-
Requested
{requestAge(request.createdAt)}
-
-

Only approve a code you requested from your own terminal.

-
- - -
- - )} - {!loading && isAuthenticated && screen === 'done' && <>

Device authorized

You can return to your terminal.

} - {!loading && isAuthenticated && screen === 'denied' && <>

Authorization denied

No token was issued for this device.

} - {!loading && isAuthenticated && screen === 'expired' && <>

Code expired

Return to your terminal and run commonly login again.

} - {!loading && isAuthenticated && screen === 'used' && <>

Code already used

This code was already approved or completed. Return to your terminal.

} - {!loading && isAuthenticated && screen === 'error' && <>

Couldn’t authorize this device

Check the code and try again from your terminal.

} -
+
+
+ + {children} +
+
); + const outcome = (title: string, message: React.ReactNode, kind: 'alert' | 'status' = 'status') => card( + <> +

{title}

+ {message} + , + ); + + if (loading) { + return card(

Checking your session…

); + } + + if (!isAuthenticated) { + return card( + <> +

Authorize Commonly CLI

+

Sign in to approve this device. The code stays tied to this browser tab.

+ + + , + ); + } + + if (screen === 'code') { + return card( +
+

Authorize Commonly CLI

+

Enter the code shown in your terminal.

+ + +
, + ); + } + + if (screen === 'confirm' && request) { + return card( + <> +

+ Authorize {request.hostname} as @{user?.username || 'you'}? +

+

This device is asking to use your Commonly account.

+
+
Client
{request.clientName}{request.clientVersion ? ` ${request.clientVersion}` : ''}
+
Instance
{currentInstance()}
+
Requested
{requestAge(request.createdAt)}
+
+
+ + Only approve a code you requested from your own terminal. +
+
+ + +
+ , + ); + } + + if (screen === 'done') return outcome('Device authorized', 'You can return to your terminal.'); + if (screen === 'denied') return outcome('Authorization denied', 'No token was issued for this device.'); + if (screen === 'expired') { + return outcome('Code expired', <>Return to your terminal and run commonly login again.); + } + if (screen === 'used') { + return outcome('Code already used', 'This code was already approved or completed. Return to your terminal.'); + } + if (screen === 'error') { + return outcome('Couldn’t authorize this device', 'Check the code and try again from your terminal.', 'alert'); + } + return card(null); }; export default V2CliAuthorize;