Skip to content

Commit 67aeb73

Browse files
SOIVclaude
andcommitted
feat(web): 2FA OTP 화면 및 비밀번호 찾기 화면 구현 (mock)
- OtpView: 6자리 digit 입력, 5회 잠금, 30초 재전송 쿨다운 (mock 코드: 123456) - ForgotPasswordView: 이메일 입력 → 전송 완료 화면 + 관리자 복구 안내 - LoginView: "Forgot password?" 링크를 onForgotPassword 콜백으로 연결 - main.tsx: otp/forgot-password 라우트 및 pendingOtpEmail 상태 추가 - password "otp1234" → OTP 화면, "temp1234" → 강제 비번 변경 - RouteKey에 "otp", "forgot-password" 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8e83705 commit 67aeb73

7 files changed

Lines changed: 531 additions & 19 deletions

File tree

apps/web/src/components/AppShell.tsx

Lines changed: 1 addition & 1 deletion
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" | "marketplace" | "admin" | "change-password";
4+
export type RouteKey = "login" | "otp" | "forgot-password" | "home" | "marketplace" | "admin" | "change-password";
55

66
interface AppShellProps {
77
installMode: "normal" | "bypass";

apps/web/src/main.tsx

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { SettingsView } from "./views/SettingsView";
1212
import { AdminView } from "./views/AdminView";
1313
import { MarketplaceView } from "./views/MarketplaceView";
1414
import { ChangePasswordView } from "./views/ChangePasswordView";
15+
import { OtpView } from "./views/OtpView";
16+
import { ForgotPasswordView } from "./views/ForgotPasswordView";
1517

1618
// ─── Types ────────────────────────────────────────────────────
1719
type InstallMode = "normal" | "bypass";
@@ -40,17 +42,8 @@ function resolveInstallMode(runtimeEnv: WebRuntimeEnv): InstallMode {
4042
function getRouteFromHash(rawHash: string): RouteKey {
4143
const hash = rawHash.replace("#", "");
4244
if (hash === "settings") return "home";
43-
if (hash === "home" || hash === "marketplace" || hash === "admin" || hash === "login" || hash === "change-password") {
44-
return hash;
45-
}
46-
return "login";
47-
}
48-
49-
function canAccessRoute(route: RouteKey, isAuthenticated: boolean): boolean {
50-
if (route === "login") return true;
51-
if (!isAuthenticated) return false;
52-
// admin 라우트는 인증된 유저라면 진입 허용 — AdminView 내부에서 isAdmin으로 콘텐츠 게이팅
53-
return true;
45+
const valid: RouteKey[] = ["login", "otp", "forgot-password", "home", "marketplace", "admin", "change-password"];
46+
return (valid as string[]).includes(hash) ? (hash as RouteKey) : "login";
5447
}
5548

5649
// ─── Session Storage Keys ─────────────────────────────────────
@@ -73,6 +66,8 @@ function App({ installMode }: { installMode: InstallMode }) {
7366
const [isPinVerified, setIsPinVerified] = useState(
7467
() => sessionStorage.getItem(SS.pinVerified) === "true",
7568
);
69+
// OTP 인증 대기 중인 이메일 (로그인 완료 전 임시 상태 — sessionStorage 미저장)
70+
const [pendingOtpEmail, setPendingOtpEmail] = useState<string | null>(null);
7671
const [currentUser, setCurrentUser] = useState<{ email: string } | null>(
7772
() => {
7873
const email = sessionStorage.getItem(SS.email);
@@ -98,11 +93,16 @@ function App({ installMode }: { installMode: InstallMode }) {
9893
}, []);
9994

10095
const effectiveRoute = useMemo<RouteKey>(() => {
101-
if (!canAccessRoute(route, isAuthenticated)) return "login";
102-
// 비밀번호 변경 강제: change-password 이외의 모든 경로 차단
96+
// OTP 대기 중: otp 화면만 허용
97+
if (pendingOtpEmail) return "otp";
98+
// 미인증: login / forgot-password만 허용
99+
if (!isAuthenticated) {
100+
return route === "forgot-password" ? "forgot-password" : "login";
101+
}
102+
// 비밀번호 변경 강제
103103
if (mustChangePassword && route !== "change-password") return "change-password";
104104
return route;
105-
}, [isAuthenticated, mustChangePassword, route]);
105+
}, [isAuthenticated, mustChangePassword, pendingOtpEmail, route]);
106106

107107
useEffect(() => {
108108
if (window.location.hash !== `#${effectiveRoute}`) {
@@ -120,8 +120,16 @@ function App({ installMode }: { installMode: InstallMode }) {
120120
event.preventDefault();
121121
const formData = new FormData(event.currentTarget);
122122
const email = (formData.get("email") as string | null) ?? "user@fieldstack.dev";
123-
// mock: 비밀번호가 "temp1234"이면 임시 비번 첫 로그인으로 처리
124123
const password = formData.get("password") as string | null;
124+
125+
// mock: "otp1234" → 2FA OTP 화면으로 이동
126+
if (password === "otp1234") {
127+
setPendingOtpEmail(email);
128+
navigate("otp");
129+
return;
130+
}
131+
132+
// mock: "temp1234" → 임시 비번 첫 로그인 강제 변경
125133
const isTempLogin = password === "temp1234";
126134

127135
setIsAuthenticated(true);
@@ -156,6 +164,23 @@ function App({ installMode }: { installMode: InstallMode }) {
156164
navigate("home");
157165
};
158166

167+
const onOtpVerified = () => {
168+
if (!pendingOtpEmail) return;
169+
const email = pendingOtpEmail;
170+
setPendingOtpEmail(null);
171+
setIsAuthenticated(true);
172+
setCurrentUser({ email });
173+
sessionStorage.setItem(SS.auth, "true");
174+
sessionStorage.setItem(SS.email, email);
175+
setNotice("2단계 인증 완료.");
176+
navigate("home");
177+
};
178+
179+
const onOtpCancel = () => {
180+
setPendingOtpEmail(null);
181+
navigate("login");
182+
};
183+
159184
const onPinVerified = () => {
160185
setIsAdmin(true);
161186
setIsPinVerified(true);
@@ -187,13 +212,30 @@ function App({ installMode }: { installMode: InstallMode }) {
187212
<LoginView
188213
onLogin={onLogin}
189214
onQuickLogin={onQuickLogin}
215+
onForgotPassword={() => navigate("forgot-password")}
190216
showDevBypass={installMode === "bypass"}
191217
/>
192218
</section>
193219
</main>
194220
);
195221
}
196222

223+
// 비밀번호 찾기 (no shell)
224+
if (effectiveRoute === "forgot-password") {
225+
return <ForgotPasswordView onBack={() => navigate("login")} />;
226+
}
227+
228+
// 2FA OTP 인증 (no shell)
229+
if (effectiveRoute === "otp") {
230+
return (
231+
<OtpView
232+
email={pendingOtpEmail ?? ""}
233+
onVerified={onOtpVerified}
234+
onCancel={onOtpCancel}
235+
/>
236+
);
237+
}
238+
197239
// 비밀번호 강제 변경 (shell 없이 전체 화면)
198240
if (effectiveRoute === "change-password") {
199241
return (
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
.fpw-shell {
2+
min-height: 100vh;
3+
display: grid;
4+
place-items: center;
5+
padding: 24px 16px;
6+
background: var(--bg);
7+
}
8+
9+
.fpw-panel {
10+
width: 100%;
11+
max-width: 420px;
12+
display: grid;
13+
gap: 16px;
14+
}
15+
16+
.fpw-header {
17+
text-align: center;
18+
display: grid;
19+
gap: 6px;
20+
}
21+
22+
.fpw-icon {
23+
font-size: 32px;
24+
line-height: 1;
25+
}
26+
27+
.fpw-title {
28+
margin: 0;
29+
font-size: 20px;
30+
font-weight: 800;
31+
color: var(--text);
32+
}
33+
34+
.fpw-desc {
35+
margin: 0;
36+
font-size: 13px;
37+
color: var(--text-muted);
38+
line-height: 1.6;
39+
}
40+
41+
.fpw-form {
42+
gap: 14px;
43+
}
44+
45+
.fpw-input-error {
46+
border-color: var(--err);
47+
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.2);
48+
}
49+
50+
.fpw-input-error:focus {
51+
border-color: var(--err);
52+
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.2);
53+
}
54+
55+
.fpw-field-error {
56+
margin: 2px 0 0;
57+
font-size: 12px;
58+
color: var(--err);
59+
}
60+
61+
.fpw-divider {
62+
height: 1px;
63+
background: var(--border-subtle);
64+
}
65+
66+
/* ── Admin recovery notice ────────────────────────────────────── */
67+
.fpw-notice {
68+
padding: 12px 14px;
69+
border: 1px solid var(--border-subtle);
70+
border-radius: 9px;
71+
background: var(--bg-surface);
72+
display: grid;
73+
gap: 4px;
74+
}
75+
76+
.fpw-notice-label {
77+
margin: 0;
78+
font-size: 12px;
79+
font-weight: 700;
80+
color: var(--text-muted);
81+
}
82+
83+
.fpw-notice-text {
84+
margin: 0;
85+
font-size: 12px;
86+
color: var(--text-faint);
87+
line-height: 1.5;
88+
}
89+
90+
.fpw-back-btn {
91+
background: none;
92+
border: none;
93+
font-size: 12px;
94+
color: var(--text-muted);
95+
cursor: pointer;
96+
font-family: inherit;
97+
padding: 4px 8px;
98+
border-radius: 6px;
99+
justify-self: center;
100+
}
101+
102+
.fpw-back-btn:hover {
103+
color: var(--text);
104+
text-decoration: underline;
105+
}

apps/web/src/styles/otp.css

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
.otp-shell {
2+
min-height: 100vh;
3+
display: grid;
4+
place-items: center;
5+
padding: 24px 16px;
6+
background: var(--bg);
7+
}
8+
9+
.otp-panel {
10+
width: 100%;
11+
max-width: 400px;
12+
}
13+
14+
.otp-header {
15+
text-align: center;
16+
display: grid;
17+
gap: 6px;
18+
margin-bottom: 24px;
19+
}
20+
21+
.otp-icon {
22+
font-size: 32px;
23+
line-height: 1;
24+
}
25+
26+
.otp-title {
27+
margin: 0;
28+
font-size: 20px;
29+
font-weight: 800;
30+
color: var(--text);
31+
}
32+
33+
.otp-desc {
34+
margin: 0;
35+
font-size: 13px;
36+
color: var(--text-muted);
37+
line-height: 1.6;
38+
}
39+
40+
.otp-form {
41+
gap: 16px;
42+
}
43+
44+
/* ── Digit boxes ──────────────────────────────────────────────── */
45+
.otp-digits {
46+
display: flex;
47+
gap: 8px;
48+
justify-content: center;
49+
}
50+
51+
.otp-digit {
52+
width: 44px;
53+
height: 52px;
54+
border-radius: 9px;
55+
border: 1px solid var(--border);
56+
background: var(--bg-elevated);
57+
color: var(--text);
58+
font-size: 22px;
59+
font-weight: 700;
60+
text-align: center;
61+
font-family: inherit;
62+
outline: none;
63+
transition: border-color 110ms ease, box-shadow 110ms ease;
64+
}
65+
66+
.otp-digit:focus {
67+
border-color: var(--accent);
68+
box-shadow: 0 0 0 3px rgba(124, 124, 240, 0.2);
69+
}
70+
71+
.otp-digit:disabled {
72+
opacity: 0.45;
73+
cursor: not-allowed;
74+
}
75+
76+
.otp-digit-error {
77+
border-color: var(--err);
78+
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.2);
79+
}
80+
81+
.otp-error {
82+
margin: 0;
83+
font-size: 12px;
84+
color: var(--err);
85+
text-align: center;
86+
}
87+
88+
/* ── Footer buttons ───────────────────────────────────────────── */
89+
.otp-footer {
90+
display: grid;
91+
gap: 4px;
92+
justify-items: center;
93+
}
94+
95+
.otp-text-btn {
96+
background: none;
97+
border: none;
98+
font-size: 12px;
99+
color: var(--accent-hover);
100+
cursor: pointer;
101+
font-family: inherit;
102+
padding: 4px 8px;
103+
border-radius: 6px;
104+
transition: opacity 110ms ease;
105+
}
106+
107+
.otp-text-btn:disabled {
108+
color: var(--text-faint);
109+
cursor: default;
110+
}
111+
112+
.otp-text-btn:not(:disabled):hover {
113+
text-decoration: underline;
114+
}
115+
116+
.otp-text-btn-muted {
117+
color: var(--text-muted);
118+
}

0 commit comments

Comments
 (0)