track requesters for builds releases deployments and migrations
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+581
-11
@@ -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<HTMLImageElement>((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<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', quality));
|
||||
if (blob && blob.size <= 80 * 1024) return blob;
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob | null>((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<string | null>(null);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [passwordMessage, setPasswordMessage] = useState<string | null>(null);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [profileSshLabel, setProfileSshLabel] = useState('');
|
||||
const [profileSshPublicKey, setProfileSshPublicKey] = useState('');
|
||||
const [profileSshSaving, setProfileSshSaving] = useState(false);
|
||||
const [profileSshDeleting, setProfileSshDeleting] = useState(false);
|
||||
const [profileSshError, setProfileSshError] = useState<string | null>(null);
|
||||
const profileRef = useRef<HTMLDivElement | null>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-border bg-background/90 px-4 backdrop-blur-xl md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="outline" className="lg:hidden">
|
||||
@@ -773,18 +1055,36 @@ function Topbar({
|
||||
</Button>
|
||||
<div ref={profileRef} className="relative">
|
||||
<Button variant="outline" className="gap-2" onClick={() => setProfileOpen((open) => !open)}>
|
||||
<span className="flex size-6 items-center justify-center rounded bg-muted text-xs font-semibold">
|
||||
{avatarLetter}
|
||||
</span>
|
||||
<span className="hidden max-w-48 truncate md:inline">{accountLabel}</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
{profileOpen ? (
|
||||
<div className="absolute right-0 top-11 z-50 w-64 overflow-hidden rounded-md border border-border bg-card shadow-xl">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<p className="truncate text-sm text-foreground">{currentAdmin?.name || accountLabel}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-sm font-semibold">
|
||||
{avatarUrl ? (
|
||||
<img className="h-full w-full object-cover" src={avatarSrc} alt="" onError={() => setAvatarFailed(true)} />
|
||||
) : avatarLetter}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-foreground">{currentAdmin?.name || accountLabel}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
setProfileSettingsOpen(true);
|
||||
setAvatarError(null);
|
||||
setPasswordError(null);
|
||||
setPasswordMessage(null);
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
프로필 설정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
|
||||
@@ -801,6 +1101,258 @@ function Topbar({
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Modal
|
||||
open={profileSettingsOpen}
|
||||
title="프로필 설정"
|
||||
onClose={() => setProfileSettingsOpen(false)}
|
||||
className="max-w-xl"
|
||||
footer={<Button variant="outline" onClick={() => setProfileSettingsOpen(false)}>닫기</Button>}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<section className="flex items-center gap-4 rounded-md border border-border bg-muted/10 p-4">
|
||||
<div className="relative size-20 shrink-0">
|
||||
<span className="flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-muted text-xl font-semibold">
|
||||
{avatarUrl ? (
|
||||
<img className="h-full w-full object-cover" src={avatarSrc} alt="" onError={() => setAvatarFailed(true)} />
|
||||
) : avatarLetter}
|
||||
</span>
|
||||
{avatarUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 z-10 flex size-7 items-center justify-center rounded-full border border-rose-400/70 bg-card text-rose-300 shadow-sm transition-colors hover:bg-rose-500/15 hover:text-rose-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="프로필 이미지 삭제"
|
||||
aria-label="프로필 이미지 삭제"
|
||||
disabled={avatarUploading || avatarDeleting}
|
||||
onClick={() => setAvatarDeleteConfirmOpen(true)}
|
||||
>
|
||||
{avatarDeleting ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-0 right-0 flex size-8 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:border-primary/60 hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="프로필 이미지 업로드"
|
||||
aria-label="프로필 이미지 업로드"
|
||||
disabled={avatarUploading || avatarDeleting}
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
>
|
||||
{avatarUploading ? <Loader2 className="size-4 animate-spin" /> : <Camera className="size-4" />}
|
||||
</button>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={uploadAvatar}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{currentAdmin?.name || accountLabel}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
|
||||
{avatarError ? <p className="mt-2 line-clamp-2 text-xs text-rose-300">{avatarError}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-foreground">권한 정보</h3>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<ProfileInfoRow label="역할" value={roleLabel} />
|
||||
<ProfileInfoRow label="앱 권한" value={isOwnerRole(currentAdmin?.role) ? '전체 앱' : `${appPermissionCount}개 앱`} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border border-border bg-muted/10 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">SSH 키</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">관리자 계정에 등록된 저장소 접근 키입니다.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{sshKeys.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 border-rose-500/40 px-3 text-xs text-rose-200 hover:bg-rose-500/10"
|
||||
disabled={!currentAdmin?.id}
|
||||
onClick={() => {
|
||||
setProfileSshError(null);
|
||||
setProfileSshDeleteConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
{!sshKeys.length ? (
|
||||
<Button variant="outline" className="h-8 px-3 text-xs" disabled={!currentAdmin?.id} onClick={openProfileSshModal}>
|
||||
등록
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-md border border-border bg-background/40">
|
||||
{sshKeys.length ? sshKeys.map((key) => (
|
||||
<div key={key.id} className="border-b border-border px-3 py-2 last:border-b-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{key.label ?? 'SSH Key'}</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{key.fingerprint ?? '-'}</p>
|
||||
{key.giteaUsername ? <p className="mt-1 text-xs text-muted-foreground">저장소 사용자명 {key.giteaUsername}</p> : null}
|
||||
</div>
|
||||
)) : (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">등록된 SSH 키가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex items-center justify-between gap-4 rounded-md border border-border bg-muted/10 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">비밀번호</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">현재 비밀번호 확인 후 새 비밀번호로 변경합니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPasswordModalOpen(true);
|
||||
setPasswordError(null);
|
||||
setPasswordMessage(null);
|
||||
}}
|
||||
>
|
||||
비밀번호 변경
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={avatarDeleteConfirmOpen}
|
||||
title="프로필 이미지 삭제"
|
||||
onClose={() => setAvatarDeleteConfirmOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setAvatarDeleteConfirmOpen(false)}>취소</Button>
|
||||
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" disabled={avatarDeleting} onClick={deleteAvatar}>
|
||||
{avatarDeleting ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">현재 프로필 이미지를 삭제합니다. 삭제 후 기본 이니셜 이미지로 표시됩니다.</p>
|
||||
{avatarError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{avatarError}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={profileSshModalOpen}
|
||||
title="SSH 키 등록"
|
||||
onClose={closeProfileSshModal}
|
||||
className="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeProfileSshModal}>취소</Button>
|
||||
<Button disabled={profileSshSaving || !profileSshPublicKey.trim()} onClick={submitProfileSshKey}>
|
||||
{profileSshSaving ? '등록 중' : '등록'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">키 이름</span>
|
||||
<input
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={profileSshLabel}
|
||||
onChange={(event) => setProfileSshLabel(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
저장소 사용자명은 관리자 이메일 기준으로 자동 적용됩니다.
|
||||
</p>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">공개키</span>
|
||||
<textarea
|
||||
className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={profileSshPublicKey}
|
||||
onChange={(event) => setProfileSshPublicKey(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{profileSshError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{profileSshError}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={profileSshDeleteConfirmOpen}
|
||||
title="SSH 키 삭제"
|
||||
onClose={() => setProfileSshDeleteConfirmOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setProfileSshDeleteConfirmOpen(false)}>취소</Button>
|
||||
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" disabled={profileSshDeleting} onClick={deleteProfileSshKey}>
|
||||
{profileSshDeleting ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">등록된 SSH 키를 삭제합니다. Gitea에 등록된 키도 함께 삭제됩니다.</p>
|
||||
{profileSshError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{profileSshError}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={passwordModalOpen}
|
||||
title="비밀번호 변경"
|
||||
onClose={closePasswordModal}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closePasswordModal}>취소</Button>
|
||||
<Button
|
||||
disabled={passwordSaving || !currentPassword || !newPassword || !newPasswordConfirm}
|
||||
onClick={submitPasswordChange}
|
||||
>
|
||||
{passwordSaving ? '변경 중' : '변경'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">현재 비밀번호</span>
|
||||
<input
|
||||
type="password"
|
||||
className="login-light-field login-password-input mt-2 h-9 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
|
||||
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff', WebkitTextFillColor: '#ffffff' }}
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">새 비밀번호</span>
|
||||
<input
|
||||
type="password"
|
||||
className="login-light-field login-password-input mt-2 h-9 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
|
||||
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff', WebkitTextFillColor: '#ffffff' }}
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">새 비밀번호 확인</span>
|
||||
<input
|
||||
type="password"
|
||||
className="login-light-field login-password-input mt-2 h-9 w-full rounded-md border border-input bg-[#111827] px-3 text-sm text-white outline-none placeholder:text-white/35 focus:ring-2 focus:ring-ring"
|
||||
style={{ backgroundColor: '#111827', color: '#ffffff', caretColor: '#ffffff', WebkitTextFillColor: '#ffffff' }}
|
||||
value={newPasswordConfirm}
|
||||
onChange={(event) => setNewPasswordConfirm(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{passwordError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{passwordError}</p> : null}
|
||||
{passwordMessage ? <p className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">{passwordMessage}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileInfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/10 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -825,7 +1377,7 @@ function LoginPage({ onLogin }: { onLogin: (admin: CurrentAdmin) => void }) {
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error('Admin login failed');
|
||||
return response.json() as Promise<{ token: string; expiresAt: string; admin: { email: string; name: string; role: string } }>;
|
||||
return response.json() as Promise<{ token: string; expiresAt: string; admin: CurrentAdmin }>;
|
||||
})
|
||||
.then((data) => {
|
||||
localStorage.setItem('studio-admin-token', data.token);
|
||||
@@ -1697,6 +2249,23 @@ function App() {
|
||||
if (location.pathname === '/') navigate('/dashboard', { replace: true });
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
let cancelled = false;
|
||||
fetchCurrentAdmin()
|
||||
.then((admin) => {
|
||||
if (cancelled) return;
|
||||
storeCurrentAdmin(admin);
|
||||
setCurrentAdmin(admin);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView === 'adminManagement' && !isOwner) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
@@ -1818,6 +2387,7 @@ function App() {
|
||||
menuSearch={menuSearch}
|
||||
onMenuSearchChange={setMenuSearch}
|
||||
currentAdmin={currentAdmin}
|
||||
onAdminChange={setCurrentAdmin}
|
||||
onLogout={logout}
|
||||
/>
|
||||
<main className={`scrollbar-stable min-h-0 flex-1 overflow-y-auto ${isViewer ? 'viewer-readonly' : ''}`}>
|
||||
|
||||
Reference in New Issue
Block a user