Skip to content

Commit 6aaa33a

Browse files
SOIVclaude
andcommitted
feat(admin): 사용자 관리 & Whitelist 운영 구현 (Phase 2.x.7)
- DB: users.is_active 컬럼 추가 마이그레이션 (008_user_status.sql) - requireAdmin 미들웨어 — JWT 검증 + DB is_admin 재확인 - REST API: GET/POST /admin/users, PATCH/DELETE /admin/users/:id, POST /admin/users/:id/invite, GET/POST/PATCH/DELETE /admin/whitelist/:id - UserAuthService: listUsers, setUserActive, setUserAdmin, deleteUser, countAdmins, findUserIdByEmail, isUserAdmin, createUserWithInvite - WhitelistServiceImpl.setEnabled 추가 - AdminView: 사용자/Whitelist 서브탭, 목록 테이블, 사용자 추가·초대 토큰·삭제 모달 - login() 에서 is_active=false 계정 차단 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 78f78e6 commit 6aaa33a

9 files changed

Lines changed: 1287 additions & 8 deletions

File tree

TODO.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
- [x] 2.x.2 i18n 작업
1919
- [ ] 나머지 추가 작업 진행
2020
- [ ] 구독 관리 모듈 작업(2.2)
21+
- [ ] CSV 가져오기 기능 수정
22+
- [ ] CSV 뿐만 아니라 xls 등 다른 확장자도 지원하도록 변경
2123
- [ ]**UI 수정**
2224
- [ ] 다음 결제일에 해당 되는 리스트를 왠쪽에 한 라인으로 배치
2325
- 결제 예정으로 표시되는 것들은 7일 이내 결제 예정인 것들만 표시
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { NextFunction, Request, Response } from 'express';
2+
3+
import type {
4+
JwtSessionManagerImpl,
5+
UserAuthService,
6+
} from '@fieldstack/core' with { "resolution-mode": "import" };
7+
8+
import { requireAuth } from './require-auth';
9+
10+
/**
11+
* JWT 검증 후 DB에서 `is_admin` 플래그를 재확인한다.
12+
* JWT에 admin claim을 넣지 않고 매번 DB를 조회하는 이유:
13+
* - 토큰 발급 후 권한이 강등될 수 있고
14+
* - 관리자 라우트 호출 빈도는 낮아 DB 1회 조회 비용이 무시 가능
15+
*/
16+
export function requireAdmin(jwtManager: JwtSessionManagerImpl, userAuth: UserAuthService) {
17+
const auth = requireAuth(jwtManager);
18+
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
19+
await auth(req, res, async () => {
20+
try {
21+
const isAdmin = await userAuth.isUserAdmin(req.auth!.userId);
22+
if (!isAdmin) {
23+
res.status(403).json({ success: false, error: 'Admin privilege required' });
24+
return;
25+
}
26+
next();
27+
} catch (err) {
28+
res.status(500).json({ success: false, error: (err as Error).message });
29+
}
30+
});
31+
};
32+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- ── 008_user_status.sql ─────────────────────────────────────────
2+
-- users.is_active: 비활성 계정은 로그인 차단.
3+
-- 기본값 TRUE로 시작해 기존 사용자는 자동 활성 상태 유지.
4+
5+
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT {{BOOLEAN_TRUE}};

apps/api/src/routes/admin.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Router } from 'express';
22
import { z } from 'zod';
33

4+
import { requireAdmin } from '../middleware/require-admin';
45
import { requireAuth } from '../middleware/require-auth';
56
import { clearConfig, clearInstalled, scheduleRestart } from '../setup/mode';
67
import { tunnelManager } from '../tunnel/cloudflare-tunnel';
@@ -17,6 +18,37 @@ const ChangePinBody = z.object({
1718
newPin: z.string().min(4),
1819
});
1920

21+
const CreateUserBody = z.object({
22+
email: z.string().email(),
23+
isAdmin: z.boolean().optional(),
24+
addToWhitelist: z.boolean().optional(),
25+
});
26+
27+
const PatchUserBody = z
28+
.object({
29+
isActive: z.boolean().optional(),
30+
isAdmin: z.boolean().optional(),
31+
})
32+
.refine((d) => d.isActive !== undefined || d.isAdmin !== undefined, {
33+
message: 'At least one of isActive or isAdmin is required',
34+
});
35+
36+
const DeleteUserBody = z.object({
37+
pin: z.string().min(4),
38+
});
39+
40+
const WhitelistAddBody = z.object({
41+
type: z.enum(['email', 'domain']),
42+
value: z.string().min(1),
43+
enabled: z.boolean().optional(),
44+
});
45+
46+
const WhitelistPatchBody = z.object({
47+
enabled: z.boolean(),
48+
});
49+
50+
const UuidParam = z.string().uuid();
51+
2052
// ── 완전 초기화: 삭제할 테이블 목록 (FK 의존성 역순) ──────────
2153

2254
const ALL_TABLES = [
@@ -41,6 +73,242 @@ const DATA_TABLES = ['shared_link_logs', 'shared_links'];
4173

4274
export function createAdminRouter(services: AppServices): Router {
4375
const router = Router();
76+
const adminGuard = requireAdmin(services.jwtManager, services.userAuth);
77+
78+
// ── 사용자 관리 ─────────────────────────────────────────────
79+
80+
/** GET /admin/users — 사용자 목록 */
81+
router.get('/users', adminGuard, async (_req, res) => {
82+
try {
83+
const users = await services.userAuth.listUsers();
84+
res.json({ success: true, data: { users } });
85+
} catch (err) {
86+
res.status(500).json({ success: false, error: (err as Error).message });
87+
}
88+
});
89+
90+
/**
91+
* POST /admin/users — 사용자 생성 + 일회용 초대 토큰 발급
92+
*
93+
* 응답의 `inviteToken`은 1회만 표시한다.
94+
* 사용자는 ForgotPasswordView 토큰 경로로 비밀번호를 직접 설정한다.
95+
*/
96+
router.post('/users', adminGuard, async (req, res) => {
97+
const parsed = CreateUserBody.safeParse(req.body);
98+
if (!parsed.success) {
99+
res.status(400).json({ success: false, error: parsed.error.flatten() });
100+
return;
101+
}
102+
103+
const { email, isAdmin = false, addToWhitelist = false } = parsed.data;
104+
105+
try {
106+
const existing = await services.userAuth.findUserIdByEmail(email);
107+
if (existing) {
108+
res.status(409).json({ success: false, error: '이미 존재하는 이메일입니다.' });
109+
return;
110+
}
111+
112+
const { userId, inviteToken } = await services.userAuth.createUserWithInvite(email, isAdmin);
113+
114+
if (addToWhitelist) {
115+
// 활성 룰이 하나라도 있으면 화이트리스트가 강제 적용되므로,
116+
// 새 사용자가 즉시 로그인할 수 있도록 룰을 추가한다.
117+
await services.whitelist.addRule({ type: 'email', value: email, enabled: true });
118+
}
119+
120+
res.json({
121+
success: true,
122+
data: { userId, email, inviteToken, adminToken: inviteToken, expiresInMinutes: 30 },
123+
});
124+
} catch (err) {
125+
res.status(500).json({ success: false, error: (err as Error).message });
126+
}
127+
});
128+
129+
/** PATCH /admin/users/:id — 활성/관리자 토글 */
130+
router.patch('/users/:id', adminGuard, async (req, res) => {
131+
const idParse = UuidParam.safeParse(req.params['id']);
132+
if (!idParse.success) {
133+
res.status(400).json({ success: false, error: 'Invalid user id' });
134+
return;
135+
}
136+
const parsed = PatchUserBody.safeParse(req.body);
137+
if (!parsed.success) {
138+
res.status(400).json({ success: false, error: parsed.error.flatten() });
139+
return;
140+
}
141+
142+
const targetId = idParse.data;
143+
const requesterId = req.auth!.userId;
144+
const { isActive, isAdmin } = parsed.data;
145+
146+
try {
147+
// 자기 자신 보호 — 강등/비활성으로 락아웃되는 사고 방지
148+
if (targetId === requesterId) {
149+
if (isActive === false) {
150+
res.status(400).json({ success: false, error: '본인 계정은 비활성화할 수 없습니다.' });
151+
return;
152+
}
153+
if (isAdmin === false) {
154+
res.status(400).json({ success: false, error: '본인의 관리자 권한은 해제할 수 없습니다.' });
155+
return;
156+
}
157+
}
158+
159+
// 마지막 활성 관리자 보호
160+
if (isAdmin === false || isActive === false) {
161+
const adminCount = await services.userAuth.countAdmins();
162+
const targetIsAdmin = await services.userAuth.isUserAdmin(targetId);
163+
if (targetIsAdmin && adminCount <= 1) {
164+
res.status(400).json({
165+
success: false,
166+
error: '마지막 관리자는 강등하거나 비활성화할 수 없습니다.',
167+
});
168+
return;
169+
}
170+
}
171+
172+
if (isActive !== undefined) {
173+
await services.userAuth.setUserActive(targetId, isActive);
174+
}
175+
if (isAdmin !== undefined) {
176+
await services.userAuth.setUserAdmin(targetId, isAdmin);
177+
}
178+
res.json({ success: true });
179+
} catch (err) {
180+
res.status(500).json({ success: false, error: (err as Error).message });
181+
}
182+
});
183+
184+
/** POST /admin/users/:id/invite — 초대/복구 토큰 재발급 */
185+
router.post('/users/:id/invite', adminGuard, async (req, res) => {
186+
const idParse = UuidParam.safeParse(req.params['id']);
187+
if (!idParse.success) {
188+
res.status(400).json({ success: false, error: 'Invalid user id' });
189+
return;
190+
}
191+
192+
try {
193+
const { adminToken } = await services.userAuth.issueRecoveryToken(idParse.data);
194+
res.json({ success: true, data: { inviteToken: adminToken, adminToken, expiresInMinutes: 30 } });
195+
} catch (err) {
196+
res.status(500).json({ success: false, error: (err as Error).message });
197+
}
198+
});
199+
200+
/** DELETE /admin/users/:id — 사용자 삭제 (PIN 재확인 필수) */
201+
router.delete('/users/:id', adminGuard, async (req, res) => {
202+
const idParse = UuidParam.safeParse(req.params['id']);
203+
if (!idParse.success) {
204+
res.status(400).json({ success: false, error: 'Invalid user id' });
205+
return;
206+
}
207+
const parsed = DeleteUserBody.safeParse(req.body);
208+
if (!parsed.success) {
209+
res.status(400).json({ success: false, error: parsed.error.flatten() });
210+
return;
211+
}
212+
213+
const targetId = idParse.data;
214+
const requesterId = req.auth!.userId;
215+
216+
try {
217+
const pinOk = await services.adminPin.verifyPin(parsed.data.pin);
218+
if (!pinOk) {
219+
res.status(403).json({ success: false, error: 'PIN이 올바르지 않습니다.' });
220+
return;
221+
}
222+
223+
if (targetId === requesterId) {
224+
res.status(400).json({ success: false, error: '본인 계정은 삭제할 수 없습니다.' });
225+
return;
226+
}
227+
228+
const targetIsAdmin = await services.userAuth.isUserAdmin(targetId);
229+
if (targetIsAdmin) {
230+
const adminCount = await services.userAuth.countAdmins();
231+
if (adminCount <= 1) {
232+
res.status(400).json({
233+
success: false,
234+
error: '마지막 관리자는 삭제할 수 없습니다.',
235+
});
236+
return;
237+
}
238+
}
239+
240+
await services.userAuth.deleteUser(targetId);
241+
res.json({ success: true });
242+
} catch (err) {
243+
res.status(500).json({ success: false, error: (err as Error).message });
244+
}
245+
});
246+
247+
// ── Whitelist 관리 ──────────────────────────────────────────
248+
249+
/** GET /admin/whitelist — 룰 목록 */
250+
router.get('/whitelist', adminGuard, async (_req, res) => {
251+
try {
252+
const rules = await services.whitelist.listRules();
253+
res.json({ success: true, data: { rules } });
254+
} catch (err) {
255+
res.status(500).json({ success: false, error: (err as Error).message });
256+
}
257+
});
258+
259+
/** POST /admin/whitelist — 룰 추가 */
260+
router.post('/whitelist', adminGuard, async (req, res) => {
261+
const parsed = WhitelistAddBody.safeParse(req.body);
262+
if (!parsed.success) {
263+
res.status(400).json({ success: false, error: parsed.error.flatten() });
264+
return;
265+
}
266+
try {
267+
const rule = await services.whitelist.addRule({
268+
type: parsed.data.type,
269+
value: parsed.data.value,
270+
enabled: parsed.data.enabled ?? true,
271+
});
272+
res.json({ success: true, data: { rule } });
273+
} catch (err) {
274+
res.status(500).json({ success: false, error: (err as Error).message });
275+
}
276+
});
277+
278+
/** PATCH /admin/whitelist/:id — enabled 토글 */
279+
router.patch('/whitelist/:id', adminGuard, async (req, res) => {
280+
const idParse = UuidParam.safeParse(req.params['id']);
281+
if (!idParse.success) {
282+
res.status(400).json({ success: false, error: 'Invalid rule id' });
283+
return;
284+
}
285+
const parsed = WhitelistPatchBody.safeParse(req.body);
286+
if (!parsed.success) {
287+
res.status(400).json({ success: false, error: parsed.error.flatten() });
288+
return;
289+
}
290+
try {
291+
await services.whitelist.setEnabled(idParse.data, parsed.data.enabled);
292+
res.json({ success: true });
293+
} catch (err) {
294+
res.status(500).json({ success: false, error: (err as Error).message });
295+
}
296+
});
297+
298+
/** DELETE /admin/whitelist/:id — 룰 삭제 */
299+
router.delete('/whitelist/:id', adminGuard, async (req, res) => {
300+
const idParse = UuidParam.safeParse(req.params['id']);
301+
if (!idParse.success) {
302+
res.status(400).json({ success: false, error: 'Invalid rule id' });
303+
return;
304+
}
305+
try {
306+
await services.whitelist.removeRule(idParse.data);
307+
res.json({ success: true });
308+
} catch (err) {
309+
res.status(500).json({ success: false, error: (err as Error).message });
310+
}
311+
});
44312

45313
/**
46314
* POST /admin/change-pin — 관리자 PIN 변경

0 commit comments

Comments
 (0)