import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { Activity, Camera, ChevronDown, ChevronRight, CircleHelp, Loader2, LogOut, Settings, Search, SlidersHorizontal, Trash2 } from 'lucide-react'; import { navGroups } from './data/mock'; import { Badge, Button, Card, CardHeader, Modal, Progress } from './components/ui'; import { formatDateTime as formatDateTimeValue } from './lib/date-format'; import { apiBaseUrl, apiFetch, apiRequest, authExpiredEventName, authHeaders, clearAdminSession } from './lib/api'; import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions'; import { cn } from './lib/utils'; import { openPageAgent } from './lib/page-agent'; import { AppManagementPage as AppManagementApiPage } from './pages/applications/AppManagementPage'; import { BuildManagementPage } from './pages/applications/BuildManagementPage'; import { DeploymentManagementPage } from './pages/applications/DeploymentManagementPage'; import { MigrationManagementPage } from './pages/applications/MigrationManagementPage'; import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPage'; import { RepositoryManagementPage } from './pages/applications/RepositoryManagementPage'; import { SourceManagementPage as AppSourcesApiPage } from './pages/applications/SourceManagementPage'; import { AdminManagementPage as AdminManagementApiPage } from './pages/operations/AdminManagementPage'; import { createAdminSshKey } from './pages/operations/operations-api'; import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage'; import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage'; import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage'; import { TermsPage as TermsApiPage } from './pages/operations/TermsPage'; import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage'; import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage'; import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage'; import { RuntimeLogsPage as RuntimeLogsApiPage } from './pages/runtime/RuntimeLogsPage'; import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage'; import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage'; import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage'; import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage'; import { LauncherLogsPage } from './pages/launcher/LauncherLogsPage'; import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage'; import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage'; import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage'; import { CommonAppLogsPage } from './pages/common-apps/CommonAppLogsPage'; import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage'; import { InfrastructureClusterPage, InfrastructureNetworkPage, InfrastructureStoragePage, InfrastructureWorkloadsPage } from './pages/infrastructure/InfrastructurePages'; import { MonitoringPage } from './pages/system/MonitoringPage'; import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils'; import { AgentCommandsPage as AgentCommandsRestoredPage, AgentInstallStatusPage as AgentInstallStatusRestoredPage, AgentRegistrationPage as AgentRegistrationRestoredPage, AgentSshKeysPage as AgentSshKeysRestoredPage, AgentWorkspacePage as AgentWorkspaceRestoredPage, GatewayAgentsPage as GatewayAgentsRestoredPage, GatewayDevicesPage as GatewayDevicesRestoredPage, GatewayStatusPage as GatewayStatusRestoredPage, TailscaleAclPage as TailscaleAclRestoredPage, TailscaleRoutesPage as TailscaleRoutesRestoredPage, TailscaleStatusPage as TailscaleStatusRestoredPage } from './pages/RestoredPages'; type ViewKey = | 'dashboard' | 'commonSources' | 'commonBuilds' | 'commonComponents' | 'commonLogs' | 'launcherSources' | 'launcherBuilds' | 'launcherVersions' | 'launcherComponents' | 'launcherLogs' | 'launcherApps' | 'notices' | 'terms' | 'appManagement' | 'appRepositories' | 'appSources' | 'appBuilds' | 'appReleases' | 'appDeployments' | 'appMigrations' | 'gatewayStatus' | 'gatewayAgents' | 'gatewayDevices' | 'tailscaleStatus' | 'tailscaleRoutes' | 'tailscaleAcl' | 'infraCluster' | 'infraWorkloads' | 'infraNetwork' | 'infraStorage' | 'monitoring' | 'agentRegistration' | 'agentInstallStatus' | 'agentSshKeys' | 'agentWorkspace' | 'agentCommands' | 'runtimeConfig' | 'runtimeLogs' | 'runtimeDeviceControl' | 'adminManagement' | 'organizations' | 'userSessions' | 'licenses'; const viewByLabel: Record = { 대시보드: 'dashboard', '버전 관리': 'launcherVersions', 구성요소: 'launcherComponents', '앱 연결': 'launcherApps', 공지사항: 'notices', 약관: 'terms', '앱 관리': 'appManagement', '저장소 관리': 'appRepositories', '소스 관리': 'appSources', '빌드 관리': 'appBuilds', '릴리즈 관리': 'appReleases', '실행 설정': 'runtimeConfig', '배포 관리': 'appDeployments', 마이그레이션: 'appMigrations', '연결 상태': 'gatewayStatus', 'Agent 관리': 'gatewayAgents', '장비 동기화': 'gatewayDevices', '노드 현황': 'tailscaleStatus', 라우트: 'tailscaleRoutes', ACL: 'tailscaleAcl', 클러스터: 'infraCluster', 워크로드: 'infraWorkloads', 네트워크: 'infraNetwork', 스토리지: 'infraStorage', 모니터링: 'monitoring', '장비 등록': 'agentRegistration', '설치 상태': 'agentInstallStatus', 'SSH 키 배포': 'agentSshKeys', 'Workspace 설정': 'agentWorkspace', '원격 명령': 'agentCommands', '로그/이벤트': 'runtimeLogs', '장비 제어': 'runtimeDeviceControl', '관리자 관리': 'adminManagement', '조직 관리': 'organizations', '사용자/세션': 'userSessions', 라이센스: 'licenses' }; const pathByView: Record = { dashboard: '/dashboard', commonSources: '/common-apps/sources', commonBuilds: '/common-apps/builds', commonComponents: '/common-apps/components', commonLogs: '/common-apps/logs', launcherSources: '/launcher/sources', launcherBuilds: '/launcher/builds', launcherVersions: '/launcher/versions', launcherComponents: '/launcher/components', launcherLogs: '/launcher/logs', launcherApps: '/launcher/apps', notices: '/operations/notices', terms: '/operations/terms', appManagement: '/applications/apps', appRepositories: '/applications/repositories', appSources: '/applications/sources', appBuilds: '/applications/builds', appReleases: '/applications/releases', appDeployments: '/applications/deployments', appMigrations: '/applications/migrations', gatewayStatus: '/gateway/status', gatewayAgents: '/gateway/agents', gatewayDevices: '/gateway/devices', tailscaleStatus: '/tailscale/status', tailscaleRoutes: '/tailscale/routes', tailscaleAcl: '/tailscale/acl', infraCluster: '/infrastructure/cluster', infraWorkloads: '/infrastructure/workloads', infraNetwork: '/infrastructure/network', infraStorage: '/infrastructure/storage', monitoring: '/system/monitoring', agentRegistration: '/agents/registration', agentInstallStatus: '/agents/install-status', agentSshKeys: '/agents/ssh-keys', agentWorkspace: '/agents/workspace', agentCommands: '/agents/commands', runtimeConfig: '/applications/runtime-settings', runtimeLogs: '/runtime/logs', runtimeDeviceControl: '/runtime/devices', adminManagement: '/operations/admins', organizations: '/operations/organizations', userSessions: '/operations/users', licenses: '/operations/licenses' }; const viewByPath = Object.fromEntries( Object.entries(pathByView).map(([view, path]) => [path, view as ViewKey]) ) as Record; function viewFromPath(pathname: string): ViewKey { if (pathname === '/') return 'dashboard'; return viewByPath[pathname] ?? 'dashboard'; } type CurrentAdmin = { id?: string; email: string; name?: string; role?: string; avatarUrl?: string | null; appPermissions?: string[]; sshKeys?: Array<{ id: string; label?: string | null; fingerprint?: string | null; giteaUsername?: string | null; createdAt?: string | null; }>; }; function readCurrentAdmin(): CurrentAdmin | null { try { const raw = localStorage.getItem('studio-admin'); if (!raw) return null; const admin = JSON.parse(raw) as CurrentAdmin; return admin?.email ? admin : null; } catch { return null; } } async function fetchCurrentAdmin() { const response = await apiFetch(`${apiBaseUrl}/auth/admin/me`, { headers: authHeaders() }); if (!response.ok) throw new Error('Admin profile request failed'); const data = await response.json() as { admin: CurrentAdmin }; return data.admin; } function storeCurrentAdmin(admin: CurrentAdmin) { localStorage.setItem('studio-admin', JSON.stringify(admin)); } async function resizeImageToWebp(file: File, size = 128) { if (!file.type.startsWith('image/')) { throw new Error('이미지 파일만 업로드할 수 있습니다.'); } const sourceUrl = URL.createObjectURL(file); try { const image = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = sourceUrl; }); const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('이미지 처리에 실패했습니다.'); } const sourceSize = Math.min(image.width, image.height); const sourceX = (image.width - sourceSize) / 2; const sourceY = (image.height - sourceSize) / 2; ctx.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, size, size); const qualitySteps = [0.82, 0.72, 0.62, 0.52]; for (const quality of qualitySteps) { const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', quality)); if (blob && blob.size <= 80 * 1024) return blob; } const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', 0.45)); if (!blob) throw new Error('이미지 변환에 실패했습니다.'); return blob; } finally { URL.revokeObjectURL(sourceUrl); } } type ApiAppComponent = { id: string; appId: string; name: string; type: string; repository?: string | null; branch?: string | null; buildTarget?: string | null; app?: ApiApp; }; type ApiApp = { id: string; key: string; name: string; description?: string | null; namespace?: string | null; status: string; components?: ApiAppComponent[]; }; type ApiLauncherVersion = { id: string; version: string; minimumVersion?: string | null; repository?: string | null; branch?: string | null; commitSha?: string | null; artifact: string; downloadUrl: string; releaseNotes?: string | null; isActive: boolean; createdAt: string; updatedAt: string; }; type ApiAppRelease = { id: string; appId: string; version: string; branch: string; artifact?: string | null; status: string; createdAt: string; updatedAt?: string | null; app?: ApiApp; }; type CommonAppPipeline = { id: string; repository: string; message: string; branch: string; commitSha?: string | null; durationSeconds?: number | null; status: string; createdAt: number; url?: string | null; }; type ApiNotice = { id: string; appKey: string; title: string; body: string; linkUrl?: string | null; isActive: boolean; startsAt?: string | null; endsAt?: string | null; priority?: number; status?: string; createdAt: string; updatedAt?: string | null; }; type RuntimeResource = { kind: string; name: string; status: string; ready: string; restarts: number | null; age: string; detail: string; }; type RuntimeResourceResponse = { namespace: string; exists: boolean; summary: { pods: number; deployments: number; statefulSets: number; services: number; persistentVolumeClaims: number; }; resources: RuntimeResource[]; }; type RuntimePodMetric = { namespace: string; pod: string; cpu: string; memory: string; }; type ClusterMetricValue = { usage: string | null; capacity: string | null; percent: number | null; }; type ClusterNodeMetric = { name: string; status: string; cpu: ClusterMetricValue; memory: ClusterMetricValue; storage: ClusterMetricValue; }; type ClusterMetrics = { nodes: ClusterNodeMetric[]; summary: { cpuPercent: number | null; memoryPercent: number | null; storagePercent: number | null; }; }; type DashboardJob = { id: string; status: string; }; type ActiveWorkSummary = { builds: number; releases: number; deployments: number; }; type DashboardRuntimeResource = RuntimeResource & { namespace: string; appName: string; cpu?: string; memory?: string; }; type UserSessionRow = { id: string; no?: number; sessionId: string | null; userId: string; user: string; email?: string; role: string; session: string; createdAt?: string | null; expiresAt: string | null; lastSeen: string | null; state: string; }; function formatDateTime(value?: string | null) { return formatDateTimeValue(value); } function loadLauncherVersions() { return apiRequest('/launcher/versions'); } function loadApps() { return apiRequest('/apps') .then(filterAppsByCurrentAdmin); } function loadAppReleases() { return apiRequest('/apps/releases'); } function loadUserSessions() { return apiRequest<{ sessions?: UserSessionRow[] }>('/users/sessions') .then((response) => (response.sessions ?? []).filter((session) => session.sessionId)); } function loadNotices() { return apiRequest<{ notices?: ApiNotice[] }>('/notices') .then((response) => response.notices ?? []); } function loadCommonAppPipelines() { return apiRequest<{ repos: CommonAppPipeline[] }>('/woodpecker/repos') .then((result) => result.repos ?? []); } function isActiveWorkStatus(status?: string | null) { return status === 'PENDING' || status === 'RUNNING'; } function loadActiveWorkSummary(releases: ApiAppRelease[]): Promise { const activeReleases = releases.filter((release) => isActiveWorkStatus(release.status)).length; return Promise.allSettled([ apiRequest<{ jobs?: DashboardJob[] }>('/apps/builds?page=1&limit=20'), apiRequest<{ deployments?: DashboardJob[] }>('/apps/deployments?page=1&limit=20') ]).then(([buildsResult, deploymentsResult]) => ({ builds: buildsResult.status === 'fulfilled' ? (buildsResult.value.jobs ?? []).filter((job) => isActiveWorkStatus(job.status)).length : 0, releases: activeReleases, deployments: deploymentsResult.status === 'fulfilled' ? (deploymentsResult.value.deployments ?? []).filter((job) => isActiveWorkStatus(job.status)).length : 0 })); } function loadRuntimeNamespaceResources(namespace: string) { return apiRequest(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`); } function loadRuntimePodMetrics(namespace: string) { return apiRequest(`/runtime/metrics/pods?namespace=${encodeURIComponent(namespace)}`); } function loadClusterMetrics() { return apiRequest('/runtime/metrics/cluster'); } const statusTone = { success: 'green', succeeded: 'green', done: 'green', running: 'blue', started: 'blue', pending: 'amber', blocked: 'amber', created: 'amber', failure: 'red', failed: 'red', error: 'red', killed: 'red', 활성: 'green', '실행 중': 'green', 게시됨: 'green', 카나리: 'blue', 예약됨: 'blue', 점검: 'amber', 주의: 'amber', 드레이닝: 'amber', 준비중: 'amber', 배포됨: 'green', 초안: 'neutral', 대기: 'neutral', 만료: 'neutral', 종료: 'neutral', '세션 없음': 'neutral', 비활성: 'neutral', '지원 종료': 'red', 잠김: 'red', 구성완료: 'green', 검토필요: 'amber', 완료: 'green', 성공: 'green', 실패: 'red', 취소: 'neutral' } as const; function getTone(status: string) { return statusTone[status as keyof typeof statusTone] ?? 'neutral'; } function formatPipelineStatus(status?: string | null) { const labels: Record = { success: '성공', succeeded: '성공', done: '성공', running: '실행 중', started: '실행 중', pending: '대기', blocked: '대기', created: '생성됨', failure: '실패', failed: '실패', error: '오류', killed: '중단' }; const normalized = (status ?? '').toLowerCase(); return labels[normalized] ?? status ?? '-'; } function formatRelativeTime(timestamp: number) { if (!timestamp) return '-'; const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); if (seconds < 60) return '방금 전'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}분 전`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}시간 전`; const days = Math.floor(hours / 24); if (days < 30) return `${days}일 전`; return new Intl.DateTimeFormat('ko-KR', { month: 'short', day: 'numeric' }).format(new Date(timestamp)); } type NavGroup = (typeof navGroups)[number]; type NavItem = NavGroup['items'][number]; type NavChild = { label: string; view?: string }; function resolveNavView(item: NavItem | NavChild): ViewKey | undefined { return 'view' in item && item.view ? item.view as ViewKey : viewByLabel[item.label]; } function isOwnerRole(role?: string | null) { return role === 'OWNER'; } function isViewerRole(role?: string | null) { return role === 'VIEWER'; } function filterNavGroupsByRole(groups: typeof navGroups, role?: string | null) { const isOwner = isOwnerRole(role); return groups .map((group) => ({ ...group, items: group.items.filter((item) => isOwner || item.label !== '관리자 관리') })) .filter((group) => group.items.length > 0); } function pageAgentAccessAttrs(allowed: boolean) { return allowed ? {} : { 'data-page-agent-ignore': 'true', 'data-page-agent-not-interactive': 'true' }; } function isTrustedClick(event: ReactMouseEvent) { return event.nativeEvent.isTrusted; } function Sidebar({ activeView, menuSearch, currentAdmin, onViewChange }: { activeView: ViewKey; menuSearch: string; currentAdmin: CurrentAdmin | null; onViewChange: (view: ViewKey) => void; }) { const [openMenus, setOpenMenus] = useState>({}); const normalizedMenuSearch = menuSearch.trim().toLowerCase(); const roleNavGroups = useMemo( () => filterNavGroupsByRole(navGroups, currentAdmin?.role) as typeof navGroups, [currentAdmin?.role] ); useEffect(() => { for (const group of roleNavGroups) { for (const item of group.items) { if (!('children' in item) || !item.children) continue; const hasActiveChild = item.children.some((child) => resolveNavView(child) === activeView); if (!hasActiveChild) continue; setOpenMenus((prev) => (prev[item.label] ? prev : { ...prev, [item.label]: true })); return; } } }, [activeView, roleNavGroups]); const visibleGroups = normalizedMenuSearch ? roleNavGroups .map((group) => { const groupMatches = group.label.toLowerCase().includes(normalizedMenuSearch); const items = groupMatches ? group.items : group.items.flatMap((item) => { const itemMatches = item.label.toLowerCase().includes(normalizedMenuSearch); if ('children' in item && item.children) { const matchedChildren = item.children.filter((child) => child.label.toLowerCase().includes(normalizedMenuSearch)); if (itemMatches) return [item]; if (matchedChildren.length) return [{ ...item, children: matchedChildren } as NavItem]; return []; } return itemMatches ? [item] : []; }); return { ...group, items }; }) .filter((group) => group.items.length > 0) : roleNavGroups; return ( ); } function PageTitle({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description: string; actions?: ReactNode; }) { return (

{eyebrow}

{title}

{description}

{actions ?
{actions}
: null}
); } function Topbar({ menuSearch, onMenuSearchChange, currentAdmin, onAdminChange, onLogout }: { menuSearch: string; onMenuSearchChange: (value: string) => void; currentAdmin: CurrentAdmin | null; onAdminChange: (admin: CurrentAdmin) => void; onLogout: () => void; }) { const [profileOpen, setProfileOpen] = useState(false); const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); const [passwordModalOpen, setPasswordModalOpen] = useState(false); const [avatarDeleteConfirmOpen, setAvatarDeleteConfirmOpen] = useState(false); const [profileSshModalOpen, setProfileSshModalOpen] = useState(false); const [profileSshDeleteConfirmOpen, setProfileSshDeleteConfirmOpen] = useState(false); const [avatarFailed, setAvatarFailed] = useState(false); const [avatarRevision, setAvatarRevision] = useState(0); const [avatarUploading, setAvatarUploading] = useState(false); const [avatarDeleting, setAvatarDeleting] = useState(false); const [avatarError, setAvatarError] = useState(null); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [newPasswordConfirm, setNewPasswordConfirm] = useState(''); const [passwordSaving, setPasswordSaving] = useState(false); const [passwordMessage, setPasswordMessage] = useState(null); const [passwordError, setPasswordError] = useState(null); const [profileSshLabel, setProfileSshLabel] = useState(''); const [profileSshPublicKey, setProfileSshPublicKey] = useState(''); const [profileSshSaving, setProfileSshSaving] = useState(false); const [profileSshDeleting, setProfileSshDeleting] = useState(false); const [profileSshError, setProfileSshError] = useState(null); const profileRef = useRef(null); const avatarInputRef = useRef(null); const accountLabel = currentAdmin?.email ?? '관리자'; const avatarLetter = accountLabel.trim().charAt(0).toUpperCase() || 'A'; const avatarUrl = currentAdmin?.avatarUrl && !avatarFailed ? currentAdmin.avatarUrl : ''; const avatarSrc = avatarUrl ? `${avatarUrl}${avatarUrl.includes('?') ? '&' : '?'}v=${avatarRevision}` : ''; const roleLabel = currentAdmin?.role ?? '-'; const appPermissionCount = currentAdmin?.appPermissions?.length ?? 0; const sshKeys = currentAdmin?.sshKeys ?? []; const openProfileSshModal = () => { const account = currentAdmin?.email.split('@')[0] || 'admin'; const random = Math.random().toString(36).slice(2, 8); setProfileSshLabel(`${account}-${random}-key`); setProfileSshPublicKey(''); setProfileSshError(null); setProfileSshModalOpen(true); }; const closeProfileSshModal = () => { setProfileSshModalOpen(false); setProfileSshLabel(''); setProfileSshPublicKey(''); setProfileSshError(null); }; const closePasswordModal = () => { setPasswordModalOpen(false); setCurrentPassword(''); setNewPassword(''); setNewPasswordConfirm(''); setPasswordError(null); setPasswordMessage(null); }; useEffect(() => { setAvatarFailed(false); setAvatarError(null); }, [currentAdmin?.avatarUrl]); const refreshAdmin = async () => { const admin = await fetchCurrentAdmin(); storeCurrentAdmin(admin); onAdminChange(admin); setAvatarFailed(false); setAvatarRevision((revision) => revision + 1); }; const uploadAvatar = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file || avatarUploading || avatarDeleting) return; setAvatarUploading(true); setAvatarError(null); try { const imageBlob = await resizeImageToWebp(file, 128); const response = await apiFetch(`${apiBaseUrl}/auth/admin/avatar`, { method: 'PUT', headers: authHeaders({ 'Content-Type': imageBlob.type }), body: imageBlob, }); if (!response.ok) { const body = await response.json().catch(() => null); throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || '프로필 이미지 업로드에 실패했습니다.'); } await refreshAdmin(); } catch (error) { setAvatarError(error instanceof Error ? error.message : '프로필 이미지 업로드에 실패했습니다.'); } finally { setAvatarUploading(false); } }; const deleteAvatar = async () => { if (!avatarUrl || avatarUploading || avatarDeleting) return; setAvatarDeleting(true); setAvatarError(null); try { const response = await apiFetch(`${apiBaseUrl}/auth/admin/avatar`, { method: 'DELETE', headers: authHeaders(), }); if (!response.ok) { const body = await response.json().catch(() => null); throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || '프로필 이미지 삭제에 실패했습니다.'); } await refreshAdmin(); setAvatarDeleteConfirmOpen(false); } catch (error) { setAvatarError(error instanceof Error ? error.message : '프로필 이미지 삭제에 실패했습니다.'); } finally { setAvatarDeleting(false); } }; const submitPasswordChange = async () => { setPasswordMessage(null); setPasswordError(null); if (!currentPassword || !newPassword || !newPasswordConfirm) { setPasswordError('비밀번호를 모두 입력해 주세요.'); return; } if (newPassword.length < 8) { setPasswordError('새 비밀번호는 8자 이상이어야 합니다.'); return; } if (newPassword !== newPasswordConfirm) { setPasswordError('새 비밀번호 확인이 일치하지 않습니다.'); return; } setPasswordSaving(true); try { const response = await apiFetch(`${apiBaseUrl}/auth/admin/password`, { method: 'PATCH', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ currentPassword, newPassword }), }); if (!response.ok) { const body = await response.json().catch(() => null); throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || '비밀번호 변경에 실패했습니다.'); } setCurrentPassword(''); setNewPassword(''); setNewPasswordConfirm(''); setPasswordMessage('비밀번호가 변경되었습니다.'); } catch (error) { setPasswordError(error instanceof Error ? error.message : '비밀번호 변경에 실패했습니다.'); } finally { setPasswordSaving(false); } }; const submitProfileSshKey = async () => { if (!currentAdmin?.id || !profileSshPublicKey.trim() || profileSshSaving) return; setProfileSshSaving(true); setProfileSshError(null); try { await createAdminSshKey(currentAdmin.id, { label: profileSshLabel.trim() || undefined, publicKey: profileSshPublicKey.trim(), }); await refreshAdmin(); closeProfileSshModal(); } catch (error) { setProfileSshError(error instanceof Error ? error.message : 'SSH 키 등록에 실패했습니다.'); } finally { setProfileSshSaving(false); } }; const deleteProfileSshKey = async () => { const keyId = sshKeys[0]?.id; if (!currentAdmin?.id || !keyId || profileSshDeleting) return; setProfileSshDeleting(true); setProfileSshError(null); try { const response = await apiFetch(`${apiBaseUrl}/admins/${currentAdmin.id}/ssh-keys/${keyId}`, { method: 'DELETE', headers: authHeaders(), }); if (!response.ok) { const body = await response.json().catch(() => null); throw new Error(Array.isArray(body?.message) ? body.message.join(', ') : body?.message || 'SSH 키 삭제에 실패했습니다.'); } await refreshAdmin(); setProfileSshDeleteConfirmOpen(false); } catch (error) { setProfileSshError(error instanceof Error ? error.message : 'SSH 키 삭제에 실패했습니다.'); } finally { setProfileSshDeleting(false); } }; useEffect(() => { const closeProfile = (event: MouseEvent) => { if (!profileRef.current?.contains(event.target as Node)) setProfileOpen(false); }; document.addEventListener('mousedown', closeProfile); return () => document.removeEventListener('mousedown', closeProfile); }, []); return ( <>
onMenuSearchChange(event.target.value)} />
{profileOpen ? (
{avatarUrl ? ( setAvatarFailed(true)} /> ) : avatarLetter}

{currentAdmin?.name || accountLabel}

{accountLabel}

) : null}
setProfileSettingsOpen(false)} className="max-w-xl" footer={} >
{avatarUrl ? ( setAvatarFailed(true)} /> ) : avatarLetter} {avatarUrl ? ( ) : null}

{currentAdmin?.name || accountLabel}

{accountLabel}

{avatarError ?

{avatarError}

: null}

권한 정보

SSH 키

관리자 계정에 등록된 저장소 접근 키입니다.

{sshKeys.length > 0 ? ( ) : null} {!sshKeys.length ? ( ) : null}
{sshKeys.length ? sshKeys.map((key) => (

{key.label ?? 'SSH Key'}

{key.fingerprint ?? '-'}

{key.giteaUsername ?

저장소 사용자명 {key.giteaUsername}

: null}
)) : (

등록된 SSH 키가 없습니다.

)}

비밀번호

현재 비밀번호 확인 후 새 비밀번호로 변경합니다.

setAvatarDeleteConfirmOpen(false)} footer={ <> } >

현재 프로필 이미지를 삭제합니다. 삭제 후 기본 이니셜 이미지로 표시됩니다.

{avatarError ?

{avatarError}

: null}
} >

저장소 사용자명은 관리자 이메일 기준으로 자동 적용됩니다.