diff --git a/e2e/real-account-api.e2e.ts b/e2e/real-account-api.e2e.ts index 4ba764d..aef3a9f 100644 --- a/e2e/real-account-api.e2e.ts +++ b/e2e/real-account-api.e2e.ts @@ -65,32 +65,26 @@ test('browser completes the production account principal and user-case journey', expect(loadedPreferences.status()).toBe(200); await expect(page.getByRole('heading', { name: '로그인 및 보안' })).toBeVisible(); - await expect(page.getByLabel('서버 시간대')).toBeVisible(); - await expect(page.getByRole('textbox', { name: '서버 시간대' })).toHaveCount(0); - const [preferences] = await Promise.all([ - page.waitForResponse((response) => response.url().endsWith('/api/v1/account/preferences') && response.request().method() === 'PATCH'), - page.getByRole('button', { name: '환경 저장' }).click(), - ]); - expect(preferences.status()).toBe(200); - await expect(page.getByText('서버 설정을 저장했습니다.')).toBeVisible(); + // Display preferences intentionally live outside the account screen. The + // account route exposes only customer-facing security and notification + // controls. The new email-preference response is covered by backend tests + // until the root repository points at that backend revision. + await expect(page.getByLabel('서버 시간대')).toHaveCount(0); + await expect(page.getByRole('heading', { name: '이메일 알림' })).toBeVisible(); await page.getByRole('button', { name: '문의하기', exact: true }).click(); - await expect(page.getByRole('dialog', { name: '문의하기' })).toBeVisible(); - await page.getByLabel('케이스 제목').fill('Actual browser API incident'); - await page.getByLabel('케이스 설명').fill('Production bearer principal reaches the PostgreSQL user-case store.'); + const supportDialog = page.getByRole('dialog', { name: '문의하기' }); + await expect(supportDialog).toBeVisible(); + await supportDialog.getByLabel('문의 제목').fill('실제 브라우저 API 문의'); + await supportDialog.getByLabel('문의 내용').fill('로그인한 사용자의 문의가 안전하게 접수되는지 확인합니다.'); const [submittedCase] = await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/api/v1/cases') && response.request().method() === 'POST'), - page.getByRole('button', { name: '접수하기' }).click(), + supportDialog.getByRole('button', { name: '접수하기' }).click(), ]); expect(submittedCase.status()).toBe(201); const created = await submittedCase.json() as { id: string }; - await expect(page.getByText(new RegExp(`추적 번호 ${created.id}`))).toBeVisible(); - - const [loadedCase] = await Promise.all([ - page.waitForResponse((response) => response.url().endsWith(`/api/v1/cases/${created.id}`)), - page.getByRole('button', { name: '상태 확인' }).click(), - ]); - expect(loadedCase.status()).toBe(200); + await expect(supportDialog.getByText('문의가 접수되었습니다.')).toBeVisible(); + await expect(supportDialog.getByText(created.id, { exact: true })).toHaveCount(0); // Exercise the signed-in product shell against the same real backend. These // reads catch missing controllers, stale root pointers, auth propagation, diff --git a/src/App.test.tsx b/src/App.test.tsx index bf41d1f..812f8c8 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -294,7 +294,7 @@ describe('Signal product UI', () => { expect(performance).toHaveTextContent('개인 운용과 대회 성과는 합산하지 않습니다.'); }); - test('removes admin and watchlist entry points and centralizes account settings in My account', async () => { + test('removes admin, watchlist and account-sidebar entry points from My account', async () => { const user = userEvent.setup(); render(); @@ -306,11 +306,21 @@ describe('Signal product UI', () => { // login that never existed, only what the real API panels can prove. expect(screen.queryByText('김전략')).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: '접근 보안' })).not.toBeInTheDocument(); - expect(screen.getByRole('navigation', { name: '계정 설정' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: '로그인 및 보안' })).toHaveAttribute('href', '#account-security'); - expect(screen.getByRole('link', { name: '서비스 환경' })).toHaveAttribute('href', '#account-environment'); + expect(screen.queryByRole('navigation', { name: '계정 설정' })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '로그인 및 보안' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '계정 관리' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '서비스 환경' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '서버 알림 채널' })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: '화면 설정' })).not.toBeInTheDocument(); - expect(screen.getByText('테마와 화면 표시는 상단 톱니바퀴에서 변경할 수 있습니다.')).toBeInTheDocument(); + }); + + test('keeps the inquiry title field styled and the withdrawal action free of a glow', () => { + expect(balancedStyles).toMatch( + /\.account-support-modal-body \.case-api-form input\s*\{[^}]*border:\s*1px solid var\(--line\)[^}]*background:\s*var\(--surface-2\)/s, + ); + expect(balancedStyles).toMatch( + /\.account-withdrawal-trigger[\s\S]*?\{[^}]*box-shadow:\s*none;/, + ); }); test('switches the product between Korean and English and remembers the choice', async () => { @@ -450,8 +460,8 @@ describe('Signal product UI', () => { const notificationClient: NotificationClient = { list, markRead: vi.fn(), - preferences: vi.fn(), - replacePreference: vi.fn(), + emailPreference: vi.fn().mockResolvedValue({ enabled: false }), + replaceEmailPreference: vi.fn(), }; render(); @@ -467,8 +477,8 @@ describe('Signal product UI', () => { const notificationClient: NotificationClient = { list: vi.fn().mockRejectedValue(new NotificationApiError(401, 'AUTHENTICATION_REQUIRED', 'corr-topbar')), markRead: vi.fn(), - preferences: vi.fn(), - replacePreference: vi.fn(), + emailPreference: vi.fn().mockResolvedValue({ enabled: false }), + replaceEmailPreference: vi.fn(), }; render(); diff --git a/src/AuthRoutes.test.tsx b/src/AuthRoutes.test.tsx index 088997e..cce698e 100644 --- a/src/AuthRoutes.test.tsx +++ b/src/AuthRoutes.test.tsx @@ -388,8 +388,8 @@ describe('login entry points', () => { const notificationClient: NotificationClient = { list: vi.fn().mockRejectedValue(new NotificationApiError(401, 'UNAUTHENTICATED', 'corr-notif-1')), markRead: vi.fn(), - preferences: vi.fn().mockResolvedValue([]), - replacePreference: vi.fn(), + emailPreference: vi.fn().mockResolvedValue({ enabled: false }), + replaceEmailPreference: vi.fn(), }; window.history.replaceState({}, '', '/help'); render(); diff --git a/src/EnglishLocale.test.tsx b/src/EnglishLocale.test.tsx index 5b91c31..495ab18 100644 --- a/src/EnglishLocale.test.tsx +++ b/src/EnglishLocale.test.tsx @@ -22,13 +22,8 @@ const englishPreferencesClient = { const emptyNotificationClient = { list: async () => ({ items: [], nextCreatedAt: null, nextId: null }), markRead: async () => undefined, - preferences: async () => [], - replacePreference: async () => ({ - notificationTypeCode: 'TEST', - inAppEnabled: true, - emailEnabled: false, - updatedAt: '2026-08-07T00:00:00Z', - }), + emailPreference: async () => ({ enabled: false }), + replaceEmailPreference: async (enabled: boolean) => ({ enabled }), } as unknown as NotificationClient; const renderEnglishApp = () => render( diff --git a/src/ProductScreens.test.tsx b/src/ProductScreens.test.tsx index 64f8297..1bf7bc8 100644 --- a/src/ProductScreens.test.tsx +++ b/src/ProductScreens.test.tsx @@ -767,6 +767,7 @@ describe('Account settings', () => { const operationsClient: AccountOperationsClient = { submitCase: vi.fn(), addCaseEvidence: vi.fn(), + userCases: vi.fn().mockResolvedValue({ items: [], nextCursor: null }), userCase: vi.fn(), operatorCaseQueue: vi.fn(), operatorCase: vi.fn(), @@ -793,26 +794,28 @@ describe('Account settings', () => { return { setTheme, setTimezone, setReduceMotion }; }; - test('keeps display settings in the topbar and presents account sections with clear navigation', () => { + test('removes the account sidebar and display settings from the account page', () => { setup(); - expect(screen.getByRole('navigation', { name: '계정 설정' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: '로그인 및 보안' })).toHaveAttribute('href', '#account-security'); - expect(screen.getByRole('link', { name: '서비스 환경' })).toHaveAttribute('href', '#account-environment'); + expect(screen.queryByRole('navigation', { name: '계정 설정' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '서비스 환경' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '서버 알림 채널' })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: '화면 설정' })).not.toBeInTheDocument(); expect(screen.queryByRole('combobox', { name: '테마 선택' })).not.toBeInTheDocument(); expect(screen.queryByRole('combobox', { name: '시간대 표기 선택' })).not.toBeInTheDocument(); }); - test('opens support as a focused modal instead of an always-visible form', async () => { + test('shows inquiry history on the page and opens only the composer in a modal', async () => { const user = userEvent.setup(); setup(true); - expect(screen.queryByLabelText('케이스 제목')).not.toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: '문의 내역' })).toBeInTheDocument(); + expect(screen.getByText('아직 작성한 문의가 없습니다.')).toBeInTheDocument(); + expect(screen.queryByLabelText('문의 제목')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: '문의하기' })); const dialog = screen.getByRole('dialog', { name: '문의하기' }); - expect(within(dialog).getByLabelText('케이스 제목')).toHaveFocus(); + expect(within(dialog).getByLabelText('문의 제목')).toHaveFocus(); await user.click(within(dialog).getByRole('button', { name: '문의 창 닫기' })); expect(screen.queryByRole('dialog', { name: '문의하기' })).not.toBeInTheDocument(); }); diff --git a/src/StrategyApiView.test.tsx b/src/StrategyApiView.test.tsx index df8e7d1..7b9fc75 100644 --- a/src/StrategyApiView.test.tsx +++ b/src/StrategyApiView.test.tsx @@ -45,7 +45,7 @@ describe('Strategy API view', () => { expect(await screen.findByTestId('strategy-row-Live Momentum')).toBeInTheDocument(); expect(screen.getByTestId('strategy-counts')).toHaveTextContent('출시 가능 1'); - expect(client.list).toHaveBeenCalledWith(50, undefined, expect.any(AbortSignal)); + expect(client.list).toHaveBeenCalledWith(10, undefined, expect.any(AbortSignal)); // A single complete page must not offer to load more. expect(screen.queryByTestId('strategy-load-more')).not.toBeInTheDocument(); }); @@ -75,7 +75,7 @@ describe('Strategy API view', () => { expect(await screen.findByTestId('strategy-row-Second Page')).toBeInTheDocument(); // The first page is appended to, never replaced. expect(screen.getByTestId('strategy-row-First Page')).toBeInTheDocument(); - expect(client.list).toHaveBeenLastCalledWith(50, 'cursor-2'); + expect(client.list).toHaveBeenLastCalledWith(10, 'cursor-2'); // Exhausted pages retire the control and the partial marker. expect(screen.queryByTestId('strategy-load-more')).not.toBeInTheDocument(); expect(screen.getByTestId('strategy-counts')).toHaveTextContent('전체 2'); diff --git a/src/api/account.test.ts b/src/api/account.test.ts index 36dffe3..9f88498 100644 --- a/src/api/account.test.ts +++ b/src/api/account.test.ts @@ -4,6 +4,7 @@ import { AccountApiError, createAccountClient } from './account'; describe('account API client', () => { it('stores the one-time login token and sends correlation evidence', async () => { const setAccessToken = vi.fn(); + const signIn = vi.fn(); const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ accountId: 'account-1', tokenType: 'Bearer', accessToken: 'access-jwt', @@ -11,12 +12,17 @@ describe('account API client', () => { }), { status: 200 })); const client = createAccountClient({ baseUrl: 'https://api.example.com/', fetchImpl, setAccessToken, + sessionStore: { + read: vi.fn().mockReturnValue({ status: 'anonymous', reason: 'absent' }), + accessToken: vi.fn(), canRefresh: vi.fn(), signIn, signOut: vi.fn(), subscribe: vi.fn(), + }, createCorrelationId: () => 'correlation-1', }); await client.login('user@example.com', 'password'); expect(setAccessToken).toHaveBeenCalledWith('access-jwt'); + expect(signIn).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com' })); expect(fetchImpl).toHaveBeenCalledWith('https://api.example.com/api/v1/auth/login', expect.objectContaining({ method: 'POST', credentials: 'include', headers: expect.objectContaining({ 'X-Correlation-Id': 'correlation-1' }), @@ -58,11 +64,19 @@ describe('account API client', () => { }), { status: 202 })); const client = createAccountClient({ fetchImpl, getAccessToken: () => 'session-token', createCorrelationId: () => 'correlation-2', + sessionStore: { + read: vi.fn().mockReturnValue({ + status: 'authenticated', + session: { accessToken: 'session-token', accountId: 'account-1', email: 'user@example.com', expiresAt: null }, + }), + accessToken: vi.fn(), canRefresh: vi.fn(), signIn: vi.fn(), signOut: vi.fn(), subscribe: vi.fn(), + }, }); - await client.requestWithdrawal('user@example.com', 'password', 'withdrawal-1'); + await client.requestWithdrawal('password', 'withdrawal-1'); expect(fetchImpl).toHaveBeenCalledWith('/api/v1/account/withdrawal-requests', expect.objectContaining({ + body: JSON.stringify({ email: 'user@example.com', password: 'password', acceptedPolicyDocumentIds: [] }), headers: expect.objectContaining({ Authorization: 'Bearer session-token', 'Idempotency-Key': 'withdrawal-1', }), diff --git a/src/api/account.ts b/src/api/account.ts index 28c6485..ff053c2 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -78,8 +78,8 @@ export interface AccountClient { logoutAll(signal?: AbortSignal): Promise; preferences(signal?: AbortSignal): Promise; updatePreferences(input: Pick, signal?: AbortSignal): Promise; - requestWithdrawal(email: string, password: string, idempotencyKey: string, signal?: AbortSignal): Promise; - cancelWithdrawal(email: string, password: string, idempotencyKey: string, signal?: AbortSignal): Promise; + requestWithdrawal(password: string, idempotencyKey: string, signal?: AbortSignal): Promise; + cancelWithdrawal(password: string, idempotencyKey: string, signal?: AbortSignal): Promise; } export function createAccountClient({ @@ -95,11 +95,21 @@ export function createAccountClient({ const requireSession = () => { if (!getAccessToken?.()) throw new AccountApiError(401, 'AUTHENTICATION_REQUIRED', createCorrelationId()); }; - const publishTokens = (result: LoginResult) => { + const rememberedEmail = () => { + const state = sessionStore?.read(); + return state?.status === 'authenticated' ? state.session.email : undefined; + }; + const requireRememberedEmail = () => { + const email = rememberedEmail(); + if (!email) throw new AccountApiError(401, 'PASSWORD_STEP_UP_EMAIL_UNAVAILABLE', createCorrelationId()); + return email; + }; + const publishTokens = (result: LoginResult, email = rememberedEmail()) => { setAccessToken?.(result.accessToken); sessionStore?.signIn({ accessToken: result.accessToken, accountId: result.accountId, + ...(email ? { email: email.trim() } : {}), expiresAt: result.accessExpiresAt, refreshExpiresAt: result.refreshExpiresAt, }); @@ -193,7 +203,7 @@ export function createAccountClient({ method: 'POST', signal, body: JSON.stringify({ email, password }), })).json()); const result = readLoginResult(value); - publishTokens(result); + publishTokens(result, email); return result; }, async loginWithGoogle(idToken, expectedNonce, signal) { @@ -243,11 +253,13 @@ export function createAccountClient({ method: 'PATCH', signal, body: JSON.stringify(input), })).json()); }, - requestWithdrawal(email, password, idempotencyKey, signal) { - return lifecycle('/api/v1/account/withdrawal-requests', email, password, [], idempotencyKey, signal); + requestWithdrawal(password, idempotencyKey, signal) { + requireSession(); + return lifecycle('/api/v1/account/withdrawal-requests', requireRememberedEmail(), password, [], idempotencyKey, signal); }, - cancelWithdrawal(email, password, idempotencyKey, signal) { - return lifecycle('/api/v1/account/withdrawal-cancellations', email, password, [], idempotencyKey, signal); + cancelWithdrawal(password, idempotencyKey, signal) { + requireSession(); + return lifecycle('/api/v1/account/withdrawal-cancellations', requireRememberedEmail(), password, [], idempotencyKey, signal); }, }; } diff --git a/src/api/accountOperations.test.ts b/src/api/accountOperations.test.ts index 339a830..26d151d 100644 --- a/src/api/accountOperations.test.ts +++ b/src/api/accountOperations.test.ts @@ -18,6 +18,31 @@ describe('account operations API client', () => { })); }); + it('loads the signed-in users inquiry list and customer-safe detail', async () => { + const responses = [ + new Response(JSON.stringify({ items: [{ + id: 'case-1', type: 'INQUIRY', status: 'UNDER_REVIEW', subject: '결제 문의', + createdAt: '2026-08-03T00:00:00Z', updatedAt: '2026-08-03T01:00:00Z', + }], nextCursor: 'opaque-cursor' }), { status: 200 }), + new Response(JSON.stringify({ + id: 'case-1', type: 'INQUIRY', status: 'UNDER_REVIEW', subject: '결제 문의', + description: '결제 내역을 확인해 주세요.', createdAt: '2026-08-03T00:00:00Z', + updatedAt: '2026-08-03T01:00:00Z', responseDeadlineAt: null, + history: [{ actor: 'SUPPORT', status: 'UNDER_REVIEW', message: '확인하고 있습니다.', createdAt: '2026-08-03T01:00:00Z' }], + }), { status: 200 }), + ]; + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(responses.shift())); + const client = createAccountOperationsClient({ fetchImpl, getAccessToken: () => 'token' }); + + await expect(client.userCases(null, 10)).resolves.toEqual(expect.objectContaining({ nextCursor: 'opaque-cursor' })); + await expect(client.userCase('case-1')).resolves.toEqual(expect.objectContaining({ + subject: '결제 문의', description: '결제 내역을 확인해 주세요.', + history: [expect.objectContaining({ actor: 'SUPPORT', message: '확인하고 있습니다.' })], + })); + expect(String(fetchImpl.mock.calls[0][0])).toContain('/api/v1/cases?limit=10'); + expect(fetchImpl.mock.calls[1][0]).toBe('/api/v1/cases/case-1'); + }); + it('encodes all operator queue filters and validates the response', async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [{ caseId: 'case-1', type: 'REPORT', status: 'UNDER_REVIEW', version: 2, assigneeOperatorId: null, updatedAt: '2026-08-03T00:00:00Z' }], @@ -40,11 +65,11 @@ describe('account operations API client', () => { it('sends case commands with server-owned request hashing and surfaces the receipt', async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ status: 'APPLIED', code: 'CASE_REVIEW_STARTED', correlationId: 'corr-3', caseVersion: 3 }), { status: 200 })); const client = createAccountOperationsClient({ fetchImpl, createCorrelationId: () => 'corr-3', getOperatorAccessToken: () => 'operator-token' }); - await expect(client.commandCase('case-1', 'START_REVIEW', { expectedVersion: 2, reasonCode: 'REVIEW_READY' }, 'idem-3')) + await expect(client.commandCase('case-1', 'START_REVIEW', { expectedVersion: 2, reasonCode: 'REVIEW_READY', customerMessage: '확인하고 있습니다.' }, 'idem-3')) .resolves.toEqual({ status: 'APPLIED', code: 'CASE_REVIEW_STARTED', correlationId: 'corr-3', caseVersion: 3 }); const init = fetchImpl.mock.calls[0][1] as RequestInit; expect(init.headers).toEqual(expect.objectContaining({ 'Idempotency-Key': 'idem-3', 'X-Correlation-Id': 'corr-3' })); - expect(JSON.parse(String(init.body))).toEqual(expect.objectContaining({ expectedVersion: 2, reasonCode: 'REVIEW_READY', evidenceIds: [], expectedSanctionVersion: 0 })); + expect(JSON.parse(String(init.body))).toEqual(expect.objectContaining({ expectedVersion: 2, reasonCode: 'REVIEW_READY', customerMessage: '확인하고 있습니다.', evidenceIds: [], expectedSanctionVersion: 0 })); }); it('binds RBAC idempotency, correlation, and a deterministic SHA-256 request hash into the body', async () => { diff --git a/src/api/accountOperations.ts b/src/api/accountOperations.ts index e537a10..39efdcd 100644 --- a/src/api/accountOperations.ts +++ b/src/api/accountOperations.ts @@ -23,6 +23,39 @@ export interface UserCaseView { updatedAt: string; } +export interface UserCaseSummary { + id: string; + type: UserCaseType; + status: UserCaseStatus; + subject: string; + createdAt: string; + updatedAt: string; +} + +export interface UserCasePage { + items: UserCaseSummary[]; + nextCursor: string | null; +} + +export interface UserCaseHistoryItem { + actor: 'CUSTOMER' | 'SUPPORT' | 'SYSTEM'; + status: UserCaseStatus; + message: string; + createdAt: string; +} + +export interface UserCaseDetail { + id: string; + type: UserCaseType; + status: UserCaseStatus; + subject: string; + description: string; + createdAt: string; + updatedAt: string; + responseDeadlineAt: string | null; + history: UserCaseHistoryItem[]; +} + export interface OperatorEvidenceView { evidenceId: string; kind: string; @@ -87,13 +120,15 @@ interface ClientOptions { export interface AccountOperationsClient { submitCase(input: { type: UserCaseType; subject: string; description: string; evidence: EvidenceReference[] }, idempotencyKey: string, signal?: AbortSignal): Promise; addCaseEvidence(caseId: string, expectedVersion: number, evidence: EvidenceReference[], idempotencyKey: string, signal?: AbortSignal): Promise; - userCase(caseId: string, signal?: AbortSignal): Promise; + userCases(cursor?: string | null, limit?: number, signal?: AbortSignal): Promise; + userCase(caseId: string, signal?: AbortSignal): Promise; operatorCaseQueue(query: { types: UserCaseType[]; statuses?: UserCaseStatus[]; assigneeOperatorId?: string; cursor?: string; limit?: number }, signal?: AbortSignal): Promise; operatorCase(caseId: string, signal?: AbortSignal): Promise; commandCase(caseId: string, action: OperatorCaseAction, input: { expectedVersion: number; assigneeOperatorId?: string | null; reasonCode: string; + customerMessage?: string | null; evidenceIds?: string[]; sanctionId?: string | null; sanctionType?: SanctionType | null; @@ -155,8 +190,13 @@ export function createAccountOperationsClient({ method: 'POST', signal, headers: { 'Idempotency-Key': idempotencyKey }, body: JSON.stringify({ expectedVersion, evidence }), }))); }, + async userCases(cursor = null, limit = 10, signal) { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set('cursor', cursor); + return readUserCasePage(await json(await request(`/api/v1/cases?${params}`, { signal }))); + }, async userCase(caseId, signal) { - return readUserCase(await json(await request(`/api/v1/cases/${encodeURIComponent(caseId)}`, { signal }))); + return readUserCaseDetail(await json(await request(`/api/v1/cases/${encodeURIComponent(caseId)}`, { signal }))); }, async operatorCaseQueue(query, signal) { const params = new URLSearchParams(); @@ -175,7 +215,8 @@ export function createAccountOperationsClient({ method: 'POST', signal, headers: { 'Idempotency-Key': idempotencyKey }, body: JSON.stringify({ expectedVersion: input.expectedVersion, assigneeOperatorId: input.assigneeOperatorId ?? null, - reasonCode: input.reasonCode, evidenceIds: input.evidenceIds ?? [], sanctionId: input.sanctionId ?? null, + reasonCode: input.reasonCode, customerMessage: input.customerMessage ?? null, + evidenceIds: input.evidenceIds ?? [], sanctionId: input.sanctionId ?? null, sanctionType: input.sanctionType ?? null, sanctionExpiresAt: input.sanctionExpiresAt ?? null, expectedSanctionVersion: input.expectedSanctionVersion ?? 0, }), @@ -267,6 +308,40 @@ function readUserCase(value: unknown): UserCaseView { }; } +function readUserCaseSummary(value: unknown): UserCaseSummary { + const v = object(value); + return { + id: text(v.id, 'case id'), + type: enumeration(v.type, ['INQUIRY', 'REPORT', 'APPEAL'], 'case type'), + status: enumeration(v.status, ['OPEN', 'NEEDS_INFORMATION', 'UNDER_REVIEW', 'RESOLVED', 'REJECTED'], 'case status'), + subject: text(v.subject, 'case subject'), createdAt: text(v.createdAt, 'createdAt'), updatedAt: text(v.updatedAt, 'updatedAt'), + }; +} + +function readUserCasePage(value: unknown): UserCasePage { + const v = object(value); + if (!Array.isArray(v.items)) throw new Error('Invalid case items'); + return { items: v.items.map(readUserCaseSummary), nextCursor: nullableText(v.nextCursor) }; +} + +function readUserCaseDetail(value: unknown): UserCaseDetail { + const v = object(value); + if (!Array.isArray(v.history)) throw new Error('Invalid case history'); + return { + ...readUserCaseSummary(v), + description: text(v.description, 'case description'), + responseDeadlineAt: nullableText(v.responseDeadlineAt), + history: v.history.map((entry) => { + const item = object(entry); + return { + actor: enumeration(item.actor, ['CUSTOMER', 'SUPPORT', 'SYSTEM'], 'case actor'), + status: enumeration(item.status, ['OPEN', 'NEEDS_INFORMATION', 'UNDER_REVIEW', 'RESOLVED', 'REJECTED'], 'case status'), + message: text(item.message, 'case message'), createdAt: text(item.createdAt, 'createdAt'), + }; + }), + }; +} + function readSummary(value: unknown): OperatorCaseSummary { const v = object(value); return { diff --git a/src/api/notifications.test.ts b/src/api/notifications.test.ts index da56c0f..e80268f 100644 --- a/src/api/notifications.test.ts +++ b/src/api/notifications.test.ts @@ -26,16 +26,17 @@ describe('notification API client', () => { expect(fetchImpl).toHaveBeenCalledWith('/api/v1/account/notifications/notification%2Funsafe/read', expect.objectContaining({ method: 'PUT' })); }); - it('loads and replaces versioned channel preferences without allowing unknown channels', async () => { - const preference = { typeCode: 'CASE_UPDATED', policyVersion: 'policy-v1', mandatory: false, enabledChannels: ['APP', 'EMAIL'] }; + it('loads and replaces the account email preference without internal policy fields', async () => { + const preference = { enabled: false }; const fetchImpl = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify([preference]), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify(preference), { status: 200 })); + .mockResolvedValueOnce(new Response(JSON.stringify(preference), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ enabled: true }), { status: 200 })); const client = createNotificationClient({ fetchImpl, getAccessToken: () => 'token', createCorrelationId: () => 'corr-preference' }); - await expect(client.preferences()).resolves.toEqual([preference]); - await expect(client.replacePreference('CASE/UPDATED', ['APP', 'EMAIL'])).resolves.toEqual(preference); - expect(fetchImpl.mock.calls[1][0]).toBe('/api/v1/account/notifications/preferences/CASE%2FUPDATED'); - expect(JSON.parse(String((fetchImpl.mock.calls[1][1] as RequestInit).body))).toEqual({ enabledChannels: ['APP', 'EMAIL'] }); + await expect(client.emailPreference()).resolves.toEqual(preference); + await expect(client.replaceEmailPreference(true)).resolves.toEqual({ enabled: true }); + expect(fetchImpl.mock.calls[0][0]).toBe('/api/v1/account/notifications/email-preference'); + expect(fetchImpl.mock.calls[1][0]).toBe('/api/v1/account/notifications/email-preference'); + expect(JSON.parse(String((fetchImpl.mock.calls[1][1] as RequestInit).body))).toEqual({ enabled: true }); }); it('rejects an incomplete server cursor instead of guessing pagination semantics', async () => { diff --git a/src/api/notifications.ts b/src/api/notifications.ts index 339cfe3..5853bf6 100644 --- a/src/api/notifications.ts +++ b/src/api/notifications.ts @@ -1,7 +1,5 @@ import { getSessionAccessToken } from './sessionAccessToken'; -export type NotificationChannel = 'APP' | 'EMAIL'; - export interface NotificationRecord { id: string; typeCode: string; @@ -19,12 +17,7 @@ export interface NotificationPage { nextId: string | null; } -export interface NotificationPreference { - typeCode: string; - policyVersion: string; - mandatory: boolean; - enabledChannels: NotificationChannel[]; -} +export interface EmailNotificationPreference { enabled: boolean; } export class NotificationApiError extends Error { constructor(public readonly status: number, public readonly code: string, public readonly correlationId: string | null) { @@ -45,8 +38,8 @@ interface NotificationClientOptions { export interface NotificationClient { list(cursor?: { beforeCreatedAt: string; beforeId: string } | null, limit?: number, signal?: AbortSignal): Promise; markRead(notificationId: string, signal?: AbortSignal): Promise; - preferences(signal?: AbortSignal): Promise; - replacePreference(typeCode: string, enabledChannels: NotificationChannel[], signal?: AbortSignal): Promise; + emailPreference(signal?: AbortSignal): Promise; + replaceEmailPreference(enabled: boolean, signal?: AbortSignal): Promise; } export function createNotificationClient({ @@ -85,14 +78,12 @@ export function createNotificationClient({ async markRead(notificationId, signal) { await request(`/api/v1/account/notifications/${encodeURIComponent(notificationId)}/read`, { method: 'PUT', signal }); }, - async preferences(signal) { - const value = await json(await request('/api/v1/account/notifications/preferences', { signal })); - if (!Array.isArray(value)) throw new Error('Invalid notification preferences'); - return value.map(readPreference); + async emailPreference(signal) { + return readEmailPreference(await json(await request('/api/v1/account/notifications/email-preference', { signal }))); }, - async replacePreference(typeCode, enabledChannels, signal) { - return readPreference(await json(await request(`/api/v1/account/notifications/preferences/${encodeURIComponent(typeCode)}`, { - method: 'PUT', signal, body: JSON.stringify({ enabledChannels }), + async replaceEmailPreference(enabled, signal) { + return readEmailPreference(await json(await request('/api/v1/account/notifications/email-preference', { + method: 'PUT', signal, body: JSON.stringify({ enabled }), }))); }, }; @@ -129,15 +120,9 @@ function readRecord(value: unknown): NotificationRecord { }; } -function readPreference(value: unknown): NotificationPreference { +function readEmailPreference(value: unknown): EmailNotificationPreference { const preference = object(value); - if (!Array.isArray(preference.enabledChannels)) throw new Error('Invalid enabledChannels'); - const channels: NotificationChannel[] = preference.enabledChannels.map((channel) => enumeration(channel, ['APP', 'EMAIL'], 'notification channel')); - if (!channels.includes('APP')) throw new Error('APP notification channel is required'); - return { - typeCode: text(preference.typeCode, 'typeCode'), policyVersion: text(preference.policyVersion, 'policyVersion'), - mandatory: bool(preference.mandatory, 'mandatory'), enabledChannels: channels, - }; + return { enabled: bool(preference.enabled, 'enabled') }; } function object(value: unknown): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('Invalid API response'); return value as Record; } diff --git a/src/api/strategies.test.ts b/src/api/strategies.test.ts index 28bdcb9..1ba228b 100644 --- a/src/api/strategies.test.ts +++ b/src/api/strategies.test.ts @@ -13,7 +13,7 @@ describe('strategy library API client', () => { await createStrategyLibraryClient({ fetchImpl }).list(); - expect(fetchImpl).toHaveBeenCalledWith('/api/v1/strategies?limit=50', expect.objectContaining({ + expect(fetchImpl).toHaveBeenCalledWith('/api/v1/strategies?limit=10', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer browser-session-token' }), })); }); @@ -42,7 +42,7 @@ describe('strategy library API client', () => { const page = await createStrategyLibraryClient({ baseUrl: 'https://api.example.com/', fetchImpl, - }).list(50); + }).list(10); expect(page.items[0]).toMatchObject({ mode: 'BASIC', @@ -52,7 +52,7 @@ describe('strategy library API client', () => { symbols: ['AAPL', 'MSFT'], }); expect(fetchImpl).toHaveBeenCalledWith( - 'https://api.example.com/api/v1/strategies?limit=50', + 'https://api.example.com/api/v1/strategies?limit=10', expect.objectContaining({ credentials: 'include' }), ); }); diff --git a/src/api/strategies.ts b/src/api/strategies.ts index 8694e43..7c08f47 100644 --- a/src/api/strategies.ts +++ b/src/api/strategies.ts @@ -1,5 +1,7 @@ import { getSessionAccessToken } from './sessionAccessToken'; +export const STRATEGY_LIBRARY_PAGE_SIZE = 10; + export type StrategyMode = 'BASIC' | 'PRO'; export interface StrategyLibraryItem { @@ -199,7 +201,7 @@ export function createStrategyLibraryClient({ }: ClientOptions = {}): StrategyLibraryClient { const root = baseUrl.replace(/\/$/, ''); return { - async list(limit = 50, cursor, signal) { + async list(limit = STRATEGY_LIBRARY_PAGE_SIZE, cursor, signal) { const query = new URLSearchParams({ limit: String(limit) }); if (cursor) query.set('cursor', cursor); const token = getAccessToken?.(); diff --git a/src/components/AccountApiPanels.test.tsx b/src/components/AccountApiPanels.test.tsx index 11ad5a7..518fbd3 100644 --- a/src/components/AccountApiPanels.test.tsx +++ b/src/components/AccountApiPanels.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AccountClient, AccountPreferences } from '../api/account'; @@ -20,31 +20,52 @@ const client = (overrides: Partial = {}): AccountClient => ({ afterEach(cleanup); describe('AccountApiPanels', () => { - it('loads preferences without requesting a server-side session list', async () => { + it('keeps the account page focused on security and account management', () => { const api = client(); render(); - expect(await screen.findByRole('heading', { name: '로그인 및 보안' })).toBeInTheDocument(); - expect(api.preferences).toHaveBeenCalledTimes(1); - expect(screen.queryByText(/활성 세션/)).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '로그인 및 보안' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '계정 관리' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '서비스 환경' })).not.toBeInTheDocument(); + expect(api.preferences).not.toHaveBeenCalled(); }); it('offers normal current and all-device logout actions', async () => { const logoutCurrent = vi.fn().mockResolvedValue(undefined); const logoutAll = vi.fn().mockResolvedValue(undefined); render(); - await screen.findByRole('heading', { name: '로그인 및 보안' }); + const actions = screen.getByRole('group', { name: '로그인 보안 작업' }); - await userEvent.click(screen.getByRole('button', { name: '로그아웃' })); + expect(within(actions).getByRole('button', { name: '로그아웃' })).toHaveClass('account-logout-button'); + expect(within(actions).getByRole('button', { name: '모든 기기에서 로그아웃' })).toHaveClass('account-logout-all-button'); + await userEvent.click(within(actions).getByRole('button', { name: '로그아웃' })); await waitFor(() => expect(logoutCurrent).toHaveBeenCalledTimes(1)); }); - it('saves the account language preference', async () => { - const updatePreferences = vi.fn().mockResolvedValue({ ...preferences, languageCode: 'en' }); - render(); - await userEvent.selectOptions(await screen.findByLabelText('서버 언어 선택'), 'en'); - await userEvent.click(screen.getByRole('button', { name: '환경 저장' })); + it('requests withdrawal from a warning modal using only the password field', async () => { + const user = userEvent.setup(); + const requestWithdrawal = vi.fn().mockResolvedValue({ + accountId: 'account-1', status: 'CLOSING', version: 2, + withdrawalRequestedAt: '2026-08-09T00:00:00Z', cancellationDeadlineAt: null, applied: true, + }); + render( 'withdrawal-1'} + />); - await waitFor(() => expect(updatePreferences).toHaveBeenCalledWith(expect.objectContaining({ languageCode: 'en' }))); + expect(screen.queryByLabelText('현재 비밀번호')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: '회원 탈퇴' })); + + const dialog = screen.getByRole('dialog', { name: '회원 탈퇴' }); + expect(within(dialog).getByText('계정을 삭제하면 복구할 수 없습니다.')).toBeInTheDocument(); + const password = within(dialog).getByLabelText('현재 비밀번호'); + expect(password).toHaveFocus(); + expect(within(dialog).getByRole('button', { name: '탈퇴' })).toBeDisabled(); + + await user.type(password, 'password'); + await user.click(within(dialog).getByRole('button', { name: '탈퇴' })); + + await waitFor(() => expect(requestWithdrawal).toHaveBeenCalledWith('password', 'withdrawal-1')); + expect(screen.queryByRole('dialog', { name: '회원 탈퇴' })).not.toBeInTheDocument(); }); }); diff --git a/src/components/AccountApiPanels.tsx b/src/components/AccountApiPanels.tsx index 8d432f8..b6c1eff 100644 --- a/src/components/AccountApiPanels.tsx +++ b/src/components/AccountApiPanels.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Languages, Loader2, LockKeyhole, LogOut, Settings, ShieldCheck } from 'lucide-react'; -import type { AccountClient, AccountPreferences, LifecycleResult } from '../api/account'; +import { useEffect, useRef, useState } from 'react'; +import { AlertTriangle, Loader2, LockKeyhole, LogOut, ShieldCheck, Trash2, X } from 'lucide-react'; +import type { AccountClient } from '../api/account'; import { AccountApiError } from '../api/account'; import { setSessionAccessToken } from '../api/sessionAccessToken'; import { browserSessionStore } from '../lib/session'; -import { Button, ErrorState, Panel, SignInRequiredState } from './common'; +import { Button } from './common'; function dropTabSession(reason?: 'rejected') { setSessionAccessToken(null); @@ -14,18 +14,11 @@ function dropTabSession(reason?: 'rejected') { interface AccountApiPanelsProps { client: AccountClient; createIdempotencyKey?: () => string; - onPreferences?: (preferences: AccountPreferences) => void; } -type LoadState = - | { kind: 'loading' } - | { kind: 'error'; error: AccountApiError } - | { kind: 'ready'; preferences: AccountPreferences }; - -type ActionState = - | { kind: 'idle' | 'pending' | 'saved' } - | { kind: 'error'; error: AccountApiError; retry: () => void } - | { kind: 'lifecycle'; result: LifecycleResult }; +type WithdrawalState = + | { kind: 'idle' | 'pending' } + | { kind: 'error'; error: AccountApiError }; const fallbackError = (error: unknown) => error instanceof AccountApiError ? error @@ -34,144 +27,209 @@ const fallbackError = (error: unknown) => error instanceof AccountApiError export function AccountApiPanels({ client, createIdempotencyKey = () => crypto.randomUUID(), - onPreferences, }: AccountApiPanelsProps) { - const [attempt, setAttempt] = useState(0); - const [loadState, setLoadState] = useState({ kind: 'loading' }); - const [securityState, setSecurityState] = useState({ kind: 'idle' }); - const [preferenceState, setPreferenceState] = useState({ kind: 'idle' }); - const [lifecycleState, setLifecycleState] = useState({ kind: 'idle' }); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - - useEffect(() => { - const controller = new AbortController(); - let current = true; - setLoadState({ kind: 'loading' }); - client.preferences(controller.signal).then((preferences) => { - if (!current) return; - setLoadState({ kind: 'ready', preferences }); - onPreferences?.(preferences); - }).catch((cause: unknown) => { - if (!current || controller.signal.aborted) return; - const error = fallbackError(cause); - if (error.status === 401) dropTabSession('rejected'); - setLoadState({ kind: 'error', error }); - }); - return () => { current = false; controller.abort(); }; - }, [attempt, client, onPreferences]); - - const updateDraft = (patch: Partial) => { - setLoadState((current) => current.kind === 'ready' - ? { ...current, preferences: { ...current.preferences, ...patch } } - : current); - setPreferenceState({ kind: 'idle' }); - }; - - const savePreferences = useCallback(async () => { - if (loadState.kind !== 'ready') return; - setPreferenceState({ kind: 'pending' }); - try { - const { languageCode, timezoneName, themePreference } = loadState.preferences; - const preferences = await client.updatePreferences({ languageCode, timezoneName, themePreference }); - setLoadState({ kind: 'ready', preferences }); - onPreferences?.(preferences); - setPreferenceState({ kind: 'saved' }); - } catch (cause) { - setPreferenceState({ kind: 'error', error: fallbackError(cause), retry: () => void savePreferences() }); - } - }, [client, loadState, onPreferences]); + const [securityPending, setSecurityPending] = useState<'current' | 'all' | null>(null); + const [withdrawalOpen, setWithdrawalOpen] = useState(false); - const logout = useCallback(async (all: boolean) => { - setSecurityState({ kind: 'pending' }); + const logout = async (all: boolean) => { + setSecurityPending(all ? 'all' : 'current'); try { if (all) await client.logoutAll(); else await client.logoutCurrent(); } catch { - // Local logout must still complete when the server is temporarily unavailable. + // A local sign-out still protects the current tab when the server is unavailable. } finally { dropTabSession(); + setSecurityPending(null); } - }, [client]); - - const runLifecycle = useCallback(async (operation: 'withdraw' | 'cancel', retryKey?: string) => { - setLifecycleState({ kind: 'pending' }); - const key = retryKey ?? createIdempotencyKey(); - try { - const result = operation === 'withdraw' - ? await client.requestWithdrawal(email, password, key) - : await client.cancelWithdrawal(email, password, key); - setPassword(''); - setLifecycleState({ kind: 'lifecycle', result }); - } catch (cause) { - setLifecycleState({ - kind: 'error', error: fallbackError(cause), retry: () => void runLifecycle(operation, key), - }); - } - }, [client, createIdempotencyKey, email, password]); - - if (loadState.kind === 'loading') { - return
계정 정보를 불러오는 중입니다.
; - } - if (loadState.kind === 'error') { - return setAttempt((value) => value + 1)} />; - } + }; return <>
-

로그인 및 보안

JWT 로그인은 기기 수를 제한하지 않습니다.

+
+

로그인 및 보안

+

현재 기기에서 로그아웃하거나 로그인된 모든 기기의 세션을 종료할 수 있습니다.

+
-
- - +
+ +
- {securityState.kind === 'pending' &&

로그아웃 요청을 처리하는 중입니다.

}
-
+
- -

서비스 환경

계정에 저장되는 언어를 관리합니다.

+ +
+

계정 관리

+

회원 탈퇴는 계정과 연결된 서비스 이용을 종료하는 중요한 작업입니다.

+
-
- -
시간대
{loadState.preferences.timezoneName}
+
+
+ Idea2Strategy 회원 탈퇴 + 본인 확인 후 탈퇴 요청을 진행합니다. +
+
-
- {preferenceState.kind === 'saved' &&

서버 설정을 저장했습니다.

} - {preferenceState.kind === 'error' && }
-
-

계정 관리

-
탈퇴 요청 · 취소 -
- - -
-
- {lifecycleState.kind === 'lifecycle' &&

계정 상태: {lifecycleState.result.status} · 버전 {lifecycleState.result.version}

} - {lifecycleState.kind === 'error' && } -
-
+ {withdrawalOpen && setWithdrawalOpen(false)} + />} ; } -export function AccountSignOutButton({ client }: { client: AccountClient }) { - const [pending, setPending] = useState(false); - const signOut = async () => { - setPending(true); - try { await client.logoutCurrent(); } catch { /* local sign-out still wins */ } - finally { dropTabSession(); } +function WithdrawalDialog({ + client, + createIdempotencyKey, + onClose, +}: { + client: AccountClient; + createIdempotencyKey: () => string; + onClose: () => void; +}) { + const dialogRef = useRef(null); + const passwordRef = useRef(null); + const [password, setPassword] = useState(''); + const [state, setState] = useState({ kind: 'idle' }); + + useEffect(() => { + passwordRef.current?.focus(); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && state.kind !== 'pending') { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== 'Tab' || !dialogRef.current) return; + const focusable = [...dialogRef.current.querySelectorAll( + 'button:not(:disabled), input:not(:disabled)', + )]; + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener('keydown', onKeyDown); + }; + }, [onClose, state.kind]); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!password || state.kind === 'pending') return; + setState({ kind: 'pending' }); + try { + await client.requestWithdrawal(password, createIdempotencyKey()); + setPassword(''); + onClose(); + dropTabSession(); + } catch (cause) { + setState({ kind: 'error', error: fallbackError(cause) }); + passwordRef.current?.focus(); + } }; - return ; -} -function ApiErrorState({ error, onRetry }: { error: AccountApiError; onRetry: () => void }) { - if (error.status === 401) return ; - /* A customer screen states the outcome only; the raw code and correlation id - stay in the server log where support can already reach them. */ - return ; + const errorMessage = state.kind === 'error' + ? state.error.code === 'PASSWORD_STEP_UP_EMAIL_UNAVAILABLE' + ? '보안을 위해 다시 로그인한 뒤 시도해주세요.' + : state.error.status === 401 + ? '비밀번호를 다시 확인해주세요.' + : '탈퇴 요청을 처리하지 못했습니다. 잠시 후 다시 시도해주세요.' + : null; + + return
{ + if (event.currentTarget === event.target && state.kind !== 'pending') onClose(); + }} + > +
+
+ +
+ DELETE ACCOUNT +

회원 탈퇴

+
+ +
+
void submit(event)}> +
+
+
+ + {errorMessage &&

{errorMessage}

} +
+
+ + +
+
+
+
; } diff --git a/src/components/CaseApiPanels.test.tsx b/src/components/CaseApiPanels.test.tsx index 2c90ded..77ec311 100644 --- a/src/components/CaseApiPanels.test.tsx +++ b/src/components/CaseApiPanels.test.tsx @@ -2,71 +2,93 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { AccountOperationsApiError } from '../api/accountOperations'; -import type { AccountOperationsClient, UserCaseView } from '../api/accountOperations'; -import { OperatorCaseWorkspace, OperatorSanctionPanel, UserCasePanel } from './CaseApiPanels'; +import type { AccountOperationsClient, UserCaseDetail, UserCaseView } from '../api/accountOperations'; +import { OperatorCaseWorkspace, OperatorSanctionPanel, UserCaseForm, UserCasePanel } from './CaseApiPanels'; const userCase: UserCaseView = { id: 'case-1', accountId: 'account-1', type: 'APPEAL', status: 'OPEN', version: 1, evidenceObjectIds: [], updatedAt: '2026-08-03T00:00:00Z', }; +const userCaseDetail: UserCaseDetail = { + id: 'case-1', type: 'APPEAL', status: 'UNDER_REVIEW', subject: '제재 이의', description: '검토를 요청합니다.', + createdAt: '2026-08-03T00:00:00Z', updatedAt: '2026-08-03T01:00:00Z', responseDeadlineAt: null, + history: [ + { actor: 'CUSTOMER', status: 'OPEN', message: '문의를 접수했습니다.', createdAt: '2026-08-03T00:00:00Z' }, + { actor: 'SUPPORT', status: 'UNDER_REVIEW', message: '고객지원팀에서 확인하고 있습니다.', createdAt: '2026-08-03T01:00:00Z' }, + ], +}; function client(overrides: Partial = {}): AccountOperationsClient { return { - submitCase: vi.fn().mockResolvedValue(userCase), addCaseEvidence: vi.fn(), userCase: vi.fn().mockResolvedValue(userCase), + submitCase: vi.fn().mockResolvedValue(userCase), addCaseEvidence: vi.fn(), + userCases: vi.fn().mockResolvedValue({ items: [], nextCursor: null }), userCase: vi.fn().mockResolvedValue(userCaseDetail), operatorCaseQueue: vi.fn().mockResolvedValue({ items: [], nextCursor: null }), operatorCase: vi.fn(), commandCase: vi.fn(), grantOperator: vi.fn(), revokeOperator: vi.fn(), applySanction: vi.fn(), liftSanction: vi.fn(), ...overrides, }; } -describe('UserCasePanel', () => { +describe('customer inquiry UI', () => { it('requires meaningful fields, submits once with a fresh key, and shows the server receipt', async () => { const submitCase = vi.fn().mockResolvedValue(userCase); - render( 'idem-case'} />); + render( 'idem-case'} />); const submit = screen.getByRole('button', { name: '접수하기' }); expect(submit).toBeDisabled(); - await userEvent.selectOptions(screen.getByLabelText('케이스 유형'), 'APPEAL'); - await userEvent.type(screen.getByLabelText('케이스 제목'), '제재 이의'); - await userEvent.type(screen.getByLabelText('케이스 설명'), '검토를 요청합니다.'); + await userEvent.selectOptions(screen.getByLabelText('문의 유형'), 'APPEAL'); + await userEvent.type(screen.getByLabelText('문의 제목'), '제재 이의'); + await userEvent.type(screen.getByLabelText('문의 내용'), '검토를 요청합니다.'); await userEvent.click(submit); - await screen.findByText('추적 번호 case-1 · 버전 1'); + await screen.findByText('문의가 접수되었습니다.'); expect(submitCase).toHaveBeenCalledWith(expect.objectContaining({ type: 'APPEAL', evidence: [] }), 'idem-case'); + expect(screen.queryByText(/case-1|버전/)).not.toBeInTheDocument(); }); - it('offers a retry action for retryable failures without exposing the raw code', async () => { - const createIdempotencyKey = vi.fn(() => 'idem-lost-response'); + it('keeps the inquiry form usable after failure without showing a retry button', async () => { + const createIdempotencyKey = vi.fn() + .mockReturnValueOnce('idem-first-attempt') + .mockReturnValueOnce('idem-second-attempt'); const submitCase = vi.fn() .mockRejectedValueOnce(new AccountOperationsApiError(503, 'CASE_SERVICE_UNAVAILABLE', 'corr-case')) .mockResolvedValueOnce(userCase); - render(); - await userEvent.type(screen.getByLabelText('케이스 제목'), '문의'); - await userEvent.type(screen.getByLabelText('케이스 설명'), '내용'); + render(); + await userEvent.type(screen.getByLabelText('문의 제목'), '문의'); + await userEvent.type(screen.getByLabelText('문의 내용'), '내용'); await userEvent.click(screen.getByRole('button', { name: '접수하기' })); expect(await screen.findByText('일시적으로 서버에 연결할 수 없습니다.')).toBeInTheDocument(); expect(screen.queryByText(/corr-case/)).not.toBeInTheDocument(); expect(screen.queryByText(/CASE_SERVICE_UNAVAILABLE/)).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: '다시 시도' })); - await screen.findByText('추적 번호 case-1 · 버전 1'); - expect(createIdempotencyKey).toHaveBeenCalledTimes(1); - expect(submitCase).toHaveBeenNthCalledWith(1, expect.any(Object), 'idem-lost-response'); - expect(submitCase).toHaveBeenNthCalledWith(2, expect.any(Object), 'idem-lost-response'); + expect(screen.queryByRole('button', { name: '다시 시도' })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: '접수하기' })); + await screen.findByText('문의가 접수되었습니다.'); + expect(createIdempotencyKey).toHaveBeenCalledTimes(2); + expect(submitCase).toHaveBeenNthCalledWith(1, expect.any(Object), 'idem-first-attempt'); + expect(submitCase).toHaveBeenNthCalledWith(2, expect.any(Object), 'idem-second-attempt'); }); - it('links follow-up evidence with the exact current case version', async () => { - const addCaseEvidence = vi.fn().mockResolvedValue({ ...userCase, version: 2, evidenceObjectIds: ['object-1'] }); - render( 'idem-evidence'} />); - await userEvent.type(screen.getByLabelText('케이스 제목'), '문의'); - await userEvent.type(screen.getByLabelText('케이스 설명'), '내용'); - await userEvent.click(screen.getByRole('button', { name: '접수하기' })); - await screen.findByText('추적 번호 case-1 · 버전 1'); - await userEvent.type(screen.getByLabelText('Evidence storage object ID'), 'object-1'); - await userEvent.type(screen.getByLabelText('Evidence source domain'), 'BACKTEST'); - await userEvent.type(screen.getByLabelText('Evidence source resource ID'), 'run-1'); - await userEvent.click(screen.getByRole('button', { name: '증거 연결' })); - - await waitFor(() => expect(addCaseEvidence).toHaveBeenCalledWith('case-1', 1, [{ - storageObjectId: 'object-1', sourceDomain: 'BACKTEST', sourceResourceId: 'run-1', - }], 'idem-evidence')); - expect(await screen.findByText('증거가 연결되었습니다. 현재 버전 2')).toBeInTheDocument(); + it('does not show a retry button when the inquiry list fails to load', async () => { + render(); + + expect(await screen.findByText('일시적으로 서버에 연결할 수 없습니다.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '다시 시도' })).not.toBeInTheDocument(); + }); + + it('shows a Korean inquiry list and opens safe detail without ids or internal status codes', async () => { + const userCases = vi.fn().mockResolvedValue({ items: [{ + id: 'case-1', type: 'APPEAL', status: 'UNDER_REVIEW', subject: '제재 이의', + createdAt: '2026-08-03T00:00:00Z', updatedAt: '2026-08-03T01:00:00Z', + }], nextCursor: null }); + render(); + + expect(await screen.findByText('제재 이의')).toBeInTheDocument(); + expect(screen.queryByLabelText('문의 제목')).not.toBeInTheDocument(); + expect(screen.getByText('검토 중')).toBeInTheDocument(); + expect(screen.queryByText('UNDER_REVIEW')).not.toBeInTheDocument(); + expect(screen.queryByText('case-1')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: '제재 이의 상세 보기' })); + expect(await screen.findByRole('dialog', { name: '제재 이의' })).toBeInTheDocument(); + expect(screen.getByText('검토를 요청합니다.')).toBeInTheDocument(); + expect(screen.getByText('고객지원팀에서 확인하고 있습니다.')).toBeInTheDocument(); }); }); diff --git a/src/components/CaseApiPanels.tsx b/src/components/CaseApiPanels.tsx index 52b7d41..aef8b9b 100644 --- a/src/components/CaseApiPanels.tsx +++ b/src/components/CaseApiPanels.tsx @@ -1,104 +1,139 @@ -import { useEffect, useRef, useState } from 'react'; -import { CheckCircle2, LoaderCircle, RefreshCw, ShieldCheck } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { CheckCircle2, ChevronRight, Clock3, Inbox, LoaderCircle, RefreshCw, Send, ShieldCheck, X } from 'lucide-react'; import { Button, EmptyState, ErrorState, PageHeading, Panel, SignInRequiredState, Status } from './common'; import { AccountOperationsApiError } from '../api/accountOperations'; import type { - AccountOperationsClient, OperatorCaseAction, OperatorCaseDetail, OperatorCaseSummary, SanctionType, UserCaseType, UserCaseView, + AccountOperationsClient, OperatorCaseAction, OperatorCaseDetail, OperatorCaseSummary, SanctionType, + UserCaseDetail, UserCasePage, UserCaseStatus, UserCaseType, UserCaseView, } from '../api/accountOperations'; type AsyncState = { kind: 'idle' } | { kind: 'loading' } | { kind: 'ready'; value: T } | { kind: 'error'; error: AccountOperationsApiError }; const error = (value: unknown) => value instanceof AccountOperationsApiError ? value : new AccountOperationsApiError(0, 'NETWORK_ERROR', null); -export function UserCasePanel({ client, createIdempotencyKey = () => crypto.randomUUID() }: { +export function UserCaseForm({ client, createIdempotencyKey = () => crypto.randomUUID(), onSubmitted }: { client: AccountOperationsClient; createIdempotencyKey?: () => string; + onSubmitted?: (value: UserCaseView) => void; }) { const [type, setType] = useState('INQUIRY'); const [subject, setSubject] = useState(''); const [description, setDescription] = useState(''); - const [caseId, setCaseId] = useState(''); const [state, setState] = useState>({ kind: 'idle' }); - const [storageObjectId, setStorageObjectId] = useState(''); - const [sourceDomain, setSourceDomain] = useState(''); - const [sourceResourceId, setSourceResourceId] = useState(''); - const [evidenceState, setEvidenceState] = useState>({ kind: 'idle' }); - const retrySubmit = useRef<(() => void) | null>(null); - const retryEvidence = useRef<(() => void) | null>(null); - - const submit = async (retryKey?: string) => { + const submit = async () => { setState({ kind: 'loading' }); - const idempotencyKey = retryKey ?? createIdempotencyKey(); + const idempotencyKey = createIdempotencyKey(); try { const value = await client.submitCase({ type, subject: subject.trim(), description: description.trim(), evidence: [] }, idempotencyKey); - retrySubmit.current = null; - setCaseId(value.id); setState({ kind: 'ready', value }); + setSubject(''); + setDescription(''); + onSubmitted?.(value); } catch (cause) { const failure = error(cause); setState({ kind: 'error', error: failure }); - retrySubmit.current = failure.retryable ? () => void submit(idempotencyKey) : null; - } - }; - const reload = async () => { - if (!caseId.trim()) return; - setState({ kind: 'loading' }); - try { setState({ kind: 'ready', value: await client.userCase(caseId.trim()) }); } - catch (cause) { setState({ kind: 'error', error: error(cause) }); } - }; - const addEvidence = async (retryKey?: string) => { - if (state.kind !== 'ready') return; - setEvidenceState({ kind: 'loading' }); - const idempotencyKey = retryKey ?? createIdempotencyKey(); - try { - const value = await client.addCaseEvidence(state.value.id, state.value.version, [{ - storageObjectId: storageObjectId.trim(), sourceDomain: sourceDomain.trim(), sourceResourceId: sourceResourceId.trim(), - }], idempotencyKey); - retryEvidence.current = null; - setState({ kind: 'ready', value }); - setEvidenceState({ kind: 'ready', value }); - setStorageObjectId(''); setSourceDomain(''); setSourceResourceId(''); - } catch (cause) { - const failure = error(cause); - setEvidenceState({ kind: 'error', error: failure }); - retryEvidence.current = failure.retryable ? () => void addEvidence(idempotencyKey) : null; } }; - - return -
- - -