diff --git a/src/api/errors.ts b/src/api/errors.ts index ff1bc97..2a0f292 100644 --- a/src/api/errors.ts +++ b/src/api/errors.ts @@ -36,6 +36,9 @@ const ERROR_MESSAGES: Record = { VALIDATION_FAILED: '입력값을 다시 확인해 주세요.', AUTHENTICATION_REQUIRED: '로그인이 필요합니다.', INVALID_CREDENTIALS: '이메일 또는 비밀번호가 올바르지 않습니다.', + ACCOUNT_TEMPORARILY_LOCKED: + '로그인 시도가 반복되어 계정이 잠시 잠겼습니다. 잠시 후 다시 시도해 주세요.', + PASSWORD_EXPIRED: '비밀번호 사용기간이 만료되었습니다. 비밀번호를 재설정해 주세요.', INVALID_REFRESH_TOKEN: '로그인이 만료되었습니다. 다시 로그인해 주세요.', ACCESS_DENIED: '이 작업에 대한 권한이 없습니다.', RESOURCE_NOT_FOUND: '요청한 정보를 찾을 수 없습니다.', diff --git a/src/api/signupPolicy.ts b/src/api/signupPolicy.ts index a5923b1..c720300 100644 --- a/src/api/signupPolicy.ts +++ b/src/api/signupPolicy.ts @@ -13,6 +13,11 @@ export interface SignupPolicy { require_letter: boolean require_digit: boolean } + account_protection: { + max_failed_attempts: number + lock_duration_seconds: number + password_max_age_days: number + } agreements: { service_terms: AgreementPolicy privacy_policy: AgreementPolicy diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx index b6a58f0..9d9ccab 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DocumentDetailResponse, DocumentItemResponse } from '../../api/documents' import type { DocumentOcrRunResponse } from '../../api/documentOcr' +import { useAuthStore } from '../../store/authStore' import { DocumentDetailPage } from './DocumentDetailPage' vi.mock('./PdfPreviewCanvas', () => ({ @@ -148,9 +149,11 @@ function renderPage(documentId: string) { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) + useAuthStore.setState({ user: null, status: 'ready' }) }) afterEach(() => { + useAuthStore.setState({ user: null, status: 'ready' }) vi.useRealTimers() vi.restoreAllMocks() vi.unstubAllGlobals() @@ -234,6 +237,16 @@ describe('DocumentDetailPage', () => { it('runs OCR, polls until ready, submits only HR corrections, and marks review complete', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + useAuthStore.setState({ + user: { + name: 'HR 담당자', + phone: null, + email: 'hr@example.com', + workplace: '한빛정밀', + role: 'HR', + }, + status: 'ready', + }) const fileDocument = detail({ worker_document_id: 'D-1', display_name: '응웬반A', @@ -265,6 +278,13 @@ describe('DocumentDetailPage', () => { expect(await screen.findByText('OCR 결과를 확인하는 중입니다.')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(1600) + const registrationNumberInput = await screen.findByDisplayValue('900101-5000000') + expect(registrationNumberInput).toHaveAttribute('type', 'password') + await user.click(screen.getByRole('button', { name: '민감정보 보기' })) + expect(registrationNumberInput).toHaveAttribute('type', 'text') + await user.click(screen.getByRole('button', { name: '민감정보 숨기기' })) + expect(registrationNumberInput).toHaveAttribute('type', 'password') + const expiryInput = await screen.findByDisplayValue('2026-12-01') await user.clear(expiryInput) await user.type(expiryInput, '2026-12-31') diff --git a/src/pages/DocumentDetailPage/DocumentOcrPanel.module.css b/src/pages/DocumentDetailPage/DocumentOcrPanel.module.css index c550427..e988483 100644 --- a/src/pages/DocumentDetailPage/DocumentOcrPanel.module.css +++ b/src/pages/DocumentDetailPage/DocumentOcrPanel.module.css @@ -20,6 +20,12 @@ gap: 20px; } +.headerActions { + display: flex; + align-items: center; + gap: 10px; +} + .panelHeader p, .notice { margin: 6px 0 0; diff --git a/src/pages/DocumentDetailPage/DocumentOcrPanel.tsx b/src/pages/DocumentDetailPage/DocumentOcrPanel.tsx index 7073ccf..08fdb59 100644 --- a/src/pages/DocumentDetailPage/DocumentOcrPanel.tsx +++ b/src/pages/DocumentDetailPage/DocumentOcrPanel.tsx @@ -11,6 +11,8 @@ import type { DocumentType } from '../../api/documents' import { ApiError, getErrorMessage } from '../../api/errors' import { Button } from '../../components/ui/Button/Button' import { StatusLabel, type StatusTone } from '../../components/ui/StatusLabel/StatusLabel' +import { useAuthStore } from '../../store/authStore' +import { isSensitiveOcrField, maskSensitiveValue } from '../../utils/privacyMasking' import styles from './DocumentOcrPanel.module.css' const POLL_INTERVAL_MS = 1500 @@ -123,12 +125,15 @@ function messageFor(error: unknown) { export function DocumentOcrPanel({ documentId, documentType, fileId }: DocumentOcrPanelProps) { const supported = documentType === 'PASSPORT_COPY' || documentType === 'ARC' + const userRole = useAuthStore((state) => state.user?.role) + const canRevealSensitiveValues = userRole === 'ADMIN' || userRole === 'HR' const [panelState, setPanelState] = useState('loading') const [run, setRun] = useState(null) const [fieldDrafts, setFieldDrafts] = useState>({}) const [rejectReason, setRejectReason] = useState('') const [requestError, setRequestError] = useState(null) const [busyAction, setBusyAction] = useState<'create' | 'approve' | 'reject' | null>(null) + const [sensitiveValuesVisible, setSensitiveValuesVisible] = useState(false) const requestKeyRef = useRef(null) const applyRun = useCallback( @@ -193,6 +198,16 @@ export function DocumentOcrPanel({ documentId, documentType, fileId }: DocumentO } }, [applyRun, documentId, run]) + useEffect(() => { + if (!sensitiveValuesVisible) return + const timer = window.setTimeout(() => setSensitiveValuesVisible(false), 60_000) + return () => window.clearTimeout(timer) + }, [sensitiveValuesVisible]) + + useEffect(() => { + if (!canRevealSensitiveValues) setSensitiveValuesVisible(false) + }, [canRevealSensitiveValues]) + const changedFields = useMemo(() => { if (!run?.result || (documentType !== 'PASSPORT_COPY' && documentType !== 'ARC')) return {} const allowed = CORRECTABLE_FIELDS[documentType] @@ -277,11 +292,22 @@ export function DocumentOcrPanel({ documentId, documentType, fileId }: DocumentO

문서 OCR

원본에서 정보를 추출한 뒤 담당자가 수정하고 검토 상태를 확정합니다.

- {run && ( - - {STATUS_PRESENTATION[run.status].label} - - )} +
+ {run?.result && canRevealSensitiveValues && ( + + )} + {run && ( + + {STATUS_PRESENTATION[run.status].label} + + )} +
{requestError && ( @@ -365,6 +391,10 @@ export function DocumentOcrPanel({ documentId, documentType, fileId }: DocumentO {editable && REVIEWABLE_STATUSES.includes(run.status) ? ( @@ -372,13 +402,20 @@ export function DocumentOcrPanel({ documentId, documentType, fileId }: DocumentO } /> ) : ( - {draftValue} + + {sensitiveValuesVisible ? draftValue : maskSensitiveValue(field, draftValue)} + )} {editable && REVIEWABLE_STATUSES.includes(run.status) && originalValue && draftValue !== originalValue && ( - OCR 추출값 · {originalValue} + + OCR 추출값 ·{' '} + {sensitiveValuesVisible + ? originalValue + : maskSensitiveValue(field, originalValue)} + )} ) diff --git a/src/pages/LegalPolicyPage/LegalPolicyPage.test.tsx b/src/pages/LegalPolicyPage/LegalPolicyPage.test.tsx index 37a9ac9..b5901b7 100644 --- a/src/pages/LegalPolicyPage/LegalPolicyPage.test.tsx +++ b/src/pages/LegalPolicyPage/LegalPolicyPage.test.tsx @@ -15,6 +15,11 @@ beforeEach(() => { require_letter: true, require_digit: true, }, + account_protection: { + max_failed_attempts: 5, + lock_duration_seconds: 900, + password_max_age_days: 180, + }, agreements: { service_terms: { version: '2.0', required: true, content_path: '/legal/terms' }, privacy_policy: { diff --git a/src/pages/LegalPolicyPage/LegalPolicyPage.tsx b/src/pages/LegalPolicyPage/LegalPolicyPage.tsx index fdf2f1e..fb848e7 100644 --- a/src/pages/LegalPolicyPage/LegalPolicyPage.tsx +++ b/src/pages/LegalPolicyPage/LegalPolicyPage.tsx @@ -122,6 +122,7 @@ const POLICY_DOCUMENTS: Record = { paragraphs: [ '계정 인증, 사업장 업무 공간 제공, 기한 감지, 문서 초안 작성, 근로자 안내와 응답 연결, 장애 조사와 감사 이력 확인을 위해 정보를 사용합니다.', ], + note: '필수정보 수집을 거부할 수 있으나, 거부 시 계정 생성과 업무 공간 제공이 제한됩니다.', }, { title: '4. 최소 수집과 화면 표시', @@ -143,7 +144,7 @@ const POLICY_DOCUMENTS: Record = { title: '6. OCR·번역·알림 처리', paragraphs: [ 'OCR, 번역, 문자 알림 등 외부 처리 기능에는 해당 작업에 필요한 정보만 전달하는 것을 원칙으로 합니다.', - '실제 민감정보를 외부 제공자에게 전송하기 전에는 제공 항목, 처리 위치, 보관 조건과 사업장 정책을 별도로 확인해야 합니다.', + '프로젝트에서 활성화한 제공자는 NAVER Cloud CLOVA OCR과 문자 발송 제공자이며, 제공자·처리 항목·보관 조건이 변경되면 시행 전에 별도로 알립니다.', ], }, { @@ -156,7 +157,8 @@ const POLICY_DOCUMENTS: Record = { { title: '8. 보관과 삭제', paragraphs: [ - '정보는 프로젝트 운영과 서비스 검증에 필요한 기간 동안 보관합니다. 계정 또는 업무 종료 후의 구체적인 보관기간과 파기 절차는 정식 서비스 전환 시 확정합니다.', + '계정과 사업장 정보는 회원 탈퇴 또는 프로젝트 종료 시까지, 업로드 문서는 기본 365일, 비식별 AI 품질 로그는 90일 동안 보관합니다. 법령상 보존 의무나 분쟁 대응이 필요한 경우에는 해당 기간 동안 분리 보관합니다.', + '보관기간이 끝난 전자 파일은 복구하기 어려운 방식으로 삭제하고, 백업 데이터는 백업 주기가 만료되는 순서대로 삭제합니다.', '삭제 요청이 접수되면 관련 법령 또는 분쟁 대응을 위해 보관해야 하는 범위를 제외하고 처리 상태를 확인합니다.', ], }, @@ -165,6 +167,7 @@ const POLICY_DOCUMENTS: Record = { paragraphs: [ '사용자는 자신의 계정 정보 확인과 정정을 요청할 수 있으며, 선택 동의는 철회할 수 있습니다.', '근로자 정보의 열람·정정·삭제 요청은 해당 정보를 등록한 사업장 담당자를 통해 처리하는 것을 원칙으로 합니다.', + '권리 요청은 서비스 내 담당자 문의 또는 FOWOCO 프로젝트 문의 채널로 접수하며, 본인 또는 정당한 대리인 여부를 확인한 뒤 처리합니다.', ], }, { diff --git a/src/pages/SignupPage/SignupPage.test.tsx b/src/pages/SignupPage/SignupPage.test.tsx index 4558370..0de684e 100644 --- a/src/pages/SignupPage/SignupPage.test.tsx +++ b/src/pages/SignupPage/SignupPage.test.tsx @@ -44,6 +44,11 @@ function signupPolicyResponse( require_letter: true, require_digit: true, }, + account_protection: { + max_failed_attempts: 5, + lock_duration_seconds: 900, + password_max_age_days: 180, + }, agreements: { service_terms: { version: serviceTermsVersion, @@ -101,6 +106,9 @@ describe('SignupPage', () => { renderPage() await waitFor(() => expect(screen.getByRole('button', { name: '계정 만들기' })).toBeEnabled()) + expect( + screen.getByText('계정 보호 · 로그인 5회 실패 시 15분 잠금 · 비밀번호 180일 사용'), + ).toBeInTheDocument() await user.type(screen.getByLabelText('이름'), '김경민') await user.type(screen.getByLabelText('업무용 이메일'), 'mini@naver.com') diff --git a/src/pages/SignupPage/SignupPage.tsx b/src/pages/SignupPage/SignupPage.tsx index 07d7bde..e8249b2 100644 --- a/src/pages/SignupPage/SignupPage.tsx +++ b/src/pages/SignupPage/SignupPage.tsx @@ -66,6 +66,7 @@ export function SignupPage() { const [policyError, setPolicyError] = useState(null) const passwordStrength = getPasswordStrength(password) + const accountProtection = signupPolicy?.account_protection const loadSignupPolicy = useCallback(async () => { setPolicyLoading(true) @@ -299,6 +300,13 @@ export function SignupPage() { {fieldErrors.password ?? `영문과 숫자를 포함해 ${signupPolicy?.password_policy.min_length ?? 8}자 이상 입력해 주세요.`}

+ {accountProtection && ( +

+ 계정 보호 · 로그인 {accountProtection.max_failed_attempts}회 실패 시{' '} + {Math.ceil(accountProtection.lock_duration_seconds / 60)}분 잠금 · 비밀번호{' '} + {accountProtection.password_max_age_days}일 사용 +

+ )}
diff --git a/src/pages/WorkerListPage/WorkerListPage.tsx b/src/pages/WorkerListPage/WorkerListPage.tsx index 0f17b70..2910746 100644 --- a/src/pages/WorkerListPage/WorkerListPage.tsx +++ b/src/pages/WorkerListPage/WorkerListPage.tsx @@ -216,7 +216,7 @@ export function WorkerListPage() { ariaLabel="기한 필터" /> - 개인정보 마스킹 켜짐 + 업무 최소정보 표시
{status === 'loading' && ( diff --git a/src/utils/privacyMasking.test.ts b/src/utils/privacyMasking.test.ts new file mode 100644 index 0000000..0932e59 --- /dev/null +++ b/src/utils/privacyMasking.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { isSensitiveOcrField, maskSensitiveValue } from './privacyMasking' + +describe('privacyMasking', () => { + it('masks identifiers while retaining the minimum comparison hint', () => { + expect(maskSensitiveValue('passport_number', 'M12345678')).toBe('M1*****78') + expect(maskSensitiveValue('alien_registration_number', '930101-5123456')).toBe('930101-5******') + }) + + it('masks names and addresses but leaves operational dates untouched', () => { + expect(maskSensitiveValue('given_names', 'NGUYEN VAN AN')).toBe('N************') + expect(maskSensitiveValue('residence_address_1', '경기도 안산시 단원구')).toMatch(/^경기도 /) + expect(maskSensitiveValue('stay_expiration_date', '2026-12-31')).toBe('2026-12-31') + expect(isSensitiveOcrField('passport_number')).toBe(true) + }) +}) diff --git a/src/utils/privacyMasking.ts b/src/utils/privacyMasking.ts new file mode 100644 index 0000000..273d603 --- /dev/null +++ b/src/utils/privacyMasking.ts @@ -0,0 +1,39 @@ +const SENSITIVE_OCR_FIELDS = new Set([ + 'passport_number', + 'alien_registration_number', + 'surname', + 'given_names', + 'date_of_birth', + 'residence_address_1', +]) + +export function isSensitiveOcrField(field: string): boolean { + return SENSITIVE_OCR_FIELDS.has(field) +} + +export function maskSensitiveValue(field: string, value: string): string { + if (!value || !isSensitiveOcrField(field)) return value + if (field === 'alien_registration_number') { + const normalized = value.replace(/\s/g, '') + const separator = normalized.includes('-') ? '-' : '' + const visiblePrefix = normalized.slice(0, 6) + const visibleGenderCode = normalized.replace('-', '').slice(6, 7) + return `${visiblePrefix}${separator}${visibleGenderCode}${'*'.repeat(Math.max(0, normalized.replace('-', '').length - 7))}` + } + if (field === 'passport_number') { + return value.length <= 4 + ? '*'.repeat(value.length) + : `${value.slice(0, 2)}${'*'.repeat(value.length - 4)}${value.slice(-2)}` + } + if (field === 'date_of_birth') { + return value.replace(/\d(?=\d{2})/g, '*') + } + if (field === 'residence_address_1') { + return value.length <= 4 + ? '****' + : `${value.slice(0, 4)}${'*'.repeat(Math.min(8, value.length - 4))}` + } + const characters = Array.from(value) + if (characters.length <= 1) return '*' + return `${characters[0]}${'*'.repeat(characters.length - 1)}` +}