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 (
+
+