Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 695c0bd4dd |
@@ -506,25 +506,52 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
|
|||||||
return findUserById(id)
|
return findUserById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listUsers() {
|
async function findPrimaryAdminUser() {
|
||||||
const rows = await query(`
|
const rows = await query(
|
||||||
SELECT
|
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
|
||||||
u.id,
|
)
|
||||||
u.email,
|
return mapUserRow(rows[0])
|
||||||
u.nickname,
|
}
|
||||||
u.is_admin,
|
|
||||||
u.avatar_src,
|
async function listUsers({ queryText = '', sort = 'recent' } = {}) {
|
||||||
u.created_at,
|
const where = []
|
||||||
COUNT(t.id) AS tierlist_count,
|
const params = []
|
||||||
GREATEST(
|
const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : ''
|
||||||
|
|
||||||
|
if (trimmedQuery) {
|
||||||
|
where.push('(u.email LIKE ? OR u.nickname LIKE ?)')
|
||||||
|
params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBy =
|
||||||
|
sort === 'created'
|
||||||
|
? 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
|
||||||
|
: sort === 'tierlists'
|
||||||
|
? 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
|
||||||
|
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
|
||||||
|
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.email,
|
||||||
|
u.nickname,
|
||||||
|
u.is_admin,
|
||||||
|
u.avatar_src,
|
||||||
u.created_at,
|
u.created_at,
|
||||||
COALESCE(MAX(t.updated_at), 0)
|
COUNT(t.id) AS tierlist_count,
|
||||||
) AS recent_activity_at
|
GREATEST(
|
||||||
FROM users u
|
u.created_at,
|
||||||
LEFT JOIN tierlists t ON t.author_id = u.id
|
COALESCE(MAX(t.updated_at), 0)
|
||||||
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
|
) AS recent_activity_at
|
||||||
ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC
|
FROM users u
|
||||||
`)
|
LEFT JOIN tierlists t ON t.author_id = u.id
|
||||||
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||||
|
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
)
|
||||||
return rows.map(mapUserRow)
|
return rows.map(mapUserRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1852,6 +1879,7 @@ module.exports = {
|
|||||||
findUserById,
|
findUserById,
|
||||||
createUser,
|
createUser,
|
||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
|
findPrimaryAdminUser,
|
||||||
listUsers,
|
listUsers,
|
||||||
adminUpdateUser,
|
adminUpdateUser,
|
||||||
adminUpdateUserPassword,
|
adminUpdateUserPassword,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const {
|
|||||||
findCustomItemsByIds,
|
findCustomItemsByIds,
|
||||||
deleteCustomItems,
|
deleteCustomItems,
|
||||||
listUsers,
|
listUsers,
|
||||||
|
findPrimaryAdminUser,
|
||||||
listAdminTierLists,
|
listAdminTierLists,
|
||||||
findTierListById,
|
findTierListById,
|
||||||
listAdminTemplateRequests,
|
listAdminTemplateRequests,
|
||||||
@@ -62,6 +63,30 @@ function buildItemLabelFromFilename(file) {
|
|||||||
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
||||||
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
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) => {
|
router.post('/games', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
||||||
const parsed = schema.safeParse(req.body)
|
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) => {
|
router.get('/users', requireAdmin, async (req, res) => {
|
||||||
const users = await listUsers()
|
const schema = z.object({
|
||||||
res.json({ users })
|
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) => {
|
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)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
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) {
|
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
|
||||||
return res.status(400).json({ error: 'self_admin_required' })
|
return res.status(400).json({ error: 'self_admin_required' })
|
||||||
}
|
}
|
||||||
|
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
|
||||||
const user = await findUserById(req.params.userId)
|
return res.status(403).json({ error: 'primary_admin_protected' })
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
}
|
||||||
|
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 {
|
try {
|
||||||
const updated = await adminUpdateUser({
|
const updated = await adminUpdateUser({
|
||||||
id: user.id,
|
id: targetUser.id,
|
||||||
email: parsed.data.email,
|
email: parsed.data.email,
|
||||||
nickname: parsed.data.nickname,
|
nickname: parsed.data.nickname,
|
||||||
isAdmin: parsed.data.isAdmin,
|
isAdmin: parsed.data.isAdmin,
|
||||||
})
|
})
|
||||||
res.json({ user: updated })
|
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e && e.code === 'ER_DUP_ENTRY') {
|
if (e && e.code === 'ER_DUP_ENTRY') {
|
||||||
return res.status(409).json({ error: 'email_taken' })
|
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)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const user = await findUserById(req.params.userId)
|
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
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
|
const optimized = req.file
|
||||||
? await writeOptimizedImage({
|
? await writeOptimizedImage({
|
||||||
@@ -594,16 +645,16 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
||||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
|
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || ''
|
||||||
const updated = await adminUpdateUser({
|
const updated = await adminUpdateUser({
|
||||||
id: user.id,
|
id: targetUser.id,
|
||||||
email: user.email,
|
email: targetUser.email,
|
||||||
nickname: user.nickname || '',
|
nickname: targetUser.nickname || '',
|
||||||
isAdmin: !!user.isAdmin,
|
isAdmin: !!targetUser.isAdmin,
|
||||||
avatarSrc: nextAvatarSrc,
|
avatarSrc: nextAvatarSrc,
|
||||||
})
|
})
|
||||||
|
|
||||||
res.json({ user: updated })
|
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
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' })
|
return res.status(400).json({ error: 'cannot_delete_self' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await findUserById(req.params.userId)
|
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
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 })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -625,11 +685,14 @@ router.patch('/users/:userId/password', requireAdmin, async (req, res) => {
|
|||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const user = await findUserById(req.params.userId)
|
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
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)
|
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 })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const {
|
|||||||
findUserById,
|
findUserById,
|
||||||
createUser,
|
createUser,
|
||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
|
findPrimaryAdminUser,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAuth } = require('../middleware/auth')
|
const { requireAuth } = require('../middleware/auth')
|
||||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||||
@@ -25,6 +26,24 @@ const profileSchema = z.object({
|
|||||||
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
|
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function serializeUser(user) {
|
||||||
|
if (!user) return null
|
||||||
|
const primaryAdmin = await findPrimaryAdminUser()
|
||||||
|
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname || '',
|
||||||
|
isAdmin: !!user.isAdmin,
|
||||||
|
isPrimaryAdmin,
|
||||||
|
isOperator: !!user.isAdmin && !isPrimaryAdmin,
|
||||||
|
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
|
||||||
|
avatarSrc: user.avatarSrc || '',
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/signup', async (req, res) => {
|
router.post('/signup', async (req, res) => {
|
||||||
const parsed = signupSchema.safeParse(req.body)
|
const parsed = signupSchema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
@@ -39,9 +58,9 @@ router.post('/signup', async (req, res) => {
|
|||||||
|
|
||||||
req.session.userId = user.id
|
req.session.userId = user.id
|
||||||
req.session.isAdmin = !!user.isAdmin
|
req.session.isAdmin = !!user.isAdmin
|
||||||
req.session.save((err) => {
|
req.session.save(async (err) => {
|
||||||
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
||||||
res.json(user)
|
res.json(await serializeUser(user))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,16 +77,9 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
req.session.userId = user.id
|
req.session.userId = user.id
|
||||||
req.session.isAdmin = !!user.isAdmin
|
req.session.isAdmin = !!user.isAdmin
|
||||||
req.session.save((err) => {
|
req.session.save(async (err) => {
|
||||||
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
||||||
res.json({
|
res.json(await serializeUser(user))
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
nickname: user.nickname || '',
|
|
||||||
isAdmin: !!user.isAdmin,
|
|
||||||
avatarSrc: user.avatarSrc || '',
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,7 +92,7 @@ router.get('/me', async (req, res) => {
|
|||||||
if (!req.session || !req.session.userId) return res.json({ user: null })
|
if (!req.session || !req.session.userId) return res.json({ user: null })
|
||||||
const user = await findUserById(req.session.userId)
|
const user = await findUserById(req.session.userId)
|
||||||
if (!user) return res.json({ user: null })
|
if (!user) return res.json({ user: null })
|
||||||
res.json({ user })
|
res.json({ user: await serializeUser(user) })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/meta', async (req, res) => {
|
router.get('/meta', async (req, res) => {
|
||||||
@@ -115,7 +127,7 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
|||||||
avatarSrc: nextAvatarSrc,
|
avatarSrc: nextAvatarSrc,
|
||||||
})
|
})
|
||||||
|
|
||||||
res.json({ user: updated })
|
res.json({ user: await serializeUser(updated) })
|
||||||
})
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
|
|
||||||
## 중기 개선
|
## 중기 개선
|
||||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||||
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-01 v1.3.11
|
||||||
|
- **회원 관리 편집 모달 전환**: 관리자 회원 카드를 읽기 전용 정보 카드로 바꾸고, `회원 정보 수정` 버튼으로 Settings 톤의 편집 모달에서 이메일/닉네임/운영자 권한을 저장하도록 재구성
|
||||||
|
- **회원 검색/정렬 추가**: 회원 관리 상단에 이메일/닉네임 검색과 `최근 활동순`, `가입순`, `작성 티어표 많은 순` 정렬을 추가해 운영자가 원하는 기준으로 목록을 다시 볼 수 있도록 확장
|
||||||
|
- **최고 관리자 보호 도입**: 가장 먼저 생성된 관리자 계정을 `최고 관리자`로 구분하고, 운영자는 최고 관리자 권한/아바타/비밀번호/삭제를 변경할 수 없도록 백엔드 보호 로직과 역할 메타데이터를 추가
|
||||||
|
|
||||||
## 2026-04-01 v1.3.10
|
## 2026-04-01 v1.3.10
|
||||||
- 게임 허브 공개 티어표 카드 그리드는 최소/최대 폭을 고정해, 목록이 1~2장뿐일 때도 카드가 화면 전체를 먹으며 과하게 커지지 않도록 보정함.
|
- 게임 허브 공개 티어표 카드 그리드는 최소/최대 폭을 고정해, 목록이 1~2장뿐일 때도 카드가 화면 전체를 먹으며 과하게 커지지 않도록 보정함.
|
||||||
- 티어표 행 삭제는 상단 아이콘 대신 우측 하단의 작은 텍스트 액션으로 바꿔, 랭크 카드 안에서 더 조용하고 정돈된 편집 흐름으로 정리함.
|
- 티어표 행 삭제는 상단 아이콘 대신 우측 하단의 작은 텍스트 액션으로 바꿔, 랭크 카드 안에서 더 조용하고 정돈된 편집 흐름으로 정리함.
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ export const api = {
|
|||||||
approveAdminTemplateRequest: (requestId, payload) =>
|
approveAdminTemplateRequest: (requestId, payload) =>
|
||||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
|
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
|
||||||
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
|
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
|
||||||
listAdminUsers: () => request('/api/admin/users'),
|
listAdminUsers: ({ q = '', sort = 'recent' } = {}) =>
|
||||||
|
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
|
||||||
updateAdminUser: (userId, payload) =>
|
updateAdminUser: (userId, payload) =>
|
||||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||||
updateAdminUserPassword: (userId, payload) =>
|
updateAdminUserPassword: (userId, payload) =>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const importModalNewGameId = ref('')
|
|||||||
const importModalNewGameName = ref('')
|
const importModalNewGameName = ref('')
|
||||||
const previewModalOpen = ref(false)
|
const previewModalOpen = ref(false)
|
||||||
const previewTierList = ref(null)
|
const previewTierList = ref(null)
|
||||||
|
const userEditModalOpen = ref(false)
|
||||||
const userPasswordModalOpen = ref(false)
|
const userPasswordModalOpen = ref(false)
|
||||||
const userDeleteModalOpen = ref(false)
|
const userDeleteModalOpen = ref(false)
|
||||||
const userRoleModalOpen = ref(false)
|
const userRoleModalOpen = ref(false)
|
||||||
@@ -55,9 +56,14 @@ const customItemDeleteModalOpen = ref(false)
|
|||||||
const modalTargetUser = ref(null)
|
const modalTargetUser = ref(null)
|
||||||
const modalPasswordDraft = ref('')
|
const modalPasswordDraft = ref('')
|
||||||
const modalRoleNextAdmin = ref(false)
|
const modalRoleNextAdmin = ref(false)
|
||||||
|
const modalUserDraftEmail = ref('')
|
||||||
|
const modalUserDraftNickname = ref('')
|
||||||
|
const modalUserDraftIsAdmin = ref(false)
|
||||||
const modalTargetCustomItem = ref(null)
|
const modalTargetCustomItem = ref(null)
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
|
const userQuery = ref('')
|
||||||
|
const userSort = ref('recent')
|
||||||
const imageStats = ref(null)
|
const imageStats = ref(null)
|
||||||
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
|
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
|
||||||
const imageRecentJobs = ref([])
|
const imageRecentJobs = ref([])
|
||||||
@@ -128,7 +134,7 @@ const adminOverviewStats = computed(() => {
|
|||||||
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
||||||
const pendingRequests = templateRequests.value.length
|
const pendingRequests = templateRequests.value.length
|
||||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||||
const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length
|
const adminCount = users.value.filter((user) => user.isAdmin).length
|
||||||
|
|
||||||
if (activeTab.value === 'featured') {
|
if (activeTab.value === 'featured') {
|
||||||
return [
|
return [
|
||||||
@@ -351,9 +357,25 @@ function setUserAvatarInput(userId, el) {
|
|||||||
userAvatarInputs.value[userId] = el
|
userAvatarInputs.value[userId] = el
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUserDirty(user) {
|
const canManageModalRole = computed(() => {
|
||||||
if (!user) return false
|
if (!auth.user?.isPrimaryAdmin) return false
|
||||||
return user.draftEmail !== user.email || (user.draftNickname || '') !== (user.nickname || '') || !!user.draftIsAdmin !== !!user.isAdmin
|
if (!modalTargetUser.value) return false
|
||||||
|
return !modalTargetUser.value.isPrimaryAdmin
|
||||||
|
})
|
||||||
|
|
||||||
|
const isUserEditDirty = computed(() => {
|
||||||
|
if (!modalTargetUser.value) return false
|
||||||
|
return (
|
||||||
|
modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') ||
|
||||||
|
modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') ||
|
||||||
|
!!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function roleLabelOf(user) {
|
||||||
|
if (user?.isPrimaryAdmin) return '최고 관리자'
|
||||||
|
if (user?.isAdmin) return '운영자'
|
||||||
|
return '일반 회원'
|
||||||
}
|
}
|
||||||
|
|
||||||
function openUserAvatarPicker(user) {
|
function openUserAvatarPicker(user) {
|
||||||
@@ -372,17 +394,14 @@ async function uploadUserAvatar(user, file, { remove = false } = {}) {
|
|||||||
entry.id === updated.id
|
entry.id === updated.id
|
||||||
? {
|
? {
|
||||||
...entry,
|
...entry,
|
||||||
avatarSrc: updated.avatarSrc || '',
|
...updated,
|
||||||
email: updated.email,
|
|
||||||
nickname: updated.nickname || '',
|
|
||||||
isAdmin: !!updated.isAdmin,
|
|
||||||
draftEmail: updated.email,
|
|
||||||
draftNickname: updated.nickname || '',
|
|
||||||
draftIsAdmin: !!updated.isAdmin,
|
|
||||||
isAvatarBusy: false,
|
isAvatarBusy: false,
|
||||||
}
|
}
|
||||||
: entry
|
: entry
|
||||||
)
|
)
|
||||||
|
if (modalTargetUser.value?.id === updated.id) {
|
||||||
|
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
|
||||||
|
}
|
||||||
if (updated.id === auth.user?.id) await auth.refresh()
|
if (updated.id === auth.user?.id) await auth.refresh()
|
||||||
await refreshUsers()
|
await refreshUsers()
|
||||||
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
|
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
|
||||||
@@ -496,12 +515,9 @@ async function refreshTemplateRequests() {
|
|||||||
async function refreshUsers() {
|
async function refreshUsers() {
|
||||||
if (!auth.user?.isAdmin) return
|
if (!auth.user?.isAdmin) return
|
||||||
try {
|
try {
|
||||||
const data = await api.listAdminUsers()
|
const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value })
|
||||||
users.value = (data.users || []).map((user) => ({
|
users.value = (data.users || []).map((user) => ({
|
||||||
...user,
|
...user,
|
||||||
draftEmail: user.email,
|
|
||||||
draftNickname: user.nickname || '',
|
|
||||||
draftIsAdmin: !!user.isAdmin,
|
|
||||||
isAvatarBusy: false,
|
isAvatarBusy: false,
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -778,29 +794,45 @@ async function removeGame() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveUser(user) {
|
function openUserEditModal(user) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
|
modalUserDraftEmail.value = user?.email || ''
|
||||||
|
modalUserDraftNickname.value = user?.nickname || ''
|
||||||
|
modalUserDraftIsAdmin.value = !!user?.isAdmin
|
||||||
|
userEditModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserEditModal() {
|
||||||
|
userEditModalOpen.value = false
|
||||||
|
modalTargetUser.value = null
|
||||||
|
modalUserDraftEmail.value = ''
|
||||||
|
modalUserDraftNickname.value = ''
|
||||||
|
modalUserDraftIsAdmin.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserEdit() {
|
||||||
|
resetMessages()
|
||||||
|
if (!modalTargetUser.value?.id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.updateAdminUser(user.id, {
|
const data = await api.updateAdminUser(modalTargetUser.value.id, {
|
||||||
email: user.draftEmail,
|
email: modalUserDraftEmail.value.trim(),
|
||||||
nickname: user.draftNickname,
|
nickname: modalUserDraftNickname.value.trim(),
|
||||||
isAdmin: !!user.draftIsAdmin,
|
isAdmin: !!modalUserDraftIsAdmin.value,
|
||||||
})
|
})
|
||||||
const updated = data.user
|
const updated = data.user
|
||||||
users.value = users.value.map((entry) =>
|
users.value = users.value.map((entry) =>
|
||||||
entry.id === updated.id
|
entry.id === updated.id
|
||||||
? {
|
? {
|
||||||
...entry,
|
...entry,
|
||||||
email: updated.email,
|
...updated,
|
||||||
nickname: updated.nickname || '',
|
isAvatarBusy: entry.isAvatarBusy || false,
|
||||||
isAdmin: !!updated.isAdmin,
|
|
||||||
draftEmail: updated.email,
|
|
||||||
draftNickname: updated.nickname || '',
|
|
||||||
draftIsAdmin: !!updated.isAdmin,
|
|
||||||
}
|
}
|
||||||
: entry
|
: entry
|
||||||
)
|
)
|
||||||
if (updated.id === auth.user?.id) await auth.refresh()
|
if (updated.id === auth.user?.id) await auth.refresh()
|
||||||
|
closeUserEditModal()
|
||||||
await refreshUsers()
|
await refreshUsers()
|
||||||
success.value = '회원 정보를 저장했어요.'
|
success.value = '회원 정보를 저장했어요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -810,7 +842,7 @@ async function saveUser(user) {
|
|||||||
|
|
||||||
function openUserPasswordModal(user) {
|
function openUserPasswordModal(user) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
modalTargetUser.value = user || null
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
modalPasswordDraft.value = ''
|
modalPasswordDraft.value = ''
|
||||||
userPasswordModalOpen.value = true
|
userPasswordModalOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -842,7 +874,7 @@ async function confirmUserPasswordReset() {
|
|||||||
|
|
||||||
function openUserDeleteModal(user) {
|
function openUserDeleteModal(user) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
modalTargetUser.value = user || null
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
userDeleteModalOpen.value = true
|
userDeleteModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,34 +901,31 @@ async function confirmUserDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function openUserRoleModal(user) {
|
function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
modalTargetUser.value = user || null
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
modalRoleNextAdmin.value = !user?.draftIsAdmin
|
modalRoleNextAdmin.value = !!nextIsAdmin
|
||||||
userRoleModalOpen.value = true
|
userRoleModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeUserRoleModal() {
|
function closeUserRoleModal() {
|
||||||
userRoleModalOpen.value = false
|
userRoleModalOpen.value = false
|
||||||
modalTargetUser.value = null
|
if (!userEditModalOpen.value) modalTargetUser.value = null
|
||||||
modalRoleNextAdmin.value = false
|
modalRoleNextAdmin.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmUserRoleDraft() {
|
function confirmUserRoleDraft() {
|
||||||
if (!modalTargetUser.value?.id) return
|
if (!modalTargetUser.value?.id) return
|
||||||
users.value = users.value.map((entry) =>
|
modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
|
||||||
entry.id === modalTargetUser.value.id
|
const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
|
||||||
? {
|
|
||||||
...entry,
|
|
||||||
draftIsAdmin: modalRoleNextAdmin.value,
|
|
||||||
}
|
|
||||||
: entry
|
|
||||||
)
|
|
||||||
const targetLabel = modalRoleNextAdmin.value ? '관리자로 지정했어요. 저장하면 반영됩니다.' : '관리자 권한 해제로 표시했어요. 저장하면 반영됩니다.'
|
|
||||||
closeUserRoleModal()
|
closeUserRoleModal()
|
||||||
success.value = targetLabel
|
success.value = targetLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitUserFilters() {
|
||||||
|
refreshUsers()
|
||||||
|
}
|
||||||
|
|
||||||
function submitCustomItemSearch() {
|
function submitCustomItemSearch() {
|
||||||
customItemPage.value = 1
|
customItemPage.value = 1
|
||||||
refreshCustomItems()
|
refreshCustomItems()
|
||||||
@@ -1548,6 +1577,21 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar toolbar--secondary">
|
||||||
|
<input
|
||||||
|
v-model="userQuery"
|
||||||
|
class="input toolbar__search"
|
||||||
|
placeholder="이메일, 닉네임 검색"
|
||||||
|
@keydown.enter.prevent="submitUserFilters"
|
||||||
|
/>
|
||||||
|
<select v-model="userSort" class="select toolbar__select" @change="submitUserFilters">
|
||||||
|
<option value="recent">최근 활동순</option>
|
||||||
|
<option value="created">가입순</option>
|
||||||
|
<option value="tierlists">작성 티어표 많은 순</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn--ghost toolbar__button" type="button" @click="submitUserFilters">조회</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
|
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
|
||||||
<div v-else class="userList">
|
<div v-else class="userList">
|
||||||
<article v-for="user in users" :key="user.id" class="userCard">
|
<article v-for="user in users" :key="user.id" class="userCard">
|
||||||
@@ -1584,25 +1628,17 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="user.draftIsAdmin" class="roleBadge userCard__roleBadge">Administrator</div>
|
<div v-if="user.isAdmin" class="roleBadge userCard__roleBadge">{{ roleLabelOf(user) }}</div>
|
||||||
|
|
||||||
<div class="userInfoList">
|
<div class="userInfoList">
|
||||||
<div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div>
|
<div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div>
|
||||||
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}개</strong></div>
|
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}개</strong></div>
|
||||||
<div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
<div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
||||||
|
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
|
||||||
|
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
|
||||||
|
<div class="userInfoLine"><span>권한</span><strong>{{ roleLabelOf(user) }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input v-model="user.draftEmail" class="input" placeholder="이메일" />
|
|
||||||
<input v-model="user.draftNickname" class="input" placeholder="닉네임" />
|
|
||||||
<button
|
|
||||||
class="userRoleAction"
|
|
||||||
type="button"
|
|
||||||
:disabled="user.id === auth.user?.id"
|
|
||||||
@click="openUserRoleModal(user)"
|
|
||||||
>
|
|
||||||
{{ user.draftIsAdmin ? '관리자 권한 해제' : '관리자 권한 임명' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="userCard__actions userCard__actions--compact">
|
<div class="userCard__actions userCard__actions--compact">
|
||||||
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
|
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
|
||||||
<SvgIcon class="iconActionButton__icon" :src="lockResetIcon" :size="18" />
|
<SvgIcon class="iconActionButton__icon" :src="lockResetIcon" :size="18" />
|
||||||
@@ -1610,7 +1646,7 @@ async function saveFeaturedOrder() {
|
|||||||
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)">
|
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)">
|
||||||
<SvgIcon class="iconActionButton__icon" :src="deleteIcon" :size="18" />
|
<SvgIcon class="iconActionButton__icon" :src="deleteIcon" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--ghost userSaveButton" :disabled="!isUserDirty(user)" @click="saveUser(user)">회원정보 저장</button>
|
<button class="btn btn--ghost userSaveButton" type="button" @click="openUserEditModal(user)">회원 정보 수정</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -1632,6 +1668,39 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="userEditModalOpen" class="modalOverlay" @click.self="closeUserEditModal">
|
||||||
|
<div class="modalCard modalCard--userEdit" role="dialog" aria-modal="true">
|
||||||
|
<div class="modalCard__title">회원 정보 수정</div>
|
||||||
|
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정의 정보와 권한을 조정할 수 있어요.` : '' }}</div>
|
||||||
|
<div class="userEditForm">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">이메일</span>
|
||||||
|
<input v-model="modalUserDraftEmail" class="field__input" placeholder="계정 이메일" />
|
||||||
|
<span class="field__hint">로그인 계정으로 사용하는 이메일입니다.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">닉네임</span>
|
||||||
|
<input v-model="modalUserDraftNickname" class="field__input" placeholder="표시용 닉네임" />
|
||||||
|
<span class="field__hint">티어표 작성자명과 프로필에 표시됩니다.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canManageModalRole"
|
||||||
|
class="userRoleAction"
|
||||||
|
type="button"
|
||||||
|
@click="openUserRoleModal(modalTargetUser, !modalUserDraftIsAdmin)"
|
||||||
|
>
|
||||||
|
{{ modalUserDraftIsAdmin ? '운영자 권한 해제' : '운영자 권한 부여' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modalCard__actions">
|
||||||
|
<button class="btn btn--ghost" @click="closeUserEditModal">취소</button>
|
||||||
|
<button class="btn btn--primary" :disabled="!isUserEditDirty" @click="saveUserEdit">회원 정보 저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
|
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true">
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
<div class="modalCard__title">비밀번호 초기화</div>
|
<div class="modalCard__title">비밀번호 초기화</div>
|
||||||
@@ -1659,13 +1728,13 @@ async function saveFeaturedOrder() {
|
|||||||
|
|
||||||
<div v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal">
|
<div v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true">
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
<div class="modalCard__title">관리자 권한 변경</div>
|
<div class="modalCard__title">운영자 권한 변경</div>
|
||||||
<div class="modalCard__desc">
|
<div class="modalCard__desc">
|
||||||
{{
|
{{
|
||||||
modalTargetUser
|
modalTargetUser
|
||||||
? modalRoleNextAdmin
|
? modalRoleNextAdmin
|
||||||
? `${userDisplayName(modalTargetUser)} 사용자를 관리자로 임명할까요?`
|
? `${userDisplayName(modalTargetUser)} 사용자를 운영자로 지정할까요?`
|
||||||
: `${userDisplayName(modalTargetUser)} 사용자의 관리자 권한을 해제할까요?`
|
: `${userDisplayName(modalTargetUser)} 사용자의 운영자 권한을 해제할까요?`
|
||||||
: ''
|
: ''
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
@@ -2995,6 +3064,44 @@ async function saveFeaturedOrder() {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.field__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
}
|
||||||
|
.field__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.94);
|
||||||
|
outline: none;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.field__input:focus {
|
||||||
|
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||||
|
}
|
||||||
|
.field__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userEditForm {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.userEditForm .field {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.modalCard--userEdit {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
.userCard__actions {
|
.userCard__actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user