Merge pull request 'release: 인증 시스템 역할/권한 관리 기능 강화' (#8) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m26s
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m26s
Reviewed-on: #8
This commit is contained in:
커밋
8bc2cfa7f3
@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||
import { listRolesWithPermissions, updatePermissions, updateRoleDefault } from './roleService.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault } from './roleService.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@ -18,6 +19,62 @@ router.get('/', async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/roles — 역할 생성
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { code, name, description } = req.body
|
||||
|
||||
if (!code || !name) {
|
||||
res.status(400).json({ error: '역할 코드와 이름은 필수입니다.' })
|
||||
return
|
||||
}
|
||||
|
||||
const role = await createRole({ code, name, description })
|
||||
res.status(201).json(role)
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
res.status(err.status).json({ error: err.message })
|
||||
return
|
||||
}
|
||||
console.error('[roles] 역할 생성 오류:', err)
|
||||
res.status(500).json({ error: '역할 생성 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/roles/:id — 역할 수정
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const roleSn = Number(req.params.id)
|
||||
const { name, description } = req.body
|
||||
|
||||
await updateRole(roleSn, { name, description })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
res.status(err.status).json({ error: err.message })
|
||||
return
|
||||
}
|
||||
console.error('[roles] 역할 수정 오류:', err)
|
||||
res.status(500).json({ error: '역할 수정 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/roles/:id — 역할 삭제
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const roleSn = Number(req.params.id)
|
||||
await deleteRole(roleSn)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
res.status(err.status).json({ error: err.message })
|
||||
return
|
||||
}
|
||||
console.error('[roles] 역할 삭제 오류:', err)
|
||||
res.status(500).json({ error: '역할 삭제 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/roles/:id/permissions
|
||||
router.put('/:id/permissions', async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
import { authPool } from '../db/authDb.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
|
||||
const PERM_RESOURCE_CODES = [
|
||||
'prediction', 'hns', 'rescue', 'reports', 'aerial',
|
||||
'assets', 'scat', 'incidents', 'board', 'weather', 'admin',
|
||||
] as const
|
||||
|
||||
const PROTECTED_ROLE_CODES = ['ADMIN']
|
||||
|
||||
interface RoleWithPermissions {
|
||||
sn: number
|
||||
@ -13,6 +21,17 @@ interface RoleWithPermissions {
|
||||
}>
|
||||
}
|
||||
|
||||
interface CreateRoleInput {
|
||||
code: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface UpdateRoleInput {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]> {
|
||||
const rolesResult = await authPool.query(
|
||||
`SELECT ROLE_SN as sn, ROLE_CD as code, ROLE_NM as name, ROLE_DC as description, DFLT_YN as is_default
|
||||
@ -52,6 +71,110 @@ export async function updateRoleDefault(roleSn: number, isDefault: boolean): Pro
|
||||
)
|
||||
}
|
||||
|
||||
export async function createRole(input: CreateRoleInput): Promise<RoleWithPermissions> {
|
||||
if (!/^[A-Z][A-Z0-9_]{0,18}[A-Z0-9]$/.test(input.code)) {
|
||||
throw new AuthError('역할 코드는 영문 대문자, 숫자, 언더스코어만 허용되며 2~20자입니다.', 400)
|
||||
}
|
||||
|
||||
const client = await authPool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
|
||||
const existing = await client.query(
|
||||
'SELECT 1 FROM AUTH_ROLE WHERE ROLE_CD = $1', [input.code]
|
||||
)
|
||||
if (existing.rows.length > 0) {
|
||||
throw new AuthError('이미 존재하는 역할 코드입니다.', 409)
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC) VALUES ($1, $2, $3)
|
||||
RETURNING ROLE_SN as sn, ROLE_CD as code, ROLE_NM as name, ROLE_DC as description, DFLT_YN as is_default`,
|
||||
[input.code, input.name, input.description || null]
|
||||
)
|
||||
const row = result.rows[0]
|
||||
|
||||
for (const rsrc of PERM_RESOURCE_CODES) {
|
||||
await client.query(
|
||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
|
||||
[row.sn, rsrc, 'N']
|
||||
)
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
|
||||
const permsResult = await authPool.query(
|
||||
'SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD',
|
||||
[row.sn]
|
||||
)
|
||||
|
||||
return {
|
||||
sn: row.sn,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
isDefault: row.is_default === 'Y',
|
||||
permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({
|
||||
sn: p.sn,
|
||||
resourceCode: p.resource_code,
|
||||
granted: p.granted === 'Y',
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRole(roleSn: number, input: UpdateRoleInput): Promise<void> {
|
||||
const roleResult = await authPool.query(
|
||||
'SELECT ROLE_CD FROM AUTH_ROLE WHERE ROLE_SN = $1', [roleSn]
|
||||
)
|
||||
if (roleResult.rows.length === 0) {
|
||||
throw new AuthError('역할을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
const sets: string[] = []
|
||||
const params: (string | number | null)[] = []
|
||||
let idx = 1
|
||||
|
||||
if (input.name !== undefined) {
|
||||
sets.push(`ROLE_NM = $${idx++}`)
|
||||
params.push(input.name)
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
sets.push(`ROLE_DC = $${idx++}`)
|
||||
params.push(input.description)
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
throw new AuthError('수정할 항목이 없습니다.', 400)
|
||||
}
|
||||
|
||||
params.push(roleSn)
|
||||
await authPool.query(
|
||||
`UPDATE AUTH_ROLE SET ${sets.join(', ')} WHERE ROLE_SN = $${idx}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteRole(roleSn: number): Promise<void> {
|
||||
const roleResult = await authPool.query(
|
||||
'SELECT ROLE_CD FROM AUTH_ROLE WHERE ROLE_SN = $1', [roleSn]
|
||||
)
|
||||
if (roleResult.rows.length === 0) {
|
||||
throw new AuthError('역할을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
if (PROTECTED_ROLE_CODES.includes(roleResult.rows[0].role_cd)) {
|
||||
throw new AuthError('시스템 보호 역할은 삭제할 수 없습니다.', 403)
|
||||
}
|
||||
|
||||
await authPool.query('DELETE FROM AUTH_ROLE WHERE ROLE_SN = $1', [roleSn])
|
||||
}
|
||||
|
||||
export async function updatePermissions(
|
||||
roleSn: number,
|
||||
permissions: Array<{ resourceCode: string; granted: boolean }>
|
||||
|
||||
@ -14,6 +14,7 @@ interface UserListItem {
|
||||
failCount: number
|
||||
lastLogin: string | null
|
||||
roles: string[]
|
||||
roleSns: number[]
|
||||
regDtm: string
|
||||
oauthProvider: string | null
|
||||
email: string | null
|
||||
@ -69,7 +70,7 @@ export async function listUsers(search?: string, status?: string): Promise<UserL
|
||||
const users: UserListItem[] = []
|
||||
for (const row of result.rows) {
|
||||
const rolesResult = await authPool.query(
|
||||
`SELECT r.ROLE_CD FROM AUTH_USER_ROLE ur JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN WHERE ur.USER_ID = $1`,
|
||||
`SELECT r.ROLE_CD, r.ROLE_SN FROM AUTH_USER_ROLE ur JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN WHERE ur.USER_ID = $1`,
|
||||
[row.id]
|
||||
)
|
||||
users.push({
|
||||
@ -84,6 +85,7 @@ export async function listUsers(search?: string, status?: string): Promise<UserL
|
||||
failCount: row.fail_count,
|
||||
lastLogin: row.last_login,
|
||||
roles: rolesResult.rows.map((r: { role_cd: string }) => r.role_cd),
|
||||
roleSns: rolesResult.rows.map((r: { role_sn: number }) => r.role_sn),
|
||||
regDtm: row.reg_dtm,
|
||||
oauthProvider: row.oauth_provider,
|
||||
email: row.email,
|
||||
@ -114,7 +116,7 @@ export async function getUser(userId: string): Promise<UserListItem> {
|
||||
const row = result.rows[0]
|
||||
|
||||
const rolesResult = await authPool.query(
|
||||
`SELECT r.ROLE_CD FROM AUTH_USER_ROLE ur JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN WHERE ur.USER_ID = $1`,
|
||||
`SELECT r.ROLE_CD, r.ROLE_SN FROM AUTH_USER_ROLE ur JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN WHERE ur.USER_ID = $1`,
|
||||
[userId]
|
||||
)
|
||||
|
||||
@ -130,6 +132,7 @@ export async function getUser(userId: string): Promise<UserListItem> {
|
||||
failCount: row.fail_count,
|
||||
lastLogin: row.last_login,
|
||||
roles: rolesResult.rows.map((r: { role_cd: string }) => r.role_cd),
|
||||
roleSns: rolesResult.rows.map((r: { role_sn: number }) => r.role_sn),
|
||||
regDtm: row.reg_dtm,
|
||||
oauthProvider: row.oauth_provider,
|
||||
email: row.email,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useSubMenu } from '../../hooks/useSubMenu'
|
||||
import {
|
||||
fetchUsers,
|
||||
@ -8,6 +8,10 @@ import {
|
||||
updateRoleDefaultApi,
|
||||
approveUserApi,
|
||||
rejectUserApi,
|
||||
assignRolesApi,
|
||||
createRoleApi,
|
||||
updateRoleApi,
|
||||
deleteRoleApi,
|
||||
fetchRegistrationSettings,
|
||||
updateRegistrationSettingsApi,
|
||||
fetchOAuthSettings,
|
||||
@ -18,11 +22,19 @@ import {
|
||||
type OAuthSettings,
|
||||
} from '../../services/authApi'
|
||||
|
||||
const roleLabels: Record<string, { label: string; color: string }> = {
|
||||
ADMIN: { label: '관리자', color: 'var(--red)' },
|
||||
MANAGER: { label: '매니저', color: 'var(--orange)' },
|
||||
USER: { label: '사용자', color: 'var(--cyan)' },
|
||||
VIEWER: { label: '뷰어', color: 'var(--t3)' },
|
||||
const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: 'var(--red)',
|
||||
MANAGER: 'var(--orange)',
|
||||
USER: 'var(--cyan)',
|
||||
VIEWER: 'var(--t3)',
|
||||
}
|
||||
|
||||
const CUSTOM_ROLE_COLORS = [
|
||||
'#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf',
|
||||
]
|
||||
|
||||
function getRoleColor(code: string, index: number): string {
|
||||
return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length]
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, { label: string; color: string; dot: string }> = {
|
||||
@ -52,6 +64,10 @@ function UsersPanel() {
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [users, setUsers] = useState<UserListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [allRoles, setAllRoles] = useState<RoleWithPermissions[]>([])
|
||||
const [roleEditUserId, setRoleEditUserId] = useState<string | null>(null)
|
||||
const [selectedRoleSns, setSelectedRoleSns] = useState<number[]>([])
|
||||
const roleDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@ -69,6 +85,22 @@ function UsersPanel() {
|
||||
loadUsers()
|
||||
}, [loadUsers])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles().then(setAllRoles).catch(console.error)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) {
|
||||
setRoleEditUserId(null)
|
||||
}
|
||||
}
|
||||
if (roleEditUserId) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [roleEditUserId])
|
||||
|
||||
const handleUnlock = async (userId: string) => {
|
||||
try {
|
||||
await updateUserApi(userId, { status: 'ACTIVE' })
|
||||
@ -96,6 +128,45 @@ function UsersPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeactivate = async (userId: string) => {
|
||||
try {
|
||||
await updateUserApi(userId, { status: 'INACTIVE' })
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
console.error('사용자 비활성화 실패:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivate = async (userId: string) => {
|
||||
try {
|
||||
await updateUserApi(userId, { status: 'ACTIVE' })
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
console.error('사용자 활성화 실패:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRoleEdit = (user: UserListItem) => {
|
||||
setRoleEditUserId(user.id)
|
||||
setSelectedRoleSns(user.roleSns || [])
|
||||
}
|
||||
|
||||
const toggleRoleSelection = (roleSn: number) => {
|
||||
setSelectedRoleSns(prev =>
|
||||
prev.includes(roleSn) ? prev.filter(s => s !== roleSn) : [...prev, roleSn]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSaveRoles = async (userId: string) => {
|
||||
try {
|
||||
await assignRolesApi(userId, selectedRoleSns)
|
||||
await loadUsers()
|
||||
setRoleEditUserId(null)
|
||||
} catch (err) {
|
||||
console.error('역할 할당 실패:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('ko-KR', {
|
||||
@ -165,8 +236,6 @@ function UsersPanel() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => {
|
||||
const primaryRole = user.roles[0] || 'USER'
|
||||
const roleInfo = roleLabels[primaryRole] || roleLabels.USER
|
||||
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE
|
||||
return (
|
||||
<tr key={user.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
@ -174,16 +243,74 @@ function UsersPanel() {
|
||||
<td className="px-6 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
|
||||
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.orgAbbr || '-'}</td>
|
||||
<td className="px-6 py-3">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="flex flex-wrap gap-1 cursor-pointer"
|
||||
onClick={() => handleOpenRoleEdit(user)}
|
||||
title="클릭하여 역할 변경"
|
||||
>
|
||||
{user.roles.length > 0 ? user.roles.map((roleCode, idx) => {
|
||||
const color = getRoleColor(roleCode, idx)
|
||||
const roleName = allRoles.find(r => r.code === roleCode)?.name || roleCode
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
|
||||
key={roleCode}
|
||||
className="px-2 py-0.5 text-[10px] font-semibold rounded-md font-korean"
|
||||
style={{
|
||||
background: `${roleInfo.color}20`,
|
||||
color: roleInfo.color,
|
||||
border: `1px solid ${roleInfo.color}40`
|
||||
background: `${color}20`,
|
||||
color: color,
|
||||
border: `1px solid ${color}40`
|
||||
}}
|
||||
>
|
||||
{roleInfo.label}
|
||||
{roleName}
|
||||
</span>
|
||||
)
|
||||
}) : (
|
||||
<span className="text-[10px] text-text-3 font-korean">역할 없음</span>
|
||||
)}
|
||||
<span className="text-[10px] text-text-3 ml-0.5">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
{roleEditUserId === user.id && (
|
||||
<div
|
||||
ref={roleDropdownRef}
|
||||
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-1 border border-border rounded-lg shadow-lg min-w-[200px]"
|
||||
>
|
||||
<div className="text-[10px] text-text-3 font-korean font-semibold mb-1.5 px-1">역할 선택</div>
|
||||
{allRoles.map((role, idx) => {
|
||||
const color = getRoleColor(role.code, idx)
|
||||
return (
|
||||
<label key={role.sn} className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoleSns.includes(role.sn)}
|
||||
onChange={() => toggleRoleSelection(role.sn)}
|
||||
style={{ accentColor: color }}
|
||||
/>
|
||||
<span className="text-xs font-korean" style={{ color }}>{role.name}</span>
|
||||
<span className="text-[10px] text-text-3 font-mono">{role.code}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-border">
|
||||
<button
|
||||
onClick={() => setRoleEditUserId(null)}
|
||||
className="px-3 py-1 text-[10px] text-text-3 border border-border rounded hover:bg-bg-hover font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSaveRoles(user.id)}
|
||||
disabled={selectedRoleSns.length === 0}
|
||||
className="px-3 py-1 text-[10px] font-semibold rounded bg-primary-cyan text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
{user.oauthProvider ? (
|
||||
@ -238,9 +365,22 @@ function UsersPanel() {
|
||||
잠금해제
|
||||
</button>
|
||||
)}
|
||||
<button className="px-2 py-1 text-[10px] font-semibold text-primary-cyan border border-primary-cyan rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean">
|
||||
수정
|
||||
{user.status === 'ACTIVE' && (
|
||||
<button
|
||||
onClick={() => handleDeactivate(user.id)}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-text-3 border border-border rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
비활성화
|
||||
</button>
|
||||
)}
|
||||
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
|
||||
<button
|
||||
onClick={() => handleActivate(user.id)}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
|
||||
>
|
||||
활성화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -274,6 +414,14 @@ function PermissionsPanel() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [newRoleCode, setNewRoleCode] = useState('')
|
||||
const [newRoleName, setNewRoleName] = useState('')
|
||||
const [newRoleDesc, setNewRoleDesc] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState('')
|
||||
const [editingRoleSn, setEditingRoleSn] = useState<number | null>(null)
|
||||
const [editRoleName, setEditRoleName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles()
|
||||
@ -345,6 +493,54 @@ function PermissionsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateRole = async () => {
|
||||
setCreating(true)
|
||||
setCreateError('')
|
||||
try {
|
||||
await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined })
|
||||
await loadRoles()
|
||||
setShowCreateForm(false)
|
||||
setNewRoleCode('')
|
||||
setNewRoleName('')
|
||||
setNewRoleDesc('')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.'
|
||||
setCreateError(message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRole = async (roleSn: number, roleName: string) => {
|
||||
if (!window.confirm(`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteRoleApi(roleSn)
|
||||
await loadRoles()
|
||||
} catch (err) {
|
||||
console.error('역할 삭제 실패:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartEditName = (role: RoleWithPermissions) => {
|
||||
setEditingRoleSn(role.sn)
|
||||
setEditRoleName(role.name)
|
||||
}
|
||||
|
||||
const handleSaveRoleName = async (roleSn: number) => {
|
||||
if (!editRoleName.trim()) return
|
||||
try {
|
||||
await updateRoleApi(roleSn, { name: editRoleName.trim() })
|
||||
setRoles(prev => prev.map(r =>
|
||||
r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r
|
||||
))
|
||||
setEditingRoleSn(null)
|
||||
} catch (err) {
|
||||
console.error('역할 이름 수정 실패:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||||
}
|
||||
@ -356,6 +552,13 @@ function PermissionsPanel() {
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">사용자 권한 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">역할별 메뉴 접근 권한을 설정합니다</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setShowCreateForm(true); setCreateError('') }}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
||||
>
|
||||
+ 역할 추가
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
@ -366,19 +569,52 @@ function PermissionsPanel() {
|
||||
{saving ? '저장 중...' : '변경사항 저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1">
|
||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[200px]">기능</th>
|
||||
{roles.map(role => {
|
||||
const info = roleLabels[role.code] || { label: role.name, color: 'var(--t3)' }
|
||||
{roles.map((role, idx) => {
|
||||
const color = getRoleColor(role.code, idx)
|
||||
return (
|
||||
<th key={role.sn} className="px-6 py-3 text-center min-w-[100px]">
|
||||
<div className="text-[11px] font-semibold font-korean" style={{ color: info.color }}>
|
||||
{info.label}
|
||||
<th key={role.sn} className="px-4 py-3 text-center min-w-[100px]">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{editingRoleSn === role.sn ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editRoleName}
|
||||
onChange={(e) => setEditRoleName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveRoleName(role.sn)
|
||||
if (e.key === 'Escape') setEditingRoleSn(null)
|
||||
}}
|
||||
onBlur={() => handleSaveRoleName(role.sn)}
|
||||
autoFocus
|
||||
className="w-20 px-1 py-0.5 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-[11px] font-semibold font-korean cursor-pointer hover:underline"
|
||||
style={{ color }}
|
||||
onClick={() => handleStartEditName(role)}
|
||||
title="클릭하여 이름 수정"
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
)}
|
||||
{role.code !== 'ADMIN' && (
|
||||
<button
|
||||
onClick={() => handleDeleteRole(role.sn, role.name)}
|
||||
className="w-4 h-4 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
|
||||
title="역할 삭제"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-mono mt-0.5">{role.code}</div>
|
||||
<button
|
||||
onClick={() => toggleDefault(role.sn)}
|
||||
className={`mt-1 px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
||||
@ -403,7 +639,7 @@ function PermissionsPanel() {
|
||||
<div className="text-[10px] text-text-3 font-korean mt-0.5">{perm.desc}</div>
|
||||
</td>
|
||||
{roles.map(role => (
|
||||
<td key={role.sn} className="px-6 py-3 text-center">
|
||||
<td key={role.sn} className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => togglePerm(role.sn, perm.id)}
|
||||
className={`w-8 h-8 rounded-md border text-sm transition-all ${
|
||||
@ -421,6 +657,70 @@ function PermissionsPanel() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 역할 생성 모달 */}
|
||||
{showCreateForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-[400px] bg-bg-1 rounded-lg border border-border shadow-2xl">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="text-sm font-bold text-text-1 font-korean">새 역할 추가</h3>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 코드</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleCode}
|
||||
onChange={(e) => setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
|
||||
placeholder="CUSTOM_ROLE"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-3 mt-1 font-korean">영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleName}
|
||||
onChange={(e) => setNewRoleName(e.target.value)}
|
||||
placeholder="사용자 정의 역할"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-text-3 font-korean block mb-1">설명 (선택)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleDesc}
|
||||
onChange={(e) => setNewRoleDesc(e.target.value)}
|
||||
placeholder="역할에 대한 설명"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
{createError && (
|
||||
<div className="px-3 py-2 text-[11px] text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
className="px-4 py-2 text-xs text-text-3 border border-border rounded-md hover:bg-bg-hover font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateRole}
|
||||
disabled={!newRoleCode || !newRoleName || creating}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
|
||||
>
|
||||
{creating ? '생성 중...' : '생성'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -47,6 +47,7 @@ export interface UserListItem {
|
||||
failCount: number
|
||||
lastLogin: string | null
|
||||
roles: string[]
|
||||
roleSns: number[]
|
||||
regDtm: string
|
||||
oauthProvider: string | null
|
||||
email: string | null
|
||||
@ -124,6 +125,26 @@ export async function updateRoleDefaultApi(roleSn: number, isDefault: boolean):
|
||||
await api.put(`/roles/${roleSn}/default`, { isDefault })
|
||||
}
|
||||
|
||||
export async function createRoleApi(data: {
|
||||
code: string
|
||||
name: string
|
||||
description?: string
|
||||
}): Promise<RoleWithPermissions> {
|
||||
const response = await api.post<RoleWithPermissions>('/roles', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateRoleApi(
|
||||
roleSn: number,
|
||||
data: { name?: string; description?: string }
|
||||
): Promise<void> {
|
||||
await api.put(`/roles/${roleSn}`, data)
|
||||
}
|
||||
|
||||
export async function deleteRoleApi(roleSn: number): Promise<void> {
|
||||
await api.delete(`/roles/${roleSn}`)
|
||||
}
|
||||
|
||||
// 사용자 승인/거절 API (ADMIN 전용)
|
||||
export async function approveUserApi(id: string): Promise<void> {
|
||||
await api.put(`/users/${id}/approve`)
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user