Skip to content

Commit ac11098

Browse files
SOIVclaude
andcommitted
feat: Phase 1.95.4 초기화 UI 및 로그인 API 연동
백엔드: - routes/admin.ts: POST /admin/factory-reset (전체 초기화 → Setup 모드 복귀), POST /admin/partial-reset (데이터 테이블만 DELETE). JWT + 관리자 PIN 필수. - migrations/core/003_add_is_admin.sql: users 테이블 is_admin 컬럼 추가. - user-auth-service: createUser isAdmin 파라미터 추가, login 결과에 isAdmin 포함. - routes/setup: setup 완료 시 첫 계정을 isAdmin=true로 생성. - setup/docker: WSL2 Hyper-V 예약 포트 회피 (5433부터 시작, docker run 실패 시 다음 포트 자동 시도). stopped 상태 컨테이너 자동 제거. - app.ts: /admin 라우터 등록. setup 앱 프로덕션 전용 정적 파일 서빙 수정. - index.ts: setup 배너에서 dev 모드 시 Vite 포트(5173) 안내. 프론트엔드: - AdminView: 부분 초기화(1단계 확인 → PIN) / 완전 초기화(2단계 확인 → PIN) UI. admin.css reset-zone 스타일 추가. - main.tsx: 로그인·OTP 핸들러를 bypass/normal 모드로 분기. normal 모드에서 실제 /auth/login, /auth/totp/verify API 호출. 로그아웃 시 /auth/logout 호출. accessToken/refreshToken sessionStorage 저장(fs_token, fs_refresh). - LoginView: OTP 코드를 App으로 위임, otpApiError prop 추가. - SettingsView: bypass 모드에서만 개발용 관리자 권한 토글 표시. - vite.config.ts: host: true (로컬 네트워크 접속 허용). - .gitignore: fieldstack.config.json, installed.lock 추가 (로컬 런타임 상태). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 340b3c3 commit ac11098

16 files changed

Lines changed: 3208 additions & 3127 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,9 @@ local/
132132
**/local/
133133
.claude/
134134
.sisyphus/
135+
136+
# =============================================================================
137+
# Fieldstack 런타임 상태 (로컬 전용)
138+
# =============================================================================
139+
fieldstack.config.json
140+
installed.lock

apps/api/src/app.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { SharedLinkRenderer } from '@fieldstack/core' with { "resolution-mo
2020
import { validateEnv } from './config/env';
2121
import { errorHandler } from './middleware/error';
2222
import type { BackendRouteRegistration } from './loader';
23+
import { createAdminRouter } from './routes/admin';
2324
import { createAuthRouter } from './routes/auth';
2425
import { healthRouter } from './routes/health';
2526
import { createPublicRouter } from './routes/public';
@@ -68,6 +69,7 @@ export function createApp(services?: AppServices) {
6869
if (services) {
6970
app.use('/auth', createAuthRouter(services));
7071
app.use('/core/share', createShareRouter(services));
72+
app.use('/admin', createAdminRouter(services));
7173
}
7274

7375
return app;
@@ -97,6 +99,7 @@ export function createAppWithPublicRouter(
9799

98100
app.use('/auth', createAuthRouter(services));
99101
app.use('/core/share', createShareRouter(services));
102+
app.use('/admin', createAdminRouter(services));
100103
app.use('/s', createPublicRouter(services.sharedLink, getRenderer));
101104

102105
return app;
@@ -115,10 +118,13 @@ export function createSetupApp(): express.Application {
115118
app.use('/setup', createSetupRouter());
116119

117120
// 프로덕션: 빌드된 프론트엔드 정적 파일 서빙
121+
// 프로덕션에서만 빌드된 프론트엔드 정적 파일 서빙
122+
// 개발 모드에서는 Vite dev server(port 5173)가 프론트엔드를 담당하므로 건너뜀
118123
const publicDir = path.join(__dirname, '..', 'public');
119-
if (fs.existsSync(publicDir)) {
124+
if (process.env['NODE_ENV'] === 'production' && fs.existsSync(publicDir)) {
120125
app.use(express.static(publicDir));
121-
app.get('*', (_req, res) => {
126+
// Express 5 + path-to-regexp v8: '*' 와일드카드 미지원 → app.use() 로 catch-all 처리
127+
app.use((_req, res) => {
122128
res.sendFile(path.join(publicDir, 'index.html'));
123129
});
124130
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- ── 003_add_is_admin.sql ────────────────────────────────────────
2+
-- users 테이블에 is_admin 컬럼 추가.
3+
-- Setup 마법사에서 생성되는 첫 번째 계정은 관리자로 표시된다.
4+
5+
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT {{BOOLEAN_FALSE}};

apps/api/src/index.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,12 @@ function getLocalIPs(): string[] {
5656
return ips;
5757
}
5858

59-
function printSetupBanner(port: number) {
59+
function printSetupBanner(apiPort: number, isDev: boolean) {
6060
const ips = getLocalIPs();
61+
// 개발 모드: Vite dev server(5173)가 프론트엔드를 서빙하므로 해당 포트를 안내
62+
// 프로덕션: API 서버가 직접 프론트엔드까지 서빙하므로 apiPort를 안내
63+
const frontendPort = isDev ? 5173 : apiPort;
64+
6165
const lines = [
6266
'',
6367
' ╔══════════════════════════════════════════════════════╗',
@@ -66,14 +70,18 @@ function printSetupBanner(port: number) {
6670
' ║ 아래 주소 중 하나를 브라우저에서 열어 설치를 ║',
6771
' ║ 진행해 주세요. ║',
6872
' ╠══════════════════════════════════════════════════════╣',
69-
` ║ 로컬 → http://localhost:${port}`.padEnd(56) + '║',
73+
` ║ 로컬 → http://localhost:${frontendPort}`.padEnd(56) + '║',
7074
];
7175
for (const ip of ips) {
72-
lines.push(` ║ 네트워크 → http://${ip}:${port}`.padEnd(56) + '║');
76+
lines.push(` ║ 네트워크 → http://${ip}:${frontendPort}`.padEnd(56) + '║');
7377
}
7478
if (ips.length === 0) {
7579
lines.push(' ║ (네트워크 인터페이스를 감지하지 못했습니다)'.padEnd(56) + '║');
7680
}
81+
if (isDev) {
82+
lines.push(' ╠══════════════════════════════════════════════════════╣');
83+
lines.push(` ║ API → http://localhost:${apiPort} (dev only)`.padEnd(56) + '║');
84+
}
7785
lines.push(' ╚══════════════════════════════════════════════════════╝');
7886
lines.push('');
7987
console.log(lines.join('\n'));
@@ -86,7 +94,7 @@ async function startSetup() {
8694
const app = createSetupApp();
8795
finalizeApp(app);
8896
app.listen(env.PORT, () => {
89-
printSetupBanner(env.PORT);
97+
printSetupBanner(env.PORT, env.NODE_ENV !== 'production');
9098
});
9199
}
92100

apps/api/src/routes/admin.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Router } from 'express';
2+
import { z } from 'zod';
3+
4+
import { requireAuth } from '../middleware/require-auth';
5+
import { clearConfig, clearInstalled, scheduleRestart } from '../setup/mode';
6+
import type { AppServices } from '../app';
7+
8+
// ── 입력 스키마 ───────────────────────────────────────────────
9+
10+
const ResetBody = z.object({
11+
pin: z.string().min(4),
12+
});
13+
14+
// ── 완전 초기화: 삭제할 테이블 목록 (FK 의존성 역순) ──────────
15+
16+
const ALL_TABLES = [
17+
'shared_link_logs',
18+
'shared_links',
19+
'system_settings',
20+
'password_recovery_tokens',
21+
'totp_challenges',
22+
'totp_credentials',
23+
'sessions',
24+
'whitelist_rules',
25+
'admin_pin',
26+
'users',
27+
'_migrations',
28+
];
29+
30+
// ── 부분 초기화: 데이터 테이블만 삭제 (계정·설정 유지) ──────────
31+
32+
const DATA_TABLES = ['shared_link_logs', 'shared_links'];
33+
34+
// ── 라우터 팩토리 ──────────────────────────────────────────────
35+
36+
export function createAdminRouter(services: AppServices): Router {
37+
const router = Router();
38+
39+
/**
40+
* POST /admin/factory-reset — 완전 초기화
41+
*
42+
* 모든 테이블 삭제 → installed.lock + fieldstack.config.json 제거
43+
* → 서버 재시작 → Setup 모드로 복귀
44+
*
45+
* 필수: JWT 인증 + 관리자 PIN
46+
*/
47+
router.post('/factory-reset', requireAuth(services.jwtManager), async (req, res) => {
48+
const parsed = ResetBody.safeParse(req.body);
49+
if (!parsed.success) {
50+
res.status(400).json({ success: false, error: parsed.error.flatten() });
51+
return;
52+
}
53+
54+
// PIN 검증
55+
const pinOk = await services.adminPin.verifyPin(parsed.data.pin);
56+
if (!pinOk) {
57+
res.status(403).json({ success: false, error: 'PIN이 올바르지 않습니다.' });
58+
return;
59+
}
60+
61+
try {
62+
const { getDb } = await import('@fieldstack/core');
63+
const db = await getDb();
64+
65+
// 모든 테이블 삭제 — 오류가 나도 나머지 계속 진행
66+
for (const table of ALL_TABLES) {
67+
try {
68+
await db.query(`DROP TABLE IF EXISTS "${table}"`);
69+
} catch {
70+
// 테이블 부재 또는 권한 문제 → 무시하고 다음 테이블로
71+
}
72+
}
73+
74+
// lock + config 삭제 → Setup 모드로 복귀
75+
clearInstalled();
76+
clearConfig();
77+
78+
res.json({ success: true, data: { message: '완전 초기화 완료. 서버가 재시작됩니다.' } });
79+
80+
// 응답 전송 후 재시작 (클라이언트가 응답을 받을 시간 확보)
81+
scheduleRestart(800);
82+
} catch (err) {
83+
res.status(500).json({ success: false, error: (err as Error).message });
84+
}
85+
});
86+
87+
/**
88+
* POST /admin/partial-reset — 부분 초기화
89+
*
90+
* 공유 링크 등 데이터 테이블만 삭제.
91+
* 사용자 계정·화이트리스트·관리자 PIN·시스템 설정은 유지.
92+
* installed.lock은 유지되므로 앱 모드가 그대로 계속된다.
93+
*
94+
* 필수: JWT 인증 + 관리자 PIN
95+
*/
96+
router.post('/partial-reset', requireAuth(services.jwtManager), async (req, res) => {
97+
const parsed = ResetBody.safeParse(req.body);
98+
if (!parsed.success) {
99+
res.status(400).json({ success: false, error: parsed.error.flatten() });
100+
return;
101+
}
102+
103+
// PIN 검증
104+
const pinOk = await services.adminPin.verifyPin(parsed.data.pin);
105+
if (!pinOk) {
106+
res.status(403).json({ success: false, error: 'PIN이 올바르지 않습니다.' });
107+
return;
108+
}
109+
110+
try {
111+
const { getDb } = await import('@fieldstack/core');
112+
const db = await getDb();
113+
114+
// 데이터 테이블만 비움 — 계정·설정 테이블은 건드리지 않음
115+
for (const table of DATA_TABLES) {
116+
try {
117+
await db.query(`DELETE FROM "${table}"`);
118+
} catch {
119+
// 테이블이 없으면 무시
120+
}
121+
}
122+
123+
res.json({ success: true, data: { message: '부분 초기화 완료.' } });
124+
} catch (err) {
125+
res.status(500).json({ success: false, error: (err as Error).message });
126+
}
127+
});
128+
129+
return router;
130+
}

apps/api/src/routes/setup.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export function createSetupRouter(): Router {
125125
res.end();
126126
return;
127127
}
128+
// stopped 상태는 provisionPostgresContainer() 내부에서 자동 제거 후 재생성
128129

129130
// 이미지 pull (최초 1회)
130131
const alreadyPulled = await isImagePulled();
@@ -255,7 +256,8 @@ export function createSetupRouter(): Router {
255256
const userAuth = new UserAuthService(db, jwtManager, whitelist, totp);
256257
const adminPin = new AdminPinServiceImpl(db);
257258

258-
await userAuth.createUser(admin.email, admin.password, false);
259+
// isTempPassword=false, isAdmin=true — Setup으로 생성하는 첫 계정은 관리자
260+
await userAuth.createUser(admin.email, admin.password, false, true);
259261
// 관리자 이메일을 화이트리스트에 추가 (rules가 1개라도 있으면 체크됨)
260262
await whitelist.addRule({ type: 'email', value: admin.email, enabled: true });
261263
// 관리자 PIN 설정

apps/api/src/setup/docker.ts

Lines changed: 55 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -89,32 +89,11 @@ export function pullDockerImage(onProgress: (msg: string) => void): Promise<void
8989
});
9090
}
9191

92-
// ── 포트 충돌 대응 후보 포트 목록 ────────────────────────────
92+
// ── 포트 후보 목록 ────────────────────────────────────────────
93+
// Windows Hyper-V/WSL2 환경에서 5432가 자주 예약되므로 5433부터 시작.
94+
// 고정 범위를 모두 실패하면 15432 대역으로 넘어간다.
9395

94-
const PORT_CANDIDATES = [5432, 5433, 5434, 5435];
95-
96-
/**
97-
* 이미 실행 중인 컨테이너가 없는 포트를 순서대로 반환.
98-
* 모두 사용 중이면 기본 포트를 그대로 사용 (Docker가 에러를 낸다).
99-
*/
100-
async function pickAvailablePort(): Promise<number> {
101-
for (const port of PORT_CANDIDATES) {
102-
try {
103-
// 해당 포트를 점유한 컨테이너가 없는지 확인
104-
const { stdout } = await execFileAsync('docker', [
105-
'ps',
106-
'--filter',
107-
`publish=${port}`,
108-
'--format',
109-
'{{.ID}}',
110-
]);
111-
if (!stdout.trim()) return port;
112-
} catch {
113-
return port;
114-
}
115-
}
116-
return DEFAULT_PORT;
117-
}
96+
const PORT_CANDIDATES = [5433, 5434, 5435, 5436, 5437, 15432, 15433, 15434];
11897

11998
// ── PostgreSQL 컨테이너 프로비저닝 ───────────────────────────
12099

@@ -124,29 +103,58 @@ export interface ProvisionResult {
124103
}
125104

126105
export async function provisionPostgresContainer(): Promise<ProvisionResult> {
106+
// stopped 상태로 남아있는 동일 이름 컨테이너 제거 (이전 실패 잔여물)
107+
const existing = await getContainerStatus();
108+
if (existing === 'stopped') {
109+
await execFileAsync('docker', ['rm', CONTAINER_NAME]);
110+
}
111+
127112
const password = crypto.randomBytes(16).toString('hex');
128-
const port = await pickAvailablePort();
129-
130-
await execFileAsync('docker', [
131-
'run',
132-
'-d',
133-
'--name',
134-
CONTAINER_NAME,
135-
'--restart',
136-
'unless-stopped',
137-
'-e',
138-
`POSTGRES_USER=${POSTGRES_USER}`,
139-
'-e',
140-
`POSTGRES_PASSWORD=${password}`,
141-
'-e',
142-
`POSTGRES_DB=${POSTGRES_DB}`,
143-
'-p',
144-
`${port}:5432`,
145-
POSTGRES_IMAGE,
146-
]);
147-
148-
const connectionUrl = `postgresql://${POSTGRES_USER}:${password}@localhost:${port}/${POSTGRES_DB}`;
149-
return { connectionUrl, port };
113+
114+
// 포트 후보를 순서대로 시도 — Docker 실행 결과로 직접 판별한다.
115+
// WSL2 ↔ Windows 네트워크 스택 차이로 Node.js 바인딩 테스트는 신뢰할 수 없으므로
116+
// docker run을 실제로 시도하고 포트 충돌 에러 시 다음 포트로 넘어간다.
117+
for (const port of PORT_CANDIDATES) {
118+
try {
119+
await execFileAsync('docker', [
120+
'run',
121+
'-d',
122+
'--name',
123+
CONTAINER_NAME,
124+
'--restart',
125+
'unless-stopped',
126+
'-e',
127+
`POSTGRES_USER=${POSTGRES_USER}`,
128+
'-e',
129+
`POSTGRES_PASSWORD=${password}`,
130+
'-e',
131+
`POSTGRES_DB=${POSTGRES_DB}`,
132+
'-p',
133+
`${port}:5432`,
134+
POSTGRES_IMAGE,
135+
]);
136+
// 성공
137+
const connectionUrl = `postgresql://${POSTGRES_USER}:${password}@localhost:${port}/${POSTGRES_DB}`;
138+
return { connectionUrl, port };
139+
} catch (err) {
140+
const msg = (err as Error).message ?? '';
141+
const isPortError =
142+
msg.includes('ports are not available') ||
143+
msg.includes('address already in use') ||
144+
msg.includes('bind:') ||
145+
msg.includes('access permissions');
146+
147+
if (!isPortError) throw err; // 포트 문제가 아니면 즉시 실패
148+
149+
// 포트 문제 → 컨테이너가 절반만 생성됐을 수 있으므로 정리 후 다음 포트 시도
150+
try { await execFileAsync('docker', ['rm', '-f', CONTAINER_NAME]); } catch { /* ignore */ }
151+
}
152+
}
153+
154+
throw new Error(
155+
`후보 포트(${PORT_CANDIDATES.join(', ')}) 모두 사용 불가. ` +
156+
'PostgreSQL 연결 URL을 직접 입력해주세요.',
157+
);
150158
}
151159

152160
// ── PostgreSQL 준비 대기 (연결 폴링) ─────────────────────────

0 commit comments

Comments
 (0)