diff --git a/src/App.tsx b/src/App.tsx index 90a67bf..dc0ee9a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,18 +1,22 @@ -import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; +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 + SlidersHorizontal, + Trash2 } from 'lucide-react'; import { navGroups } from './data/mock'; -import { Badge, Button, Card, CardHeader, Progress } from './components/ui'; +import { Badge, Button, Card, CardHeader, Modal, Progress } from './components/ui'; import { formatDateTime as formatDateTimeValue } from './lib/date-format'; -import { apiBaseUrl, apiRequest, authExpiredEventName, clearAdminSession } from './lib/api'; +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'; @@ -24,6 +28,7 @@ import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPag 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'; @@ -200,9 +205,19 @@ function viewFromPath(pathname: string): ViewKey { } 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 { @@ -216,6 +231,59 @@ function readCurrentAdmin(): CurrentAdmin | 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; @@ -731,17 +799,230 @@ 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) => { @@ -752,6 +1033,7 @@ function Topbar({ }, []); return ( + <>
{profileOpen ? (
-
-

{currentAdmin?.name || accountLabel}

-

{accountLabel}

+
+ + {avatarUrl ? ( + setAvatarFailed(true)} /> + ) : avatarLetter} + +
+

{currentAdmin?.name || accountLabel}

+

{accountLabel}

+
+
+ 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} +
+
+ + + + + } + > +
+ +

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

+