Skip to content

Commit 4feb158

Browse files
SOIVclaude
andcommitted
fix: mock 제거 및 실제 API 연동 + 버그 수정
- 004_add_user_modules.sql: user_id TEXT → UUID (FK 타입 불일치 수정) - vite.config.ts: /admin 프록시 누락 추가 (PIN 인증 등 admin 엔드포인트 전달 안 되던 문제) - admin.ts: POST /admin/change-pin 엔드포인트 추가 (rotatePin 사용) POST /admin/verify-pin 엔드포인트 추가 - AdminPinModal: MOCK_ADMIN_PIN 비교 제거 → POST /admin/verify-pin 실제 API 호출 - AdminView: 부분/완전 초기화, PIN 변경 mock 비교 제거 → 실제 API 호출 - ForgotPasswordView: MOCK_ADMIN_TOKEN 제거 → POST /auth/password/recovery/confirm 연동 - ChangePasswordView: POST /auth/password/change 실제 API 호출, 에러 표시 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fbe1b24 commit 4feb158

7 files changed

Lines changed: 218 additions & 86 deletions

File tree

apps/api/src/db/migrations/core/004_add_user_modules.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
CREATE TABLE IF NOT EXISTS user_modules (
1010
id {{UUID_PRIMARY_KEY}},
11-
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
1212
module_name TEXT NOT NULL,
1313
enabled BOOLEAN NOT NULL DEFAULT {{BOOLEAN_TRUE}},
1414
installed_at TEXT NOT NULL DEFAULT ({{NOW}}),

apps/api/src/routes/admin.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ const ResetBody = z.object({
1111
pin: z.string().min(4),
1212
});
1313

14+
const ChangePinBody = z.object({
15+
currentPin: z.string().min(4),
16+
newPin: z.string().min(4),
17+
});
18+
1419
// ── 완전 초기화: 삭제할 테이블 목록 (FK 의존성 역순) ──────────
1520

1621
const ALL_TABLES = [
@@ -36,6 +41,52 @@ const DATA_TABLES = ['shared_link_logs', 'shared_links'];
3641
export function createAdminRouter(services: AppServices): Router {
3742
const router = Router();
3843

44+
/**
45+
* POST /admin/change-pin — 관리자 PIN 변경
46+
*
47+
* 현재 PIN 검증 후 새 PIN으로 교체한다.
48+
* rotatePin()이 내부에서 현재 PIN 검증 + setPin을 원자적으로 처리한다.
49+
*/
50+
router.post('/change-pin', requireAuth(services.jwtManager), async (req, res) => {
51+
const parsed = ChangePinBody.safeParse(req.body);
52+
if (!parsed.success) {
53+
res.status(400).json({ success: false, error: parsed.error.flatten() });
54+
return;
55+
}
56+
57+
try {
58+
await services.adminPin.rotatePin(parsed.data.currentPin, parsed.data.newPin);
59+
res.json({ success: true });
60+
} catch (err) {
61+
const msg = (err as Error).message;
62+
const status = msg.includes('incorrect') ? 403 : 500;
63+
res.status(status).json({ success: false, error: msg });
64+
}
65+
});
66+
67+
/**
68+
* POST /admin/verify-pin — 관리자 PIN 단독 검증
69+
*
70+
* 프론트엔드 AdminPinModal에서 PIN 인증 전용으로 호출한다.
71+
* 성공 시 { success: true }만 반환하며 세션/토큰을 별도로 발급하지 않는다.
72+
* 실제 인가는 각 관리자 액션 API에서 PIN을 재검증한다.
73+
*/
74+
router.post('/verify-pin', requireAuth(services.jwtManager), async (req, res) => {
75+
const parsed = ResetBody.safeParse(req.body);
76+
if (!parsed.success) {
77+
res.status(400).json({ success: false, error: parsed.error.flatten() });
78+
return;
79+
}
80+
81+
const pinOk = await services.adminPin.verifyPin(parsed.data.pin);
82+
if (!pinOk) {
83+
res.status(403).json({ success: false, error: 'PIN이 올바르지 않습니다.' });
84+
return;
85+
}
86+
87+
res.json({ success: true });
88+
});
89+
3990
/**
4091
* POST /admin/factory-reset — 완전 초기화
4192
*

apps/web/src/components/AdminPinModal.tsx

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { useEffect, useState, type FormEvent } from "react";
22

33
import { Button, Modal, PinInput } from "@fieldstack/controls";
44

5-
// 개발 mock PIN — 실제 구현 시 API 검증으로 교체
6-
const MOCK_ADMIN_PIN = "1234";
75
const MAX_ATTEMPTS = 5;
86
const LOCKOUT_SECONDS = 300; // 5분
97

@@ -18,6 +16,7 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
1816
const [attempts, setAttempts] = useState(0);
1917
const [lockedUntil, setLockedUntil] = useState<number | null>(null);
2018
const [remaining, setRemaining] = useState(0);
19+
const [isVerifying, setIsVerifying] = useState(false);
2120

2221
// 잠금 카운트다운
2322
useEffect(() => {
@@ -40,25 +39,43 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
4039

4140
const isLocked = lockedUntil !== null;
4241

43-
const handleSubmit = (e: FormEvent) => {
42+
const handleSubmit = async (e: FormEvent) => {
4443
e.preventDefault();
45-
if (isLocked || pin.length < 4) return;
44+
if (isLocked || pin.length < 4 || isVerifying) return;
4645

47-
if (pin === MOCK_ADMIN_PIN) {
48-
onVerified();
49-
return;
50-
}
46+
setIsVerifying(true);
47+
try {
48+
const token = sessionStorage.getItem("fs_token") ?? "";
49+
const res = await fetch("/admin/verify-pin", {
50+
method: "POST",
51+
headers: {
52+
"Content-Type": "application/json",
53+
Authorization: `Bearer ${token}`,
54+
},
55+
body: JSON.stringify({ pin }),
56+
});
57+
58+
if (res.ok) {
59+
onVerified();
60+
return;
61+
}
5162

52-
const next = attempts + 1;
53-
setAttempts(next);
54-
setPin("");
63+
const next = attempts + 1;
64+
setAttempts(next);
65+
setPin("");
5566

56-
if (next >= MAX_ATTEMPTS) {
57-
const until = Date.now() + LOCKOUT_SECONDS * 1000;
58-
setLockedUntil(until);
59-
setError(`PIN ${MAX_ATTEMPTS}회 오류 — 5분간 잠금`);
60-
} else {
61-
setError(`PIN이 올바르지 않습니다. (${next}/${MAX_ATTEMPTS})`);
67+
if (next >= MAX_ATTEMPTS) {
68+
const until = Date.now() + LOCKOUT_SECONDS * 1000;
69+
setLockedUntil(until);
70+
setError(`PIN ${MAX_ATTEMPTS}회 오류 — 5분간 잠금`);
71+
} else {
72+
setError(`PIN이 올바르지 않습니다. (${next}/${MAX_ATTEMPTS})`);
73+
}
74+
} catch {
75+
setError("서버 연결 오류. 다시 시도해주세요.");
76+
setPin("");
77+
} finally {
78+
setIsVerifying(false);
6279
}
6380
};
6481

@@ -70,8 +87,14 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
7087
size="sm"
7188
footer={
7289
<>
73-
<Button type="button" onClick={onClose}>취소</Button>
74-
<Button variant="primary" type="submit" form="pin-form" disabled={pin.length < 4 || isLocked}>
90+
<Button type="button" onClick={onClose} disabled={isVerifying}>취소</Button>
91+
<Button
92+
variant="primary"
93+
type="submit"
94+
form="pin-form"
95+
disabled={pin.length < 4 || isLocked || isVerifying}
96+
loading={isVerifying}
97+
>
7598
확인
7699
</Button>
77100
</>
@@ -90,12 +113,10 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
90113
setPin(val);
91114
if (!isLocked) setError("");
92115
}}
93-
disabled={isLocked}
116+
disabled={isLocked || isVerifying}
94117
error={error ? (isLocked ? `${error}${remaining}초 후 재시도 가능` : error) : undefined}
95118
/>
96119
</form>
97-
98-
<p className="pin-modal-hint">개발 mock PIN: 1234</p>
99120
</Modal>
100121
);
101122
}

apps/web/src/views/AdminView.tsx

Lines changed: 70 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { Button, FormField, PinInput } from "@fieldstack/controls";
44

55
import "../styles/admin.css";
66

7-
// 개발 mock PIN — 실제 구현 시 API 검증으로 교체
8-
const MOCK_ADMIN_PIN = "1234";
97

108
// 초기화 플로우 단계
119
type ResetPhase =
@@ -94,6 +92,8 @@ export function AdminView({ isPinVerified, onRequestPin, installMode }: AdminVie
9492
const [resetPhase, setResetPhase] = useState<ResetPhase>("idle");
9593
const [resetPin, setResetPin] = useState("");
9694
const [resetPinError, setResetPinError] = useState("");
95+
const [isResetting, setIsResetting] = useState(false);
96+
const [isPinChanging, setIsPinChanging] = useState(false);
9797

9898
// 모듈 목록 조회
9999
const fetchModules = useCallback(async () => {
@@ -176,54 +176,84 @@ export function AdminView({ isPinVerified, onRequestPin, installMode }: AdminVie
176176
if (next !== "system") resetResetFlow();
177177
};
178178

179-
// 부분 초기화 PIN 확인 (mock)
180-
const handlePartialResetSubmit = (e: FormEvent) => {
179+
const handlePartialResetSubmit = async (e: FormEvent) => {
181180
e.preventDefault();
182-
if (resetPin !== MOCK_ADMIN_PIN) {
183-
setResetPinError("PIN이 올바르지 않습니다.");
184-
setResetPin("");
185-
return;
186-
}
181+
if (isResetting) return;
182+
setIsResetting(true);
187183
setResetPinError("");
188-
setResetPhase("p-done");
189-
// 실제 구현 시: POST /admin/partial-reset { pin } 호출
184+
try {
185+
const token = sessionStorage.getItem("fs_token") ?? "";
186+
const res = await fetch("/admin/partial-reset", {
187+
method: "POST",
188+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
189+
body: JSON.stringify({ pin: resetPin }),
190+
});
191+
const json = await res.json() as { success: boolean; error?: string };
192+
if (!res.ok || !json.success) {
193+
setResetPinError(json.error ?? "초기화 실패");
194+
setResetPin("");
195+
return;
196+
}
197+
setResetPhase("p-done");
198+
} catch {
199+
setResetPinError("서버 연결 오류");
200+
} finally {
201+
setIsResetting(false);
202+
}
190203
};
191204

192-
// 완전 초기화 PIN 확인 (mock)
193-
const handleFactoryResetSubmit = (e: FormEvent) => {
205+
const handleFactoryResetSubmit = async (e: FormEvent) => {
194206
e.preventDefault();
195-
if (resetPin !== MOCK_ADMIN_PIN) {
196-
setResetPinError("PIN이 올바르지 않습니다.");
197-
setResetPin("");
198-
return;
199-
}
207+
if (isResetting) return;
208+
setIsResetting(true);
200209
setResetPinError("");
201-
setResetPhase("f-done");
202-
// 실제 구현 시: POST /admin/factory-reset { pin } 호출 → 서버 재시작
210+
try {
211+
const token = sessionStorage.getItem("fs_token") ?? "";
212+
const res = await fetch("/admin/factory-reset", {
213+
method: "POST",
214+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
215+
body: JSON.stringify({ pin: resetPin }),
216+
});
217+
const json = await res.json() as { success: boolean; error?: string };
218+
if (!res.ok || !json.success) {
219+
setResetPinError(json.error ?? "초기화 실패");
220+
setResetPin("");
221+
return;
222+
}
223+
setResetPhase("f-done");
224+
} catch {
225+
setResetPinError("서버 연결 오류");
226+
} finally {
227+
setIsResetting(false);
228+
}
203229
};
204230

205-
const handlePinChange = (e: FormEvent) => {
231+
const handlePinChange = async (e: FormEvent) => {
206232
e.preventDefault();
207-
if (currentPin !== MOCK_ADMIN_PIN) {
208-
setPinError("현재 PIN이 올바르지 않습니다.");
209-
setCurrentPin("");
210-
return;
211-
}
212-
if (newPin.length < 4) {
213-
setPinError("새 PIN은 4자리 이상이어야 합니다.");
214-
return;
215-
}
216-
if (newPin !== confirmPin) {
217-
setPinError("새 PIN이 일치하지 않습니다.");
218-
setConfirmPin("");
219-
return;
220-
}
221-
if (newPin === MOCK_ADMIN_PIN) {
222-
setPinError("현재 PIN과 동일한 PIN은 사용할 수 없습니다.");
223-
return;
224-
}
233+
if (isPinChanging) return;
234+
if (newPin.length < 4) { setPinError("새 PIN은 4자리 이상이어야 합니다."); return; }
235+
if (newPin !== confirmPin) { setPinError("새 PIN이 일치하지 않습니다."); setConfirmPin(""); return; }
236+
setIsPinChanging(true);
225237
setPinError("");
226-
setPinSuccess(true);
238+
try {
239+
const token = sessionStorage.getItem("fs_token") ?? "";
240+
const res = await fetch("/admin/change-pin", {
241+
method: "POST",
242+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
243+
body: JSON.stringify({ currentPin, newPin }),
244+
});
245+
const json = await res.json() as { success: boolean; error?: string };
246+
if (!res.ok || !json.success) {
247+
setPinError(json.error ?? "PIN 변경 실패");
248+
setCurrentPin("");
249+
return;
250+
}
251+
setPinSuccess(true);
252+
} catch {
253+
setPinError("서버 연결 오류");
254+
} finally {
255+
setIsPinChanging(false);
256+
}
227257
};
228258

229259
if (!isPinVerified) {

apps/web/src/views/ChangePasswordView.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState, type FormEvent } from "react";
22

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

5-
import { Button, FormField, Input } from "@fieldstack/controls";
5+
import { Alert, Button, FormField, Input } from "@fieldstack/controls";
66

77
import "../styles/change-password.css";
88

@@ -21,6 +21,7 @@ export function ChangePasswordView({ isFirstLogin, onChanged }: ChangePasswordVi
2121
const [next, setNext] = useState<FieldState>({ value: "", touched: false });
2222
const [confirm, setConfirm] = useState<FieldState>({ value: "", touched: false });
2323
const [submitted, setSubmitted] = useState(false);
24+
const [apiError, setApiError] = useState("");
2425

2526
const nextValidation = validatePassword(next.value);
2627
const confirmMismatch = confirm.value !== next.value;
@@ -30,12 +31,27 @@ export function ChangePasswordView({ isFirstLogin, onChanged }: ChangePasswordVi
3031

3132
const canSubmit = current.value.length > 0 && nextValidation.valid && !confirmMismatch;
3233

33-
const handleSubmit = (e: FormEvent) => {
34+
const handleSubmit = async (e: FormEvent) => {
3435
e.preventDefault();
3536
setSubmitted(true);
3637
if (!canSubmit) return;
37-
// TODO: API 연결 시 current 비밀번호 검증 후 변경
38-
onChanged();
38+
setApiError("");
39+
try {
40+
const token = sessionStorage.getItem("fs_token") ?? "";
41+
const res = await fetch("/auth/password/change", {
42+
method: "POST",
43+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
44+
body: JSON.stringify({ newPassword: next.value }),
45+
});
46+
const json = await res.json() as { success: boolean; error?: string };
47+
if (!res.ok || !json.success) {
48+
setApiError(json.error ?? "비밀번호 변경에 실패했습니다.");
49+
return;
50+
}
51+
onChanged();
52+
} catch {
53+
setApiError("서버 연결 오류. 다시 시도해주세요.");
54+
}
3955
};
4056

4157
return (
@@ -53,6 +69,8 @@ export function ChangePasswordView({ isFirstLogin, onChanged }: ChangePasswordVi
5369
</p>
5470
</div>
5571

72+
{apiError && <Alert variant="error">{apiError}</Alert>}
73+
5674
<form className="stack cpw-form" onSubmit={handleSubmit} noValidate>
5775
<FormField
5876
label={isFirstLogin ? "임시 비밀번호" : "현재 비밀번호"}

0 commit comments

Comments
 (0)