From b52d8097b0d3bd6d08257793c270c7d2d063e433 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 01:03:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=EC=97=AD=ED=95=A0=20CRUD=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8B=A4=EC=A4=91=20=EC=97=AD=ED=95=A0=20=ED=95=A0?= =?UTF-8?q?=EB=8B=B9=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 역할 생성/수정/삭제 API 추가 (POST/PUT/DELETE /api/roles) - 권한 관리 패널에 역할 추가/이름수정/삭제 UI 구현 - 사용자 관리 패널에 다중 역할 뱃지 표시 및 역할 할당 드롭다운 추가 - 사용자 활성화/비활성화 상태 변경 버튼 추가 - UserListItem에 roleSns 필드 추가로 역할 SN 기반 할당 지원 Co-Authored-By: Claude Opus 4.6 --- backend/src/roles/roleRouter.ts | 59 +++- backend/src/roles/roleService.ts | 123 +++++++ backend/src/users/userService.ts | 7 +- frontend/src/components/views/AdminView.tsx | 372 ++++++++++++++++++-- frontend/src/services/authApi.ts | 21 ++ 5 files changed, 543 insertions(+), 39 deletions(-) diff --git a/backend/src/roles/roleRouter.ts b/backend/src/roles/roleRouter.ts index 938a18b..0f59ef3 100644 --- a/backend/src/roles/roleRouter.ts +++ b/backend/src/roles/roleRouter.ts @@ -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 { diff --git a/backend/src/roles/roleService.ts b/backend/src/roles/roleService.ts index 80de5a0..b3b0862 100644 --- a/backend/src/roles/roleService.ts +++ b/backend/src/roles/roleService.ts @@ -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 { 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 { + 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 { + 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 { + 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 }> diff --git a/backend/src/users/userService.ts b/backend/src/users/userService.ts index eb4092a..0881a66 100644 --- a/backend/src/users/userService.ts +++ b/backend/src/users/userService.ts @@ -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 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 { 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 { 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, diff --git a/frontend/src/components/views/AdminView.tsx b/frontend/src/components/views/AdminView.tsx index 3a1e204..292636f 100755 --- a/frontend/src/components/views/AdminView.tsx +++ b/frontend/src/components/views/AdminView.tsx @@ -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 = { - 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 = { + 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 = { @@ -52,6 +64,10 @@ function UsersPanel() { const [statusFilter, setStatusFilter] = useState('') const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) + const [allRoles, setAllRoles] = useState([]) + const [roleEditUserId, setRoleEditUserId] = useState(null) + const [selectedRoleSns, setSelectedRoleSns] = useState([]) + const roleDropdownRef = useRef(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() { {users.map((user) => { - const primaryRole = user.roles[0] || 'USER' - const roleInfo = roleLabels[primaryRole] || roleLabels.USER const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE return ( @@ -174,16 +243,74 @@ function UsersPanel() { {user.account} {user.orgAbbr || '-'} - - {roleInfo.label} - +
+
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 ( + + {roleName} + + ) + }) : ( + 역할 없음 + )} + + + +
+ {roleEditUserId === user.id && ( +
+
역할 선택
+ {allRoles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + return ( + + ) + })} +
+ + +
+
+ )} +
{user.oauthProvider ? ( @@ -238,9 +365,22 @@ function UsersPanel() { 잠금해제 )} - + {user.status === 'ACTIVE' && ( + + )} + {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( + + )} @@ -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(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
불러오는 중...
} @@ -356,15 +552,23 @@ function PermissionsPanel() {

사용자 권한 관리

역할별 메뉴 접근 권한을 설정합니다

- +
+ + +
@@ -372,13 +576,45 @@ function PermissionsPanel() { 기능 - {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 ( - -
- {info.label} + +
+ {editingRoleSn === role.sn ? ( + 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" + /> + ) : ( + handleStartEditName(role)} + title="클릭하여 이름 수정" + > + {role.name} + + )} + {role.code !== 'ADMIN' && ( + + )}
+
{role.code}
+ + {/* 역할 생성 모달 */} + {showCreateForm && ( +
+
+
+

새 역할 추가

+
+
+
+ + 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" + /> +

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ {createError && ( +
+ {createError} +
+ )} +
+
+ + +
+
+
+ )}
) } diff --git a/frontend/src/services/authApi.ts b/frontend/src/services/authApi.ts index 86d39f9..c3db860 100644 --- a/frontend/src/services/authApi.ts +++ b/frontend/src/services/authApi.ts @@ -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 { + const response = await api.post('/roles', data) + return response.data +} + +export async function updateRoleApi( + roleSn: number, + data: { name?: string; description?: string } +): Promise { + await api.put(`/roles/${roleSn}`, data) +} + +export async function deleteRoleApi(roleSn: number): Promise { + await api.delete(`/roles/${roleSn}`) +} + // 사용자 승인/거절 API (ADMIN 전용) export async function approveUserApi(id: string): Promise { await api.put(`/users/${id}/approve`) -- 2.45.2