Merge pull request 'feat(auth): 역할 CRUD 및 다중 역할 할당 기능 구현' (#7) from feature/auth-system into develop

Reviewed-on: #7
This commit is contained in:
htlee 2026-02-28 01:08:46 +09:00
커밋 ba714372d8
5개의 변경된 파일543개의 추가작업 그리고 39개의 파일을 삭제

파일 보기

@ -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">
<span
className="px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
style={{
background: `${roleInfo.color}20`,
color: roleInfo.color,
border: `1px solid ${roleInfo.color}40`
}}
>
{roleInfo.label}
</span>
<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
key={roleCode}
className="px-2 py-0.5 text-[10px] font-semibold rounded-md font-korean"
style={{
background: `${color}20`,
color: color,
border: `1px solid ${color}40`
}}
>
{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">
</button>
{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,15 +552,23 @@ 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>
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
<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}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
</div>
</div>
<div className="flex-1 overflow-auto">
@ -372,13 +576,45 @@ function PermissionsPanel() {
<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`)