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 { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
|
Loader2,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Settings,
|
||||||
Search,
|
Search,
|
||||||
SlidersHorizontal
|
SlidersHorizontal,
|
||||||
|
Trash2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { navGroups } from './data/mock';
|
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 { 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 { filterAppsByCurrentAdmin } from './lib/admin-app-permissions';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
import { openPageAgent } from './lib/page-agent';
|
import { openPageAgent } from './lib/page-agent';
|
||||||
@@ -24,6 +28,7 @@ import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPag
|
|||||||
import { RepositoryManagementPage } from './pages/applications/RepositoryManagementPage';
|
import { RepositoryManagementPage } from './pages/applications/RepositoryManagementPage';
|
||||||
import { SourceManagementPage as AppSourcesApiPage } from './pages/applications/SourceManagementPage';
|
import { SourceManagementPage as AppSourcesApiPage } from './pages/applications/SourceManagementPage';
|
||||||
import { AdminManagementPage as AdminManagementApiPage } from './pages/operations/AdminManagementPage';
|
import { AdminManagementPage as AdminManagementApiPage } from './pages/operations/AdminManagementPage';
|
||||||
|
import { createAdminSshKey } from './pages/operations/operations-api';
|
||||||
import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage';
|
import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage';
|
||||||
import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage';
|
import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage';
|
||||||
import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage';
|
import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage';
|
||||||
@@ -200,9 +205,19 @@ function viewFromPath(pathname: string): ViewKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CurrentAdmin = {
|
type CurrentAdmin = {
|
||||||
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
role?: 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 {
|
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 = {
|
type ApiAppComponent = {
|
||||||
id: string;
|
id: string;
|
||||||
appId: string;
|
appId: string;
|
||||||
@@ -731,17 +799,230 @@ function Topbar({
|
|||||||
menuSearch,
|
menuSearch,
|
||||||
onMenuSearchChange,
|
onMenuSearchChange,
|
||||||
currentAdmin,
|
currentAdmin,
|
||||||
|
onAdminChange,
|
||||||
onLogout
|
onLogout
|
||||||
}: {
|
}: {
|
||||||
menuSearch: string;
|
menuSearch: string;
|
||||||
onMenuSearchChange: (value: string) => void;
|
onMenuSearchChange: (value: string) => void;
|
||||||
currentAdmin: CurrentAdmin | null;
|
currentAdmin: CurrentAdmin | null;
|
||||||
|
onAdminChange: (admin: CurrentAdmin) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [profileOpen, setProfileOpen] = useState(false);
|
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 profileRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const accountLabel = currentAdmin?.email ?? '관리자';
|
const accountLabel = currentAdmin?.email ?? '관리자';
|
||||||
const avatarLetter = accountLabel.trim().charAt(0).toUpperCase() || 'A';
|
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(() => {
|
useEffect(() => {
|
||||||
const closeProfile = (event: MouseEvent) => {
|
const closeProfile = (event: MouseEvent) => {
|
||||||
@@ -752,6 +1033,7 @@ function Topbar({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Button variant="outline" className="lg:hidden">
|
<Button variant="outline" className="lg:hidden">
|
||||||
@@ -773,18 +1055,36 @@ function Topbar({
|
|||||||
</Button>
|
</Button>
|
||||||
<div ref={profileRef} className="relative">
|
<div ref={profileRef} className="relative">
|
||||||
<Button variant="outline" className="gap-2" onClick={() => setProfileOpen((open) => !open)}>
|
<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>
|
<span className="hidden max-w-48 truncate md:inline">{accountLabel}</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
{profileOpen ? (
|
{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="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">
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
<p className="truncate text-sm text-foreground">{currentAdmin?.name || accountLabel}</p>
|
<span className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-sm font-semibold">
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{accountLabel}</p>
|
{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>
|
</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
|
<button
|
||||||
type="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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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) => {
|
.then((response) => {
|
||||||
if (!response.ok) throw new Error('Admin login failed');
|
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) => {
|
.then((data) => {
|
||||||
localStorage.setItem('studio-admin-token', data.token);
|
localStorage.setItem('studio-admin-token', data.token);
|
||||||
@@ -1697,6 +2249,23 @@ function App() {
|
|||||||
if (location.pathname === '/') navigate('/dashboard', { replace: true });
|
if (location.pathname === '/') navigate('/dashboard', { replace: true });
|
||||||
}, [location.pathname, navigate]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (activeView === 'adminManagement' && !isOwner) {
|
if (activeView === 'adminManagement' && !isOwner) {
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
@@ -1818,6 +2387,7 @@ function App() {
|
|||||||
menuSearch={menuSearch}
|
menuSearch={menuSearch}
|
||||||
onMenuSearchChange={setMenuSearch}
|
onMenuSearchChange={setMenuSearch}
|
||||||
currentAdmin={currentAdmin}
|
currentAdmin={currentAdmin}
|
||||||
|
onAdminChange={setCurrentAdmin}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
/>
|
/>
|
||||||
<main className={`scrollbar-stable min-h-0 flex-1 overflow-y-auto ${isViewer ? 'viewer-readonly' : ''}`}>
|
<main className={`scrollbar-stable min-h-0 flex-1 overflow-y-auto ${isViewer ? 'viewer-readonly' : ''}`}>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
type CommitAuthorUser = {
|
||||||
|
login?: string;
|
||||||
|
username?: string;
|
||||||
|
full_name?: string;
|
||||||
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
} | null | undefined;
|
||||||
|
|
||||||
|
export type CommitAuthorLike = {
|
||||||
|
commit?: {
|
||||||
|
author?: {
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
author?: CommitAuthorUser;
|
||||||
|
committer?: CommitAuthorUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function commitAuthorName(commit: CommitAuthorLike) {
|
||||||
|
return commit.author?.login
|
||||||
|
|| commit.author?.username
|
||||||
|
|| commit.author?.full_name
|
||||||
|
|| commit.author?.name
|
||||||
|
|| commit.commit?.author?.name
|
||||||
|
|| commit.committer?.login
|
||||||
|
|| commit.committer?.username
|
||||||
|
|| commit.committer?.full_name
|
||||||
|
|| commit.committer?.name
|
||||||
|
|| '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitAuthorAvatarUrl(commit: CommitAuthorLike) {
|
||||||
|
return commit.author?.avatar_url
|
||||||
|
|| commit.author?.avatarUrl
|
||||||
|
|| commit.committer?.avatar_url
|
||||||
|
|| commit.committer?.avatarUrl
|
||||||
|
|| '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommitAuthor({ commit }: { commit: CommitAuthorLike }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = commitAuthorName(commit);
|
||||||
|
const avatarUrl = commitAuthorAvatarUrl(commit);
|
||||||
|
const initial = name === '-' ? '?' : name.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate font-medium">{name}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export type DataTableColumn<T> = {
|
|||||||
key: string;
|
key: string;
|
||||||
header: string;
|
header: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
width?: number;
|
||||||
cell: (row: T) => ReactNode;
|
cell: (row: T) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,6 +17,25 @@ export type DataTableFilter<T> = {
|
|||||||
getValue: (row: T) => string;
|
getValue: (row: T) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const spacingWidthPattern = /\bw-(?:\[(\d+)px\]|(\d+))\b/;
|
||||||
|
const widthClassPattern = /\bw-(?:\[[^\]]+\]|\d+)\b/g;
|
||||||
|
|
||||||
|
function getColumnWidth<T>(column: DataTableColumn<T>) {
|
||||||
|
if (column.key === 'no') return 75;
|
||||||
|
if (column.width) return column.width;
|
||||||
|
|
||||||
|
const widthMatch = column.className?.match(spacingWidthPattern);
|
||||||
|
if (widthMatch?.[1]) return Number(widthMatch[1]);
|
||||||
|
if (widthMatch?.[2]) return Number(widthMatch[2]) * 4;
|
||||||
|
if (column.key === 'actions') return 80;
|
||||||
|
|
||||||
|
return 160;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripWidthClass(className?: string) {
|
||||||
|
return className?.replace(widthClassPattern, '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function DataTableCard<T>({
|
export function DataTableCard<T>({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -66,6 +86,7 @@ export function DataTableCard<T>({
|
|||||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
|
const flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
||||||
|
|
||||||
const updateFilter = (key: string, value: string) => {
|
const updateFilter = (key: string, value: string) => {
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -116,11 +137,36 @@ export function DataTableCard<T>({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
||||||
<table className="w-full text-left text-sm" style={{ minWidth }}>
|
<table className="w-full table-fixed text-left text-sm" style={{ minWidth }}>
|
||||||
|
<colgroup>
|
||||||
|
{columns.map((column, columnIndex) => (
|
||||||
|
<col
|
||||||
|
key={column.key}
|
||||||
|
style={
|
||||||
|
columnIndex === flexibleColumnIndex
|
||||||
|
? { width: '100%' }
|
||||||
|
: { width: getColumnWidth(column) }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((column) => (
|
{columns.map((column, columnIndex) => (
|
||||||
<th key={column.key} className={column.className ?? 'px-5 py-3 font-medium'}>
|
<th
|
||||||
|
key={column.key}
|
||||||
|
className={
|
||||||
|
column.key === 'no'
|
||||||
|
? 'py-3 text-center font-medium'
|
||||||
|
: columnIndex === flexibleColumnIndex
|
||||||
|
? stripWidthClass(column.className) || 'px-5 py-3 font-medium'
|
||||||
|
: column.className ?? 'px-5 py-3 font-medium'
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...(column.key === 'no' ? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 } : columnIndex === flexibleColumnIndex ? { width: '100%' } : { width: getColumnWidth(column) }),
|
||||||
|
...(columnIndex === flexibleColumnIndex ? { paddingLeft: 12 } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{column.header}
|
{column.header}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -129,9 +175,25 @@ export function DataTableCard<T>({
|
|||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{!loading && pageRows.map((row) => (
|
{!loading && pageRows.map((row) => (
|
||||||
<tr key={getRowKey(row)} className="hover:bg-muted/20">
|
<tr key={getRowKey(row)} className="hover:bg-muted/20">
|
||||||
{columns.map((column) => (
|
{columns.map((column, columnIndex) => (
|
||||||
<td key={column.key} className="px-5 py-3">
|
<td
|
||||||
{column.cell(row)}
|
key={column.key}
|
||||||
|
className={column.key === 'no' ? 'py-3 text-center font-medium' : columns[0]?.key === 'no' && columnIndex === 1 ? 'py-3 pl-3 pr-5' : 'px-5 py-3'}
|
||||||
|
style={
|
||||||
|
column.key === 'no'
|
||||||
|
? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 }
|
||||||
|
: columnIndex === flexibleColumnIndex
|
||||||
|
? { width: '100%' }
|
||||||
|
: { width: getColumnWidth(column) }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.key === 'no' ? (
|
||||||
|
<span className="block w-full text-center font-semibold">
|
||||||
|
{String((row as { no?: unknown }).no ?? '')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
column.cell(row)
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type RequesterLike = {
|
||||||
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
|
createdBy?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function requesterName(item: RequesterLike) {
|
||||||
|
return item.requestedByName
|
||||||
|
|| item.createdByName
|
||||||
|
|| item.requestedBy
|
||||||
|
|| item.createdBy
|
||||||
|
|| item.requestedByEmail
|
||||||
|
|| item.createdByEmail
|
||||||
|
|| 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Requester({ item, label = '작업자' }: { item: RequesterLike; label?: string }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = requesterName(item);
|
||||||
|
const avatarUrl = item.requestedByAvatarUrl || item.createdByAvatarUrl || '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||||
|
<span>{label}</span>
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-6 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[11px] font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<b className="truncate text-foreground">{name}</b>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export type CurrentAdminLike = {
|
|||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
appPermissions?: string[];
|
appPermissions?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { useEffect, useState, type MouseEvent } from 'react';
|
import { useEffect, useState, type MouseEvent } from 'react';
|
||||||
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2, UserCog } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api';
|
import { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api';
|
||||||
import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin } from '../../lib/admin-app-permissions';
|
import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin, isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions';
|
||||||
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
|
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||||
import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime';
|
import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime';
|
||||||
import { LoadingModal } from '../operations/operation-utils';
|
import { LoadingModal } from '../operations/operation-utils';
|
||||||
|
import { loadAdmins, updateAdminAppPermissions, type AdminAccount } from '../operations/operations-api';
|
||||||
|
|
||||||
type ComponentPortForm = {
|
type ComponentPortForm = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,6 +88,28 @@ function formatStatus(status?: string | null, launcherLinked?: boolean) {
|
|||||||
return status ? statusMap[status] ?? status : '-';
|
return status ? statusMap[status] ?? status : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppAdminAvatar({ admin }: { admin: AdminAccount }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const initial = (admin.name || admin.email || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
if (admin.avatarUrl && !failed) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={admin.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-8 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function loadApps() {
|
function loadApps() {
|
||||||
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
|
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -334,6 +357,13 @@ export function AppManagementPage() {
|
|||||||
deleteKubernetesResources: false,
|
deleteKubernetesResources: false,
|
||||||
deletePersistentVolumes: false
|
deletePersistentVolumes: false
|
||||||
});
|
});
|
||||||
|
const [isAppAdminsOpen, setIsAppAdminsOpen] = useState(false);
|
||||||
|
const [appAdmins, setAppAdmins] = useState<AdminAccount[]>([]);
|
||||||
|
const [appAdminPermissions, setAppAdminPermissions] = useState<Record<string, string[]>>({});
|
||||||
|
const [appAdminsLoading, setAppAdminsLoading] = useState(false);
|
||||||
|
const [appAdminSavingId, setAppAdminSavingId] = useState('');
|
||||||
|
const [appAdminSearch, setAppAdminSearch] = useState('');
|
||||||
|
const [appAdminConfirm, setAppAdminConfirm] = useState<{ admin: AdminAccount; allowed: boolean } | null>(null);
|
||||||
const [deletingComponent, setDeletingComponent] = useState<ApiAppComponent | null>(null);
|
const [deletingComponent, setDeletingComponent] = useState<ApiAppComponent | null>(null);
|
||||||
const [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]);
|
const [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]);
|
||||||
const [workloadActionKey, setWorkloadActionKey] = useState('');
|
const [workloadActionKey, setWorkloadActionKey] = useState('');
|
||||||
@@ -351,6 +381,20 @@ export function AppManagementPage() {
|
|||||||
const workloads = runtimeResources.filter(isWorkloadResource);
|
const workloads = runtimeResources.filter(isWorkloadResource);
|
||||||
const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === selectedVolumeClaimName);
|
const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === selectedVolumeClaimName);
|
||||||
const volumeDeleteConfirmPhrase = selectedVolumeClaimName ? `${selectedVolumeClaimName} 삭제` : '';
|
const volumeDeleteConfirmPhrase = selectedVolumeClaimName ? `${selectedVolumeClaimName} 삭제` : '';
|
||||||
|
const selectedAppAuthorizedAdmins = selectedApp
|
||||||
|
? appAdmins.filter((admin) => isOwnerAdmin(admin) || (appAdminPermissions[admin.id] ?? admin.appPermissions ?? []).includes(selectedApp.id))
|
||||||
|
: [];
|
||||||
|
const appAdminAddOptions = selectedApp
|
||||||
|
? appAdmins.filter((admin) => (
|
||||||
|
admin.isActive
|
||||||
|
&& !isOwnerAdmin(admin)
|
||||||
|
&& !(appAdminPermissions[admin.id] ?? admin.appPermissions ?? []).includes(selectedApp.id)
|
||||||
|
))
|
||||||
|
: [];
|
||||||
|
const appAdminSearchKeyword = appAdminSearch.trim().toLowerCase();
|
||||||
|
const appAdminSearchResults = appAdminSearchKeyword.length >= 2
|
||||||
|
? appAdminAddOptions.filter((admin) => `${admin.name} ${admin.email} ${admin.role}`.toLowerCase().includes(appAdminSearchKeyword)).slice(0, 8)
|
||||||
|
: [];
|
||||||
const canDeleteVolume = Boolean(
|
const canDeleteVolume = Boolean(
|
||||||
deletingVolumeWorkload &&
|
deletingVolumeWorkload &&
|
||||||
selectedVolumeClaimName &&
|
selectedVolumeClaimName &&
|
||||||
@@ -884,6 +928,57 @@ export function AppManagementPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAppAdmins = () => {
|
||||||
|
if (!selectedApp) return;
|
||||||
|
setIsAppAdminsOpen(true);
|
||||||
|
setAppAdminsLoading(true);
|
||||||
|
setAppAdminSearch('');
|
||||||
|
loadAdmins()
|
||||||
|
.then((admins) => {
|
||||||
|
setAppAdmins(admins);
|
||||||
|
setAppAdminPermissions(Object.fromEntries(admins.map((admin) => [admin.id, admin.appPermissions ?? []])));
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
setAppAdmins([]);
|
||||||
|
setAppAdminPermissions({});
|
||||||
|
toast({ type: 'error', title: '관리자 조회 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setAppAdminsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestAppAdminAccess = (admin: AdminAccount, allowed: boolean) => {
|
||||||
|
if (isOwnerAdmin(admin) || appAdminSavingId) return;
|
||||||
|
setAppAdminConfirm({ admin, allowed });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAppAdminAccess = (admin: AdminAccount, allowed: boolean) => {
|
||||||
|
if (!selectedApp || isOwnerAdmin(admin) || appAdminSavingId) return;
|
||||||
|
const currentAppIds = appAdminPermissions[admin.id] ?? admin.appPermissions ?? [];
|
||||||
|
const nextAppIds = allowed
|
||||||
|
? [...new Set([...currentAppIds, selectedApp.id])]
|
||||||
|
: currentAppIds.filter((appId) => appId !== selectedApp.id);
|
||||||
|
|
||||||
|
setAppAdminSavingId(admin.id);
|
||||||
|
updateAdminAppPermissions(admin.id, nextAppIds)
|
||||||
|
.then((result) => {
|
||||||
|
const savedAppIds = result.appIds ?? nextAppIds;
|
||||||
|
setAppAdminPermissions((current) => ({ ...current, [admin.id]: savedAppIds }));
|
||||||
|
setAppAdmins((current) => current.map((item) => item.id === admin.id ? { ...item, appPermissions: savedAppIds } : item));
|
||||||
|
setAdminAppPermissions(admin, savedAppIds);
|
||||||
|
setAppAdminSearch('');
|
||||||
|
setAppAdminConfirm(null);
|
||||||
|
toast({
|
||||||
|
type: 'success',
|
||||||
|
title: allowed ? '관리자 추가' : '관리자 해제',
|
||||||
|
message: `${admin.name} 관리자 권한이 저장되었습니다.`
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '앱 관리자 저장 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setAppAdminSavingId(''));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -891,9 +986,14 @@ export function AppManagementPage() {
|
|||||||
title="앱 관리"
|
title="앱 관리"
|
||||||
description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 실행 요소와 운영 리소스를 하나의 묶음으로 운영합니다."
|
description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 실행 요소와 운영 리소스를 하나의 묶음으로 운영합니다."
|
||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="size-4" />앱 등록
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={openAppAdmins}>
|
||||||
</Button>
|
<UserCog className="size-4" />관리자
|
||||||
|
</Button>
|
||||||
|
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
|
||||||
|
<Plus className="size-4" />앱 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1191,6 +1291,141 @@ export function AppManagementPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isAppAdminsOpen ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
||||||
|
<div className="flex max-h-[86vh] w-full max-w-2xl flex-col rounded-lg border border-border bg-card shadow-xl">
|
||||||
|
<div className="border-b border-border px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">관리자</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{selectedApp?.name ?? '-'} · {selectedApp?.key ?? '-'} 앱에 접근할 수 있는 관리자입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5">
|
||||||
|
<div className="rounded-md border border-border p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">관리자 추가</p>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={appAdminSearch}
|
||||||
|
onChange={(event) => setAppAdminSearch(event.target.value)}
|
||||||
|
placeholder="이름 또는 이메일 입력"
|
||||||
|
disabled={appAdminsLoading || appAdminAddOptions.length === 0 || Boolean(appAdminSavingId)}
|
||||||
|
/>
|
||||||
|
{appAdminSearchKeyword.length >= 2 ? (
|
||||||
|
<div className="mt-2 overflow-hidden rounded-md border border-border">
|
||||||
|
{appAdminSearchResults.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-center text-sm text-muted-foreground">검색 결과가 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
appAdminSearchResults.map((admin) => (
|
||||||
|
<div
|
||||||
|
key={admin.id}
|
||||||
|
className="flex w-full items-center justify-between gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-muted/20"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
|
<AppAdminAvatar admin={admin} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-semibold text-foreground">{admin.name}</span>
|
||||||
|
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{admin.email}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge tone="blue">{admin.role}</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
disabled={Boolean(appAdminSavingId)}
|
||||||
|
onClick={() => requestAppAdminAccess(admin, true)}
|
||||||
|
>
|
||||||
|
선택
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-md border border-border">
|
||||||
|
{appAdminsLoading ? (
|
||||||
|
<div className="px-4 py-10 text-center text-sm text-muted-foreground">관리자 목록을 불러오는 중입니다.</div>
|
||||||
|
) : selectedAppAuthorizedAdmins.length === 0 ? (
|
||||||
|
<div className="px-4 py-10 text-center text-sm text-muted-foreground">권한이 있는 관리자가 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
selectedAppAuthorizedAdmins.map((admin) => {
|
||||||
|
const owner = isOwnerAdmin(admin);
|
||||||
|
const saving = appAdminSavingId === admin.id;
|
||||||
|
return (
|
||||||
|
<div key={admin.id} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<AppAdminAvatar admin={admin} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">{admin.name}</p>
|
||||||
|
<Badge tone={owner ? 'green' : 'blue'}>{owner ? '오너' : '관리자'}</Badge>
|
||||||
|
{!admin.isActive ? <Badge tone="red">비활성</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{admin.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{owner ? (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">전체 앱 접근</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 shrink-0 border-rose-500/50 px-3 text-[13px] text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||||
|
disabled={saving || Boolean(appAdminSavingId)}
|
||||||
|
onClick={() => requestAppAdminAccess(admin, false)}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중' : '해제'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
|
<Button variant="outline" onClick={() => setIsAppAdminsOpen(false)} disabled={Boolean(appAdminSavingId)}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(appAdminConfirm)}
|
||||||
|
title={appAdminConfirm?.allowed ? '관리자 권한 추가' : '관리자 권한 해제'}
|
||||||
|
onClose={() => {
|
||||||
|
if (!appAdminSavingId) setAppAdminConfirm(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={Boolean(appAdminSavingId)} onClick={() => setAppAdminConfirm(null)}>취소</Button>
|
||||||
|
<Button
|
||||||
|
className={appAdminConfirm?.allowed ? undefined : 'border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25'}
|
||||||
|
disabled={!appAdminConfirm || Boolean(appAdminSavingId)}
|
||||||
|
onClick={() => appAdminConfirm && setAppAdminAccess(appAdminConfirm.admin, appAdminConfirm.allowed)}
|
||||||
|
>
|
||||||
|
{appAdminSavingId ? '저장 중' : appAdminConfirm?.allowed ? '추가' : '해제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-foreground">{appAdminConfirm?.admin.name ?? '-'}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{appAdminConfirm?.admin.email ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
{appAdminConfirm?.allowed
|
||||||
|
? `${selectedApp?.name ?? '-'} 앱 접근 권한을 추가합니다.`
|
||||||
|
: `${selectedApp?.name ?? '-'} 앱 접근 권한을 해제합니다.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{isCreateAppOpen ? (
|
{isCreateAppOpen ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
||||||
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
|
||||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { Requester } from '../../components/requester';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { BuildJob, SourceComponent } from '../../types/application';
|
import type { BuildJob, SourceComponent } from '../../types/application';
|
||||||
@@ -383,6 +384,7 @@ export function BuildManagementPage() {
|
|||||||
<span className="font-medium">{job.component.name}</span>
|
<span className="font-medium">{job.component.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
|
<Requester item={job} />
|
||||||
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
|
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
|
||||||
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
|
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
|
||||||
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { Requester } from '../../components/requester';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { AppRelease, DeploymentJob } from '../../types/application';
|
import type { AppRelease, DeploymentJob } from '../../types/application';
|
||||||
@@ -249,7 +250,7 @@ export function DeploymentManagementPage() {
|
|||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
||||||
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
|
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
|
||||||
<span className="text-muted-foreground">요청자 <b className="text-foreground">{deployment.requestedBy || 'admin'}</b></span>
|
<Requester item={deployment} label="요청자" />
|
||||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
|
||||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { Requester } from '../../components/requester';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { AppRelease, RuntimeControlJob } from '../../types/application';
|
import type { AppRelease, RuntimeControlJob } from '../../types/application';
|
||||||
@@ -312,6 +313,7 @@ export function MigrationManagementPage() {
|
|||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">릴리즈 <b className="text-foreground">{releaseVersion}</b></span>
|
<span className="text-muted-foreground">릴리즈 <b className="text-foreground">{releaseVersion}</b></span>
|
||||||
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span>
|
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span>
|
||||||
|
<Requester item={job} />
|
||||||
<span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span>
|
<span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span>
|
||||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span>
|
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { Requester } from '../../components/requester';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { AppRelease, BuildJob } from '../../types/application';
|
import type { AppRelease, BuildJob } from '../../types/application';
|
||||||
@@ -425,6 +426,7 @@ export function ReleaseManagementPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
|
<Requester item={release} />
|
||||||
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
|
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
|
||||||
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
||||||
<span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span>
|
<span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Copy, FileText, RotateCcw } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { CommitAuthor } from '../../components/commit-author';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
@@ -558,7 +559,7 @@ export function SourceManagementPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||||
<tr key={commit.sha} className="border-b border-border last:border-0">
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||||
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||||
|
|||||||
@@ -30,12 +30,16 @@ export type GiteaCommit = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
} | null;
|
} | null;
|
||||||
committer?: {
|
committer?: {
|
||||||
login?: string;
|
login?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
} | null;
|
} | null;
|
||||||
created?: string;
|
created?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FileText, RotateCcw } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
|
import { CommitAuthor } from '../../components/commit-author';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import type { GiteaRepo } from '../../types/application';
|
import type { GiteaRepo } from '../../types/application';
|
||||||
@@ -362,7 +363,7 @@ export function CommonAppSourcePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||||
<tr key={commit.sha} className="border-b border-border last:border-0">
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||||
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { readCurrentAdmin } from '../../lib/admin-app-permissions';
|
||||||
import { formatDateTime } from '../../lib/date-format';
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +49,41 @@ function jobTime(job: LauncherBuildJob) {
|
|||||||
return Number.isNaN(value) ? 0 : value;
|
return Number.isNaN(value) ? 0 : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BuildWorker({ job }: { job: LauncherBuildJob }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const currentAdmin = readCurrentAdmin();
|
||||||
|
const name = job.requestedByName
|
||||||
|
|| job.createdByName
|
||||||
|
|| job.requestedBy
|
||||||
|
|| job.createdBy
|
||||||
|
|| currentAdmin?.name
|
||||||
|
|| currentAdmin?.email
|
||||||
|
|| 'admin';
|
||||||
|
const avatarUrl = job.requestedByAvatarUrl
|
||||||
|
|| job.createdByAvatarUrl
|
||||||
|
|| currentAdmin?.avatarUrl
|
||||||
|
|| '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-6 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[11px] font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<b className="truncate text-foreground">{name}</b>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const launcherBuildJobPageSize = 3;
|
const launcherBuildJobPageSize = 3;
|
||||||
type BuildTarget = 'launcher-client' | 'launcher-backend';
|
type BuildTarget = 'launcher-client' | 'launcher-backend';
|
||||||
type BuildRange = 'week' | 'month' | 'all';
|
type BuildRange = 'week' | 'month' | 'all';
|
||||||
@@ -405,6 +441,7 @@ export function LauncherBuildPage() {
|
|||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
<span>브랜치 <b className="text-foreground">{job.branch}</b></span>
|
<span>브랜치 <b className="text-foreground">{job.branch}</b></span>
|
||||||
<span>커밋 <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
<span>커밋 <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-2">작업자 <BuildWorker job={job} /></span>
|
||||||
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Copy, FileText, Link2, RotateCcw } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
|
import { CommitAuthor } from '../../components/commit-author';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import type { GiteaRepo } from '../../types/application';
|
import type { GiteaRepo } from '../../types/application';
|
||||||
@@ -688,7 +689,7 @@ export function LauncherSourcePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||||
<tr key={commit.sha} className="border-b border-border last:border-0">
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||||
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export type LauncherBuildJob = {
|
|||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
artifact: string;
|
artifact: string;
|
||||||
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELED';
|
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELED';
|
||||||
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
|
createdBy?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
@@ -97,12 +105,16 @@ export type GiteaCommit = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
} | null;
|
} | null;
|
||||||
committer?: {
|
committer?: {
|
||||||
login?: string;
|
login?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
} | null;
|
} | null;
|
||||||
created?: string;
|
created?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ export function AdminManagementPage() {
|
|||||||
const [sshTarget, setSshTarget] = useState({
|
const [sshTarget, setSshTarget] = useState({
|
||||||
admin: null as AdminAccount | null,
|
admin: null as AdminAccount | null,
|
||||||
label: '',
|
label: '',
|
||||||
giteaUsername: '',
|
|
||||||
publicKey: ''
|
publicKey: ''
|
||||||
});
|
});
|
||||||
|
const [sshDeleteTarget, setSshDeleteTarget] = useState<{ admin: AdminAccount; keyId: string } | null>(null);
|
||||||
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
||||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
|
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||||
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
|
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||||
@@ -119,7 +119,7 @@ export function AdminManagementPage() {
|
|||||||
setMenuAdmin(null);
|
setMenuAdmin(null);
|
||||||
const account = admin.email.split('@')[0] || 'admin';
|
const account = admin.email.split('@')[0] || 'admin';
|
||||||
const random = Math.random().toString(36).slice(2, 8);
|
const random = Math.random().toString(36).slice(2, 8);
|
||||||
setSshTarget({ admin, label: `${account}-${random}-key`, giteaUsername: '', publicKey: '' });
|
setSshTarget({ admin, label: `${account}-${random}-key`, publicKey: '' });
|
||||||
setSshState('idle');
|
setSshState('idle');
|
||||||
setSshError('');
|
setSshError('');
|
||||||
};
|
};
|
||||||
@@ -207,12 +207,11 @@ export function AdminManagementPage() {
|
|||||||
setSshError('');
|
setSshError('');
|
||||||
createAdminSshKey(sshTarget.admin.id, {
|
createAdminSshKey(sshTarget.admin.id, {
|
||||||
label: sshTarget.label.trim() || undefined,
|
label: sshTarget.label.trim() || undefined,
|
||||||
giteaUsername: sshTarget.giteaUsername.trim() || undefined,
|
|
||||||
publicKey: sshTarget.publicKey.trim()
|
publicKey: sshTarget.publicKey.trim()
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSshState('idle');
|
setSshState('idle');
|
||||||
setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' });
|
setSshTarget({ admin: null, label: '', publicKey: '' });
|
||||||
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
|
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
|
||||||
refresh();
|
refresh();
|
||||||
})
|
})
|
||||||
@@ -223,9 +222,12 @@ export function AdminManagementPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSshKey = (admin: AdminAccount, keyId: string) => {
|
const removeSshKey = () => {
|
||||||
deleteAdminSshKey(admin.id, keyId)
|
if (!sshDeleteTarget) return;
|
||||||
|
|
||||||
|
deleteAdminSshKey(sshDeleteTarget.admin.id, sshDeleteTarget.keyId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
setSshDeleteTarget(null);
|
||||||
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
|
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
|
||||||
refresh();
|
refresh();
|
||||||
})
|
})
|
||||||
@@ -240,9 +242,12 @@ export function AdminManagementPage() {
|
|||||||
header: '관리자',
|
header: '관리자',
|
||||||
className: 'w-[260px] px-5 py-3 font-medium',
|
className: 'w-[260px] px-5 py-3 font-medium',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<p className="font-semibold">{row.name}</p>
|
<AdminAvatar admin={row} />
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{row.email}</p>
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{row.name}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{row.email}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -278,7 +283,7 @@ export function AdminManagementPage() {
|
|||||||
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
|
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
|
||||||
onClick={() => openSshModal(row)}
|
onClick={() => openSshModal(row)}
|
||||||
>
|
>
|
||||||
<StatusBadge status={`${keyCount}개`} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
<StatusBadge status={keyCount > 0 ? '등록' : '미등록'} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -355,13 +360,26 @@ export function AdminManagementPage() {
|
|||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return (
|
return (
|
||||||
<div onClick={(event) => 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%)' }}>
|
<div onClick={(event) => 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%)' }}>
|
||||||
<button
|
{row.sshKeys?.length ? (
|
||||||
type="button"
|
<button
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
type="button"
|
||||||
onClick={() => openSshModal(row)}
|
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-muted"
|
||||||
>
|
onClick={() => {
|
||||||
SSH 키 등록
|
setMenuAdmin(null);
|
||||||
</button>
|
setSshDeleteTarget({ admin: row, keyId: row.sshKeys![0].id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SSH 키 삭제
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
|
onClick={() => openSshModal(row)}
|
||||||
|
>
|
||||||
|
SSH 키 등록
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
@@ -451,11 +469,11 @@ export function AdminManagementPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
open={Boolean(sshTarget.admin)}
|
open={Boolean(sshTarget.admin)}
|
||||||
title="SSH 키 등록"
|
title="SSH 키 등록"
|
||||||
onClose={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}
|
onClose={() => setSshTarget({ admin: null, label: '', publicKey: '' })}
|
||||||
className="max-w-2xl"
|
className="max-w-2xl"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}>취소</Button>
|
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', publicKey: '' })}>취소</Button>
|
||||||
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
|
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -465,10 +483,9 @@ export function AdminManagementPage() {
|
|||||||
<span className="text-xs font-medium text-muted-foreground">키 이름</span>
|
<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={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} />
|
<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={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<p className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||||
<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={sshTarget.giteaUsername} onChange={(event) => setSshTarget((prev) => ({ ...prev, giteaUsername: event.target.value }))} />
|
</p>
|
||||||
</label>
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">공개키</span>
|
<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={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} />
|
<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={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} />
|
||||||
@@ -482,7 +499,7 @@ export function AdminManagementPage() {
|
|||||||
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
|
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
|
||||||
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
|
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && removeSshKey(sshTarget.admin, key.id)}>
|
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && setSshDeleteTarget({ admin: sshTarget.admin, keyId: key.id })}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,6 +509,20 @@ export function AdminManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(sshDeleteTarget)}
|
||||||
|
title="SSH 키 삭제"
|
||||||
|
onClose={() => setSshDeleteTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setSshDeleteTarget(null)}>취소</Button>
|
||||||
|
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={removeSshKey}>삭제</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">등록된 관리자 SSH 키를 삭제합니다. Gitea에 등록된 키도 함께 삭제됩니다.</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(permissionTarget.admin)}
|
open={Boolean(permissionTarget.admin)}
|
||||||
title="앱 권한"
|
title="앱 권한"
|
||||||
@@ -547,3 +578,25 @@ export function AdminManagementPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AdminAvatar({ admin }: { admin: AdminAccount }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const initial = (admin.name || admin.email || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
if (admin.avatarUrl && !failed) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={admin.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-9 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-sm font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function UserSessionsPage() {
|
|||||||
{ key: 'session', header: '세션', className: 'w-28 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
|
{ key: 'session', header: '세션', className: 'w-28 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
|
||||||
{ key: 'expiresAt', header: '만료', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
|
{ key: 'expiresAt', header: '만료', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
|
||||||
{ key: 'lastSeen', header: '최근 접속', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
|
{ key: 'lastSeen', header: '최근 접속', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
|
||||||
{ key: 'state', header: '상태', className: 'w-24 px-5 py-3', cell: (row) => <StatusBadge status={row.state} /> },
|
{ key: 'state', header: '상태', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={row.state} /> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type AdminAccount = {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
|
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
lastLoginAt?: string | null;
|
lastLoginAt?: string | null;
|
||||||
@@ -135,7 +136,7 @@ export function resetAdminPassword(adminId: string, temporaryPassword: string) {
|
|||||||
|
|
||||||
export function createAdminSshKey(
|
export function createAdminSshKey(
|
||||||
adminId: string,
|
adminId: string,
|
||||||
payload: { label?: string; giteaUsername?: string; publicKey: string }
|
payload: { label?: string; publicKey: string }
|
||||||
) {
|
) {
|
||||||
return apiRequest(`/admins/${adminId}/ssh-keys`, {
|
return apiRequest(`/admins/${adminId}/ssh-keys`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export type BuildJob = {
|
|||||||
image?: string | null;
|
image?: string | null;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -40,6 +44,10 @@ export type AppRelease = {
|
|||||||
imageTags?: unknown;
|
imageTags?: unknown;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
app: ApiApp;
|
app: ApiApp;
|
||||||
@@ -53,6 +61,9 @@ export type DeploymentJob = {
|
|||||||
namespace: string;
|
namespace: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
requestedBy?: string | null;
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
@@ -69,6 +80,9 @@ export type RuntimeControlJob = {
|
|||||||
action: string;
|
action: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
requestedBy?: string | null;
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
request?: Record<string, unknown> | null;
|
request?: Record<string, unknown> | null;
|
||||||
result?: Record<string, unknown> | null;
|
result?: Record<string, unknown> | null;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user