From 5e51fd23d5b10e56d7f11c2c084d32af271c2474 Mon Sep 17 00:00:00 2001 From: Ryan Min Date: Mon, 10 Aug 2026 06:40:04 +0900 Subject: [PATCH] feat: approve a command-line sign-in from the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI takes a password on standard input today, so anything driving it — including an agent asked to "install and set it up" — has to be handed the customer's password. This screen is the other half of removing that: the terminal shows a short code, the person confirms it here in a session that already holds their credential, and the terminal never sees the password. The code arrives in the address but is not approved on arrival. A link is easy to send to someone; a code they have to recognise on their own screen is not, and this page hands out a live session. The copy says plainly what the terminal will be able to do and what it will not. The account is never in the request body. The server reads it from this session, so a body naming an account cannot approve a device onto someone else's. Refs Idea2Strategy/Idea2Strategy#479 --- src/App.tsx | 2 + src/api/deviceAuthorization.ts | 56 +++++++++++++++++++++ src/views/CliAuthView.test.tsx | 63 +++++++++++++++++++++++ src/views/CliAuthView.tsx | 92 ++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 src/api/deviceAuthorization.ts create mode 100644 src/views/CliAuthView.test.tsx create mode 100644 src/views/CliAuthView.tsx diff --git a/src/App.tsx b/src/App.tsx index 1d5ca0c..a676fd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import './styles/balanced.css'; const DashboardView = lazy(() => import('./views/DashboardView').then((module) => ({ default: module.DashboardView }))); const LandingView = lazy(() => import('./views/LandingView').then((module) => ({ default: module.LandingView }))); +const CliAuthView = lazy(() => import("./views/CliAuthView").then((module) => ({ default: module.CliAuthView }))); const StrategyHome = lazy(() => import('./views/StrategyViews').then((module) => ({ default: module.StrategyHome }))); const BasicEditor = lazy(() => import('./views/StrategyViews').then((module) => ({ default: module.BasicEditor }))); const ProEditorUnavailableView = lazy(() => import('./views/ProEditorUnavailableView').then((module) => ({ default: module.ProEditorUnavailableView }))); @@ -523,6 +524,7 @@ function ProductApp({ accountClient, operationsClient, notificationClient, compe const content = }> } /> } /> + } /> } /> navigate(pagePaths.strategy)} openEditor={openEditor} onLaunchBot={() => navigate(pagePaths.bots)} />} /> navigate(pagePaths.strategy)} />} /> diff --git a/src/api/deviceAuthorization.ts b/src/api/deviceAuthorization.ts new file mode 100644 index 0000000..0d4112e --- /dev/null +++ b/src/api/deviceAuthorization.ts @@ -0,0 +1,56 @@ +import { getSessionAccessToken } from './sessionAccessToken'; + +export class DeviceAuthorizationApiError extends Error { + constructor(readonly status: number, readonly code: string) { + super(code); + this.name = 'DeviceAuthorizationApiError'; + } +} + +export interface DeviceAuthorizationApi { + approve(userCode: string): Promise; + deny(userCode: string): Promise; +} + +/** + * Approving a command-line client. + * + *

The account is never sent: the server reads it from this session. A body that could name an + * account would let anyone approve a device onto somebody else's. + */ +export const createDeviceAuthorizationApi = ( + baseUrl = import.meta.env.VITE_API_BASE_URL ?? '', + fetchImpl: typeof fetch = fetch, + readToken: () => string | null = getSessionAccessToken, +): DeviceAuthorizationApi => { + const root = baseUrl.replace(/\/$/, ''); + const send = async (path: string, userCode: string) => { + const token = readToken(); + if (!token) throw new DeviceAuthorizationApiError(401, 'SIGN_IN_REQUIRED'); + let response: Response; + try { + response = await fetchImpl(`${root}${path}`, { + method: 'POST', + credentials: 'omit', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ userCode }), + }); + } catch { + throw new DeviceAuthorizationApiError(0, 'NETWORK_ERROR'); + } + if (!response.ok) { + throw new DeviceAuthorizationApiError( + response.status, + response.status === 401 ? 'SIGN_IN_REQUIRED' : 'CODE_NOT_PENDING', + ); + } + }; + return { + approve: (userCode) => send('/api/v1/auth/device/approve', userCode), + deny: (userCode) => send('/api/v1/auth/device/deny', userCode), + }; +}; diff --git a/src/views/CliAuthView.test.tsx b/src/views/CliAuthView.test.tsx new file mode 100644 index 0000000..57116a4 --- /dev/null +++ b/src/views/CliAuthView.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { CliAuthView } from './CliAuthView'; + +type StubApi = { + approve: ReturnType Promise>>; + deny: ReturnType Promise>>; +}; + +const renderAt = (path: string, api: StubApi) => + render( + + + , + ); + +describe('CliAuthView', () => { + const api = (): StubApi => ({ + approve: vi.fn<(userCode: string) => Promise>().mockResolvedValue(undefined), + deny: vi.fn<(userCode: string) => Promise>().mockResolvedValue(undefined), + }); + + it('never approves from the address alone', () => { + const client = api(); + + renderAt('/cli-auth?code=ABCD-EFGH', client); + + expect(client.approve).not.toHaveBeenCalled(); + expect(screen.getByLabelText('코드')).toHaveValue('ABCD-EFGH'); + }); + + it('approves the code the person confirmed', async () => { + const client = api(); + renderAt('/cli-auth?code=ABCD-EFGH', client); + + await userEvent.click(screen.getByRole('button', { name: '승인' })); + + expect(client.approve).toHaveBeenCalledWith('ABCD-EFGH'); + expect(await screen.findByText('터미널에 로그인했습니다')).toBeInTheDocument(); + }); + + it('denies without approving', async () => { + const client = api(); + renderAt('/cli-auth?code=ABCD-EFGH', client); + + await userEvent.click(screen.getByRole('button', { name: '거절' })); + + expect(client.deny).toHaveBeenCalledWith('ABCD-EFGH'); + expect(client.approve).not.toHaveBeenCalled(); + }); + + it('explains a code that can no longer be approved', async () => { + const client = api(); + client.approve.mockRejectedValue(new Error('CODE_NOT_PENDING')); + renderAt('/cli-auth?code=ABCD-EFGH', client); + + await userEvent.click(screen.getByRole('button', { name: '승인' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('다시 시작'); + }); +}); diff --git a/src/views/CliAuthView.tsx b/src/views/CliAuthView.tsx new file mode 100644 index 0000000..1d476aa --- /dev/null +++ b/src/views/CliAuthView.tsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { createDeviceAuthorizationApi, DeviceAuthorizationApi } from '../api/deviceAuthorization'; + +type Outcome = 'idle' | 'working' | 'approved' | 'denied' | 'failed'; + +/** + * Approves a command-line client that asked to sign in. + * + *

The code arrives in the address, but it is shown for the person to compare against what their + * terminal printed rather than approved on arrival. A link is easy to send to someone; a code they + * have to recognise is not, and this screen hands out a live session. + */ +export function CliAuthView({ api }: { api?: DeviceAuthorizationApi }) { + const [params] = useSearchParams(); + const client = useMemo(() => api ?? createDeviceAuthorizationApi(), [api]); + const [code, setCode] = useState(params.get('code') ?? ''); + const [outcome, setOutcome] = useState('idle'); + const [failure, setFailure] = useState(''); + + const act = async (decide: (userCode: string) => Promise, settled: Outcome) => { + setOutcome('working'); + setFailure(''); + try { + await decide(code.trim()); + setOutcome(settled); + } catch (error) { + setOutcome('failed'); + setFailure( + error instanceof Error && error.message === 'SIGN_IN_REQUIRED' + ? '로그인이 필요합니다. 로그인한 뒤 다시 시도하세요.' + : '이 코드는 더 이상 승인할 수 없습니다. 터미널에서 다시 시작하세요.', + ); + } + }; + + if (outcome === 'approved') { + return ( +

+

터미널에 로그인했습니다

+

이 창을 닫아도 됩니다. 터미널로 돌아가세요.

+
+ ); + } + + if (outcome === 'denied') { + return ( +
+

요청을 거절했습니다

+

터미널은 로그인되지 않았습니다.

+
+ ); + } + + return ( +
+

터미널 로그인을 승인할까요?

+

+ 터미널에 표시된 코드와 아래 코드가 같은지 확인하세요. 다르면 승인하지 마세요. +

+ + setCode(event.target.value)} + autoComplete="off" + spellCheck={false} + /> +

+ 승인하면 그 터미널이 회원님 계정으로 전략을 읽고 만들 수 있습니다. 주문·출시·자금 이동은 할 수 + 없습니다. +

+ {failure ?

{failure}

: null} + + +
+ ); +} + +export default CliAuthView;