import { MoreHorizontal, Plus, Trash2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table'; import { useToast } from '../../components/toast'; import { Button, Modal } from '../../components/ui'; import { PageTitle } from '../../layout/AppLayout'; import { isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions'; import { listApps } from '../applications/application-api'; import type { AppWithComponents } from '../../types/application'; import { createAdmin, createAdminSshKey, deleteAdminSshKey, loadAdminAppPermissions, loadAdminRoles, loadAdmins, resetAdminPassword, updateAdminAppPermissions, updateAdminRole, updateAdminStatus, type AdminAccount, type AdminRoleOption } from './operations-api'; import { formatDateTime, LoadingModal, StatusBadge, statusTone, useMinimumLoading } from './operation-utils'; type AdminRow = AdminAccount & { no: number; state: string; }; const emptyAdminForm = { email: '', name: '', role: 'ADMIN', temporaryPassword: '' }; const fallbackAdminRoles: AdminRoleOption[] = [ { id: 'OWNER', code: 'OWNER', name: 'OWNER', sortOrder: 1, isActive: true }, { id: 'ADMIN', code: 'ADMIN', name: 'ADMIN', sortOrder: 2, isActive: true }, { id: 'VIEWER', code: 'VIEWER', name: 'VIEWER', sortOrder: 3, isActive: true } ]; export function AdminManagementPage() { const { toast } = useToast(); const [admins, setAdmins] = useState([]); const [adminRoles, setAdminRoles] = useState([]); const [loading, setLoading] = useState(true); const [failed, setFailed] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [passwordTarget, setPasswordTarget] = useState<{ admin: AdminAccount | null; temporaryPassword: string }>({ admin: null, temporaryPassword: '' }); const [sshTarget, setSshTarget] = useState({ admin: null as AdminAccount | null, label: '', giteaUsername: '', publicKey: '' }); const [adminForm, setAdminForm] = useState(emptyAdminForm); const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle'); const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle'); const [sshState, setSshState] = useState<'idle' | 'saving' | 'error'>('idle'); const [sshError, setSshError] = useState(''); const [menuAdmin, setMenuAdmin] = useState<{ id: string; x: number; y: number } | null>(null); const [permissionTarget, setPermissionTarget] = useState<{ admin: AdminAccount | null; appIds: string[] }>({ admin: null, appIds: [] }); const [permissionApps, setPermissionApps] = useState([]); const [permissionLoading, setPermissionLoading] = useState(false); const [permissionSaving, setPermissionSaving] = useState(false); const loadingVisible = useMinimumLoading(loading); const refresh = () => { setLoading(true); Promise.all([loadAdmins(), loadAdminRoles().catch(() => fallbackAdminRoles)]) .then(([nextAdmins, nextRoles]) => { setAdmins(nextAdmins); setAdminRoles(nextRoles.filter((role) => role.isActive)); setFailed(false); }) .catch(() => { setAdmins([]); setAdminRoles(fallbackAdminRoles); setFailed(true); }) .finally(() => setLoading(false)); }; useEffect(() => { refresh(); }, []); useEffect(() => { if (!menuAdmin) return; const close = () => setMenuAdmin(null); const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setMenuAdmin(null); }; window.addEventListener('click', close); window.addEventListener('keydown', closeOnEscape); return () => { window.removeEventListener('click', close); window.removeEventListener('keydown', closeOnEscape); }; }, [menuAdmin]); const rows = useMemo( () => admins.map((admin, index) => ({ ...admin, no: admins.length - index, state: admin.isActive ? '활성' : '비활성' })), [admins] ); const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles; const openSshModal = (admin: AdminAccount) => { setMenuAdmin(null); const account = admin.email.split('@')[0] || 'admin'; const random = Math.random().toString(36).slice(2, 8); setSshTarget({ admin, label: `${account}-${random}-key`, giteaUsername: '', publicKey: '' }); setSshState('idle'); setSshError(''); }; const openPermissionModal = (admin: AdminAccount) => { setMenuAdmin(null); setPermissionTarget({ admin, appIds: [] }); setPermissionLoading(true); Promise.all([ listApps(), isOwnerAdmin(admin) ? Promise.resolve([]) : loadAdminAppPermissions(admin.id) ]) .then(([apps, appIds]) => { setPermissionApps(apps); setPermissionTarget({ admin, appIds: isOwnerAdmin(admin) ? apps.map((app) => app.id) : appIds }); }) .catch(() => { setPermissionApps([]); toast({ type: 'error', title: '앱 목록 조회 실패', message: '앱 권한 설정에 사용할 앱 목록을 불러오지 못했습니다.' }); }) .finally(() => setPermissionLoading(false)); }; const togglePermissionApp = (appId: string) => { setPermissionTarget((prev) => ({ ...prev, appIds: prev.appIds.includes(appId) ? prev.appIds.filter((id) => id !== appId) : [...prev.appIds, appId] })); }; const saveAppPermissions = () => { if (!permissionTarget.admin || isOwnerAdmin(permissionTarget.admin)) { setPermissionTarget({ admin: null, appIds: [] }); return; } setPermissionSaving(true); updateAdminAppPermissions(permissionTarget.admin.id, permissionTarget.appIds) .then((result) => { setAdminAppPermissions(permissionTarget.admin as AdminAccount, result.appIds ?? permissionTarget.appIds); setPermissionTarget({ admin: null, appIds: [] }); toast({ type: 'success', title: '앱 권한 저장', message: '관리자 앱 권한이 저장되었습니다.' }); refresh(); }) .catch((error) => { toast({ type: 'error', title: '앱 권한 저장 실패', message: error instanceof Error ? error.message : '관리자 앱 권한 저장에 실패했습니다.' }); }) .finally(() => setPermissionSaving(false)); }; const submitAdmin = () => { if (!adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8) return; setSaveState('saving'); createAdmin(adminForm) .then(() => { setSaveState('idle'); setCreateOpen(false); setAdminForm(emptyAdminForm); refresh(); }) .catch((error) => { setSaveState('error'); toast({ type: 'error', title: '관리자 등록 실패', message: error.message }); }); }; const submitPassword = () => { if (!passwordTarget.admin) return; setPasswordState('saving'); resetAdminPassword(passwordTarget.admin.id, passwordTarget.temporaryPassword) .then(() => { setPasswordState('idle'); setPasswordTarget({ admin: null, temporaryPassword: '' }); refresh(); }) .catch((error) => { setPasswordState('error'); toast({ type: 'error', title: '비밀번호 초기화 실패', message: error.message }); }); }; const submitSshKey = () => { if (!sshTarget.admin || !sshTarget.publicKey.trim()) return; setSshState('saving'); setSshError(''); createAdminSshKey(sshTarget.admin.id, { label: sshTarget.label.trim() || undefined, giteaUsername: sshTarget.giteaUsername.trim() || undefined, publicKey: sshTarget.publicKey.trim() }) .then(() => { setSshState('idle'); setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' }); toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' }); refresh(); }) .catch((error) => { setSshError(error.message); setSshState('error'); toast({ type: 'error', title: 'SSH 키 등록 실패', message: error.message }); }); }; const removeSshKey = (admin: AdminAccount, keyId: string) => { deleteAdminSshKey(admin.id, keyId) .then(() => { toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' }); refresh(); }) .catch((error) => toast({ type: 'error', title: 'SSH 키 삭제 실패', message: error.message })); }; const columns = useMemo>>( () => [ { key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => {row.no} }, { key: 'name', header: '관리자', className: 'w-[260px] px-5 py-3 font-medium', cell: (row) => (

{row.name}

{row.email}

) }, { key: 'role', header: '권한', cell: (row) => ( ) }, { key: 'sshKeys', header: 'SSH 키', cell: (row) => { const keyCount = row.sshKeys?.length ?? 0; return ( ); } }, { key: 'lastLoginAt', header: '마지막 로그인', cell: (row) => {formatDateTime(row.lastLoginAt)} }, { key: 'state', header: '상태', cell: (row) => }, { key: 'actions', header: '', className: 'w-20 px-5 py-3 text-right font-medium', cell: (row) => (
) } ], [menuAdmin?.id, roleOptions] ); const filters = useMemo>>( () => [ { key: 'role', label: '권한', options: [...new Set(rows.map((row) => row.role))].map((role) => ({ label: role, value: role })), getValue: (row) => row.role }, { key: 'state', label: '상태', options: [...new Set(rows.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state } ], [rows] ); return ( <> setCreateOpen(true)}> 추가 } /> {failed ? (
관리자 데이터를 불러오지 못했습니다.
) : null} `${row.name} ${row.email} ${row.role} ${row.state}`} getRowKey={(row) => row.id} pageSize={6} minWidth={1040} loading={loading} emptyMessage="등록된 관리자가 없습니다." /> {menuAdmin ? (() => { const row = rows.find((item) => item.id === menuAdmin.id); if (!row) return null; return (
event.stopPropagation()} className="fixed z-[70] min-w-36 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuAdmin.x, top: menuAdmin.y, transform: 'translateX(-100%)' }}>
); })() : null} setCreateOpen(false)} footer={ <> } >
setPasswordTarget({ admin: null, temporaryPassword: '' })} footer={ <> } > setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })} className="max-w-2xl" footer={ <> } >