11import { Router } from 'express' ;
22import { z } from 'zod' ;
33
4+ import { requireAdmin } from '../middleware/require-admin' ;
45import { requireAuth } from '../middleware/require-auth' ;
56import { clearConfig , clearInstalled , scheduleRestart } from '../setup/mode' ;
67import { 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
2254const ALL_TABLES = [
@@ -41,6 +73,242 @@ const DATA_TABLES = ['shared_link_logs', 'shared_links'];
4173
4274export 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