Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const ERROR_MESSAGES: Record<string, string> = {
VALIDATION_FAILED: '입력값을 다시 확인해 주세요.',
AUTHENTICATION_REQUIRED: '로그인이 필요합니다.',
INVALID_CREDENTIALS: '이메일 또는 비밀번호가 올바르지 않습니다.',
ACCOUNT_TEMPORARILY_LOCKED:
'로그인 시도가 반복되어 계정이 잠시 잠겼습니다. 잠시 후 다시 시도해 주세요.',
PASSWORD_EXPIRED: '비밀번호 사용기간이 만료되었습니다. 비밀번호를 재설정해 주세요.',
INVALID_REFRESH_TOKEN: '로그인이 만료되었습니다. 다시 로그인해 주세요.',
ACCESS_DENIED: '이 작업에 대한 권한이 없습니다.',
RESOURCE_NOT_FOUND: '요청한 정보를 찾을 수 없습니다.',
Expand Down
5 changes: 5 additions & 0 deletions src/api/signupPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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')
Expand Down
6 changes: 6 additions & 0 deletions src/pages/DocumentDetailPage/DocumentOcrPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
gap: 20px;
}

.headerActions {
display: flex;
align-items: center;
gap: 10px;
}

.panelHeader p,
.notice {
margin: 6px 0 0;
Expand Down
51 changes: 44 additions & 7 deletions src/pages/DocumentDetailPage/DocumentOcrPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<PanelState>('loading')
const [run, setRun] = useState<DocumentOcrRunResponse | null>(null)
const [fieldDrafts, setFieldDrafts] = useState<Record<string, string>>({})
const [rejectReason, setRejectReason] = useState('')
const [requestError, setRequestError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<'create' | 'approve' | 'reject' | null>(null)
const [sensitiveValuesVisible, setSensitiveValuesVisible] = useState(false)
const requestKeyRef = useRef<string | null>(null)

const applyRun = useCallback(
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -277,11 +292,22 @@ export function DocumentOcrPanel({ documentId, documentType, fileId }: DocumentO
<h2 id="document-ocr-title">문서 OCR</h2>
<p>원본에서 정보를 추출한 뒤 담당자가 수정하고 검토 상태를 확정합니다.</p>
</div>
{run && (
<StatusLabel tone={STATUS_PRESENTATION[run.status].tone}>
{STATUS_PRESENTATION[run.status].label}
</StatusLabel>
)}
<div className={styles.headerActions}>
{run?.result && canRevealSensitiveValues && (
<Button
variant="secondary"
aria-pressed={sensitiveValuesVisible}
onClick={() => setSensitiveValuesVisible((visible) => !visible)}
>
{sensitiveValuesVisible ? '민감정보 숨기기' : '민감정보 보기'}
</Button>
)}
{run && (
<StatusLabel tone={STATUS_PRESENTATION[run.status].tone}>
{STATUS_PRESENTATION[run.status].label}
</StatusLabel>
)}
</div>
</div>

{requestError && (
Expand Down Expand Up @@ -365,20 +391,31 @@ export function DocumentOcrPanel({ documentId, documentType, fileId }: DocumentO
</span>
{editable && REVIEWABLE_STATUSES.includes(run.status) ? (
<input
type={
isSensitiveOcrField(field) && !sensitiveValuesVisible ? 'password' : 'text'
}
autoComplete="off"
value={draftValue}
placeholder={originalValue ? undefined : '원본을 확인해 입력해 주세요.'}
onChange={(event) =>
setFieldDrafts((current) => ({ ...current, [field]: event.target.value }))
}
/>
) : (
<output>{draftValue}</output>
<output>
{sensitiveValuesVisible ? draftValue : maskSensitiveValue(field, draftValue)}
</output>
)}
{editable &&
REVIEWABLE_STATUSES.includes(run.status) &&
originalValue &&
draftValue !== originalValue && (
<small className={styles.originalValue}>OCR 추출값 · {originalValue}</small>
<small className={styles.originalValue}>
OCR 추출값 ·{' '}
{sensitiveValuesVisible
? originalValue
: maskSensitiveValue(field, originalValue)}
</small>
)}
</label>
)
Expand Down
5 changes: 5 additions & 0 deletions src/pages/LegalPolicyPage/LegalPolicyPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 5 additions & 2 deletions src/pages/LegalPolicyPage/LegalPolicyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ const POLICY_DOCUMENTS: Record<PolicyKind, PolicyDocument> = {
paragraphs: [
'계정 인증, 사업장 업무 공간 제공, 기한 감지, 문서 초안 작성, 근로자 안내와 응답 연결, 장애 조사와 감사 이력 확인을 위해 정보를 사용합니다.',
],
note: '필수정보 수집을 거부할 수 있으나, 거부 시 계정 생성과 업무 공간 제공이 제한됩니다.',
},
{
title: '4. 최소 수집과 화면 표시',
Expand All @@ -143,7 +144,7 @@ const POLICY_DOCUMENTS: Record<PolicyKind, PolicyDocument> = {
title: '6. OCR·번역·알림 처리',
paragraphs: [
'OCR, 번역, 문자 알림 등 외부 처리 기능에는 해당 작업에 필요한 정보만 전달하는 것을 원칙으로 합니다.',
'실제 민감정보를 외부 제공자에게 전송하기 전에는 제공 항목, 처리 위치, 보관 조건과 사업장 정책을 별도로 확인해야 합니다.',
'프로젝트에서 활성화한 제공자는 NAVER Cloud CLOVA OCR과 문자 발송 제공자이며, 제공자·처리 항목·보관 조건이 변경되면 시행 전에 별도로 알립니다.',
],
},
{
Expand All @@ -156,7 +157,8 @@ const POLICY_DOCUMENTS: Record<PolicyKind, PolicyDocument> = {
{
title: '8. 보관과 삭제',
paragraphs: [
'정보는 프로젝트 운영과 서비스 검증에 필요한 기간 동안 보관합니다. 계정 또는 업무 종료 후의 구체적인 보관기간과 파기 절차는 정식 서비스 전환 시 확정합니다.',
'계정과 사업장 정보는 회원 탈퇴 또는 프로젝트 종료 시까지, 업로드 문서는 기본 365일, 비식별 AI 품질 로그는 90일 동안 보관합니다. 법령상 보존 의무나 분쟁 대응이 필요한 경우에는 해당 기간 동안 분리 보관합니다.',
'보관기간이 끝난 전자 파일은 복구하기 어려운 방식으로 삭제하고, 백업 데이터는 백업 주기가 만료되는 순서대로 삭제합니다.',
'삭제 요청이 접수되면 관련 법령 또는 분쟁 대응을 위해 보관해야 하는 범위를 제외하고 처리 상태를 확인합니다.',
],
},
Expand All @@ -165,6 +167,7 @@ const POLICY_DOCUMENTS: Record<PolicyKind, PolicyDocument> = {
paragraphs: [
'사용자는 자신의 계정 정보 확인과 정정을 요청할 수 있으며, 선택 동의는 철회할 수 있습니다.',
'근로자 정보의 열람·정정·삭제 요청은 해당 정보를 등록한 사업장 담당자를 통해 처리하는 것을 원칙으로 합니다.',
'권리 요청은 서비스 내 담당자 문의 또는 FOWOCO 프로젝트 문의 채널로 접수하며, 본인 또는 정당한 대리인 여부를 확인한 뒤 처리합니다.',
],
},
{
Expand Down
8 changes: 8 additions & 0 deletions src/pages/SignupPage/SignupPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down
8 changes: 8 additions & 0 deletions src/pages/SignupPage/SignupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function SignupPage() {
const [policyError, setPolicyError] = useState<string | null>(null)

const passwordStrength = getPasswordStrength(password)
const accountProtection = signupPolicy?.account_protection

const loadSignupPolicy = useCallback(async () => {
setPolicyLoading(true)
Expand Down Expand Up @@ -299,6 +300,13 @@ export function SignupPage() {
{fieldErrors.password ??
`영문과 숫자를 포함해 ${signupPolicy?.password_policy.min_length ?? 8}자 이상 입력해 주세요.`}
</p>
{accountProtection && (
<p className={styles.helperText}>
계정 보호 · 로그인 {accountProtection.max_failed_attempts}회 실패 시{' '}
{Math.ceil(accountProtection.lock_duration_seconds / 60)}분 잠금 · 비밀번호{' '}
{accountProtection.password_max_age_days}일 사용
</p>
)}
</div>

<div className={styles.field}>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/WorkerListPage/WorkerListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export function WorkerListPage() {
ariaLabel="기한 필터"
/>
<Button onClick={() => setRegisterModalOpen(true)}>+ 근로자 등록</Button>
<span className={styles.maskingNote}>개인정보 마스킹 켜짐</span>
<span className={styles.maskingNote}>업무 최소정보 표시</span>
</div>

{status === 'loading' && (
Expand Down
16 changes: 16 additions & 0 deletions src/utils/privacyMasking.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
39 changes: 39 additions & 0 deletions src/utils/privacyMasking.ts
Original file line number Diff line number Diff line change
@@ -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)}`
}