Skip to content

Commit 77055ce

Browse files
SOIVclaude
andcommitted
feat(web/core): 비밀번호 변경 화면, 어드민 PIN 인증 분리, Marketplace 진입점 추가
- ChangePasswordView: 임시 비번 첫 로그인 시 강제 변경 화면 구현 (정책 체크리스트 포함) - AdminView: isAdmin/isPinVerified 분리 — 역할 보유와 PIN 인증을 독립 상태로 관리 - AppShell: Admin 버튼 비관리자 숨김, Member → User 표기 변경, Marketplace 메뉴 추가 - MarketplaceView: Phase 3 플레이스홀더 진입점 추가 - @fieldstack/core: ESM 빌드로 전환 (module: ESNext), web 패키지 의존성 추가 - vite.config.ts 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 630e061 commit 77055ce

12 files changed

Lines changed: 681 additions & 320 deletions

File tree

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/core": "workspace:^",
2122
"react": "^19.2.4",
2223
"react-dom": "^19.2.4"
2324
}

apps/web/src/components/AppShell.tsx

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ReactNode } from "react";
22
import "../styles/shell.css";
33

4-
export type RouteKey = "login" | "home" | "admin";
4+
export type RouteKey = "login" | "home" | "marketplace" | "admin" | "change-password";
55

66
interface AppShellProps {
77
installMode: "normal" | "bypass";
@@ -10,7 +10,6 @@ interface AppShellProps {
1010
currentUser: { email: string } | null;
1111
notice: string;
1212
onNavigate: (route: RouteKey) => void;
13-
onAdminAccess: () => void;
1413
onLogout: () => void;
1514
onOpenSettings: () => void;
1615
children: ReactNode;
@@ -26,7 +25,6 @@ export function AppShell({
2625
currentUser,
2726
notice,
2827
onNavigate,
29-
onAdminAccess,
3028
onLogout,
3129
onOpenSettings,
3230
children,
@@ -56,6 +54,17 @@ export function AppShell({
5654
Home
5755
</button>
5856
</li>
57+
<li>
58+
<button
59+
type="button"
60+
className="shell-nav-item"
61+
aria-current={route === "marketplace" ? "page" : undefined}
62+
onClick={() => onNavigate("marketplace")}
63+
>
64+
<span className="shell-nav-icon" aria-hidden="true"></span>
65+
Marketplace
66+
</button>
67+
</li>
5968
</ul>
6069

6170
{/* Modules */}
@@ -87,7 +96,7 @@ export function AppShell({
8796
<div className="shell-user-avatar" aria-hidden="true">{userInitial}</div>
8897
<div className="shell-user-info">
8998
<p className="shell-user-email">{currentUser.email}</p>
90-
<p className="shell-user-role">{isAdmin ? "Administrator" : "Member"}</p>
99+
<p className="shell-user-role">{isAdmin ? "Administrator" : "User"}</p>
91100
</div>
92101
</div>
93102
)}
@@ -100,18 +109,17 @@ export function AppShell({
100109
<span className="shell-nav-icon" aria-hidden="true"></span>
101110
Settings
102111
</button>
103-
<button
104-
type="button"
105-
className="shell-nav-item"
106-
aria-current={route === "admin" ? "page" : undefined}
107-
onClick={onAdminAccess}
108-
>
109-
<span className="shell-nav-icon" aria-hidden="true"></span>
110-
Admin
111-
{!isAdmin && (
112-
<span className="shell-nav-lock" aria-label="인증 필요" aria-hidden="true">🔒</span>
113-
)}
114-
</button>
112+
{isAdmin && (
113+
<button
114+
type="button"
115+
className="shell-nav-item"
116+
aria-current={route === "admin" ? "page" : undefined}
117+
onClick={() => onNavigate("admin")}
118+
>
119+
<span className="shell-nav-icon" aria-hidden="true"></span>
120+
Admin
121+
</button>
122+
)}
115123
<button
116124
type="button"
117125
className="shell-nav-item shell-nav-item-danger"

apps/web/src/main.tsx

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { HomeView } from "./views/HomeView";
1010
import { LoginView } from "./views/LoginView";
1111
import { SettingsView } from "./views/SettingsView";
1212
import { AdminView } from "./views/AdminView";
13+
import { MarketplaceView } from "./views/MarketplaceView";
14+
import { ChangePasswordView } from "./views/ChangePasswordView";
1315

1416
// ─── Types ────────────────────────────────────────────────────
1517
type InstallMode = "normal" | "bypass";
@@ -37,10 +39,8 @@ function resolveInstallMode(runtimeEnv: WebRuntimeEnv): InstallMode {
3739

3840
function getRouteFromHash(rawHash: string): RouteKey {
3941
const hash = rawHash.replace("#", "");
40-
if (hash === "settings") {
41-
return "home";
42-
}
43-
if (hash === "home" || hash === "admin" || hash === "login") {
42+
if (hash === "settings") return "home";
43+
if (hash === "home" || hash === "marketplace" || hash === "admin" || hash === "login" || hash === "change-password") {
4444
return hash;
4545
}
4646
return "login";
@@ -55,9 +55,11 @@ function canAccessRoute(route: RouteKey, isAuthenticated: boolean): boolean {
5555

5656
// ─── Session Storage Keys ─────────────────────────────────────
5757
const SS = {
58-
auth: "fs_auth",
59-
admin: "fs_admin",
60-
email: "fs_email",
58+
auth: "fs_auth",
59+
admin: "fs_admin",
60+
pinVerified: "fs_pin_verified",
61+
email: "fs_email",
62+
mustChangePw: "fs_must_change_pw",
6163
} as const;
6264

6365
// ─── App Root ─────────────────────────────────────────────────
@@ -68,12 +70,18 @@ function App({ installMode }: { installMode: InstallMode }) {
6870
const [isAdmin, setIsAdmin] = useState(
6971
() => sessionStorage.getItem(SS.admin) === "true",
7072
);
73+
const [isPinVerified, setIsPinVerified] = useState(
74+
() => sessionStorage.getItem(SS.pinVerified) === "true",
75+
);
7176
const [currentUser, setCurrentUser] = useState<{ email: string } | null>(
7277
() => {
7378
const email = sessionStorage.getItem(SS.email);
7479
return email ? { email } : null;
7580
},
7681
);
82+
const [mustChangePassword, setMustChangePassword] = useState(
83+
() => sessionStorage.getItem(SS.mustChangePw) === "true",
84+
);
7785
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
7886
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
7987
const [notice, setNotice] = useState(
@@ -90,9 +98,11 @@ function App({ installMode }: { installMode: InstallMode }) {
9098
}, []);
9199

92100
const effectiveRoute = useMemo<RouteKey>(() => {
93-
if (canAccessRoute(route, isAuthenticated)) return route;
94-
return "login";
95-
}, [isAuthenticated, route]);
101+
if (!canAccessRoute(route, isAuthenticated)) return "login";
102+
// 비밀번호 변경 강제: change-password 이외의 모든 경로 차단
103+
if (mustChangePassword && route !== "change-password") return "change-password";
104+
return route;
105+
}, [isAuthenticated, mustChangePassword, route]);
96106

97107
useEffect(() => {
98108
if (window.location.hash !== `#${effectiveRoute}`) {
@@ -108,14 +118,25 @@ function App({ installMode }: { installMode: InstallMode }) {
108118
// Auth handlers
109119
const onLogin = (event: FormEvent<HTMLFormElement>) => {
110120
event.preventDefault();
111-
const email = (new FormData(event.currentTarget).get("email") as string | null)
112-
?? "user@fieldstack.dev";
121+
const formData = new FormData(event.currentTarget);
122+
const email = (formData.get("email") as string | null) ?? "user@fieldstack.dev";
123+
// mock: 비밀번호가 "temp1234"이면 임시 비번 첫 로그인으로 처리
124+
const password = formData.get("password") as string | null;
125+
const isTempLogin = password === "temp1234";
126+
113127
setIsAuthenticated(true);
114128
setCurrentUser({ email });
115129
sessionStorage.setItem(SS.auth, "true");
116130
sessionStorage.setItem(SS.email, email);
117-
setNotice("Login successful (mock).");
118-
navigate("home");
131+
132+
if (isTempLogin) {
133+
setMustChangePassword(true);
134+
sessionStorage.setItem(SS.mustChangePw, "true");
135+
navigate("change-password");
136+
} else {
137+
setNotice("Login successful (mock).");
138+
navigate("home");
139+
}
119140
};
120141

121142
const onQuickLogin = () => {
@@ -128,28 +149,31 @@ function App({ installMode }: { installMode: InstallMode }) {
128149
navigate("home");
129150
};
130151

131-
const onAdminAccess = () => {
132-
if (isAdmin) {
133-
navigate("admin");
134-
} else {
135-
setIsPinModalOpen(true);
136-
}
152+
const onPasswordChanged = () => {
153+
setMustChangePassword(false);
154+
sessionStorage.removeItem(SS.mustChangePw);
155+
setNotice("비밀번호가 변경되었습니다.");
156+
navigate("home");
137157
};
138158

139159
const onPinVerified = () => {
140160
setIsAdmin(true);
161+
setIsPinVerified(true);
141162
setIsPinModalOpen(false);
142163
sessionStorage.setItem(SS.admin, "true");
164+
sessionStorage.setItem(SS.pinVerified, "true");
143165
setNotice("관리자 인증 완료 (mock). 30분간 유효합니다.");
144166
navigate("admin");
145167
};
146168

147169
const onLogout = () => {
148170
setIsAuthenticated(false);
149171
setIsAdmin(false);
172+
setIsPinVerified(false);
150173
setCurrentUser(null);
151174
sessionStorage.removeItem(SS.auth);
152175
sessionStorage.removeItem(SS.admin);
176+
sessionStorage.removeItem(SS.pinVerified);
153177
sessionStorage.removeItem(SS.email);
154178
setNotice("Logged out.");
155179
navigate("login");
@@ -170,6 +194,16 @@ function App({ installMode }: { installMode: InstallMode }) {
170194
);
171195
}
172196

197+
// 비밀번호 강제 변경 (shell 없이 전체 화면)
198+
if (effectiveRoute === "change-password") {
199+
return (
200+
<ChangePasswordView
201+
isFirstLogin={mustChangePassword}
202+
onChanged={onPasswordChanged}
203+
/>
204+
);
205+
}
206+
173207
return (
174208
<>
175209
{isPinModalOpen && (
@@ -185,13 +219,13 @@ function App({ installMode }: { installMode: InstallMode }) {
185219
currentUser={currentUser}
186220
notice={notice}
187221
onNavigate={navigate}
188-
onAdminAccess={onAdminAccess}
189222
onLogout={onLogout}
190223
onOpenSettings={() => setIsSettingsOpen(true)}
191224
>
192225
{effectiveRoute === "home" && <HomeView onOpenSettings={() => setIsSettingsOpen(true)} />}
226+
{effectiveRoute === "marketplace" && <MarketplaceView />}
193227
{effectiveRoute === "admin" && (
194-
<AdminView isAdmin={isAdmin} onRequestPin={() => setIsPinModalOpen(true)} />
228+
<AdminView isPinVerified={isPinVerified} onRequestPin={() => setIsPinModalOpen(true)} />
195229
)}
196230
{isSettingsOpen && (
197231
<SettingsView
@@ -200,6 +234,11 @@ function App({ installMode }: { installMode: InstallMode }) {
200234
onToggleAdmin={() => {
201235
setIsAdmin((prev) => {
202236
const next = !prev;
237+
if (!next) {
238+
// 관리자 역할 해제 시 PIN 인증도 초기화
239+
setIsPinVerified(false);
240+
sessionStorage.removeItem(SS.pinVerified);
241+
}
203242
setNotice(next ? "Admin authority enabled (mock)." : "Admin authority disabled.");
204243
return next;
205244
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
.cpw-shell {
2+
min-height: 100vh;
3+
display: grid;
4+
place-items: center;
5+
padding: 24px 16px;
6+
background: var(--bg);
7+
}
8+
9+
.cpw-panel {
10+
width: 100%;
11+
max-width: 420px;
12+
}
13+
14+
.cpw-header {
15+
text-align: center;
16+
display: grid;
17+
gap: 6px;
18+
margin-bottom: 20px;
19+
}
20+
21+
.cpw-icon {
22+
font-size: 32px;
23+
line-height: 1;
24+
}
25+
26+
.cpw-title {
27+
margin: 0;
28+
font-size: 20px;
29+
font-weight: 800;
30+
color: var(--text);
31+
}
32+
33+
.cpw-desc {
34+
margin: 0;
35+
font-size: 13px;
36+
color: var(--text-muted);
37+
line-height: 1.6;
38+
}
39+
40+
.cpw-form {
41+
gap: 14px;
42+
}
43+
44+
.cpw-input-error {
45+
border-color: var(--err);
46+
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.2);
47+
}
48+
49+
.cpw-input-error:focus {
50+
border-color: var(--err);
51+
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.2);
52+
}
53+
54+
.cpw-field-error {
55+
margin: 2px 0 0;
56+
font-size: 12px;
57+
color: var(--err);
58+
}
59+
60+
/* ── Policy Checklist ─────────────────────────────────────── */
61+
.cpw-policy {
62+
list-style: none;
63+
margin: -6px 0 0;
64+
padding: 10px 12px;
65+
border: 1px solid var(--border-subtle);
66+
border-radius: 9px;
67+
background: var(--bg-surface);
68+
display: grid;
69+
gap: 6px;
70+
}
71+
72+
.cpw-policy-item {
73+
display: flex;
74+
align-items: center;
75+
gap: 8px;
76+
font-size: 12px;
77+
color: var(--text-faint);
78+
transition: color 150ms ease;
79+
}
80+
81+
.cpw-policy-dot {
82+
width: 6px;
83+
height: 6px;
84+
border-radius: 999px;
85+
flex-shrink: 0;
86+
background: var(--text-faint);
87+
transition: background 150ms ease;
88+
}
89+
90+
/* 조건 통과 */
91+
.cpw-policy-ok {
92+
color: var(--ok);
93+
}
94+
.cpw-policy-ok .cpw-policy-dot {
95+
background: var(--ok);
96+
}
97+
98+
/* 조건 미통과 (입력 시작 후) */
99+
.cpw-policy-fail {
100+
color: var(--err);
101+
}
102+
.cpw-policy-fail .cpw-policy-dot {
103+
background: var(--err);
104+
}

apps/web/src/views/AdminView.tsx

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

33
interface AdminViewProps {
4-
isAdmin: boolean;
4+
isPinVerified: boolean;
55
onRequestPin: () => void;
66
}
77

@@ -25,8 +25,8 @@ const MOCK_AUDIT_LOG = [
2525
{ id: 3, text: "설정 저장 이벤트", time: "12분 전", dot: "info" as const },
2626
];
2727

28-
export function AdminView({ isAdmin, onRequestPin }: AdminViewProps) {
29-
if (!isAdmin) {
28+
export function AdminView({ isPinVerified, onRequestPin }: AdminViewProps) {
29+
if (!isPinVerified) {
3030
return (
3131
<section className="panel admin-root" aria-labelledby="admin-gate-title">
3232
<div className="admin-lock-wrap">

0 commit comments

Comments
 (0)