Skip to content

Commit dfc03e2

Browse files
SOIVclaude
andcommitted
feat(web): Phase 1.5.3 로그인 UX 개선 및 core 브라우저 엔트리 분리
- LoginView: 로그인 실패/잠금/세션 만료 UX 구현 - 실패 메시지를 Sign in 버튼 아래 인라인 텍스트로 표시 - 세션 만료 알림도 버튼 아래로 이동 - 5회 실패 시 30분 잠금 (Alert 유지), 3회부터 임박 경고 - 잠금 시 폼 전체 disabled 처리 - ForgotPasswordView: 경로 선택 → 이메일 / 관리자 토큰 두 갈래로 전면 재구성 - 관리자 토큰 경로: 이메일 + 토큰 쌍 검증 구조 추가 - 이메일 경로: 전송 완료 화면 유지 - 복구 완료 시 onRecovered 콜백으로 로그인 화면 복귀 - main.tsx: Mock 계정 시스템 도입 - 이메일+비밀번호 세트로 계정 정의 (admin@/user@) - 로그인 시 계정에 따라 관리자 역할 자동 적용 - packages/core: 브라우저 전용 엔트리포인트 분리 - @fieldstack/core/browser — types/utils만 export (Node.js 의존성 없음) - Vite 번들링 시 jsonwebtoken 등 포함으로 인한 오류 해결 - ChangePasswordView import를 /browser 경로로 변경 - 문서: CLAUDE.md, AGENTS.md, roadmap 01 업데이트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b0f1f68 commit dfc03e2

11 files changed

Lines changed: 536 additions & 80 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This file provides high-signal, repo-specific guidance for AI agents working in the Fieldstack repository. Read this before taking action to avoid architectural mistakes.
44

55
## Project Status & Environment
6-
- **Current Phase**: Phase 1.5 in progress. Core UI Shell is running but mostly mocked. Backend API endpoints are largely unimplemented.
6+
- **Current Phase**: Phase 1.5 in progress (2026-04-14). Phase 1.9 complete (API server, DB layer, auth backend, shared link core). Phase 1.5.3 login UX complete.
77
- **Workspace**: `pnpm` workspace with `node-linker=hoisted`.
88
- **References**: Check `CLAUDE.md` and `docs/v2_FINANCIAL-LEDGER/` for phase-specific checklists and design tokens.
99

@@ -28,14 +28,19 @@ This file provides high-signal, repo-specific guidance for AI agents working in
2828
### Frontend (`apps/web`)
2929
- **Hash Routing**: Uses Hash-based routing (`#login`, `#home`, `#admin`) managed by a custom state machine in `apps/web/src/main.tsx` (`effectiveRoute`).
3030
- **Auth State**: Authentication and session state are persisted in `sessionStorage` using the `fs_` prefix (e.g., `fs_auth`, `fs_admin`).
31-
- **Dev Mocks**: When testing auth UI locally, use `otp1234` for the OTP flow and `temp1234` for the force-password-change flow.
31+
- **Dev Mock Accounts**: `admin@fieldstack.dev` / `Admin1234!` (admin role), `user@fieldstack.dev` / `User1234!` (regular user). Special passwords work for any email: `otp1234` → OTP flow, `temp1234` → force password change flow.
32+
- **`@fieldstack/core` import rule**: Web app must always import from `@fieldstack/core/browser`, never from `@fieldstack/core` directly. The default entry pulls in Node.js-only packages (jsonwebtoken, bcryptjs, otplib) which break Vite bundling. The `/browser` entry exports only browser-safe modules (types, utils).
3233

3334
### Backend (`apps/api`)
3435
- **Dynamic Module Loading**: Backend modules are dynamically scanned and loaded via `apps/api/src/loader/index.ts`.
3536
- **Module Requirements**: A backend module will only be loaded if it has a valid `module.json` manifest with `"enabled": true`.
37+
- **Auth Routes**: `POST /auth/login`, `/auth/totp/verify`, `/auth/pin/verify`, `/auth/password/change`, `/auth/password/recovery/issue`, `/auth/password/recovery/confirm`, `/auth/refresh`, `/auth/logout`.
38+
- **Shared Link Routes**: `POST|GET /core/share`, `DELETE /core/share/:token`, `GET|PATCH /core/share/settings`, `GET /s/:token` (public).
39+
- **CJS/ESM interop**: `apps/api` is CJS (`module: Node16`). Import types from `@fieldstack/core` using `import type ... with { "resolution-mode": "import" }`. Value imports use dynamic `import('@fieldstack/core')`.
3640

3741
### Shared & UI Packages
3842
- **`packages/controls`**: All P0/P0.5 components are fully implemented (`ready: true`). Styled with `fs-` prefixed CSS classes and design tokens. Use `@fieldstack/controls` in `apps/web` — do not write inline component styles in views.
43+
- **`packages/core`**: Has two entry points — `@fieldstack/core` (full, server-only) and `@fieldstack/core/browser` (browser-safe subset). Always use the correct entry for the target environment.
3944
- **Inter-module Communication**: Direct module-to-module imports are strictly forbidden. All cross-module communication must use the Event Bus.
4045

4146
## Strict Code Rules

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
66

77
## Project Status
88

9-
Phase 1.5 진행 중 (2026-04-13 기준). Core UI Shell은 mock으로 동작하며 실제 백엔드 API 엔드포인트는 미구현. `packages/controls` P0/P0.5 컴포넌트 구현 완료 (`ready: true`). Storybook 세팅 완료 (port 6007).
9+
Phase 1.5 진행 중 (2026-04-14 기준). Phase 1.9 (API 서버·DB·인증 백엔드·공유 링크) 완료. Phase 1.5.3 로그인 UX 완료 (실패/잠금/세션 만료, 비밀번호 복구 UI, mock 계정 시스템). `packages/controls` P0/P0.5 컴포넌트 구현 완료 (`ready: true`). Storybook 세팅 완료 (port 6007). `@fieldstack/core/browser` 브라우저 전용 엔트리 분리 완료 — 웹 앱은 반드시 이 경로로 import.
1010

1111
---
1212

@@ -48,7 +48,7 @@ apps/
4848
api/ — Node.js + tsx dev server (@fieldstack/api)
4949
packages/
5050
core/ — 공유 타입·계약·유틸 (@fieldstack/core)
51-
controls/— UI 컴포넌트 계약 (@fieldstack/controls, 구현 미착수)
51+
controls/— UI 컴포넌트 (@fieldstack/controls, P0/P0.5 구현 완료)
5252
modules/ — Phase 2 모듈 (Ledger, Subscription) 위치 예정
5353
```
5454

@@ -60,7 +60,9 @@ pnpm workspace, `node-linker=hoisted`.
6060
- 모든 라우트 상태 머신은 `apps/web/src/main.tsx``App` 컴포넌트에서 관리 (`effectiveRoute` useMemo).
6161
- 인증 상태는 `sessionStorage``fs_` 접두사 키로 저장 (`fs_auth`, `fs_admin`, `fs_pin_verified` 등).
6262
- **AppShell** (`src/components/AppShell.tsx`): 220px 고정 사이드바 레이아웃. login/otp/forgot-password/change-password는 shell 없이 전체 화면.
63-
- **개발 mock 비밀번호**: `otp1234` → OTP 플로우, `temp1234` → 강제 비밀번호 변경 플로우.
63+
- **개발 mock 계정**: `admin@fieldstack.dev` / `Admin1234!` (관리자), `user@fieldstack.dev` / `User1234!` (일반)
64+
- **개발 mock 특수 비밀번호**: `otp1234` → OTP 플로우, `temp1234` → 강제 비밀번호 변경 플로우 (어떤 이메일이든 동작)
65+
- **`@fieldstack/core` import 규칙**: 웹 앱(`apps/web`)에서는 반드시 `@fieldstack/core/browser`로 import. `@fieldstack/core` 직접 import는 Node.js 전용 패키지(jsonwebtoken 등)를 끌어들여 Vite 번들링 오류 발생.
6466

6567
### apps/api — 모듈 로더
6668

apps/web/src/main.tsx

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ function loadTheme(): ThemeSetting {
7070
// 초기 테마 적용 (React 렌더 전에 FOUC 방지)
7171
applyTheme(loadTheme());
7272

73+
// ─── Mock Accounts ────────────────────────────────────────────
74+
// TODO(Phase 1.9 연결): 실제 API 호출로 교체
75+
const MOCK_ACCOUNTS: { email: string; password: string; isAdmin: boolean }[] = [
76+
{ email: "admin@fieldstack.dev", password: "Admin1234!", isAdmin: true },
77+
{ email: "user@fieldstack.dev", password: "User1234!", isAdmin: false },
78+
];
79+
7380
// ─── Session Storage Keys ─────────────────────────────────────
7481
const SS = {
7582
auth: "fs_auth",
@@ -99,6 +106,17 @@ function App({ installMode }: { installMode: InstallMode }) {
99106
);
100107
// OTP 인증 대기 중인 이메일 (로그인 완료 전 임시 상태 — sessionStorage 미저장)
101108
const [pendingOtpEmail, setPendingOtpEmail] = useState<string | null>(null);
109+
110+
// 로그인 실패 상태
111+
const [loginError, setLoginError] = useState<string | null>(null);
112+
const [loginAttempts, setLoginAttempts] = useState(0);
113+
const [loginLockedUntil, setLoginLockedUntil] = useState<number | null>(null);
114+
const [sessionExpired, setSessionExpired] = useState(false);
115+
116+
const MAX_LOGIN_ATTEMPTS = 5;
117+
const LOCKOUT_MS = 30 * 60 * 1000; // 30분
118+
119+
const isLocked = loginLockedUntil !== null && Date.now() < loginLockedUntil;
102120
const [currentUser, setCurrentUser] = useState<{ email: string } | null>(
103121
() => {
104122
const email = sessionStorage.getItem(SS.email);
@@ -149,32 +167,66 @@ function App({ installMode }: { installMode: InstallMode }) {
149167
// Auth handlers
150168
const onLogin = (event: FormEvent<HTMLFormElement>) => {
151169
event.preventDefault();
170+
if (isLocked) return;
171+
152172
const formData = new FormData(event.currentTarget);
153173
const email = (formData.get("email") as string | null) ?? "user@fieldstack.dev";
154174
const password = formData.get("password") as string | null;
155175

156176
// mock: "otp1234" → LoginView 내 OTP step으로 전환
157177
if (password === "otp1234") {
178+
setLoginError(null);
179+
setLoginAttempts(0);
158180
setPendingOtpEmail(email);
159181
return;
160182
}
161183

162184
// mock: "temp1234" → 임시 비번 첫 로그인 강제 변경
163-
const isTempLogin = password === "temp1234";
185+
if (password === "temp1234") {
186+
setLoginError(null);
187+
setLoginAttempts(0);
188+
setSessionExpired(false);
189+
setIsAuthenticated(true);
190+
setCurrentUser({ email });
191+
sessionStorage.setItem(SS.auth, "true");
192+
sessionStorage.setItem(SS.email, email);
193+
setMustChangePassword(true);
194+
sessionStorage.setItem(SS.mustChangePw, "true");
195+
navigate("change-password");
196+
return;
197+
}
164198

199+
const matchedAccount = MOCK_ACCOUNTS.find(
200+
(a) => a.email === email && a.password === password,
201+
);
202+
203+
if (!matchedAccount) {
204+
const next = loginAttempts + 1;
205+
setLoginAttempts(next);
206+
if (next >= MAX_LOGIN_ATTEMPTS) {
207+
setLoginLockedUntil(Date.now() + LOCKOUT_MS);
208+
setLoginError(null);
209+
} else {
210+
setLoginError("이메일 또는 비밀번호가 올바르지 않습니다.");
211+
}
212+
return;
213+
}
214+
215+
// 로그인 성공
216+
setLoginError(null);
217+
setLoginAttempts(0);
218+
setLoginLockedUntil(null);
219+
setSessionExpired(false);
165220
setIsAuthenticated(true);
221+
setIsAdmin(matchedAccount.isAdmin);
222+
if (matchedAccount.isAdmin) {
223+
sessionStorage.setItem(SS.admin, "true");
224+
}
166225
setCurrentUser({ email });
167226
sessionStorage.setItem(SS.auth, "true");
168227
sessionStorage.setItem(SS.email, email);
169-
170-
if (isTempLogin) {
171-
setMustChangePassword(true);
172-
sessionStorage.setItem(SS.mustChangePw, "true");
173-
navigate("change-password");
174-
} else {
175-
setNotice("Login successful (mock).");
176-
navigate("home");
177-
}
228+
setNotice("Login successful (mock).");
229+
navigate("home");
178230
};
179231

180232
const onQuickLogin = () => {
@@ -221,7 +273,7 @@ function App({ installMode }: { installMode: InstallMode }) {
221273
navigate("admin");
222274
};
223275

224-
const onLogout = () => {
276+
const onLogout = (expired = false) => {
225277
setIsAuthenticated(false);
226278
setIsAdmin(false);
227279
setIsPinVerified(false);
@@ -230,7 +282,11 @@ function App({ installMode }: { installMode: InstallMode }) {
230282
sessionStorage.removeItem(SS.admin);
231283
sessionStorage.removeItem(SS.pinVerified);
232284
sessionStorage.removeItem(SS.email);
233-
setNotice("Logged out.");
285+
setLoginError(null);
286+
setLoginAttempts(0);
287+
setLoginLockedUntil(null);
288+
setSessionExpired(expired);
289+
setNotice(expired ? "" : "Logged out.");
234290
navigate("login");
235291
};
236292

@@ -247,6 +303,10 @@ function App({ installMode }: { installMode: InstallMode }) {
247303
pendingEmail={pendingOtpEmail}
248304
onOtpVerified={onOtpVerified}
249305
onOtpCancel={onOtpCancel}
306+
loginError={loginError}
307+
loginAttempts={loginAttempts}
308+
isLocked={isLocked}
309+
sessionExpired={sessionExpired}
250310
/>
251311
</section>
252312
</main>
@@ -255,7 +315,15 @@ function App({ installMode }: { installMode: InstallMode }) {
255315

256316
// 비밀번호 찾기 (no shell)
257317
if (effectiveRoute === "forgot-password") {
258-
return <ForgotPasswordView onBack={() => navigate("login")} />;
318+
return (
319+
<ForgotPasswordView
320+
onBack={() => navigate("login")}
321+
onRecovered={() => {
322+
setNotice("비밀번호가 복구되었습니다. 새 비밀번호로 로그인하세요.");
323+
navigate("login");
324+
}}
325+
/>
326+
);
259327
}
260328

261329
// 비밀번호 강제 변경 (shell 없이 전체 화면)

apps/web/src/styles/forgot-password.css

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,61 @@
8787
line-height: 1.5;
8888
}
8989

90+
/* ── Choice cards ─────────────────────────────────────────────── */
91+
.fpw-choice-list {
92+
display: grid;
93+
gap: 10px;
94+
}
95+
96+
.fpw-choice-card {
97+
display: flex;
98+
align-items: center;
99+
gap: 12px;
100+
padding: 14px 16px;
101+
border: 1px solid var(--border-subtle);
102+
border-radius: 10px;
103+
background: var(--bg-surface);
104+
cursor: pointer;
105+
text-align: left;
106+
font-family: inherit;
107+
transition: border-color 0.15s, background 0.15s;
108+
}
109+
110+
.fpw-choice-card:hover {
111+
border-color: var(--primary);
112+
background: var(--bg-surface-raised, var(--bg-surface));
113+
}
114+
115+
.fpw-choice-icon {
116+
font-size: 22px;
117+
flex-shrink: 0;
118+
line-height: 1;
119+
}
120+
121+
.fpw-choice-body {
122+
display: grid;
123+
gap: 2px;
124+
flex: 1;
125+
}
126+
127+
.fpw-choice-label {
128+
font-size: 13px;
129+
font-weight: 700;
130+
color: var(--text);
131+
}
132+
133+
.fpw-choice-desc {
134+
font-size: 12px;
135+
color: var(--text-muted);
136+
line-height: 1.5;
137+
}
138+
139+
.fpw-choice-arrow {
140+
font-size: 18px;
141+
color: var(--text-faint);
142+
flex-shrink: 0;
143+
}
144+
90145
.fpw-back-btn {
91146
background: none;
92147
border: none;

apps/web/src/styles/login.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,20 @@
235235
gap: 10px;
236236
}
237237

238+
.login-inline-error {
239+
margin: 0;
240+
font-size: 12px;
241+
color: var(--err);
242+
text-align: center;
243+
}
244+
245+
.login-inline-warn {
246+
margin: 0;
247+
font-size: 12px;
248+
color: var(--warn);
249+
text-align: center;
250+
}
251+
238252
/* ─── Responsive ─────────────────────────────────────────────── */
239253
@media (max-width: 900px) {
240254
.auth-layout {

apps/web/src/views/ChangePasswordView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, type FormEvent } from "react";
22

3-
import { PASSWORD_POLICY, validatePassword } from "@fieldstack/core";
3+
import { PASSWORD_POLICY, validatePassword } from "@fieldstack/core/browser";
44

55
import { Button, FormField, Input } from "@fieldstack/controls";
66

0 commit comments

Comments
 (0)