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
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })));
Expand Down Expand Up @@ -523,6 +524,7 @@ function ProductApp({ accountClient, operationsClient, notificationClient, compe
const content = <Suspense fallback={<RouteLoadingState />}><Routes>
<Route path="/" element={<RequireSignIn><DashboardView setPage={setPage} botIcons={botIcons} /></RequireSignIn>} />
<Route path="/landing" element={<LandingView setPage={setPage} />} />
<Route path="/cli-auth" element={<RequireSignIn><CliAuthView /></RequireSignIn>} />
<Route path="/strategies" element={<RequireSignIn><StrategyHome openEditor={openEditor} /></RequireSignIn>} />
<Route path="/strategies/new/basic" element={<RequireSignIn><BasicEditor blank={editorBlank} goBack={() => navigate(pagePaths.strategy)} openEditor={openEditor} onLaunchBot={() => navigate(pagePaths.bots)} /></RequireSignIn>} />
<Route path="/strategies/new/pro" element={<RequireSignIn><ProEditorUnavailableView goBack={() => navigate(pagePaths.strategy)} /></RequireSignIn>} />
Expand Down
56 changes: 56 additions & 0 deletions src/api/deviceAuthorization.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
deny(userCode: string): Promise<void>;
}

/**
* Approving a command-line client.
*
* <p>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),
};
};
63 changes: 63 additions & 0 deletions src/views/CliAuthView.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn<(userCode: string) => Promise<void>>>;
deny: ReturnType<typeof vi.fn<(userCode: string) => Promise<void>>>;
};

const renderAt = (path: string, api: StubApi) =>
render(
<MemoryRouter initialEntries={[path]}>
<CliAuthView api={api} />
</MemoryRouter>,
);

describe('CliAuthView', () => {
const api = (): StubApi => ({
approve: vi.fn<(userCode: string) => Promise<void>>().mockResolvedValue(undefined),
deny: vi.fn<(userCode: string) => Promise<void>>().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('다시 시작');
});
});
92 changes: 92 additions & 0 deletions src/views/CliAuthView.tsx
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<Outcome>('idle');
const [failure, setFailure] = useState('');

const act = async (decide: (userCode: string) => Promise<void>, 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 (
<section aria-labelledby="cli-auth-heading">
<h1 id="cli-auth-heading">터미널에 로그인했습니다</h1>
<p>이 창을 닫아도 됩니다. 터미널로 돌아가세요.</p>
</section>
);
}

if (outcome === 'denied') {
return (
<section aria-labelledby="cli-auth-heading">
<h1 id="cli-auth-heading">요청을 거절했습니다</h1>
<p>터미널은 로그인되지 않았습니다.</p>
</section>
);
}

return (
<section aria-labelledby="cli-auth-heading">
<h1 id="cli-auth-heading">터미널 로그인을 승인할까요?</h1>
<p>
터미널에 표시된 코드와 아래 코드가 <strong>같은지 확인</strong>하세요. 다르면 승인하지 마세요.
</p>
<label htmlFor="cli-auth-code">코드</label>
<input
id="cli-auth-code"
value={code}
onChange={(event) => setCode(event.target.value)}
autoComplete="off"
spellCheck={false}
/>
<p>
승인하면 그 터미널이 회원님 계정으로 전략을 읽고 만들 수 있습니다. 주문·출시·자금 이동은 할 수
없습니다.
</p>
{failure ? <p role="alert">{failure}</p> : null}
<button
type="button"
disabled={outcome === 'working' || code.trim().length === 0}
onClick={() => act(client.approve, 'approved')}
>
승인
</button>
<button
type="button"
disabled={outcome === 'working' || code.trim().length === 0}
onClick={() => act(client.deny, 'denied')}
>
거절
</button>
</section>
);
}

export default CliAuthView;
Loading