track requesters for builds releases deployments and migrations
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-10 10:42:56 +09:00
parent ad52c48bca
commit 9625752a81
20 changed files with 1166 additions and 53 deletions
+241 -6
View File
@@ -1,15 +1,16 @@
import { useEffect, useState, type MouseEvent } from 'react';
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { Badge, Button, Card, CardHeader } from '../../components/ui';
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2, UserCog } from 'lucide-react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
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 { cn } from '../../lib/utils';
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime';
import { LoadingModal } from '../operations/operation-utils';
import { loadAdmins, updateAdminAppPermissions, type AdminAccount } from '../operations/operations-api';
type ComponentPortForm = {
name: string;
@@ -87,6 +88,28 @@ function formatStatus(status?: string | null, launcherLinked?: boolean) {
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() {
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
.then((response) => {
@@ -334,6 +357,13 @@ export function AppManagementPage() {
deleteKubernetesResources: 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 [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]);
const [workloadActionKey, setWorkloadActionKey] = useState('');
@@ -351,6 +381,20 @@ export function AppManagementPage() {
const workloads = runtimeResources.filter(isWorkloadResource);
const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === 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(
deletingVolumeWorkload &&
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 (
<>
<PageTitle
@@ -891,9 +986,14 @@ export function AppManagementPage() {
title="앱 관리"
description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 실행 요소와 운영 리소스를 하나의 묶음으로 운영합니다."
actions={
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
<Plus className="size-4" />앱 등록
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={openAppAdmins}>
<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}
</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 ? (
<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">