From b616c979fb866d8767d92812c44e72a1e082d0a6 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 19 Aug 2026 14:18:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=EC=95=BD=EA=B4=80=20=EC=97=B4?= =?UTF-8?q?=EB=9E=8C=EA=B3=BC=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/signupPolicy.ts | 28 ++++ src/pages/IntroPage/IntroPage.module.css | 5 +- src/pages/IntroPage/IntroPage.tsx | 15 +- .../LegalPolicyPage.module.css | 128 ++++++++++++++++ .../LegalPolicyPage/LegalPolicyPage.test.tsx | 62 ++++++++ src/pages/LegalPolicyPage/LegalPolicyPage.tsx | 129 ++++++++++++++++ src/pages/LoginPage/LoginPage.module.css | 1 + src/pages/LoginPage/LoginPage.tsx | 23 +-- src/pages/SignupPage/SignupPage.module.css | 31 ++++ src/pages/SignupPage/SignupPage.test.tsx | 141 ++++++++++++++---- src/pages/SignupPage/SignupPage.tsx | 104 +++++++++++-- src/routes.tsx | 11 ++ 12 files changed, 617 insertions(+), 61 deletions(-) create mode 100644 src/api/signupPolicy.ts create mode 100644 src/pages/LegalPolicyPage/LegalPolicyPage.module.css create mode 100644 src/pages/LegalPolicyPage/LegalPolicyPage.test.tsx create mode 100644 src/pages/LegalPolicyPage/LegalPolicyPage.tsx diff --git a/src/api/signupPolicy.ts b/src/api/signupPolicy.ts new file mode 100644 index 0000000..a5923b1 --- /dev/null +++ b/src/api/signupPolicy.ts @@ -0,0 +1,28 @@ +import { apiFetch } from './client' + +export interface AgreementPolicy { + version: string + required: boolean + content_path: string | null +} + +export interface SignupPolicy { + password_policy: { + min_length: number + max_length: number + require_letter: boolean + require_digit: boolean + } + agreements: { + service_terms: AgreementPolicy + privacy_policy: AgreementPolicy + marketing: AgreementPolicy + } +} + +export function fetchSignupPolicy() { + return apiFetch('/auth/signup-policy', { + method: 'GET', + skipAuthRetry: true, + }) +} diff --git a/src/pages/IntroPage/IntroPage.module.css b/src/pages/IntroPage/IntroPage.module.css index 183be80..cb3ebdf 100644 --- a/src/pages/IntroPage/IntroPage.module.css +++ b/src/pages/IntroPage/IntroPage.module.css @@ -370,7 +370,9 @@ max-width: 640px; background: var(--fowoco-white); border-radius: 28px; - box-shadow: 0 24px 0 -8px rgba(7, 94, 91, 0.08), 0 32px 48px rgba(7, 94, 91, 0.16); + box-shadow: + 0 24px 0 -8px rgba(7, 94, 91, 0.08), + 0 32px 48px rgba(7, 94, 91, 0.16); overflow: hidden; } @@ -984,6 +986,7 @@ background: none; border: none; cursor: pointer; + text-decoration: none; } @media (max-width: 960px) { diff --git a/src/pages/IntroPage/IntroPage.tsx b/src/pages/IntroPage/IntroPage.tsx index c9f9624..4b43eb4 100644 --- a/src/pages/IntroPage/IntroPage.tsx +++ b/src/pages/IntroPage/IntroPage.tsx @@ -146,8 +146,7 @@ export function IntroPage() {

복잡한 HR 행정업무,
- Agent가 준비하고 사람이 - 결정합니다. + Agent가 준비하고 사람이 결정합니다.

FOWOCO는 체류·계약·문서 업무를 분석해 필요한 정보와 초안을 준비합니다. 담당자는 @@ -260,8 +259,8 @@ export function IntroPage() {

SIX WORKFLOWS

FOWOCO의 6대 Workflow

- 한 사람의 한 사건을 끝까지 묶는 Master Workflow 3종과, 여러 Case에서 반복 - 사용하는 Reusable Workflow 3종으로 구성됩니다. + 한 사람의 한 사건을 끝까지 묶는 Master Workflow 3종과, 여러 Case에서 반복 사용하는 + Reusable Workflow 3종으로 구성됩니다.

{WORKFLOWS.map((workflow) => { @@ -368,12 +367,12 @@ export function IntroPage() { 프로젝트 소개 · GitHub · 팀 소개 · 문의하기 · 외부 링크는 Prototype Only

- - +
diff --git a/src/pages/LegalPolicyPage/LegalPolicyPage.module.css b/src/pages/LegalPolicyPage/LegalPolicyPage.module.css new file mode 100644 index 0000000..1a077b2 --- /dev/null +++ b/src/pages/LegalPolicyPage/LegalPolicyPage.module.css @@ -0,0 +1,128 @@ +.page { + min-height: 100svh; + padding: 0 24px 64px; + background: var(--surface-subtle); + color: var(--text-primary); +} + +.header { + width: min(880px, 100%); + min-height: 72px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.brand { + color: var(--brand-primary); + font-size: 20px; + font-weight: 700; + text-decoration: none; +} + +.nav { + display: flex; + gap: 20px; +} + +.nav a { + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + text-decoration: none; +} + +.nav a[aria-current='page'] { + color: var(--brand-primary); +} + +.document { + width: min(880px, 100%); + margin: 0 auto; + padding: 56px clamp(24px, 6vw, 72px); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + background: var(--surface-default); +} + +.eyebrow { + margin: 0; + color: var(--brand-primary); + font-size: 12px; + font-weight: 700; +} + +.document h1 { + margin: 10px 0 0; + font-size: clamp(30px, 5vw, 42px); + line-height: 1.2; +} + +.summary { + margin: 16px 0 0; + color: var(--text-secondary); + font-size: 15px; + line-height: 1.7; +} + +.meta { + margin: 12px 0 0; + color: var(--text-secondary); + font-size: 12px; +} + +.notice { + margin-top: 32px; + padding: 16px 18px; + border-left: 3px solid var(--brand-primary); + background: var(--surface-subtle); + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; +} + +.sections { + margin-top: 36px; + display: grid; + gap: 30px; +} + +.sections section { + padding-bottom: 30px; + border-bottom: 1px solid var(--border-default); +} + +.sections h2 { + margin: 0; + font-size: 17px; +} + +.sections p { + margin: 10px 0 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.8; +} + +.documentFooter { + margin-top: 36px; +} + +.documentFooter a { + color: var(--brand-primary); + font-size: 13px; + font-weight: 600; + text-decoration: none; +} + +@media (max-width: 560px) { + .page { + padding-inline: 12px; + } + + .document { + padding-block: 36px; + } +} diff --git a/src/pages/LegalPolicyPage/LegalPolicyPage.test.tsx b/src/pages/LegalPolicyPage/LegalPolicyPage.test.tsx new file mode 100644 index 0000000..7417ca0 --- /dev/null +++ b/src/pages/LegalPolicyPage/LegalPolicyPage.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { LegalPolicyPage } from './LegalPolicyPage' + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + password_policy: { + min_length: 8, + max_length: 128, + require_letter: true, + require_digit: true, + }, + agreements: { + service_terms: { version: '2.0', required: true, content_path: '/legal/terms' }, + privacy_policy: { + version: '3.0', + required: true, + content_path: '/legal/privacy', + }, + marketing: { version: '1.0', required: false, content_path: null }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('LegalPolicyPage', () => { + it('renders the service terms and current version', async () => { + render( + + + , + ) + + expect(screen.getByRole('heading', { name: '서비스 이용약관' })).toBeInTheDocument() + expect(await screen.findByText(/버전 2.0/)).toBeInTheDocument() + expect(screen.getByText('3. AI 업무보조의 범위')).toBeInTheDocument() + }) + + it('renders the privacy processing notice and current version', async () => { + render( + + + , + ) + + expect(screen.getByRole('heading', { name: '개인정보 처리 안내' })).toBeInTheDocument() + expect(await screen.findByText(/버전 3.0/)).toBeInTheDocument() + expect(screen.getByText('3. 보관과 접근')).toBeInTheDocument() + }) +}) diff --git a/src/pages/LegalPolicyPage/LegalPolicyPage.tsx b/src/pages/LegalPolicyPage/LegalPolicyPage.tsx new file mode 100644 index 0000000..d2f51aa --- /dev/null +++ b/src/pages/LegalPolicyPage/LegalPolicyPage.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { fetchSignupPolicy } from '../../api/signupPolicy' +import styles from './LegalPolicyPage.module.css' + +type PolicyKind = 'terms' | 'privacy' + +interface LegalPolicyPageProps { + kind: PolicyKind +} + +const TERMS_SECTIONS = [ + { + title: '1. 서비스 목적', + body: 'FOWOCO는 외국인 근로자 관련 HR 행정업무의 기한 확인, 문서 초안 작성, 안내와 응답 추적을 지원하는 업무보조 서비스입니다.', + }, + { + title: '2. 계정과 사업장 정보', + body: '사용자는 정확한 사업장 정보를 입력하고 계정 접근수단을 안전하게 관리해야 합니다. 계정으로 수행된 업무 검토와 승인 결과는 해당 사업장의 행위로 기록됩니다.', + }, + { + title: '3. AI 업무보조의 범위', + body: 'AI가 생성한 분류, 체크리스트, 문서와 안내문은 담당자 검토를 위한 초안입니다. 기관 제출, 법적 판단, 급여 계산과 최종 승인은 담당자가 확인하고 수행합니다.', + }, + { + title: '4. 금지 행위', + body: '타인의 계정이나 개인정보를 무단으로 사용하거나, 서비스 안정성을 해치거나, 허위·위법한 목적으로 서비스를 이용할 수 없습니다.', + }, + { + title: '5. 서비스 변경과 제한', + body: '프로젝트 검증 과정에서 기능과 제공 범위가 변경될 수 있습니다. 점검이나 장애가 발생하면 일부 기능이 일시적으로 제한될 수 있습니다.', + }, +] + +const PRIVACY_SECTIONS = [ + { + title: '1. 처리하는 정보', + body: '회원가입 시 회사명, 담당자 이름, 업무용 이메일, 비밀번호의 해시값과 선택 입력한 연락처를 처리합니다. 사업장이 업로드한 근로자 문서와 업무 이력은 서비스 제공 범위에서만 처리합니다.', + }, + { + title: '2. 이용 목적', + body: '계정 인증, 사업장 업무 공간 제공, 문서·기한 관리, 근로자 안내와 응답 연결, 오류 조사와 감사 이력 확인을 위해 정보를 사용합니다.', + }, + { + title: '3. 보관과 접근', + body: '정보는 프로젝트 운영과 서비스 검증에 필요한 기간 동안 보관하며, 사업장 권한과 역할에 따라 접근 범위를 제한합니다. 비밀번호 원문은 저장하지 않습니다.', + }, + { + title: '4. 외부 처리와 전송', + body: 'OCR, 번역, 알림 등 외부 제공자를 사용하는 기능은 필요한 정보만 전달하도록 구성합니다. 실제 민감정보 사용 전에는 사업장 정책과 제공자 조건을 별도로 확인해야 합니다.', + }, + { + title: '5. 이용자 권리', + body: '사용자는 자신의 계정 정보 확인과 정정을 요청할 수 있습니다. 동의 철회와 삭제 절차는 정식 서비스 전환 시 운영 정책과 함께 확정합니다.', + }, +] + +export function LegalPolicyPage({ kind }: LegalPolicyPageProps) { + const isTerms = kind === 'terms' + const sections = isTerms ? TERMS_SECTIONS : PRIVACY_SECTIONS + const title = isTerms ? '서비스 이용약관' : '개인정보 처리 안내' + const [version, setVersion] = useState(null) + + useEffect(() => { + let active = true + void fetchSignupPolicy() + .then((policy) => { + if (!active) return + setVersion( + isTerms + ? policy.agreements.service_terms.version + : policy.agreements.privacy_policy.version, + ) + }) + .catch(() => { + if (active) setVersion(null) + }) + + return () => { + active = false + } + }, [isTerms]) + + return ( +
+
+ + FOWOCO + + +
+ +
+

FOWOCO PROJECT POLICY

+

{title}

+

+ KT AIVLE School 빅프로젝트의 FOWOCO 프로토타입 운영 기준입니다. +

+

+ {version ? `버전 ${version}` : '현재 버전 확인 중'} · 시행일 2026년 8월 19일 +

+ +
+ 실제 사업장 도입 전 법률·노무 검토와 정식 운영 정책 확정이 필요한 프로젝트 안내입니다. +
+ +
+ {sections.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +
+ +
+ 회원가입으로 돌아가기 +
+
+
+ ) +} diff --git a/src/pages/LoginPage/LoginPage.module.css b/src/pages/LoginPage/LoginPage.module.css index f04e06a..b9a1272 100644 --- a/src/pages/LoginPage/LoginPage.module.css +++ b/src/pages/LoginPage/LoginPage.module.css @@ -308,6 +308,7 @@ background: none; border: none; cursor: pointer; + text-decoration: none; } .terms { diff --git a/src/pages/LoginPage/LoginPage.tsx b/src/pages/LoginPage/LoginPage.tsx index dbb643c..0e4d779 100644 --- a/src/pages/LoginPage/LoginPage.tsx +++ b/src/pages/LoginPage/LoginPage.tsx @@ -69,9 +69,7 @@ export function LoginPage() { ))} -

- Agent는 자동 승인·법률 판단·급여 지급을 하지 않습니다. -

+

Agent는 자동 승인·법률 판단·급여 지급을 하지 않습니다.

@@ -82,9 +80,7 @@ export function LoginPage() {

{justSignedUp && ( -

- 회원가입이 완료되었습니다. 로그인해 주세요. -

+

회원가입이 완료되었습니다. 로그인해 주세요.

)}
@@ -149,7 +145,12 @@ export function LoginPage() { {error &&

{error}

} - @@ -184,12 +185,12 @@ export function LoginPage() {
- - +

diff --git a/src/pages/SignupPage/SignupPage.module.css b/src/pages/SignupPage/SignupPage.module.css index f3a1e5a..38b55a7 100644 --- a/src/pages/SignupPage/SignupPage.module.css +++ b/src/pages/SignupPage/SignupPage.module.css @@ -229,6 +229,37 @@ background: none; border: none; cursor: pointer; + text-decoration: none; +} + +.policyStatus { + margin: 0; + font-size: 12px; + color: var(--text-secondary); +} + +.policyError { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--fowoco-red-600); + border-radius: var(--fowoco-radius-6); + color: var(--fowoco-red-600); + font-size: 12px; +} + +.policyError button { + flex: 0 0 auto; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: underline; } .submit { diff --git a/src/pages/SignupPage/SignupPage.test.tsx b/src/pages/SignupPage/SignupPage.test.tsx index 2fd883f..d9d90fc 100644 --- a/src/pages/SignupPage/SignupPage.test.tsx +++ b/src/pages/SignupPage/SignupPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -32,6 +32,34 @@ function errorResponse( ) } +function signupPolicyResponse( + serviceTermsVersion = '1.0', + privacyPolicyVersion = '1.0', + marketingVersion = '1.0', +) { + return jsonResponse({ + password_policy: { + min_length: 8, + max_length: 128, + require_letter: true, + require_digit: true, + }, + agreements: { + service_terms: { + version: serviceTermsVersion, + required: true, + content_path: '/legal/terms', + }, + privacy_policy: { + version: privacyPolicyVersion, + required: true, + content_path: '/legal/privacy', + }, + marketing: { version: marketingVersion, required: false, content_path: null }, + }, + }) +} + function renderPage() { render( @@ -44,6 +72,7 @@ function renderPage() { } async function fillValidForm(user: ReturnType) { + await waitFor(() => expect(screen.getByRole('button', { name: '계정 만들기' })).toBeEnabled()) await user.type(screen.getByLabelText('이름'), '김경민') await user.type(screen.getByLabelText('업무용 이메일'), 'mini@naver.com') await user.type(screen.getByLabelText('회사명'), '한빛정밀') @@ -64,8 +93,11 @@ afterEach(() => { describe('SignupPage', () => { it('blocks submit and does not call the API when required terms are not agreed', async () => { const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValueOnce(signupPolicyResponse()) renderPage() + await waitFor(() => expect(screen.getByRole('button', { name: '계정 만들기' })).toBeEnabled()) + await user.type(screen.getByLabelText('이름'), '김경민') await user.type(screen.getByLabelText('업무용 이메일'), 'mini@naver.com') await user.type(screen.getByLabelText('회사명'), '한빛정밀') @@ -74,11 +106,12 @@ describe('SignupPage', () => { await user.click(screen.getByRole('button', { name: '계정 만들기' })) expect(screen.getByText('필수 약관에 동의해 주세요.')).toBeInTheDocument() - expect(fetch).not.toHaveBeenCalled() + expect(fetch).toHaveBeenCalledTimes(1) }) it('shows a password strength meter once typing starts', async () => { const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValueOnce(signupPolicyResponse()) renderPage() expect(screen.queryByText(/비밀번호 강도/)).not.toBeInTheDocument() @@ -90,27 +123,29 @@ describe('SignupPage', () => { it('calls the signup API with the mapped request body and navigates on success', async () => { const user = userEvent.setup() - vi.mocked(fetch).mockResolvedValueOnce( - jsonResponse( - { - user_id: 'u-1', - company_id: 'c-1', - company_name: '한빛정밀', - display_name: '김경민', - email: 'mini@naver.com', - role: 'ADMIN', - created_at: '2026-07-27T01:00:00Z', - }, - { status: 201 }, - ), - ) + vi.mocked(fetch) + .mockResolvedValueOnce(signupPolicyResponse('2.0', '3.0', '4.0')) + .mockResolvedValueOnce( + jsonResponse( + { + user_id: 'u-1', + company_id: 'c-1', + company_name: '한빛정밀', + display_name: '김경민', + email: 'mini@naver.com', + role: 'ADMIN', + created_at: '2026-07-27T01:00:00Z', + }, + { status: 201 }, + ), + ) renderPage() await fillValidForm(user) await user.click(screen.getByRole('button', { name: '계정 만들기' })) expect(await screen.findByText('login screen')).toBeInTheDocument() - const [, requestInit] = vi.mocked(fetch).mock.calls[0] + const [, requestInit] = vi.mocked(fetch).mock.calls[1] expect(JSON.parse((requestInit as RequestInit).body as string)).toEqual({ company_name: '한빛정밀', display_name: '김경민', @@ -118,16 +153,18 @@ describe('SignupPage', () => { email: 'mini@naver.com', password: 'password123', agreements: { - service_terms: { agreed: true, version: '1.0' }, - privacy_policy: { agreed: true, version: '1.0' }, - marketing: { agreed: false, version: '1.0' }, + service_terms: { agreed: true, version: '2.0' }, + privacy_policy: { agreed: true, version: '3.0' }, + marketing: { agreed: false, version: '4.0' }, }, }) }) 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(signupPolicyResponse()) + .mockResolvedValueOnce(errorResponse(409, 'EMAIL_ALREADY_REGISTERED', 'raw')) renderPage() await fillValidForm(user) @@ -138,11 +175,13 @@ describe('SignupPage', () => { it('maps server field errors to the matching screen fields', async () => { const user = userEvent.setup() - vi.mocked(fetch).mockResolvedValueOnce( - errorResponse(400, 'VALIDATION_FAILED', 'raw', [ - { field: 'company_name', message: '사업장명 형식이 올바르지 않습니다.' }, - ]), - ) + vi.mocked(fetch) + .mockResolvedValueOnce(signupPolicyResponse()) + .mockResolvedValueOnce( + errorResponse(400, 'VALIDATION_FAILED', 'raw', [ + { field: 'company_name', message: '사업장명 형식이 올바르지 않습니다.' }, + ]), + ) renderPage() await fillValidForm(user) @@ -150,4 +189,54 @@ describe('SignupPage', () => { expect(await screen.findByText('사업장명 형식이 올바르지 않습니다.')).toBeInTheDocument() }) + + it('blocks passwords that do not include both a letter and a digit', async () => { + const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValueOnce(signupPolicyResponse()) + renderPage() + + await waitFor(() => expect(screen.getByRole('button', { name: '계정 만들기' })).toBeEnabled()) + await user.type(screen.getByLabelText('이름'), '김경민') + await user.type(screen.getByLabelText('업무용 이메일'), 'mini@naver.com') + await user.type(screen.getByLabelText('회사명'), '한빛정밀') + await user.type(screen.getByLabelText('비밀번호'), 'onlyletters') + await user.type(screen.getByLabelText('비밀번호 확인'), 'onlyletters') + await user.click(screen.getByLabelText('[필수] 서비스 이용약관 동의')) + await user.click(screen.getByLabelText('[필수] 개인정보 수집 및 이용 동의')) + await user.click(screen.getByRole('button', { name: '계정 만들기' })) + + expect( + screen.getByText('비밀번호에는 영문과 숫자가 각각 하나 이상 포함되어야 합니다.'), + ).toBeInTheDocument() + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('links to the readable terms and privacy pages', async () => { + vi.mocked(fetch).mockResolvedValueOnce(signupPolicyResponse()) + renderPage() + + expect(await screen.findByRole('link', { name: '약관 보기' })).toHaveAttribute( + 'href', + '/legal/terms', + ) + expect(screen.getByRole('link', { name: '개인정보 보기' })).toHaveAttribute( + 'href', + '/legal/privacy', + ) + }) + + it('keeps signup blocked and offers retry when policy loading fails', async () => { + const user = userEvent.setup() + vi.mocked(fetch) + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValueOnce(signupPolicyResponse()) + renderPage() + + expect(await screen.findByText('회원가입 정책을 불러오지 못했습니다.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '계정 만들기' })).toBeDisabled() + + await user.click(screen.getByRole('button', { name: '다시 시도' })) + + await waitFor(() => expect(screen.getByRole('button', { name: '계정 만들기' })).toBeEnabled()) + }) }) diff --git a/src/pages/SignupPage/SignupPage.tsx b/src/pages/SignupPage/SignupPage.tsx index 45d2231..4ec68e8 100644 --- a/src/pages/SignupPage/SignupPage.tsx +++ b/src/pages/SignupPage/SignupPage.tsx @@ -1,7 +1,8 @@ -import { useState, type FormEvent } from 'react' +import { useCallback, useEffect, useState, type FormEvent } from 'react' import { Link, useNavigate } from 'react-router-dom' import { apiFetch } from '../../api/client' import { ApiError, getErrorMessage, type ApiFieldError } from '../../api/errors' +import { fetchSignupPolicy, type SignupPolicy } from '../../api/signupPolicy' import { Button } from '../../components/ui/Button/Button' import { Checkbox } from '../../components/ui/Checkbox/Checkbox' import { EyeIcon, EyeOffIcon } from '../../components/ui/icons/EyeIcons' @@ -25,9 +26,6 @@ interface SignupResponseBody { email: string } -// 화면에 표시된 약관 버전. 약관 문구가 바뀌면 같이 올린다. -const TERMS_VERSION = '1.0' - const SERVER_FIELD_TO_SCREEN_FIELD: Record = { company_name: 'workplace', display_name: 'name', @@ -63,9 +61,29 @@ export function SignupPage() { const [marketingOptIn, setMarketingOptIn] = useState(false) const [fieldErrors, setFieldErrors] = useState({}) const [submitting, setSubmitting] = useState(false) + const [signupPolicy, setSignupPolicy] = useState(null) + const [policyLoading, setPolicyLoading] = useState(true) + const [policyError, setPolicyError] = useState(null) const passwordStrength = getPasswordStrength(password) + const loadSignupPolicy = useCallback(async () => { + setPolicyLoading(true) + setPolicyError(null) + try { + setSignupPolicy(await fetchSignupPolicy()) + } catch { + setSignupPolicy(null) + setPolicyError('회원가입 정책을 불러오지 못했습니다.') + } finally { + setPolicyLoading(false) + } + }, []) + + useEffect(() => { + void loadSignupPolicy() + }, [loadSignupPolicy]) + function validate(): FieldErrors { const errors: FieldErrors = {} if (!name.trim() || name.trim().length < 2) errors.name = '2자 이상 입력해 주세요.' @@ -74,9 +92,23 @@ export function SignupPage() { if (phone.trim() && !PHONE_PATTERN.test(phone.trim())) { errors.phone = '연락처 형식을 확인해 주세요.' } - if (password.length < 8) errors.password = '비밀번호는 8자 이상이어야 합니다.' + if (!signupPolicy) { + errors.terms = '회원가입 정책을 먼저 불러와 주세요.' + } else if ( + password.length < signupPolicy.password_policy.min_length || + password.length > signupPolicy.password_policy.max_length + ) { + errors.password = `비밀번호는 ${signupPolicy.password_policy.min_length}자 이상 ${signupPolicy.password_policy.max_length}자 이하여야 합니다.` + } else if ( + (signupPolicy.password_policy.require_letter && !/[A-Za-z]/.test(password)) || + (signupPolicy.password_policy.require_digit && !/\d/.test(password)) + ) { + errors.password = '비밀번호에는 영문과 숫자가 각각 하나 이상 포함되어야 합니다.' + } if (confirmPassword !== password) errors.confirmPassword = '비밀번호를 다시 입력해 주세요.' - if (!termsAgreed || !privacyAgreed) errors.terms = '필수 약관에 동의해 주세요.' + if (signupPolicy && (!termsAgreed || !privacyAgreed)) { + errors.terms = '필수 약관에 동의해 주세요.' + } return errors } @@ -85,6 +117,7 @@ export function SignupPage() { const errors = validate() setFieldErrors(errors) if (Object.keys(errors).length > 0) return + if (!signupPolicy) return setSubmitting(true) try { @@ -97,9 +130,18 @@ export function SignupPage() { email, password, agreements: { - service_terms: { agreed: termsAgreed, version: TERMS_VERSION }, - privacy_policy: { agreed: privacyAgreed, version: TERMS_VERSION }, - marketing: { agreed: marketingOptIn, version: TERMS_VERSION }, + service_terms: { + agreed: termsAgreed, + version: signupPolicy.agreements.service_terms.version, + }, + privacy_policy: { + agreed: privacyAgreed, + version: signupPolicy.agreements.privacy_policy.version, + }, + marketing: { + agreed: marketingOptIn, + version: signupPolicy.agreements.marketing.version, + }, }, }), skipAuthRetry: true, @@ -254,7 +296,8 @@ export function SignupPage() {

- {fieldErrors.password ?? '영문과 숫자를 포함해 8자 이상 입력해 주세요.'} + {fieldErrors.password ?? + `영문과 숫자를 포함해 ${signupPolicy?.password_policy.min_length ?? 8}자 이상 입력해 주세요.`}

@@ -313,40 +356,71 @@ export function SignupPage() { )}
+ {policyLoading && ( +

+ 회원가입 정책을 확인하고 있습니다. +

+ )} + {policyError && ( +
+ {policyError} + +
+ )}
setTermsAgreed(event.target.checked)} /> - +
setPrivacyAgreed(event.target.checked)} /> - +
setMarketingOptIn(event.target.checked)} />
{fieldErrors.terms &&

{fieldErrors.terms}

}
- diff --git a/src/routes.tsx b/src/routes.tsx index aba1a26..d423aad 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -8,6 +8,7 @@ import { IntroPage } from './pages/IntroPage/IntroPage' import { LinkExpiredPage } from './pages/LinkExpiredPage/LinkExpiredPage' import { LinkRequestPage } from './pages/LinkRequestPage/LinkRequestPage' import { LinkUploadPage } from './pages/LinkUploadPage/LinkUploadPage' +import { LegalPolicyPage } from './pages/LegalPolicyPage/LegalPolicyPage' import { LoginPage } from './pages/LoginPage/LoginPage' import { NotFoundPage } from './pages/NotFoundPage/NotFoundPage' import { OnboardingImportPage } from './pages/OnboardingImportPage/OnboardingImportPage' @@ -71,6 +72,16 @@ export const router = createBrowserRouter([ { path: '/', element: , errorElement: }, { path: '/login', element: , errorElement: }, { path: '/signup', element: , errorElement: }, + { + path: '/legal/terms', + element: , + errorElement: , + }, + { + path: '/legal/privacy', + element: , + errorElement: , + }, { path: '/forgot-password', element: , errorElement: }, { path: '/email-sent', element: , errorElement: }, { path: '/reset-password', element: , errorElement: },