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;