import { useState, useEffect, useCallback, useRef } from 'react' import { useSubMenu } from '../../hooks/useSubMenu' import data from '@emoji-mart/data' import EmojiPicker from '@emoji-mart/react' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay, type DragEndEvent, } from '@dnd-kit/core' import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { fetchUsers, fetchRoles, updatePermissionsApi, updateUserApi, updateRoleDefaultApi, approveUserApi, rejectUserApi, assignRolesApi, createRoleApi, updateRoleApi, deleteRoleApi, fetchRegistrationSettings, updateRegistrationSettingsApi, fetchOAuthSettings, updateOAuthSettingsApi, fetchMenuConfig, updateMenuConfigApi, type UserListItem, type RoleWithPermissions, type RegistrationSettings, type OAuthSettings, type MenuConfigItem, } from '../../services/authApi' import { useMenuStore } from '../../store/menuStore' 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 = { PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' }, ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' }, LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' }, INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' }, REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' }, } // ─── 사용자 관리 패널 ───────────────────────────────────────── function UsersPanel() { const [searchTerm, setSearchTerm] = useState('') 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) try { const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined) setUsers(data) } catch (err) { console.error('사용자 목록 조회 실패:', err) } finally { setLoading(false) } }, [searchTerm, statusFilter]) useEffect(() => { 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' }) await loadUsers() } catch (err) { console.error('계정 잠금 해제 실패:', err) } } const handleApprove = async (userId: string) => { try { await approveUserApi(userId) await loadUsers() } catch (err) { console.error('사용자 승인 실패:', err) } } const handleReject = async (userId: string) => { try { await rejectUserApi(userId) await loadUsers() } catch (err) { console.error('사용자 거절 실패:', err) } } 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', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) } const pendingCount = users.filter(u => u.status === 'PENDING').length return (

사용자 관리

총 {users.length}명

{pendingCount > 0 && ( 승인대기 {pendingCount}명 )}
setSearchTerm(e.target.value)} className="w-56 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" />
{loading ? (
불러오는 중...
) : ( {users.map((user) => { const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE return ( ) })}
이름 계정 소속 역할 인증 상태 최근 로그인 관리
{user.name} {user.account} {user.orgAbbr || '-'}
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 ? ( Google ) : ( ID/PW )} {statusInfo.label} {formatDate(user.lastLogin)}
{user.status === 'PENDING' && ( <> )} {user.status === 'LOCKED' && ( )} {user.status === 'ACTIVE' && ( )} {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( )}
)}
) } // ─── 권한 관리 패널 ───────────────────────────────────────── const PERM_RESOURCES = [ { id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' }, { id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' }, { id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' }, { id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' }, { id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' }, { id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' }, { id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' }, { id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' }, { id: 'board', label: '게시판', desc: '게시판 접근' }, { id: 'weather', label: '기상정보', desc: '기상 정보 조회' }, { id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' }, ] function PermissionsPanel() { const [roles, setRoles] = useState([]) 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() }, []) const loadRoles = async () => { setLoading(true) try { const data = await fetchRoles() setRoles(data) setDirty(false) } catch (err) { console.error('역할 목록 조회 실패:', err) } finally { setLoading(false) } } const getPermGranted = (roleSn: number, resourceCode: string): boolean => { const role = roles.find(r => r.sn === roleSn) if (!role) return false const perm = role.permissions.find(p => p.resourceCode === resourceCode) return perm?.granted ?? false } const togglePerm = (roleSn: number, resourceCode: string) => { setRoles(prev => prev.map(role => { if (role.sn !== roleSn) return role const perms = role.permissions.map(p => p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p ) if (!perms.find(p => p.resourceCode === resourceCode)) { perms.push({ sn: 0, resourceCode, granted: true }) } return { ...role, permissions: perms } })) setDirty(true) } const toggleDefault = async (roleSn: number) => { const role = roles.find(r => r.sn === roleSn) if (!role) return const newValue = !role.isDefault try { await updateRoleDefaultApi(roleSn, newValue) setRoles(prev => prev.map(r => r.sn === roleSn ? { ...r, isDefault: newValue } : r )) } catch (err) { console.error('기본 역할 변경 실패:', err) } } const handleSave = async () => { setSaving(true) try { for (const role of roles) { const permissions = PERM_RESOURCES.map(r => ({ resourceCode: r.id, granted: getPermGranted(role.sn, r.id), })) await updatePermissionsApi(role.sn, permissions) } setDirty(false) } catch (err) { console.error('권한 저장 실패:', err) } finally { setSaving(false) } } 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
불러오는 중...
} return (

사용자 권한 관리

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

{roles.map((role, idx) => { const color = getRoleColor(role.code, idx) return ( ) })} {PERM_RESOURCES.map((perm) => ( {roles.map(role => ( ))} ))}
기능
{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}
{perm.label}
{perm.desc}
{/* 역할 생성 모달 */} {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}
)}
)}
) } // ─── 메뉴 항목 (Sortable) ──────────────────────────────────── interface SortableMenuItemProps { menu: MenuConfigItem idx: number totalCount: number isEditing: boolean emojiPickerId: string | null emojiPickerRef: React.RefObject onToggle: (id: string) => void onMove: (idx: number, direction: -1 | 1) => void onEditStart: (id: string) => void onEditEnd: () => void onEmojiPickerToggle: (id: string | null) => void onLabelChange: (id: string, value: string) => void onEmojiSelect: (emoji: { native: string }) => void } function SortableMenuItem({ menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef, onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect, }: SortableMenuItemProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: menu.id }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1, zIndex: isDragging ? 50 : undefined, } return (
{idx + 1} {isEditing ? ( <>
{emojiPickerId === menu.id && (
)}
onLabelChange(menu.id, e.target.value)} className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none" />
{menu.id}
) : ( <> {menu.icon}
{menu.label}
{menu.id}
)}
) } // ─── 메뉴 관리 패널 ───────────────────────────────────────── function MenusPanel() { const [menus, setMenus] = useState([]) const [originalMenus, setOriginalMenus] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [editingId, setEditingId] = useState(null) const [emojiPickerId, setEmojiPickerId] = useState(null) const [activeId, setActiveId] = useState(null) const emojiPickerRef = useRef(null) const { setMenuConfig } = useMenuStore() const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ) const loadMenus = useCallback(async () => { setLoading(true) try { const config = await fetchMenuConfig() setMenus(config) setOriginalMenus(config) } catch (err) { console.error('메뉴 설정 조회 실패:', err) } finally { setLoading(false) } }, []) useEffect(() => { loadMenus() }, [loadMenus]) useEffect(() => { if (!emojiPickerId) return const handler = (e: MouseEvent) => { if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) { setEmojiPickerId(null) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [emojiPickerId]) const toggleMenu = (id: string) => { setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m)) } const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => { setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m)) } const handleEmojiSelect = (emoji: { native: string }) => { if (emojiPickerId) { updateMenuField(emojiPickerId, 'icon', emoji.native) setEmojiPickerId(null) } } const moveMenu = (idx: number, direction: -1 | 1) => { const targetIdx = idx + direction if (targetIdx < 0 || targetIdx >= menus.length) return setMenus(prev => { const arr = [...prev] ;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]] return arr.map((m, i) => ({ ...m, order: i + 1 })) }) } const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event setActiveId(null) if (!over || active.id === over.id) return setMenus(prev => { const oldIndex = prev.findIndex(m => m.id === active.id) const newIndex = prev.findIndex(m => m.id === over.id) const reordered = arrayMove(prev, oldIndex, newIndex) return reordered.map((m, i) => ({ ...m, order: i + 1 })) }) } const handleSave = async () => { setSaving(true) try { const updated = await updateMenuConfigApi(menus) setMenus(updated) setOriginalMenus(updated) setMenuConfig(updated) } catch (err) { console.error('메뉴 설정 저장 실패:', err) } finally { setSaving(false) } } if (loading) { return (
메뉴 설정을 불러오는 중...
) } const activeMenu = activeId ? menus.find(m => m.id === activeId) : null return (

메뉴 관리

메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다

setActiveId(event.active.id as string)} onDragEnd={handleDragEnd} > m.id)} strategy={verticalListSortingStrategy}>
{menus.map((menu, idx) => ( { setEditingId(null); setEmojiPickerId(null) }} onEmojiPickerToggle={setEmojiPickerId} onLabelChange={(id, value) => updateMenuField(id, 'label', value)} onEmojiSelect={handleEmojiSelect} /> ))}
{activeMenu ? (
{activeMenu.icon} {activeMenu.label}
) : null}
) } // ─── 시스템 설정 패널 ──────────────────────────────────────── function SettingsPanel() { const [settings, setSettings] = useState(null) const [oauthSettings, setOauthSettings] = useState(null) const [oauthDomainInput, setOauthDomainInput] = useState('') const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [savingOAuth, setSavingOAuth] = useState(false) useEffect(() => { loadSettings() }, []) const loadSettings = async () => { setLoading(true) try { const [regData, oauthData] = await Promise.all([ fetchRegistrationSettings(), fetchOAuthSettings(), ]) setSettings(regData) setOauthSettings(oauthData) setOauthDomainInput(oauthData.autoApproveDomains) } catch (err) { console.error('설정 조회 실패:', err) } finally { setLoading(false) } } const handleToggle = async (key: keyof RegistrationSettings) => { if (!settings) return const newValue = !settings[key] setSaving(true) try { const updated = await updateRegistrationSettingsApi({ [key]: newValue }) setSettings(updated) } catch (err) { console.error('설정 변경 실패:', err) } finally { setSaving(false) } } if (loading) { return
불러오는 중...
} return (

시스템 설정

사용자 등록 및 권한 관련 시스템 설정을 관리합니다

{/* 사용자 등록 설정 */}

사용자 등록 설정

신규 사용자 등록 시 적용되는 정책을 설정합니다

{/* 자동 승인 */}
자동 승인

활성화하면 신규 사용자가 등록 즉시 ACTIVE 상태가 됩니다. 비활성화하면 관리자 승인 전까지 PENDING 상태로 대기합니다.

{/* 기본 역할 자동 할당 */}
기본 역할 자동 할당

활성화하면 신규 사용자에게 기본 역할이 자동으로 할당됩니다. 기본 역할은 권한 관리 탭에서 설정할 수 있습니다.

{/* OAuth 설정 */}

Google OAuth 설정

Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다

자동 승인 도메인

지정된 도메인의 Google 계정은 가입 즉시 ACTIVE 상태가 됩니다. 미지정 도메인은 PENDING 상태로 관리자 승인이 필요합니다. 여러 도메인은 쉼표(,)로 구분합니다.

setOauthDomainInput(e.target.value)} placeholder="gcsc.co.kr, example.com" className="flex-1 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" />
{oauthSettings?.autoApproveDomains && (
{oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => ( @{domain} ))}
)}
{/* 현재 설정 상태 요약 */}

설정 상태 요약

신규 사용자 등록 시{' '} {settings?.autoApprove ? ( 즉시 활성화 ) : ( 관리자 승인 필요 )}
기본 역할 자동 할당{' '} {settings?.defaultRole ? ( 활성 ) : ( 비활성 )}
Google OAuth 자동 승인 도메인{' '} {oauthSettings?.autoApproveDomains ? ( {oauthSettings.autoApproveDomains} ) : ( 미설정 )}
) } // ─── AdminView ──────────────────────────────────────────── export function AdminView() { const { activeSubTab } = useSubMenu('admin') return (
{activeSubTab === 'users' && } {activeSubTab === 'permissions' && } {activeSubTab === 'menus' && } {activeSubTab === 'settings' && }
) }