릴리스: v1.3.11 회원 관리 모달과 최고 관리자 보호
This commit is contained in:
@@ -22,6 +22,7 @@ const {
|
||||
findCustomItemsByIds,
|
||||
deleteCustomItems,
|
||||
listUsers,
|
||||
findPrimaryAdminUser,
|
||||
listAdminTierLists,
|
||||
findTierListById,
|
||||
listAdminTemplateRequests,
|
||||
@@ -62,6 +63,30 @@ function buildItemLabelFromFilename(file) {
|
||||
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
||||
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
||||
|
||||
function decorateAdminUser(user, primaryAdmin) {
|
||||
if (!user) return null
|
||||
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
|
||||
return {
|
||||
...user,
|
||||
isPrimaryAdmin,
|
||||
isOperator: !!user.isAdmin && !isPrimaryAdmin,
|
||||
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
|
||||
}
|
||||
}
|
||||
|
||||
async function getAdminUserContext(targetUserId, actingUserId) {
|
||||
const [targetUser, actingUser, primaryAdmin] = await Promise.all([
|
||||
findUserById(targetUserId),
|
||||
findUserById(actingUserId),
|
||||
findPrimaryAdminUser(),
|
||||
])
|
||||
return { targetUser, actingUser, primaryAdmin }
|
||||
}
|
||||
|
||||
function canManageAdminRole(actingUser, primaryAdmin) {
|
||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
||||
}
|
||||
|
||||
router.post('/games', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
@@ -537,8 +562,18 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||
})
|
||||
|
||||
router.get('/users', requireAdmin, async (req, res) => {
|
||||
const users = await listUsers()
|
||||
res.json({ users })
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const [users, primaryAdmin] = await Promise.all([
|
||||
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort }),
|
||||
findPrimaryAdminUser(),
|
||||
])
|
||||
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
|
||||
})
|
||||
|
||||
router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
@@ -550,21 +585,34 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
|
||||
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
|
||||
const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin
|
||||
|
||||
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
|
||||
return res.status(400).json({ error: 'self_admin_required' })
|
||||
}
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
|
||||
return res.status(403).json({ error: 'primary_admin_protected' })
|
||||
}
|
||||
if (targetIsPrimaryAdmin && !parsed.data.isAdmin) {
|
||||
return res.status(400).json({ error: 'primary_admin_required' })
|
||||
}
|
||||
if (roleChanged && !actingIsPrimaryAdmin) {
|
||||
return res.status(403).json({ error: 'primary_admin_only' })
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await adminUpdateUser({
|
||||
id: user.id,
|
||||
id: targetUser.id,
|
||||
email: parsed.data.email,
|
||||
nickname: parsed.data.nickname,
|
||||
isAdmin: parsed.data.isAdmin,
|
||||
})
|
||||
res.json({ user: updated })
|
||||
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(409).json({ error: 'email_taken' })
|
||||
@@ -580,8 +628,11 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
||||
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
|
||||
return res.status(403).json({ error: 'primary_admin_protected' })
|
||||
}
|
||||
|
||||
const optimized = req.file
|
||||
? await writeOptimizedImage({
|
||||
@@ -594,16 +645,16 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
|
||||
})
|
||||
: null
|
||||
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
|
||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || ''
|
||||
const updated = await adminUpdateUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
isAdmin: !!user.isAdmin,
|
||||
id: targetUser.id,
|
||||
email: targetUser.email,
|
||||
nickname: targetUser.nickname || '',
|
||||
isAdmin: !!targetUser.isAdmin,
|
||||
avatarSrc: nextAvatarSrc,
|
||||
})
|
||||
|
||||
res.json({ user: updated })
|
||||
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
||||
})
|
||||
|
||||
router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
||||
@@ -611,10 +662,19 @@ router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'cannot_delete_self' })
|
||||
}
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
await adminDeleteUser(user.id)
|
||||
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
|
||||
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
|
||||
if (targetIsPrimaryAdmin) {
|
||||
return res.status(400).json({ error: 'cannot_delete_primary_admin' })
|
||||
}
|
||||
if (targetUser.isAdmin && !actingIsPrimaryAdmin) {
|
||||
return res.status(403).json({ error: 'primary_admin_only' })
|
||||
}
|
||||
|
||||
await adminDeleteUser(targetUser.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -625,11 +685,14 @@ router.patch('/users/:userId/password', requireAdmin, async (req, res) => {
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
||||
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
|
||||
return res.status(403).json({ error: 'primary_admin_protected' })
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 10)
|
||||
await adminUpdateUserPassword({ id: user.id, passwordHash })
|
||||
await adminUpdateUserPassword({ id: targetUser.id, passwordHash })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user