Skip to content

Commit 7762851

Browse files
SOIVclaude
andcommitted
feat(web): apps/web View를 @fieldstack/controls 컴포넌트로 교체
- @fieldstack/controls 의존성 추가 (workspace:^) - LoginView: Button, Checkbox, FormField, Input 교체 - OtpView: OtpInput, Button 교체 (digits[] → code string 리팩터) - ChangePasswordView: FormField, Input, Button 교체 - ForgotPasswordView: FormField, Input, Button 교체 - SettingsView: Modal, FormField, Input, Select, Button 교체 - AdminPinModal: Modal, Input, Button 교체 - HomeView: Button, EmptyState 교체 - AdminView: Button 교체 - MarketplaceView: EmptyState 교체 - Input에 forwardRef 추가 (AdminPinModal ref 지원) - 스토리 파일 실제 컴포넌트 API에 맞게 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 68fe304 commit 7762851

19 files changed

Lines changed: 351 additions & 519 deletions

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"vitest": "^2.1.9"
1919
},
2020
"dependencies": {
21+
"@fieldstack/controls": "workspace:^",
2122
"@fieldstack/core": "workspace:^",
2223
"react": "^19.2.4",
2324
"react-dom": "^19.2.4"
Lines changed: 42 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { FormEvent, useEffect, useRef, useState } from "react";
1+
import { useEffect, useRef, useState, type FormEvent } from "react";
2+
3+
import { Button, Input, Modal } from "@fieldstack/controls";
24

35
// 개발 mock PIN — 실제 구현 시 API 검증으로 교체
46
const MOCK_ADMIN_PIN = "1234";
@@ -43,15 +45,6 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
4345
inputRef.current?.focus();
4446
}, []);
4547

46-
// ESC로 닫기
47-
useEffect(() => {
48-
const onKey = (e: KeyboardEvent) => {
49-
if (e.key === "Escape") onClose();
50-
};
51-
window.addEventListener("keydown", onKey);
52-
return () => window.removeEventListener("keydown", onKey);
53-
}, [onClose]);
54-
5548
const isLocked = lockedUntil !== null;
5649

5750
const handleSubmit = (e: FormEvent) => {
@@ -78,64 +71,46 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
7871
};
7972

8073
return (
81-
<div
82-
className="pin-overlay"
83-
role="dialog"
84-
aria-modal="true"
85-
aria-labelledby="pin-modal-title"
86-
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
74+
<Modal
75+
open
76+
onClose={onClose}
77+
title="관리자 인증"
78+
size="sm"
79+
footer={
80+
<>
81+
<Button type="button" onClick={onClose}>취소</Button>
82+
<Button variant="primary" type="submit" form="pin-form" disabled={pin.length < 4 || isLocked}>
83+
확인
84+
</Button>
85+
</>
86+
}
8787
>
88-
<div className="pin-modal">
89-
<div className="pin-modal-header">
90-
<span className="pin-modal-icon" aria-hidden="true">🔐</span>
91-
<h2 className="pin-modal-title" id="pin-modal-title">관리자 인증</h2>
92-
<p className="pin-modal-desc">
93-
관리자 PIN을 입력하세요. 인증은 30분간 유효합니다.
94-
</p>
95-
</div>
96-
97-
<form className="pin-modal-form" onSubmit={handleSubmit} noValidate>
98-
<input
99-
ref={inputRef}
100-
className={`input pin-input${error ? " pin-input-error" : ""}`}
101-
type="password"
102-
inputMode="numeric"
103-
pattern="[0-9]*"
104-
maxLength={6}
105-
placeholder="••••"
106-
value={pin}
107-
onChange={(e) => {
108-
setPin(e.target.value.replace(/\D/g, ""));
109-
if (!isLocked) setError("");
110-
}}
111-
disabled={isLocked}
112-
autoComplete="off"
113-
aria-label="관리자 PIN"
114-
aria-describedby={error ? "pin-error" : undefined}
115-
/>
116-
117-
{error && (
118-
<p className="pin-error" id="pin-error" role="alert">
119-
{isLocked ? `${error}${remaining}초 후 재시도 가능` : error}
120-
</p>
121-
)}
122-
123-
<div className="pin-modal-actions">
124-
<button type="button" className="button" onClick={onClose}>
125-
취소
126-
</button>
127-
<button
128-
type="submit"
129-
className="button button-primary"
130-
disabled={pin.length < 4 || isLocked}
131-
>
132-
확인
133-
</button>
134-
</div>
135-
</form>
136-
137-
<p className="pin-modal-hint">개발 mock PIN: 1234</p>
88+
<div className="pin-modal-header">
89+
<span className="pin-modal-icon" aria-hidden="true">🔐</span>
90+
<p className="pin-modal-desc">관리자 PIN을 입력하세요. 인증은 30분간 유효합니다.</p>
13891
</div>
139-
</div>
92+
93+
<form id="pin-form" onSubmit={handleSubmit} noValidate>
94+
<Input
95+
ref={inputRef}
96+
type="password"
97+
inputMode="numeric"
98+
pattern="[0-9]*"
99+
maxLength={6}
100+
placeholder="••••"
101+
value={pin}
102+
onChange={(e) => {
103+
setPin(e.target.value.replace(/\D/g, ""));
104+
if (!isLocked) setError("");
105+
}}
106+
disabled={isLocked}
107+
autoComplete="off"
108+
aria-label="관리자 PIN"
109+
error={error ? (isLocked ? `${error}${remaining}초 후 재시도 가능` : error) : undefined}
110+
/>
111+
</form>
112+
113+
<p className="pin-modal-hint">개발 mock PIN: 1234</p>
114+
</Modal>
140115
);
141116
}

apps/web/src/views/AdminView.tsx

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import "../styles/admin.css";
22

3+
import { Button } from "@fieldstack/controls";
4+
35
interface AdminViewProps {
46
isPinVerified: boolean;
57
onRequestPin: () => void;
@@ -12,11 +14,11 @@ const MOCK_STATS = [
1214
];
1315

1416
const ADMIN_SECTIONS = [
15-
{ id: "users", icon: "👥", name: "사용자 관리", desc: "Whitelist 추가·제거, 역할 관리" },
16-
{ id: "modules", icon: "📦", name: "모듈 레지스트리", desc: "모듈 활성화·비활성화, 버전 관리" },
17-
{ id: "system", icon: "🗄️", name: "시스템 설정", desc: "DB 설정, 업데이트, 백업" },
18-
{ id: "security",icon: "🔐", name: "보안 설정", desc: "PIN 변경, 세션 정책" },
19-
{ id: "audit", icon: "📋", name: "감사 로그", desc: "PIN 실패, 주요 설정 변경 이력" },
17+
{ id: "users", icon: "👥", name: "사용자 관리", desc: "Whitelist 추가·제거, 역할 관리" },
18+
{ id: "modules", icon: "📦", name: "모듈 레지스트리", desc: "모듈 활성화·비활성화, 버전 관리" },
19+
{ id: "system", icon: "🗄️", name: "시스템 설정", desc: "DB 설정, 업데이트, 백업" },
20+
{ id: "security", icon: "🔐", name: "보안 설정", desc: "PIN 변경, 세션 정책" },
21+
{ id: "audit", icon: "📋", name: "감사 로그", desc: "PIN 실패, 주요 설정 변경 이력" },
2022
];
2123

2224
const MOCK_AUDIT_LOG = [
@@ -37,9 +39,9 @@ export function AdminView({ isPinVerified, onRequestPin }: AdminViewProps) {
3739
<p className="admin-lock-desc">
3840
관리자 콘솔은 PIN Step-up 인증 이후에만 접근 가능합니다.
3941
</p>
40-
<button className="button button-primary" type="button" onClick={onRequestPin}>
42+
<Button variant="primary" type="button" onClick={onRequestPin}>
4143
PIN 인증하기
42-
</button>
44+
</Button>
4345
</div>
4446
</section>
4547
);
@@ -73,9 +75,7 @@ export function AdminView({ isPinVerified, onRequestPin }: AdminViewProps) {
7375
{ADMIN_SECTIONS.map((section) => (
7476
<button key={section.id} className="admin-section-row" type="button">
7577
<div className="admin-section-info">
76-
<span className="admin-section-name">
77-
{section.icon} {section.name}
78-
</span>
78+
<span className="admin-section-name">{section.icon} {section.name}</span>
7979
<span className="admin-section-desc">{section.desc}</span>
8080
</div>
8181
<span className="admin-section-arrow" aria-hidden="true"></span>
@@ -85,21 +85,18 @@ export function AdminView({ isPinVerified, onRequestPin }: AdminViewProps) {
8585

8686
<section className="admin-audit-panel">
8787
<h2 className="admin-block-title">최근 감사 로그</h2>
88-
<ul className="admin-audit-list" aria-label="감사 로그">
89-
{MOCK_AUDIT_LOG.map((item) => (
90-
<li key={item.id} className="admin-audit-item">
91-
<span
92-
className={`admin-audit-dot admin-audit-dot-${item.dot}`}
93-
aria-hidden="true"
94-
/>
95-
<span>
96-
{item.text}
97-
<br />
98-
<span className="admin-audit-time">{item.time}</span>
99-
</span>
100-
</li>
101-
))}
102-
</ul>
88+
<ul className="admin-audit-list" aria-label="감사 로그">
89+
{MOCK_AUDIT_LOG.map((item) => (
90+
<li key={item.id} className="admin-audit-item">
91+
<span className={`admin-audit-dot admin-audit-dot-${item.dot}`} aria-hidden="true" />
92+
<span>
93+
{item.text}
94+
<br />
95+
<span className="admin-audit-time">{item.time}</span>
96+
</span>
97+
</li>
98+
))}
99+
</ul>
103100
</section>
104101
</div>
105102
</div>

apps/web/src/views/ChangePasswordView.tsx

Lines changed: 34 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { FormEvent, useState } from "react";
1+
import { useState, type FormEvent } from "react";
22

33
import { PASSWORD_POLICY, validatePassword } from "@fieldstack/core";
44

5+
import { Button, FormField, Input } from "@fieldstack/controls";
6+
57
import "../styles/change-password.css";
68

79
interface ChangePasswordViewProps {
8-
isFirstLogin: boolean; // true = 임시 비번 첫 로그인, false = 일반 변경
10+
isFirstLogin: boolean;
911
onChanged: () => void;
1012
}
1113

@@ -23,14 +25,10 @@ export function ChangePasswordView({ isFirstLogin, onChanged }: ChangePasswordVi
2325
const nextValidation = validatePassword(next.value);
2426
const confirmMismatch = confirm.value !== next.value;
2527

26-
const showNextErrors = (next.touched || submitted) && !nextValidation.valid;
27-
const showConfirmError = (confirm.touched || submitted) && confirmMismatch;
2828
const showCurrentError = (current.touched || submitted) && current.value.length === 0;
29+
const showConfirmError = (confirm.touched || submitted) && confirmMismatch;
2930

30-
const canSubmit =
31-
current.value.length > 0 &&
32-
nextValidation.valid &&
33-
!confirmMismatch;
31+
const canSubmit = current.value.length > 0 && nextValidation.valid && !confirmMismatch;
3432

3533
const handleSubmit = (e: FormEvent) => {
3634
e.preventDefault();
@@ -56,103 +54,74 @@ export function ChangePasswordView({ isFirstLogin, onChanged }: ChangePasswordVi
5654
</div>
5755

5856
<form className="stack cpw-form" onSubmit={handleSubmit} noValidate>
59-
{/* 현재(임시) 비밀번호 */}
60-
<label className="field">
61-
<span>{isFirstLogin ? "임시 비밀번호" : "현재 비밀번호"}</span>
62-
<input
63-
className={`input${showCurrentError ? " cpw-input-error" : ""}`}
57+
<FormField
58+
label={isFirstLogin ? "임시 비밀번호" : "현재 비밀번호"}
59+
htmlFor="cpw-current"
60+
error={showCurrentError ? "필수 입력 항목입니다." : undefined}
61+
>
62+
<Input
63+
id="cpw-current"
6464
type="password"
6565
autoComplete="current-password"
6666
value={current.value}
6767
onChange={(e) => setCurrent({ value: e.target.value, touched: true })}
6868
placeholder="••••••••"
6969
/>
70-
{showCurrentError && (
71-
<p className="cpw-field-error" role="alert">필수 입력 항목입니다.</p>
72-
)}
73-
</label>
74-
75-
{/* 새 비밀번호 */}
76-
<label className="field">
77-
<span>새 비밀번호</span>
78-
<input
79-
className={`input${showNextErrors ? " cpw-input-error" : ""}`}
70+
</FormField>
71+
72+
<FormField label="새 비밀번호" htmlFor="cpw-next">
73+
<Input
74+
id="cpw-next"
8075
type="password"
8176
autoComplete="new-password"
8277
value={next.value}
8378
onChange={(e) => setNext({ value: e.target.value, touched: true })}
8479
placeholder="••••••••"
8580
aria-describedby="cpw-policy"
8681
/>
87-
</label>
82+
</FormField>
8883

89-
{/* 정책 체크리스트 */}
9084
<ul className="cpw-policy" id="cpw-policy" aria-label="비밀번호 조건">
9185
<PolicyItem
9286
met={next.value.length >= PASSWORD_POLICY.minLength && next.value.length <= PASSWORD_POLICY.maxLength}
9387
active={next.touched || submitted}
9488
label={`${PASSWORD_POLICY.minLength}~${PASSWORD_POLICY.maxLength}자`}
9589
/>
96-
<PolicyItem
97-
met={/[A-Z]/.test(next.value)}
98-
active={next.touched || submitted}
99-
label="영어 대문자 포함"
100-
/>
101-
<PolicyItem
102-
met={/[a-z]/.test(next.value)}
103-
active={next.touched || submitted}
104-
label="영어 소문자 포함"
105-
/>
106-
<PolicyItem
107-
met={/\d/.test(next.value)}
108-
active={next.touched || submitted}
109-
label="숫자 포함"
110-
/>
90+
<PolicyItem met={/[A-Z]/.test(next.value)} active={next.touched || submitted} label="영어 대문자 포함" />
91+
<PolicyItem met={/[a-z]/.test(next.value)} active={next.touched || submitted} label="영어 소문자 포함" />
92+
<PolicyItem met={/\d/.test(next.value)} active={next.touched || submitted} label="숫자 포함" />
11193
<PolicyItem
11294
met={/[^a-zA-Z0-9]/.test(next.value)}
11395
active={next.touched || submitted}
11496
label="특수문자 포함"
11597
/>
11698
</ul>
11799

118-
{/* 비밀번호 확인 */}
119-
<label className="field">
120-
<span>새 비밀번호 확인</span>
121-
<input
122-
className={`input${showConfirmError ? " cpw-input-error" : ""}`}
100+
<FormField
101+
label="새 비밀번호 확인"
102+
htmlFor="cpw-confirm"
103+
error={showConfirmError ? "비밀번호가 일치하지 않습니다." : undefined}
104+
>
105+
<Input
106+
id="cpw-confirm"
123107
type="password"
124108
autoComplete="new-password"
125109
value={confirm.value}
126110
onChange={(e) => setConfirm({ value: e.target.value, touched: true })}
127111
placeholder="••••••••"
128112
/>
129-
{showConfirmError && (
130-
<p className="cpw-field-error" role="alert">비밀번호가 일치하지 않습니다.</p>
131-
)}
132-
</label>
133-
134-
<button
135-
className="button button-primary button-block"
136-
type="submit"
137-
>
113+
</FormField>
114+
115+
<Button variant="primary" block type="submit">
138116
비밀번호 변경
139-
</button>
117+
</Button>
140118
</form>
141119
</section>
142120
</main>
143121
);
144122
}
145123

146-
// ─── Policy Checklist Item ────────────────────────────────────
147-
function PolicyItem({
148-
met,
149-
active,
150-
label,
151-
}: {
152-
met: boolean;
153-
active: boolean;
154-
label: string;
155-
}) {
124+
function PolicyItem({ met, active, label }: { met: boolean; active: boolean; label: string }) {
156125
const state = !active ? "idle" : met ? "ok" : "fail";
157126
return (
158127
<li className={`cpw-policy-item cpw-policy-${state}`} aria-live="polite">

0 commit comments

Comments
 (0)