From 264136c0cb54395d9cfaf34fa856d31dac9a8397 Mon Sep 17 00:00:00 2001 From: Junyou Park Date: Sun, 9 Aug 2026 16:44:49 +0900 Subject: [PATCH 1/9] fix: refine account settings experience --- src/App.test.tsx | 11 +- src/App.tsx | 1 - src/ProductScreens.test.tsx | 8 +- src/api/account.test.ts | 16 +- src/api/account.ts | 28 +- src/components/AccountApiPanels.test.tsx | 47 +++- src/components/AccountApiPanels.tsx | 310 ++++++++++++++--------- src/lib/session.test.ts | 18 ++ src/lib/session.ts | 7 +- src/styles/balanced.css | 184 ++++++++++---- src/views/SupportViews.tsx | 57 +---- 11 files changed, 436 insertions(+), 251 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index bf41d1f..ba55a31 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,12 @@ 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('switches the product between Korean and English and remembers the choice', async () => { diff --git a/src/App.tsx b/src/App.tsx index 1d5ca0c..c873cfd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -576,7 +576,6 @@ function ProductApp({ accountClient, operationsClient, notificationClient, compe setUpdown={setUpdown} accountClient={accountClient} operationsClient={operationsClient} - notificationClient={notificationClient} />} /> } /> ; diff --git a/src/ProductScreens.test.tsx b/src/ProductScreens.test.tsx index 64f8297..6c3a428 100644 --- a/src/ProductScreens.test.tsx +++ b/src/ProductScreens.test.tsx @@ -793,12 +793,12 @@ 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(); 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/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/lib/session.test.ts b/src/lib/session.test.ts index 42a3ea4..d229651 100644 --- a/src/lib/session.test.ts +++ b/src/lib/session.test.ts @@ -50,6 +50,24 @@ describe('session store', () => { expect(store.accessToken()).toBe('owner-token'); }); + it('keeps the signed-in email in tab storage for password step-up actions', () => { + const storage = memoryStorage(); + const store = createSessionStore(storage); + + store.signIn({ + accessToken: 'owner-token', + accountId: 'account-1', + email: 'user@example.com', + expiresAt: null, + }); + + expect(store.read()).toEqual({ + status: 'authenticated', + session: expect.objectContaining({ email: 'user@example.com' }), + }); + expect(storage.map.get(SESSION_STORAGE_KEY)).toContain('user@example.com'); + }); + it('never persists a refresh credential in browser storage', () => { const storage = memoryStorage(); const store = createSessionStore(storage); diff --git a/src/lib/session.ts b/src/lib/session.ts index d256ee2..d969f3c 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -35,6 +35,8 @@ export interface Session { readonly accessToken: string; /** The account the token authenticates. Runs owned by anyone else answer 403. */ readonly accountId: string; + /** Login email remembered in this tab for password step-up actions. */ + readonly email?: string; /** ISO-8601 instant, or `null` when the issuer published no expiry. */ readonly expiresAt: string | null; /** Refresh JWT expiry; access expiry remains `expiresAt` for compatibility. */ @@ -99,9 +101,10 @@ function parse(raw: string, now: number): SessionState { } if (typeof value !== 'object' || value === null || Array.isArray(value)) return MALFORMED; - const { accessToken, accountId, expiresAt, refreshExpiresAt } = value as Record; + const { accessToken, accountId, email, expiresAt, refreshExpiresAt } = value as Record; if (typeof accessToken !== 'string' || accessToken.trim().length === 0) return MALFORMED; if (typeof accountId !== 'string' || accountId.length === 0) return MALFORMED; + if (email !== undefined && (typeof email !== 'string' || email.trim().length === 0)) return MALFORMED; if (expiresAt !== undefined && expiresAt !== null && typeof expiresAt !== 'string') { return MALFORMED; } @@ -118,6 +121,7 @@ function parse(raw: string, now: number): SessionState { session: { accessToken: accessToken.trim(), accountId, + ...(typeof email === 'string' ? { email: email.trim() } : {}), expiresAt: typeof expiresAt === 'string' ? expiresAt : null, ...(typeof refreshExpiresAt === 'string' ? { refreshExpiresAt } : {}), }, @@ -214,6 +218,7 @@ export function createSessionStore( write(JSON.stringify({ accessToken: session.accessToken, accountId: session.accountId, + email: session.email, expiresAt: session.expiresAt, refreshExpiresAt: session.refreshExpiresAt, })); diff --git a/src/styles/balanced.css b/src/styles/balanced.css index 8d4f253..b2c2b47 100644 --- a/src/styles/balanced.css +++ b/src/styles/balanced.css @@ -4320,50 +4320,8 @@ .account-page { gap: var(--space-6); } .account-page .page-heading { align-items: flex-end; padding-bottom: var(--space-5); border-bottom: 1px solid var(--line); } .account-page .page-description { max-width: 620px; } -.account-signout-button { min-width: 92px; color: var(--text-soft); border-color: var(--line-strong); background: var(--surface-1); } -.account-signout-button:hover { color: var(--negative); border-color: color-mix(in srgb, var(--negative) 42%, var(--line)); background: color-mix(in srgb, var(--negative) 7%, var(--surface-1)); } -.account-settings-layout { display: grid; grid-template-columns: 236px minmax(0, 1fr); align-items: start; gap: var(--space-5); } -.account-settings-sidebar { - position: sticky; - top: var(--space-4); - display: grid; - gap: var(--space-3); - padding: var(--space-3); - border: 1px solid var(--line); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--surface-1) 94%, transparent); - box-shadow: var(--shadow); -} -.account-sidebar-title { display: flex; align-items: center; gap: 10px; padding: 8px; } -.account-sidebar-title > span { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: 50%; color: var(--accent); background: var(--accent-soft); } -.account-sidebar-title > div { display: grid; gap: 2px; } -.account-sidebar-title strong { color: var(--text); font-size: var(--fs-control); } -.account-sidebar-title small { color: var(--text-faint); font-size: var(--fs-micro); } -.account-settings-sidebar nav { display: grid; gap: 3px; } -.account-settings-sidebar nav a, -.account-settings-sidebar nav button { - display: grid; - grid-template-columns: 20px minmax(0, 1fr) 16px; - align-items: center; - gap: 9px; - min-height: 48px; - padding: 7px 9px; - border: 0; - border-radius: var(--radius-sm); - color: var(--text-soft); - background: transparent; - cursor: pointer; - text-align: left; - text-decoration: none; -} -.account-settings-sidebar nav a:hover, -.account-settings-sidebar nav button:hover { color: var(--accent); background: var(--accent-soft); } -.account-settings-sidebar nav span { display: grid; gap: 2px; } -.account-settings-sidebar nav strong { color: inherit; font-size: var(--fs-caption); } -.account-settings-sidebar nav small { color: var(--text-faint); font-size: var(--fs-micro); } -.account-sidebar-note { display: flex; align-items: flex-start; gap: 7px; margin: 0; padding: 10px 8px 4px; border-top: 1px solid var(--line); color: var(--text-faint); font-size: var(--fs-micro); line-height: 1.5; } -.account-sidebar-note svg { flex: 0 0 auto; margin-top: 1px; } .account-settings-content { display: grid; min-width: 0; gap: var(--space-4); } +.account-settings-content-wide { width: min(100%, 920px); margin-inline: auto; } .account-section, .account-support-card, #account-notifications > .panel { @@ -4380,6 +4338,39 @@ .account-section-heading p { margin: 0; color: var(--text-faint); font-size: var(--fs-caption); line-height: 1.5; } .account-section-icon { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border-radius: var(--radius-sm); color: var(--accent); background: var(--accent-soft); } .account-section-icon.is-danger { color: var(--negative); background: color-mix(in srgb, var(--negative) 10%, transparent); } +.account-security-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 18px 20px 20px; +} +.account-security-actions .button { + min-height: 46px; + justify-content: center; + border-color: var(--line-strong); + border-radius: var(--radius-sm); + font-weight: 700; + box-shadow: 0 1px 1px rgb(0 0 0 / .08), 0 8px 20px rgb(0 0 0 / .08); +} +.account-logout-button { + color: var(--text); + background: color-mix(in srgb, var(--surface-2) 92%, var(--accent) 8%); +} +.account-logout-button:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent) 36%, var(--line-strong)); + background: color-mix(in srgb, var(--surface-2) 84%, var(--accent) 16%); + transform: translateY(-1px); +} +.account-logout-all-button { + color: var(--text-soft); + background: transparent; +} +.account-logout-all-button:hover:not(:disabled) { + color: var(--text); + border-color: color-mix(in srgb, var(--text-soft) 32%, var(--line-strong)); + background: var(--surface-2); + transform: translateY(-1px); +} .account-signout-all { margin-left: auto; white-space: nowrap; } .account-session-summary { display: flex; align-items: center; gap: 7px; padding: 12px 20px; color: var(--text-faint); background: color-mix(in srgb, var(--surface-2) 72%, transparent); font-size: var(--fs-caption); } .account-session-summary strong { margin-left: 2px; color: var(--text); } @@ -4414,6 +4405,30 @@ .account-danger-zone[open] summary { margin-bottom: 4px; border-bottom: 1px solid var(--line); } .account-danger-zone .settings-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 16px 0; } .account-danger-zone .account-api-actions { padding: 0; } +.account-danger-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: 18px 20px 20px; +} +.account-danger-action > div { display: grid; gap: 4px; } +.account-danger-action strong { color: var(--text); font-size: var(--fs-control); } +.account-danger-action span { color: var(--text-faint); font-size: var(--fs-caption); line-height: 1.5; } +.account-withdrawal-trigger, +.account-withdrawal-confirm { + color: #fff; + border-color: color-mix(in srgb, var(--negative) 78%, #fff 8%); + background: var(--negative); + box-shadow: 0 1px 1px rgb(0 0 0 / .12), 0 8px 20px color-mix(in srgb, var(--negative) 22%, transparent); +} +.account-withdrawal-trigger:hover:not(:disabled), +.account-withdrawal-confirm:hover:not(:disabled) { + color: #fff; + border-color: color-mix(in srgb, var(--negative) 86%, #fff 14%); + background: color-mix(in srgb, var(--negative) 88%, #000); + transform: translateY(-1px); +} .account-support-card { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: 18px 20px; } .account-support-icon { display: grid; width: 40px; height: 40px; place-items: center; border-radius: 50%; color: var(--accent); background: var(--accent-soft); } .account-support-card > div { display: grid; gap: 3px; } @@ -4443,6 +4458,77 @@ .account-support-modal-body .case-api-panel > .panel-heading { display: none; } .account-support-modal-body .case-api-panel > .settings-fields { grid-template-columns: 1fr 1fr; padding: 0; } .account-support-modal-body .case-api-panel .account-api-actions { margin-top: 18px; } +.account-withdrawal-modal { + width: min(500px, 100%); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--negative) 20%, var(--line-strong)); + border-radius: var(--radius-lg); + background: var(--surface-1); + box-shadow: var(--shadow-overlay); +} +.account-withdrawal-modal > header { + display: grid; + grid-template-columns: 44px minmax(0, 1fr) 34px; + align-items: center; + gap: 12px; + padding: 20px 22px 18px; + border-bottom: 1px solid var(--line); +} +.account-withdrawal-modal > header > div { display: grid; gap: 4px; } +.account-withdrawal-modal > header small { color: var(--negative); font: 700 var(--fs-micro)/1 var(--font-mono); letter-spacing: .12em; } +.account-withdrawal-modal > header h2 { margin: 0; color: var(--text); font-size: 21px; } +.account-withdrawal-modal-icon { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: var(--radius-sm); + color: var(--negative); + background: color-mix(in srgb, var(--negative) 10%, transparent); +} +.account-withdrawal-modal-body { display: grid; gap: 18px; padding: 20px 22px 22px; } +.account-withdrawal-warning { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 14px; + border: 1px solid color-mix(in srgb, var(--negative) 24%, var(--line)); + border-radius: var(--radius-sm); + color: var(--negative); + background: color-mix(in srgb, var(--negative) 7%, transparent); +} +.account-withdrawal-warning > svg { flex: 0 0 auto; margin-top: 1px; } +.account-withdrawal-warning > div { display: grid; gap: 4px; } +.account-withdrawal-warning strong { color: var(--text); font-size: var(--fs-control); } +.account-withdrawal-warning p { margin: 0; color: var(--text-faint); font-size: var(--fs-caption); line-height: 1.5; } +.account-withdrawal-password { display: grid; gap: 8px; } +.account-withdrawal-password > span { color: var(--text-soft); font-size: var(--fs-caption); font-weight: 700; } +.account-withdrawal-password input { + width: 100%; + min-height: 44px; + padding: 0 13px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + color: var(--text); + background: var(--surface-2); + font: inherit; + outline: none; +} +.account-withdrawal-password input:focus { + border-color: color-mix(in srgb, var(--accent) 58%, var(--line-strong)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); +} +.account-withdrawal-password > small { color: var(--text-faint); font-size: var(--fs-micro); line-height: 1.45; } +.account-withdrawal-error { margin: -6px 0 0; color: var(--negative); font-size: var(--fs-caption); } +.account-withdrawal-modal form > footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 22px; + border-top: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-2) 55%, transparent); +} +.account-withdrawal-modal form > footer .button { min-width: 88px; justify-content: center; } .settings-rows { display: grid; } .settings-row { display: flex; @@ -4747,17 +4833,12 @@ .help-glossary > div, .help-order-states > div { grid-template-columns: 1fr; gap: var(--space-2); } .settings-fields { grid-template-columns: 1fr; } - .account-settings-layout { grid-template-columns: 1fr; } - .account-settings-sidebar { position: static; } - .account-settings-sidebar nav { grid-template-columns: repeat(2, minmax(0, 1fr)); } .account-environment-grid { grid-template-columns: 1fr; } } @media (max-width: 560px) { .account-page .page-heading { align-items: flex-start; } .account-page .page-actions { width: 100%; } - .account-signout-button { width: 100%; } - .account-settings-sidebar nav { grid-template-columns: 1fr; } .account-section-heading { align-items: flex-start; flex-wrap: wrap; padding: 16px; } .account-signout-all { width: 100%; margin-left: 0; } .account-session-summary { padding-inline: 16px; } @@ -4770,6 +4851,13 @@ .account-support-card > .button { grid-column: 1 / -1; width: 100%; } .account-support-modal-body { padding: 16px; } .account-support-modal-body .case-api-panel > .settings-fields { grid-template-columns: 1fr; } + .account-security-actions { grid-template-columns: 1fr; padding: 16px; } + .account-danger-action { align-items: stretch; flex-direction: column; padding: 16px; } + .account-withdrawal-trigger { width: 100%; justify-content: center; } + .account-withdrawal-modal > header, + .account-withdrawal-modal-body { padding-inline: 16px; } + .account-withdrawal-modal form > footer { padding-inline: 16px; } + .account-withdrawal-modal form > footer .button { flex: 1; } } /* Backtest — the execution log folded into the chart panel */ diff --git a/src/views/SupportViews.tsx b/src/views/SupportViews.tsx index 0910a58..5f75371 100644 --- a/src/views/SupportViews.tsx +++ b/src/views/SupportViews.tsx @@ -6,13 +6,9 @@ import { BookOpen, Check, CheckCircle2, - ChevronRight, Info, LifeBuoy, Search, - Settings, - ShieldCheck, - UserRound, X, } from 'lucide-react'; import { Button, EmptyState, PageHeading, Panel, Status } from '../components/common'; @@ -21,12 +17,12 @@ import { notifications as seedNotifications } from '../data/mockData'; import type { NotificationItem } from '../data/mockData'; import type { PageId } from '../lib/navigation'; import { Localized, useLanguage } from '../lib/i18n'; -import type { AccountClient, AccountPreferences, ThemePreference } from '../api/account'; +import type { AccountClient, ThemePreference } from '../api/account'; import type { AccountOperationsClient } from '../api/accountOperations'; -import { AccountApiPanels, AccountSignOutButton } from '../components/AccountApiPanels'; +import { AccountApiPanels } from '../components/AccountApiPanels'; import { UserCasePanel } from '../components/CaseApiPanels'; import type { NotificationClient } from '../api/notifications'; -import { NotificationCenter, NotificationPreferencesPanel } from '../components/NotificationApiViews'; +import { NotificationCenter } from '../components/NotificationApiViews'; import { SignInRequiredPage } from '../components/StatePages'; import { useSessionAccessToken } from '../api/sessionAccessToken'; @@ -310,22 +306,11 @@ interface AccountViewProps { setUpdown?: (updown: Updown) => void; accountClient?: AccountClient; operationsClient?: AccountOperationsClient; - notificationClient?: NotificationClient; } -export function AccountView({ setTheme, setThemePreference, accountClient, operationsClient, notificationClient }: AccountViewProps) { - const { setLanguage } = useLanguage(); +export function AccountView({ accountClient, operationsClient }: AccountViewProps) { const sessionToken = useSessionAccessToken(); const [supportOpen, setSupportOpen] = useState(false); - const applyServerPreferences = useCallback((preferences: AccountPreferences) => { - if (preferences.languageCode === 'ko' || preferences.languageCode === 'en') setLanguage(preferences.languageCode); - if (setThemePreference) { - setThemePreference(preferences.themePreference); - } else { - if (preferences.themePreference === 'DARK') setTheme('dark'); - if (preferences.themePreference === 'LIGHT') setTheme('light'); - } - }, [setLanguage, setTheme, setThemePreference]); /* "내 계정" is meaningless with nobody signed in: every real panel on it is @@ -341,33 +326,17 @@ export function AccountView({ setTheme, setThemePreference, accountClient, opera } + description="로그인 보안과 계정 이용에 필요한 작업을 관리합니다." /> -
- - -
- {accountClient && } - {notificationClient &&
} - {operationsClient &&
- -

도움이 필요하신가요?

문의, 신고 또는 이의 제기를 접수하고 추적 번호로 상태를 확인할 수 있습니다.

- -
} -
-
+
+ {accountClient && } + {operationsClient &&
+ +

도움이 필요하신가요?

문의, 신고 또는 이의 제기를 접수하고 추적 번호로 상태를 확인할 수 있습니다.

+ +
} +
{supportOpen && operationsClient && setSupportOpen(false)} />} ; } From dd919097e96a19ee2412aa845884e3b2023c859c Mon Sep 17 00:00:00 2001 From: Junyou Park Date: Sun, 9 Aug 2026 16:48:44 +0900 Subject: [PATCH 2/9] fix: page strategy library by ten --- src/StrategyApiView.test.tsx | 4 ++-- src/api/strategies.test.ts | 6 +++--- src/api/strategies.ts | 4 +++- src/views/StrategyViews.tsx | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) 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/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/views/StrategyViews.tsx b/src/views/StrategyViews.tsx index 5d07254..f35e79a 100644 --- a/src/views/StrategyViews.tsx +++ b/src/views/StrategyViews.tsx @@ -32,7 +32,7 @@ import { getStrategyCanvasWheelZoom, } from '../lib/strategyCanvasLayout'; import type { CanvasPoint, CanvasSize, CardMoveGesture } from '../lib/strategyCanvasLayout'; -import { defaultStrategyAuthoringClient, defaultStrategyCatalogClient, defaultStrategyLibraryClient, StrategyApiError } from '../api/strategies'; +import { defaultStrategyAuthoringClient, defaultStrategyCatalogClient, defaultStrategyLibraryClient, STRATEGY_LIBRARY_PAGE_SIZE, StrategyApiError } from '../api/strategies'; import type { BasicCatalogInstrument, BasicStrategyCatalog, StrategyAuthoringClient, StrategyCatalogClient, StrategyLibraryClient, StrategyLibraryItem, StrategyReleaseInputs, StrategyValidationResult } from '../api/strategies'; type EditorMode = 'basic' | 'pro'; @@ -374,7 +374,7 @@ export function StrategyHome({ openEditor, client = automaticStrategyLibraryClie setSignInRequired(false); setNextCursor(null); const controller = new AbortController(); - void client.list(50, undefined, controller.signal) + void client.list(STRATEGY_LIBRARY_PAGE_SIZE, undefined, controller.signal) .then((page) => { const confirmedItems = page.items.map(strategyListItem); confirmedItemsRef.current = confirmedItems; @@ -399,7 +399,7 @@ export function StrategyHome({ openEditor, client = automaticStrategyLibraryClie if (!client || !nextCursor || morePending) return; setMorePending(true); try { - const page = await client.list(50, nextCursor); + const page = await client.list(STRATEGY_LIBRARY_PAGE_SIZE, nextCursor); const appended = [...(confirmedItemsRef.current ?? []), ...page.items.map(strategyListItem)]; confirmedItemsRef.current = appended; setItems(appended); From 8715b21d3677056e1d4fba24d9c0995909f62b34 Mon Sep 17 00:00:00 2001 From: Junyou Park Date: Sun, 9 Aug 2026 17:26:16 +0900 Subject: [PATCH 3/9] fix: make account support customer friendly --- src/App.tsx | 1 + src/ProductScreens.test.tsx | 7 +- src/api/accountOperations.test.ts | 29 +++- src/api/accountOperations.ts | 81 +++++++++- src/components/CaseApiPanels.test.tsx | 59 ++++--- src/components/CaseApiPanels.tsx | 157 ++++++++++++------- src/components/NotificationApiViews.test.tsx | 12 +- src/components/NotificationApiViews.tsx | 51 ++++-- src/components/OperatorRbacViews.test.tsx | 2 +- src/styles/balanced.css | 71 ++++++++- src/views/SupportViews.tsx | 14 +- 11 files changed, 365 insertions(+), 119 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c873cfd..1d5ca0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -576,6 +576,7 @@ function ProductApp({ accountClient, operationsClient, notificationClient, compe setUpdown={setUpdown} accountClient={accountClient} operationsClient={operationsClient} + notificationClient={notificationClient} />} /> } /> ; diff --git a/src/ProductScreens.test.tsx b/src/ProductScreens.test.tsx index 6c3a428..7ecd805 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(), @@ -808,11 +809,11 @@ describe('Account settings', () => { const user = userEvent.setup(); setup(true); - expect(screen.queryByLabelText('케이스 제목')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: '문의하기' })); + 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/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/components/CaseApiPanels.test.tsx b/src/components/CaseApiPanels.test.tsx index 2c90ded..d00f1ca 100644 --- a/src/components/CaseApiPanels.test.tsx +++ b/src/components/CaseApiPanels.test.tsx @@ -2,17 +2,26 @@ 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 type { AccountOperationsClient, UserCaseDetail, UserCaseView } from '../api/accountOperations'; import { OperatorCaseWorkspace, OperatorSanctionPanel, 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, }; @@ -24,12 +33,13 @@ describe('UserCasePanel', () => { 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 () => { @@ -38,35 +48,34 @@ describe('UserCasePanel', () => { .mockRejectedValueOnce(new AccountOperationsApiError(503, 'CASE_SERVICE_UNAVAILABLE', 'corr-case')) .mockResolvedValueOnce(userCase); render(); - await userEvent.type(screen.getByLabelText('케이스 제목'), '문의'); - await userEvent.type(screen.getByLabelText('케이스 설명'), '내용'); + 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'); + await screen.findByText('문의가 접수되었습니다.'); expect(createIdempotencyKey).toHaveBeenCalledTimes(1); expect(submitCase).toHaveBeenNthCalledWith(1, expect.any(Object), 'idem-lost-response'); expect(submitCase).toHaveBeenNthCalledWith(2, expect.any(Object), 'idem-lost-response'); }); - 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('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.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..55b166d 100644 --- a/src/components/CaseApiPanels.tsx +++ b/src/components/CaseApiPanels.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from 'react'; -import { CheckCircle2, LoaderCircle, RefreshCw, ShieldCheck } from 'lucide-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 }; @@ -17,14 +18,36 @@ export function UserCasePanel({ client, createIdempotencyKey = () => crypto.rand 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 [list, setList] = useState>({ kind: 'loading' }); + const [loadingMore, setLoadingMore] = useState(false); + const [detail, setDetail] = useState>({ kind: 'idle' }); const retrySubmit = useRef<(() => void) | null>(null); - const retryEvidence = useRef<(() => void) | null>(null); + + const loadCases = async (cursor?: string, append = false) => { + if (append) setLoadingMore(true); else setList({ kind: 'loading' }); + try { + const page = await client.userCases(cursor ?? null, 10); + setList((current) => ({ + kind: 'ready', + value: { + items: append && current.kind === 'ready' ? [...current.value.items, ...page.items] : page.items, + nextCursor: page.nextCursor, + }, + })); + } catch (cause) { + if (!append) setList({ kind: 'error', error: error(cause) }); + } finally { + setLoadingMore(false); + } + }; + useEffect(() => { void loadCases(); }, [client]); + + const openDetail = async (id: string) => { + setDetail({ kind: 'loading' }); + try { setDetail({ kind: 'ready', value: await client.userCase(id) }); } + catch (cause) { setDetail({ kind: 'error', error: error(cause) }); } + }; const submit = async (retryKey?: string) => { setState({ kind: 'loading' }); @@ -32,73 +55,77 @@ export function UserCasePanel({ client, createIdempotencyKey = () => crypto.rand 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(''); + await loadCases(); + await openDetail(value.id); } 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 -
- - -