From 27beb6a0ee31419f6edd3395978a6e7b77a8c148 Mon Sep 17 00:00:00 2001 From: BcKmini <151009045+BcKmini@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:48:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EC=9D=B4=EB=A6=84=C2=B7=EC=97=B0=EB=9D=BD=EC=B2=98=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20API=20=EC=97=B0=EB=8F=99=20+=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=85=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfilePage가 하드코딩된 profileData.ts 대신 실제 GET/PATCH /api/v1/auth/me/profile(fowoco/server#171)를 쓰도록 교체 — 저장한 내용이 새로고침해도 유지된다. 로그인 시에도 서버가 이미 보내주던 display_name을 그동안 안 쓰고 이메일 앞부분으로 가짜 이름을 만들던 것을 고쳤다. 회원가입 화면에 전화번호(선택) 입력 필드 추가. 서버에 없는 '이름'(name, displayName과 중복)·선호 언어·시간대 필드는 저장이 안 되는데도 있는 것처럼 보이던 문제라 이번에 편집 항목에서 제외함. Closes #335 Co-Authored-By: Claude Sonnet 5 --- src/api/profile.ts | 23 ++ .../HeaderActions/HeaderActions.test.tsx | 8 +- .../CaseDetailPage/CaseDetailPage.test.tsx | 10 +- .../ProfilePage/CompanySettingsPanel.test.tsx | 10 +- src/pages/ProfilePage/ProfilePage.test.tsx | 83 ++++++-- src/pages/ProfilePage/ProfilePage.tsx | 199 ++++++++++++------ src/pages/ProfilePage/profileData.ts | 51 ++--- src/pages/SignupPage/SignupPage.test.tsx | 5 +- src/pages/SignupPage/SignupPage.tsx | 37 +++- src/store/authStore.test.ts | 42 +++- src/store/authStore.ts | 38 +++- 11 files changed, 372 insertions(+), 134 deletions(-) create mode 100644 src/api/profile.ts diff --git a/src/api/profile.ts b/src/api/profile.ts new file mode 100644 index 0000000..f6f1273 --- /dev/null +++ b/src/api/profile.ts @@ -0,0 +1,23 @@ +import { apiFetch } from './client' + +// fowoco/server ProfileResponse (GET/PATCH /api/v1/auth/me/profile) 그대로. +export interface ProfileResponse { + display_name: string + phone: string | null +} + +export interface UpdateProfileRequest { + display_name: string + phone: string | null +} + +export function fetchMyProfile() { + return apiFetch('/auth/me/profile') +} + +export function updateMyProfile(body: UpdateProfileRequest) { + return apiFetch('/auth/me/profile', { + method: 'PATCH', + body: JSON.stringify(body), + }) +} diff --git a/src/components/layout/HeaderActions/HeaderActions.test.tsx b/src/components/layout/HeaderActions/HeaderActions.test.tsx index 963a7ff..f8e8922 100644 --- a/src/components/layout/HeaderActions/HeaderActions.test.tsx +++ b/src/components/layout/HeaderActions/HeaderActions.test.tsx @@ -5,7 +5,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { HeaderActions } from './HeaderActions' import { getSafeNotificationRoute } from './notificationPresentation' -const USER = { name: '김민지', email: 'kim@example.com', workplace: '한빛정밀', role: 'HR' } +const USER = { + name: '김민지', + phone: null, + email: 'kim@example.com', + workplace: '한빛정밀', + role: 'HR', +} const NOTIFICATIONS = { items: [ { diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index 0fee01b..10cfa2f 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -706,7 +706,13 @@ describe('CaseDetailPage', () => { it('does not offer the response review action to a viewer', async () => { const user = userEvent.setup() useAuthStore.setState({ - user: { name: 'viewer', email: 'viewer@example.com', workplace: 'FOWOCO', role: 'VIEWER' }, + user: { + name: 'viewer', + phone: null, + email: 'viewer@example.com', + workplace: 'FOWOCO', + role: 'VIEWER', + }, status: 'ready', }) mockTaskAndActivities( @@ -739,7 +745,7 @@ describe('CaseDetailPage', () => { it('adopts a submitted file as an official worker document', async () => { const user = userEvent.setup() useAuthStore.setState({ - user: { name: 'hr', email: 'hr@example.com', workplace: 'FOWOCO', role: 'HR' }, + user: { name: 'hr', phone: null, 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 4edf2c2..87e4edd 100644 --- a/src/pages/ProfilePage/CompanySettingsPanel.test.tsx +++ b/src/pages/ProfilePage/CompanySettingsPanel.test.tsx @@ -53,7 +53,13 @@ function renderPanel() { beforeEach(() => { useAuthStore.setState({ - user: { name: 'admin', email: 'admin@example.com', workplace: 'FOWOCO', role: 'ADMIN' }, + user: { + name: 'admin', + phone: null, + email: 'admin@example.com', + workplace: 'FOWOCO', + role: 'ADMIN', + }, status: 'ready', }) useToastStore.setState({ toasts: [] }) @@ -109,7 +115,7 @@ describe('CompanySettingsPanel', () => { it('renders HR and VIEWER settings as read-only', async () => { useAuthStore.setState({ - user: { name: 'hr', email: 'hr@example.com', workplace: 'FOWOCO', role: 'HR' }, + user: { name: 'hr', phone: null, email: 'hr@example.com', workplace: 'FOWOCO', role: 'HR' }, }) renderPanel() diff --git a/src/pages/ProfilePage/ProfilePage.test.tsx b/src/pages/ProfilePage/ProfilePage.test.tsx index dbf3b28..dfe2be2 100644 --- a/src/pages/ProfilePage/ProfilePage.test.tsx +++ b/src/pages/ProfilePage/ProfilePage.test.tsx @@ -1,12 +1,34 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createMemoryRouter, RouterProvider } from 'react-router-dom' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAuthStore } from '../../store/authStore' import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' import { useToastStore } from '../../store/toastStore' import { ProfilePage } from './ProfilePage' +// fowoco/server ProfileResponse (GET/PATCH /api/v1/auth/me/profile) 그대로. +const PROFILE = { display_name: '김민지 HR', phone: '010-0000-1234' } + +const SETTINGS = { + approval_policy: 'ADMIN_OR_HR', + link_expiry_hours: 72, + evidence_rules: { RECONTRACT: ['DOCUMENT'] }, + file_retention_days: 365, + ai_log_retention_days: 90, + audit_visibility: 'ADMIN_ONLY', + version: 3, +} + +const MEMBERS = { items: [] } + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + function renderPage() { const router = createMemoryRouter( [ @@ -30,27 +52,40 @@ function renderPage() { beforeEach(() => { useToastStore.setState({ toasts: [] }) + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.includes('/company-members')) return jsonResponse(MEMBERS) + if (url.includes('/settings')) return jsonResponse(SETTINGS) + if (url.includes('/auth/me/profile') && init?.method === 'PATCH') { + return jsonResponse({ ...PROFILE, ...JSON.parse(init.body as string) }) + } + if (url.includes('/auth/me/profile')) return jsonResponse(PROFILE) + return Promise.reject(new Error(`Unexpected request: ${url}`)) + }), + ) }) afterEach(() => { useAuthStore.setState({ user: null }) + vi.unstubAllGlobals() }) describe('ProfilePage', () => { - it('renders the profile summary and read-only fields', () => { + it('renders the profile summary and editable fields fetched from the API', async () => { renderPage() expect(screen.getByRole('heading', { name: '설정' })).toBeInTheDocument() - expect(screen.getByText('김민지 HR')).toBeInTheDocument() + expect((await screen.findAllByText('김민지 HR')).length).toBeGreaterThan(0) expect(screen.getByText('010-0000-1234')).toBeInTheDocument() - expect(screen.getByText('hr.demo@fowoco.example')).toBeInTheDocument() - expect(screen.getByText('체류·문서 운영')).toBeInTheDocument() }) - it("shows the real logged-in user's identity instead of the fixture persona", () => { + it("shows the real logged-in user's identity instead of the fixture persona", async () => { useAuthStore.setState({ user: { name: 'demo.admin', + phone: null, email: 'demo.admin@example.com', workplace: 'FOWOCO Demo Company', role: 'ADMIN', @@ -59,59 +94,69 @@ describe('ProfilePage', () => { }) renderPage() - expect(screen.getAllByText('demo.admin').length).toBeGreaterThan(0) + await waitFor(() => 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 () => { + it('edits and saves the editable fields, persisting through the API', async () => { const user = userEvent.setup() renderPage() + await screen.findAllByText('김민지 HR') await user.click(screen.getByRole('button', { name: '프로필 수정' })) const displayNameInput = screen.getByLabelText('표시 이름') await user.clear(displayNameInput) await user.type(displayNameInput, '김민지 매니저') await user.click(screen.getByRole('button', { name: '저장' })) - expect(screen.getByText('김민지 매니저')).toBeInTheDocument() + expect((await screen.findAllByText('김민지 매니저')).length).toBeGreaterThan(0) expect(screen.getByText('프로필을 저장했습니다.')).toBeInTheDocument() + + const patchCall = vi.mocked(fetch).mock.calls.find(([, init]) => init?.method === 'PATCH') + expect(patchCall).toBeTruthy() + expect(JSON.parse(String(patchCall?.[1]?.body))).toEqual({ + display_name: '김민지 매니저', + phone: '010-0000-1234', + }) }) it('discards edits when cancelled', async () => { const user = userEvent.setup() renderPage() + await screen.findAllByText('김민지 HR') await user.click(screen.getByRole('button', { name: '프로필 수정' })) const displayNameInput = screen.getByLabelText('표시 이름') await user.clear(displayNameInput) await user.type(displayNameInput, '지워질 이름') await user.click(screen.getByRole('button', { name: '취소' })) - expect(screen.getByText('김민지 HR')).toBeInTheDocument() + expect(screen.getAllByText('김민지 HR')[0]).toBeInTheDocument() expect(screen.queryByText('지워질 이름')).not.toBeInTheDocument() }) - it('blocks saving when name is cleared and shows a validation error', async () => { + it('blocks saving when display name is cleared and shows a validation error', async () => { const user = userEvent.setup() renderPage() + await screen.findAllByText('김민지 HR') await user.click(screen.getByRole('button', { name: '프로필 수정' })) - await user.clear(screen.getByLabelText('이름')) + await user.clear(screen.getByLabelText('표시 이름')) await user.click(screen.getByRole('button', { name: '저장' })) - expect(screen.getByText('이름을 입력해 주세요.')).toBeInTheDocument() + expect(screen.getByText('표시 이름을 입력해 주세요.')).toBeInTheDocument() // 저장 실패했으니 편집 모드가 유지돼야 한다. expect(screen.getByRole('button', { name: '취소' })).toBeInTheDocument() }) - it('rejects a name made up of only digits', async () => { + it('rejects a display name made up of only digits', async () => { const user = userEvent.setup() renderPage() + await screen.findAllByText('김민지 HR') await user.click(screen.getByRole('button', { name: '프로필 수정' })) - const nameInput = screen.getByLabelText('이름') + const nameInput = screen.getByLabelText('표시 이름') await user.clear(nameInput) await user.type(nameInput, '12345') await user.click(screen.getByRole('button', { name: '저장' })) @@ -123,6 +168,7 @@ describe('ProfilePage', () => { const user = userEvent.setup() renderPage() + await screen.findAllByText('김민지 HR') await user.click(screen.getByRole('button', { name: '이메일 변경 요청 →' })) expect(screen.getByText('이메일 변경 요청을 관리자에게 전달했습니다.')).toBeInTheDocument() @@ -175,6 +221,7 @@ describe('ProfilePage', () => { const user = userEvent.setup() renderPage() + await screen.findAllByText('김민지 HR') await user.click(screen.getByRole('button', { name: '프로필 수정' })) await user.type(screen.getByLabelText('연락처'), '9') await user.click(screen.getByRole('button', { name: '비밀번호 변경' })) @@ -194,6 +241,7 @@ describe('ProfilePage', () => { const user = userEvent.setup() renderPage() + await screen.findAllByText('김민지 HR') await user.click(screen.getByRole('button', { name: '프로필 수정' })) await user.type(screen.getByLabelText('연락처'), '9') await user.click(screen.getByRole('button', { name: '비밀번호 변경' })) @@ -206,6 +254,7 @@ describe('ProfilePage', () => { const user = userEvent.setup() renderPage() + await screen.findAllByText('김민지 HR') await user.click(screen.getByRole('button', { name: '프로필 수정' })) await user.type(screen.getByLabelText('연락처'), '9') await user.click(screen.getByRole('button', { name: '비밀번호 변경' })) diff --git a/src/pages/ProfilePage/ProfilePage.tsx b/src/pages/ProfilePage/ProfilePage.tsx index c15a2fb..2d2b736 100644 --- a/src/pages/ProfilePage/ProfilePage.tsx +++ b/src/pages/ProfilePage/ProfilePage.tsx @@ -1,42 +1,40 @@ import { useNavigate, useBlocker } from 'react-router-dom' -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { fetchMyProfile, updateMyProfile, type ProfileResponse } from '../../api/profile' +import { ApiError, getErrorMessage } from '../../api/errors' 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 { useApiQuery } from '../../hooks/useApiQuery' import { useAuthStore } from '../../store/authStore' import { useToastStore } from '../../store/toastStore' import { CompanySettingsPanel } from './CompanySettingsPanel' import { INITIAL_NOTIFICATION_PREFS, - INITIAL_PROFILE_FIELDS, PROFILE_SUMMARY, SECURITY_INFO, WORK_CONTEXT, - type EditableProfileFields, } from './profileData' import styles from './ProfilePage.module.css' -const EDITABLE_FIELD_META: { key: keyof EditableProfileFields; label: string }[] = [ - { key: 'name', label: '이름' }, - { key: 'displayName', label: '표시 이름' }, - { key: 'phone', label: '연락처' }, - { key: 'preferredLanguage', label: '선호 언어' }, - { key: 'timezone', label: '시간대' }, -] +interface EditableFields { + displayName: string + phone: string +} -type FieldErrors = Partial> +type FieldErrors = Partial> // Figma Screen Brief 04번 항목 기준 검증 규칙. -function validateFields(input: EditableProfileFields): FieldErrors { +function validateFields(input: EditableFields): FieldErrors { const errors: FieldErrors = {} - const trimmedName = input.name.trim() + const trimmedName = input.displayName.trim() if (!trimmedName) { - errors.name = '이름을 입력해 주세요.' + errors.displayName = '표시 이름을 입력해 주세요.' } else if (/^\d+$/.test(trimmedName)) { - errors.name = '이름에 숫자만 입력할 수 없습니다.' - } else if (trimmedName.length > 30) { - errors.name = '이름은 30자 이하로 입력해 주세요.' + errors.displayName = '이름에 숫자만 입력할 수 없습니다.' + } else if (trimmedName.length > 80) { + errors.displayName = '표시 이름은 80자 이하로 입력해 주세요.' } const trimmedPhone = input.phone.trim() @@ -47,24 +45,34 @@ function validateFields(input: EditableProfileFields): FieldErrors { return errors } +function toFields(profile: ProfileResponse): EditableFields { + return { displayName: profile.display_name, phone: profile.phone ?? '' } +} + export function ProfilePage() { const navigate = useNavigate() const showToast = useToastStore((state) => state.showToast) const user = useAuthStore((state) => state.user) + const setStoreProfile = useAuthStore((state) => state.updateProfile) - const initialFields: EditableProfileFields = { - ...INITIAL_PROFILE_FIELDS, - name: user?.name ?? INITIAL_PROFILE_FIELDS.name, - } - const [fields, setFields] = useState(initialFields) - const [draft, setDraft] = useState(initialFields) + const { data: profile, status: profileStatus } = useApiQuery(fetchMyProfile) + + const [fields, setFields] = useState(null) + const [draft, setDraft] = useState(null) const [editing, setEditing] = useState(false) const [fieldErrors, setFieldErrors] = useState({}) + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) const [notificationPrefs, setNotificationPrefs] = useState(INITIAL_NOTIFICATION_PREFS) - const changedFieldCount = EDITABLE_FIELD_META.filter( - ({ key }) => draft[key] !== fields[key], - ).length + useEffect(() => { + if (profile) setFields(toFields(profile)) + }, [profile]) + + const changedFieldCount = + editing && fields && draft + ? (['displayName', 'phone'] as const).filter((key) => draft[key] !== fields[key]).length + : 0 const isDirty = editing && changedFieldCount > 0 // Figma "저장하지 않은 변경사항이 있습니다" 오버레이(node 1623:2530) — 편집 중 다른 화면으로 @@ -75,27 +83,46 @@ export function ProfilePage() { ) function handleStartEdit() { + if (!fields) return setDraft(fields) setFieldErrors({}) + setSaveError(null) setEditing(true) } function handleCancelEdit() { setEditing(false) setFieldErrors({}) + setSaveError(null) } - function trySave(): boolean { + const trySave = useCallback(async (): Promise => { + if (!draft) return false const errors = validateFields(draft) setFieldErrors(errors) if (Object.keys(errors).length > 0) return false - // TODO(backend): 개인 프로필 수정 API가 없어서(#191 조사 결과) 화면 상태로만 반영한다. - setFields(draft) - setEditing(false) - showToast('프로필을 저장했습니다.') - return true - } + setSaving(true) + setSaveError(null) + try { + const updated = await updateMyProfile({ + display_name: draft.displayName.trim(), + phone: draft.phone.trim() || null, + }) + setFields(toFields(updated)) + setStoreProfile(updated.display_name, updated.phone) + setEditing(false) + showToast('프로필을 저장했습니다.') + return true + } catch (error) { + setSaveError( + error instanceof ApiError ? getErrorMessage(error) : '프로필 저장에 실패했습니다.', + ) + return false + } finally { + setSaving(false) + } + }, [draft, setStoreProfile, showToast]) function handleRequestEmailChange() { showToast('이메일 변경 요청을 관리자에게 전달했습니다.') @@ -119,10 +146,12 @@ export function ProfilePage() { blocker.proceed?.() } - function handleBlockerSaveAndLeave() { - if (trySave()) blocker.proceed?.() + async function handleBlockerSaveAndLeave() { + if (await trySave()) blocker.proceed?.() } + const displayName = user?.name ?? fields?.displayName ?? PROFILE_SUMMARY.role + return (
@@ -135,19 +164,23 @@ export function ProfilePage() { - +
) : ( - + )}
-

{fields.name}

+

{displayName}

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

@@ -175,31 +208,69 @@ export function ProfilePage() {


+ {saveError &&

{saveError}

} +
- {EDITABLE_FIELD_META.map(({ key, label }) => ( -
-
- {label} - 수정 가능 + {profileStatus === 'loading' && !fields && ( +

불러오는 중…

+ )} + {profileStatus === 'error' && !fields && ( +

프로필을 불러오지 못했습니다.

+ )} + {fields && ( + <> +
+
+ 표시 이름 + 수정 가능 +
+ {editing && draft ? ( + <> + + setDraft((prev) => + prev ? { ...prev, displayName: event.target.value } : prev, + ) + } + /> + {fieldErrors.displayName && ( +

{fieldErrors.displayName}

+ )} + + ) : ( +

{fields.displayName}

+ )}
- {editing ? ( - <> - - setDraft((prev) => ({ ...prev, [key]: event.target.value })) - } - /> - {fieldErrors[key] &&

{fieldErrors[key]}

} - - ) : ( -

{fields[key]}

- )} - {key === 'phone' &&

합성 Demo Data

} -
- ))} + +
+
+ 연락처 + 수정 가능 +
+ {editing && draft ? ( + <> + + setDraft((prev) => (prev ? { ...prev, phone: event.target.value } : prev)) + } + /> + {fieldErrors.phone && ( +

{fieldErrors.phone}

+ )} + + ) : ( +

{fields.phone || '미등록'}

+ )} +
+ + )}
@@ -312,7 +383,7 @@ export function ProfilePage() { title="저장하지 않은 변경사항이 있습니다." >

- 지금 나가면 이름·연락처·알림 설정의 변경 내용이 저장되지 않습니다. + 지금 나가면 표시 이름·연락처의 변경 내용이 저장되지 않습니다.

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

diff --git a/src/pages/ProfilePage/profileData.ts b/src/pages/ProfilePage/profileData.ts index 2348be7..b0c54b6 100644 --- a/src/pages/ProfilePage/profileData.ts +++ b/src/pages/ProfilePage/profileData.ts @@ -1,9 +1,8 @@ -// Figma PROFILE-001(node 1615:2233) 데모 데이터. 서버에 개인 프로필 API가 없어서 -// (#191 조사 결과) 전부 고정값 — 수정은 화면 상태로만 반영되고 새로고침하면 초기화된다. +// Figma PROFILE-001(node 1615:2233) 데모 데이터. 표시 이름·연락처는 fowoco/server#168로 +// 실제 API(GET/PATCH /api/v1/auth/me/profile)가 생겨서 ProfilePage.tsx가 이 파일 대신 +// 서버 값을 쓴다. 아래는 서버에 없는 나머지(업무 Context, 알림, 보안 요약)만 데모로 남는다. export const PROFILE_SUMMARY = { - initial: '김', - name: '김민지', role: 'HR 담당자', email: 'hr.demo@fowoco.example', companyName: 'FOWOCO 데모 사업장', @@ -11,22 +10,6 @@ export const PROFILE_SUMMARY = { lastLoginDevice: 'Chrome · macOS · 서울', } -export interface EditableProfileFields { - name: string - displayName: string - phone: string - preferredLanguage: string - timezone: string -} - -export const INITIAL_PROFILE_FIELDS: EditableProfileFields = { - name: '김민지', - displayName: '김민지 HR', - phone: '010-0000-1234', - preferredLanguage: '한국어', - timezone: '(GMT+09:00) 서울', -} - export const WORK_CONTEXT = { companySummary: 'FOWOCO 데모 사업장 · HR 담당자 · 관리자 관리', canApprove: true, @@ -58,10 +41,30 @@ export const INITIAL_NOTIFICATION_PREFS: ProfileNotificationPref[] = [ enabled: true, required: true, }, - { id: 'approval-request', label: '승인 요청 도착', description: '내 승인이 필요한 업무', enabled: true }, - { id: 'document-submitted', label: '문서 제출 완료', description: '담당 근로자의 제출 완료', enabled: true }, - { id: 'document-needs-fix', label: '문서 보완 필요', description: '검토 후 보완이 필요한 문서', enabled: true }, + { + id: 'approval-request', + label: '승인 요청 도착', + description: '내 승인이 필요한 업무', + enabled: true, + }, + { + id: 'document-submitted', + label: '문서 제출 완료', + description: '담당 근로자의 제출 완료', + enabled: true, + }, + { + id: 'document-needs-fix', + label: '문서 보완 필요', + description: '검토 후 보완이 필요한 문서', + enabled: true, + }, { id: 'due-soon', label: '마감 임박', description: '24시간 이내 마감 업무', enabled: true }, { id: 'assigned', label: '담당자 지정', description: '내게 새 업무가 배정됨', enabled: false }, - { id: 'agent-ready', label: 'Agent 분석 완료', description: '요청 분석 결과 준비됨', enabled: true }, + { + id: 'agent-ready', + label: 'Agent 분석 완료', + description: '요청 분석 결과 준비됨', + enabled: true, + }, ] diff --git a/src/pages/SignupPage/SignupPage.test.tsx b/src/pages/SignupPage/SignupPage.test.tsx index 0ef9fc9..2fd883f 100644 --- a/src/pages/SignupPage/SignupPage.test.tsx +++ b/src/pages/SignupPage/SignupPage.test.tsx @@ -114,6 +114,7 @@ describe('SignupPage', () => { expect(JSON.parse((requestInit as RequestInit).body as string)).toEqual({ company_name: '한빛정밀', display_name: '김경민', + phone: null, email: 'mini@naver.com', password: 'password123', agreements: { @@ -126,9 +127,7 @@ describe('SignupPage', () => { it('shows an inline email error when the email is already registered', async () => { const user = userEvent.setup() - vi.mocked(fetch).mockResolvedValueOnce( - errorResponse(409, 'EMAIL_ALREADY_REGISTERED', 'raw'), - ) + vi.mocked(fetch).mockResolvedValueOnce(errorResponse(409, 'EMAIL_ALREADY_REGISTERED', 'raw')) renderPage() await fillValidForm(user) diff --git a/src/pages/SignupPage/SignupPage.tsx b/src/pages/SignupPage/SignupPage.tsx index 3989399..45d2231 100644 --- a/src/pages/SignupPage/SignupPage.tsx +++ b/src/pages/SignupPage/SignupPage.tsx @@ -13,6 +13,7 @@ import styles from './SignupPage.module.css' interface FieldErrors { workplace?: string name?: string + phone?: string email?: string password?: string confirmPassword?: string @@ -30,10 +31,13 @@ const TERMS_VERSION = '1.0' const SERVER_FIELD_TO_SCREEN_FIELD: Record = { company_name: 'workplace', display_name: 'name', + phone: 'phone', email: 'email', password: 'password', } +const PHONE_PATTERN = /^[0-9+()\-\s]*$/ + function mapServerFieldErrors(fieldErrors: ApiFieldError[]): FieldErrors { const mapped: FieldErrors = {} for (const fieldError of fieldErrors) { @@ -49,6 +53,7 @@ export function SignupPage() { const [name, setName] = useState('') const [email, setEmail] = useState('') const [workplace, setWorkplace] = useState('') + const [phone, setPhone] = useState('') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [showPassword, setShowPassword] = useState(false) @@ -66,6 +71,9 @@ export function SignupPage() { if (!name.trim() || name.trim().length < 2) errors.name = '2자 이상 입력해 주세요.' if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = '이메일 형식을 확인합니다.' if (!workplace.trim()) errors.workplace = '회사명을 입력해 주세요.' + if (phone.trim() && !PHONE_PATTERN.test(phone.trim())) { + errors.phone = '연락처 형식을 확인해 주세요.' + } if (password.length < 8) errors.password = '비밀번호는 8자 이상이어야 합니다.' if (confirmPassword !== password) errors.confirmPassword = '비밀번호를 다시 입력해 주세요.' if (!termsAgreed || !privacyAgreed) errors.terms = '필수 약관에 동의해 주세요.' @@ -85,6 +93,7 @@ export function SignupPage() { body: JSON.stringify({ company_name: workplace, display_name: name, + phone: phone.trim() || null, email, password, agreements: { @@ -138,7 +147,9 @@ export function SignupPage() { -
+
+
+ +
+ setPhone(event.target.value)} + /> +
+ {fieldErrors.phone &&

{fieldErrors.phone}

} +
+
diff --git a/src/store/authStore.test.ts b/src/store/authStore.test.ts index 22c7a4e..76d1c39 100644 --- a/src/store/authStore.test.ts +++ b/src/store/authStore.test.ts @@ -63,6 +63,7 @@ describe('useAuthStore.login', () => { user_id: 'u-1', company_id: 'c-1', company_name: '한빛정밀', + display_name: '민지', role: 'HR', access_token: 'access-1', token_type: 'Bearer', @@ -76,7 +77,8 @@ describe('useAuthStore.login', () => { expect(result).toEqual({ success: true }) expect(getAccessToken()).toBe('access-1') expect(useAuthStore.getState().user).toEqual({ - name: 'mini', + name: '민지', + phone: null, email: 'mini@naver.com', workplace: '한빛정밀', role: 'HR', @@ -125,6 +127,7 @@ describe('useAuthStore.restoreSession', () => { }), ) .mockResolvedValueOnce(jsonResponse({ user_id: 'u-1', company_id: 'c-1', roles: ['HR'] })) + .mockResolvedValueOnce(jsonResponse({ display_name: '민지', phone: null })) const firstRestore = useAuthStore.getState().restoreSession() const secondRestore = useAuthStore.getState().restoreSession() @@ -132,27 +135,26 @@ describe('useAuthStore.restoreSession', () => { expect(secondRestore).toBe(firstRestore) await Promise.all([firstRestore, secondRestore]) - expect(fetch).toHaveBeenCalledTimes(2) + // refresh + /auth/me + /auth/me/profile, 중복 호출 없이 딱 한 세트만. + expect(fetch).toHaveBeenCalledTimes(3) expect(useAuthStore.getState().user?.role).toBe('HR') }) - it('restores the user from a valid refresh cookie plus /auth/me', async () => { + it('restores the user from a valid refresh cookie plus /auth/me and /auth/me/profile', async () => { // 이 프로젝트의 테스트 환경에서는 Node 내장 localStorage가 jsdom 것보다 먼저 잡혀 // 저장이 조용히 실패할 수 있다 (구현도 이 상황을 try/catch로 감내하도록 설계했다). // 그래서 여기서는 실제로 저장에 성공했는지를 먼저 확인하고, 그 결과에 맞는 기대값으로 - // 검증한다 — 저장에 성공하면 저장된 이름을, 실패하면 authStore의 fallback("사용자")을 기대한다. + // 검증한다 — email/workplace는 localStorage 저장, 표시이름/연락처는 /auth/me/profile 응답 기준. setTestLocalStorage( 'fowoco.auth.profile', - JSON.stringify({ name: 'mini', email: 'mini@naver.com', workplace: '한빛정밀' }), + JSON.stringify({ 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; email: string; workplace: string } - expectedName = parsed.name + const parsed = JSON.parse(raw) as { email: string; workplace: string } expectedEmail = parsed.email expectedWorkplace = parsed.workplace } @@ -170,12 +172,14 @@ describe('useAuthStore.restoreSession', () => { }), ) .mockResolvedValueOnce(jsonResponse({ user_id: 'u-1', company_id: 'c-1', roles: ['HR'] })) + .mockResolvedValueOnce(jsonResponse({ display_name: '민지', phone: '010-1234-5678' })) await useAuthStore.getState().restoreSession() expect(useAuthStore.getState().status).toBe('ready') expect(useAuthStore.getState().user).toEqual({ - name: expectedName, + name: '민지', + phone: '010-1234-5678', email: expectedEmail, workplace: expectedWorkplace, role: 'HR', @@ -183,6 +187,26 @@ describe('useAuthStore.restoreSession', () => { expect(getAccessToken()).toBe('refreshed-token') }) + it('falls back to a placeholder name when /auth/me/profile is unavailable', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + jsonResponse({ + access_token: 'refreshed-token', + token_type: 'Bearer', + expires_in_seconds: 900, + expires_at: '2026-07-22T01:15:00Z', + }), + ) + .mockResolvedValueOnce(jsonResponse({ user_id: 'u-1', company_id: 'c-1', roles: ['HR'] })) + .mockResolvedValueOnce(new Response(null, { status: 500 })) + + await useAuthStore.getState().restoreSession() + + expect(useAuthStore.getState().status).toBe('ready') + expect(useAuthStore.getState().user?.name).toBe('사용자') + expect(useAuthStore.getState().user?.phone).toBeNull() + }) + it('leaves the user logged out when there is no valid refresh cookie', async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 401 })) diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 33fcab1..f44d7a7 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import { apiFetch, setAccessToken, setAuthExpiredHandler } from '../api/client' import { ApiError, getErrorMessage } from '../api/errors' +import { fetchMyProfile } from '../api/profile' // 로그인 화면·도움말에 안내하는 데모 계정. // fowoco/server는 DEMO_SEED_* 환경변수로 이 계정을 만든다 (README "선택 사항: 데모 로그인 @@ -13,6 +14,7 @@ export const DEMO_ACCOUNT = { export interface AuthUser { name: string + phone: string | null email: string workplace: string role: string @@ -30,6 +32,7 @@ interface LoginResponseBody { user_id: string company_id: string company_name: string + display_name: string role: string access_token: string token_type: string @@ -52,15 +55,13 @@ interface CurrentActorResponseBody { roles: string[] } -// company_name과 화면 표시용 이름 -- GET /auth/me는 user_id/company_id/roles만 내려주고 -// 표시용 이름이나 사업장명을 주지 않는다. 로그인 시 한 번 받은 값을 여기 저장해뒀다가 -// 새로고침 세션 복원(restoreSession) 때 재사용한다. 민감정보가 아니라 localStorage에 둬도 -// 안전하다. 서버가 /auth/me에 company_name·표시용 이름을 추가해주면 이 저장소는 제거하고 -// 매번 서버 값을 그대로 쓰면 된다. +// company_name과 email -- GET /auth/me는 user_id/company_id/roles만 내려주고 사업장명·이메일은 +// 안 준다 (표시 이름·연락처는 GET /auth/me/profile로 별도 조회 가능, restoreSession에서 사용). +// 로그인 시 한 번 받은 company_name/email을 여기 저장해뒀다가 새로고침 세션 복원 때 재사용한다. +// 민감정보가 아니라 localStorage에 둬도 안전하다. const PROFILE_STORAGE_KEY = 'fowoco.auth.profile' interface PersistedProfile { - name: string email: string workplace: string } @@ -97,6 +98,8 @@ interface AuthState { logout: () => Promise /** 새로고침 직후 등, Refresh Token 쿠키로 세션을 조용히 복원해본다. RequireAuth가 호출한다. */ restoreSession: () => Promise + /** ProfilePage가 PATCH /auth/me/profile 성공 후 화면 전역(헤더 등)에 반영할 때 쓴다. */ + updateProfile: (displayName: string, phone: string | null) => void } function toApiErrorMessage(error: unknown, fallback: string): string { @@ -129,12 +132,19 @@ export const useAuthStore = create((set) => { setAccessToken(body.access_token) const profile: PersistedProfile = { - name: email.split('@')[0], email, workplace: body.company_name, } persistProfile(profile) - set({ user: { ...profile, role: body.role }, status: 'ready' }) + set({ + user: { + name: body.display_name, + phone: null, + ...profile, + role: body.role, + }, + status: 'ready', + }) return { success: true } } catch (error) { return { @@ -170,11 +180,15 @@ export const useAuthStore = create((set) => { }) setAccessToken(refreshBody.access_token) - const me = await apiFetch('/auth/me') + const [me, profile] = await Promise.all([ + apiFetch('/auth/me'), + fetchMyProfile().catch(() => null), + ]) const persisted = readPersistedProfile() set({ user: { - name: persisted?.name ?? '사용자', + name: profile?.display_name ?? '사용자', + phone: profile?.phone ?? null, email: persisted?.email ?? '', workplace: persisted?.workplace ?? '', role: me.roles[0] ?? '', @@ -193,5 +207,9 @@ export const useAuthStore = create((set) => { return sessionRestorePromise }, + + updateProfile: (displayName, phone) => { + set((state) => (state.user ? { user: { ...state.user, name: displayName, phone } } : state)) + }, } })