From b2e83bac967db2d90c74895d885bcae92b4122ea Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 12 Aug 2026 10:49:56 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=8D=B0=EB=AA=A8=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20+=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=8B=A4=EC=A0=9C=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DEMO_ACCOUNT.email을 mini@naver.com -> demo.admin@example.com으로 수정 (실제 서버 데모 시드 계정과 일치, '데모로 시작' 버튼이 여태 실패하고 있었음) - authStore에 email 필드 추가, 로그인 시점에 저장 - ProfilePage 요약 카드·로그인 이메일 필드를 실제 로그인 계정 정보로 표시 (기존엔 로그인 계정과 무관한 '김민지' 목업 정보가 항상 떴음) closes #329 --- .../HeaderActions/HeaderActions.test.tsx | 42 +++++++++----- .../CaseDetailPage/CaseDetailPage.test.tsx | 4 +- .../ProfilePage/CompanySettingsPanel.test.tsx | 56 +++++++++++-------- src/pages/ProfilePage/ProfilePage.test.tsx | 29 +++++++++- src/pages/ProfilePage/ProfilePage.tsx | 52 ++++++++++++----- src/store/authStore.test.ts | 21 +++++-- src/store/authStore.ts | 16 +++++- 7 files changed, 155 insertions(+), 65 deletions(-) diff --git a/src/components/layout/HeaderActions/HeaderActions.test.tsx b/src/components/layout/HeaderActions/HeaderActions.test.tsx index 53e4b7a..963a7ff 100644 --- a/src/components/layout/HeaderActions/HeaderActions.test.tsx +++ b/src/components/layout/HeaderActions/HeaderActions.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { HeaderActions } from './HeaderActions' import { getSafeNotificationRoute } from './notificationPresentation' -const USER = { name: '김민지', workplace: '한빛정밀', role: 'HR' } +const USER = { name: '김민지', email: 'kim@example.com', workplace: '한빛정밀', role: 'HR' } const NOTIFICATIONS = { items: [ { @@ -94,23 +94,31 @@ describe('HeaderActions', () => { await user.click(screen.getByRole('menuitem', { name: /체류연장 요청문 승인이 필요합니다/ })) await waitFor(() => expect(router.state.location.pathname).toBe('/tasks/task-1')) - expect(vi.mocked(fetch).mock.calls.some(([url, init]) => - String(url).includes('/notifications/n1/read') && init?.method === 'POST', - )).toBe(true) + expect( + vi + .mocked(fetch) + .mock.calls.some( + ([url, init]) => + String(url).includes('/notifications/n1/read') && init?.method === 'POST', + ), + ).toBe(true) }) it('shows an error and keeps the panel open when read processing fails', async () => { vi.mocked(fetch).mockImplementation(async (_input, init) => { if (init?.method === 'POST') { - return jsonResponse({ - timestamp: '2026-08-10T01:00:00Z', - status: 500, - code: 'INTERNAL_SERVER_ERROR', - message: 'failed', - path: '/api/v1/notifications/n1/read', - request_id: 'request-1', - field_errors: [], - }, 500) + return jsonResponse( + { + timestamp: '2026-08-10T01:00:00Z', + status: 500, + code: 'INTERNAL_SERVER_ERROR', + message: 'failed', + path: '/api/v1/notifications/n1/read', + request_id: 'request-1', + field_errors: [], + }, + 500, + ) } return jsonResponse(NOTIFICATIONS) }) @@ -120,7 +128,9 @@ describe('HeaderActions', () => { await user.click(await screen.findByLabelText('알림 1건 안 읽음')) await user.click(screen.getByRole('menuitem', { name: /체류연장 요청문 승인이 필요합니다/ })) - expect(await screen.findByText('일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.')).toBeInTheDocument() + expect( + await screen.findByText('일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'), + ).toBeInTheDocument() expect(router.state.location.pathname).toBe('/dashboard') expect(screen.getByRole('menu', { name: '알림 목록' })).toBeInTheDocument() }) @@ -156,7 +166,9 @@ describe('HeaderActions', () => { describe('getSafeNotificationRoute', () => { it('keeps expected internal routes and rejects external or unexpected routes', () => { - expect(getSafeNotificationRoute('/documents/document-1?tab=ocr')).toBe('/documents/document-1?tab=ocr') + expect(getSafeNotificationRoute('/documents/document-1?tab=ocr')).toBe( + '/documents/document-1?tab=ocr', + ) expect(getSafeNotificationRoute('https://example.com')).toBe('/dashboard') expect(getSafeNotificationRoute('//example.com/tasks/1')).toBe('/dashboard') expect(getSafeNotificationRoute('/profile')).toBe('/dashboard') diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index bf8dd8d..0fee01b 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -706,7 +706,7 @@ describe('CaseDetailPage', () => { it('does not offer the response review action to a viewer', async () => { const user = userEvent.setup() useAuthStore.setState({ - user: { name: 'viewer', workplace: 'FOWOCO', role: 'VIEWER' }, + user: { name: 'viewer', email: 'viewer@example.com', workplace: 'FOWOCO', role: 'VIEWER' }, status: 'ready', }) mockTaskAndActivities( @@ -739,7 +739,7 @@ describe('CaseDetailPage', () => { it('adopts a submitted file as an official worker document', async () => { const user = userEvent.setup() useAuthStore.setState({ - user: { name: 'hr', workplace: 'FOWOCO', role: 'HR' }, + user: { name: 'hr', email: 'hr@example.com', workplace: 'FOWOCO', role: 'HR' }, status: 'ready', }) mockTaskAndActivities( diff --git a/src/pages/ProfilePage/CompanySettingsPanel.test.tsx b/src/pages/ProfilePage/CompanySettingsPanel.test.tsx index e7cbb80..4edf2c2 100644 --- a/src/pages/ProfilePage/CompanySettingsPanel.test.tsx +++ b/src/pages/ProfilePage/CompanySettingsPanel.test.tsx @@ -53,7 +53,7 @@ function renderPanel() { beforeEach(() => { useAuthStore.setState({ - user: { name: 'admin', workplace: 'FOWOCO', role: 'ADMIN' }, + user: { name: 'admin', email: 'admin@example.com', workplace: 'FOWOCO', role: 'ADMIN' }, status: 'ready', }) useToastStore.setState({ toasts: [] }) @@ -108,7 +108,9 @@ describe('CompanySettingsPanel', () => { }) it('renders HR and VIEWER settings as read-only', async () => { - useAuthStore.setState({ user: { name: 'hr', workplace: 'FOWOCO', role: 'HR' } }) + useAuthStore.setState({ + user: { name: 'hr', email: 'hr@example.com', workplace: 'FOWOCO', role: 'HR' }, + }) renderPanel() expect(await screen.findByText('HR 조회 전용')).toBeInTheDocument() @@ -122,21 +124,22 @@ describe('CompanySettingsPanel', () => { const url = String(input) if (url.includes('/company-members')) return jsonResponse(MEMBERS) if (init?.method === 'PATCH') { - return jsonResponse({ - timestamp: '2026-08-10T01:00:00Z', - status: 409, - code: 'CONCURRENT_MODIFICATION', - message: 'conflict', - path: '/api/v1/settings', - request_id: 'request-1', - field_errors: [], - }, 409) + return jsonResponse( + { + timestamp: '2026-08-10T01:00:00Z', + status: 409, + code: 'CONCURRENT_MODIFICATION', + message: 'conflict', + path: '/api/v1/settings', + request_id: 'request-1', + field_errors: [], + }, + 409, + ) } settingsGetCount += 1 return jsonResponse( - settingsGetCount === 1 - ? SETTINGS - : { ...SETTINGS, link_expiry_hours: 24, version: 4 }, + settingsGetCount === 1 ? SETTINGS : { ...SETTINGS, link_expiry_hours: 24, version: 4 }, ) }) const user = userEvent.setup() @@ -153,20 +156,25 @@ describe('CompanySettingsPanel', () => { it('shows a recoverable error when settings cannot be loaded', async () => { vi.mocked(fetch).mockImplementation(async (input) => { if (String(input).includes('/company-members')) return jsonResponse(MEMBERS) - return jsonResponse({ - timestamp: '2026-08-10T01:00:00Z', - status: 500, - code: 'INTERNAL_SERVER_ERROR', - message: 'failed', - path: '/api/v1/settings', - request_id: 'request-1', - field_errors: [], - }, 500) + return jsonResponse( + { + timestamp: '2026-08-10T01:00:00Z', + status: 500, + code: 'INTERNAL_SERVER_ERROR', + message: 'failed', + path: '/api/v1/settings', + request_id: 'request-1', + field_errors: [], + }, + 500, + ) }) renderPanel() - expect(await screen.findByText('일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.')).toBeInTheDocument() + expect( + await screen.findByText('일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: '다시 시도' })).toBeInTheDocument() }) }) diff --git a/src/pages/ProfilePage/ProfilePage.test.tsx b/src/pages/ProfilePage/ProfilePage.test.tsx index 8156761..dbf3b28 100644 --- a/src/pages/ProfilePage/ProfilePage.test.tsx +++ b/src/pages/ProfilePage/ProfilePage.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createMemoryRouter, RouterProvider } from 'react-router-dom' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useAuthStore } from '../../store/authStore' import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' import { useToastStore } from '../../store/toastStore' import { ProfilePage } from './ProfilePage' @@ -31,6 +32,10 @@ beforeEach(() => { useToastStore.setState({ toasts: [] }) }) +afterEach(() => { + useAuthStore.setState({ user: null }) +}) + describe('ProfilePage', () => { it('renders the profile summary and read-only fields', () => { renderPage() @@ -42,6 +47,24 @@ describe('ProfilePage', () => { expect(screen.getByText('체류·문서 운영')).toBeInTheDocument() }) + it("shows the real logged-in user's identity instead of the fixture persona", () => { + useAuthStore.setState({ + user: { + name: 'demo.admin', + email: 'demo.admin@example.com', + workplace: 'FOWOCO Demo Company', + role: 'ADMIN', + }, + status: 'ready', + }) + renderPage() + + expect(screen.getAllByText('demo.admin').length).toBeGreaterThan(0) + expect(screen.getAllByText(/demo\.admin@example\.com/).length).toBeGreaterThan(0) + expect(screen.getByText('FOWOCO Demo Company')).toBeInTheDocument() + expect(screen.queryByText('hr.demo@fowoco.example')).not.toBeInTheDocument() + }) + it('edits and saves the editable fields', async () => { const user = userEvent.setup() renderPage() @@ -156,7 +179,9 @@ describe('ProfilePage', () => { await user.type(screen.getByLabelText('연락처'), '9') await user.click(screen.getByRole('button', { name: '비밀번호 변경' })) - expect(screen.getByRole('dialog', { name: '저장하지 않은 변경사항이 있습니다.' })).toBeInTheDocument() + expect( + screen.getByRole('dialog', { name: '저장하지 않은 변경사항이 있습니다.' }), + ).toBeInTheDocument() expect(screen.getByText(/변경사항 1개/)).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '계속 수정' })) diff --git a/src/pages/ProfilePage/ProfilePage.tsx b/src/pages/ProfilePage/ProfilePage.tsx index aade3f8..c15a2fb 100644 --- a/src/pages/ProfilePage/ProfilePage.tsx +++ b/src/pages/ProfilePage/ProfilePage.tsx @@ -4,6 +4,7 @@ import { Button } from '../../components/ui/Button/Button' import { DetailRow } from '../../components/ui/DetailRow/DetailRow' import { Modal } from '../../components/ui/Modal/Modal' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' +import { useAuthStore } from '../../store/authStore' import { useToastStore } from '../../store/toastStore' import { CompanySettingsPanel } from './CompanySettingsPanel' import { @@ -49,20 +50,28 @@ function validateFields(input: EditableProfileFields): FieldErrors { export function ProfilePage() { const navigate = useNavigate() const showToast = useToastStore((state) => state.showToast) + const user = useAuthStore((state) => state.user) - const [fields, setFields] = useState(INITIAL_PROFILE_FIELDS) - const [draft, setDraft] = useState(INITIAL_PROFILE_FIELDS) + const initialFields: EditableProfileFields = { + ...INITIAL_PROFILE_FIELDS, + name: user?.name ?? INITIAL_PROFILE_FIELDS.name, + } + const [fields, setFields] = useState(initialFields) + const [draft, setDraft] = useState(initialFields) const [editing, setEditing] = useState(false) const [fieldErrors, setFieldErrors] = useState({}) const [notificationPrefs, setNotificationPrefs] = useState(INITIAL_NOTIFICATION_PREFS) - const changedFieldCount = EDITABLE_FIELD_META.filter(({ key }) => draft[key] !== fields[key]).length + const changedFieldCount = EDITABLE_FIELD_META.filter( + ({ key }) => draft[key] !== fields[key], + ).length const isDirty = editing && changedFieldCount > 0 // Figma "저장하지 않은 변경사항이 있습니다" 오버레이(node 1623:2530) — 편집 중 다른 화면으로 // 이동하려 하면 확인을 받는다. const blocker = useBlocker( - ({ currentLocation, nextLocation }) => isDirty && currentLocation.pathname !== nextLocation.pathname, + ({ currentLocation, nextLocation }) => + isDirty && currentLocation.pathname !== nextLocation.pathname, ) function handleStartEdit() { @@ -94,7 +103,9 @@ export function ProfilePage() { function handleToggleNotification(id: string) { setNotificationPrefs((prev) => - prev.map((pref) => (pref.id === id && !pref.required ? { ...pref, enabled: !pref.enabled } : pref)), + prev.map((pref) => + pref.id === id && !pref.required ? { ...pref, enabled: !pref.enabled } : pref, + ), ) } @@ -133,16 +144,18 @@ export function ProfilePage() {

{fields.name}

- {PROFILE_SUMMARY.role} · {PROFILE_SUMMARY.email} + {user?.role ?? PROFILE_SUMMARY.role} · {user?.email ?? PROFILE_SUMMARY.email}

사용 중 - {PROFILE_SUMMARY.companyName} + + {user?.workplace ?? PROFILE_SUMMARY.companyName} +
@@ -175,7 +188,9 @@ export function ProfilePage() { className={styles.fieldInput} value={draft[key]} aria-label={label} - onChange={(event) => setDraft((prev) => ({ ...prev, [key]: event.target.value }))} + onChange={(event) => + setDraft((prev) => ({ ...prev, [key]: event.target.value })) + } /> {fieldErrors[key] &&

{fieldErrors[key]}

} @@ -191,8 +206,12 @@ export function ProfilePage() { 로그인 이메일 본인 확인 필요
-

{PROFILE_SUMMARY.email}

-
@@ -265,7 +284,10 @@ export function ProfilePage() {

계정 보호 상태와 최근 로그인 정보를 확인합니다.


- {SECURITY_INFO.accountStatus}} /> + {SECURITY_INFO.accountStatus}} + /> @@ -296,7 +318,11 @@ export function ProfilePage() { 변경사항 {changedFieldCount}개 · 입력값은 현재 편집 화면에 유지됩니다.

-
diff --git a/src/store/authStore.test.ts b/src/store/authStore.test.ts index 293f3c1..22c7a4e 100644 --- a/src/store/authStore.test.ts +++ b/src/store/authStore.test.ts @@ -75,14 +75,17 @@ describe('useAuthStore.login', () => { expect(result).toEqual({ success: true }) expect(getAccessToken()).toBe('access-1') - expect(useAuthStore.getState().user).toEqual({ name: 'mini', workplace: '한빛정밀', role: 'HR' }) + expect(useAuthStore.getState().user).toEqual({ + name: 'mini', + email: 'mini@naver.com', + workplace: '한빛정밀', + role: 'HR', + }) expect(useAuthStore.getState().status).toBe('ready') }) it('returns a translated error message on invalid credentials', async () => { - vi.mocked(fetch).mockResolvedValueOnce( - errorResponse(401, 'INVALID_CREDENTIALS', 'raw'), - ) + vi.mocked(fetch).mockResolvedValueOnce(errorResponse(401, 'INVALID_CREDENTIALS', 'raw')) const result = await useAuthStore.getState().login('wrong@example.com', 'wrongpass') @@ -138,14 +141,19 @@ describe('useAuthStore.restoreSession', () => { // 저장이 조용히 실패할 수 있다 (구현도 이 상황을 try/catch로 감내하도록 설계했다). // 그래서 여기서는 실제로 저장에 성공했는지를 먼저 확인하고, 그 결과에 맞는 기대값으로 // 검증한다 — 저장에 성공하면 저장된 이름을, 실패하면 authStore의 fallback("사용자")을 기대한다. - setTestLocalStorage('fowoco.auth.profile', JSON.stringify({ name: 'mini', workplace: '한빛정밀' })) + setTestLocalStorage( + 'fowoco.auth.profile', + JSON.stringify({ name: 'mini', email: 'mini@naver.com', workplace: '한빛정밀' }), + ) let expectedName = '사용자' + let expectedEmail = '' let expectedWorkplace = '' try { const raw = localStorage.getItem('fowoco.auth.profile') if (raw) { - const parsed = JSON.parse(raw) as { name: string; workplace: string } + const parsed = JSON.parse(raw) as { name: string; email: string; workplace: string } expectedName = parsed.name + expectedEmail = parsed.email expectedWorkplace = parsed.workplace } } catch { @@ -168,6 +176,7 @@ describe('useAuthStore.restoreSession', () => { expect(useAuthStore.getState().status).toBe('ready') expect(useAuthStore.getState().user).toEqual({ name: expectedName, + email: expectedEmail, workplace: expectedWorkplace, role: 'HR', }) diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 294e932..33fcab1 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -7,12 +7,13 @@ import { ApiError, getErrorMessage } from '../api/errors' // 계정 만들기" 참고). 비밀번호는 서버가 DEMO_SEED_ADMIN_PASSWORD 최소 길이(12자)를 강제하므로 // "1234"처럼 짧은 값은 쓸 수 없다 — 로컬 seed 값과 반드시 일치시켜야 한다. export const DEMO_ACCOUNT = { - email: 'mini@naver.com', + email: 'demo.admin@example.com', password: 'fowoco-demo-1234', } export interface AuthUser { name: string + email: string workplace: string role: string } @@ -60,6 +61,7 @@ const PROFILE_STORAGE_KEY = 'fowoco.auth.profile' interface PersistedProfile { name: string + email: string workplace: string } @@ -126,12 +128,19 @@ export const useAuthStore = create((set) => { }) setAccessToken(body.access_token) - const profile: PersistedProfile = { name: email.split('@')[0], workplace: body.company_name } + const profile: PersistedProfile = { + name: email.split('@')[0], + email, + workplace: body.company_name, + } persistProfile(profile) set({ user: { ...profile, role: body.role }, status: 'ready' }) return { success: true } } catch (error) { - return { success: false, message: toApiErrorMessage(error, '알 수 없는 오류가 발생했습니다.') } + return { + success: false, + message: toApiErrorMessage(error, '알 수 없는 오류가 발생했습니다.'), + } } }, @@ -166,6 +175,7 @@ export const useAuthStore = create((set) => { set({ user: { name: persisted?.name ?? '사용자', + email: persisted?.email ?? '', workplace: persisted?.workplace ?? '', role: me.roles[0] ?? '', },