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:
@@ -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">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
|
||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { Requester } from '../../components/requester';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { BuildJob, SourceComponent } from '../../types/application';
|
||||
@@ -383,6 +384,7 @@ export function BuildManagementPage() {
|
||||
<span className="font-medium">{job.component.name}</span>
|
||||
</div>
|
||||
<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">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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { Requester } from '../../components/requester';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
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">
|
||||
<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.requestedBy || 'admin'}</b></span>
|
||||
<Requester item={deployment} label="요청자" />
|
||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
|
||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { Requester } from '../../components/requester';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
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">
|
||||
<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>
|
||||
<Requester item={job} />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { Requester } from '../../components/requester';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { AppRelease, BuildJob } from '../../types/application';
|
||||
@@ -425,6 +426,7 @@ export function ReleaseManagementPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<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">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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Copy, FileText, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { CommitAuthor } from '../../components/commit-author';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
@@ -558,7 +559,7 @@ export function SourceManagementPage() {
|
||||
) : null}
|
||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||
<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="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>
|
||||
|
||||
@@ -30,12 +30,16 @@ export type GiteaCommit = {
|
||||
username?: string;
|
||||
full_name?: string;
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
committer?: {
|
||||
login?: string;
|
||||
username?: string;
|
||||
full_name?: string;
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
created?: string;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FileText, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { CommitAuthor } from '../../components/commit-author';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { GiteaRepo } from '../../types/application';
|
||||
@@ -362,7 +363,7 @@ export function CommonAppSourcePage() {
|
||||
) : null}
|
||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||
<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="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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { readCurrentAdmin } from '../../lib/admin-app-permissions';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
@@ -48,6 +49,41 @@ function jobTime(job: LauncherBuildJob) {
|
||||
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;
|
||||
type BuildTarget = 'launcher-client' | 'launcher-backend';
|
||||
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">
|
||||
<span>브랜치 <b className="text-foreground">{job.branch}</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Copy, FileText, Link2, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { CommitAuthor } from '../../components/commit-author';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { GiteaRepo } from '../../types/application';
|
||||
@@ -688,7 +689,7 @@ export function LauncherSourcePage() {
|
||||
) : null}
|
||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||
<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="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>
|
||||
|
||||
@@ -29,6 +29,14 @@ export type LauncherBuildJob = {
|
||||
downloadUrl: string;
|
||||
artifact: string;
|
||||
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;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
@@ -97,12 +105,16 @@ export type GiteaCommit = {
|
||||
username?: string;
|
||||
full_name?: string;
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
committer?: {
|
||||
login?: string;
|
||||
username?: string;
|
||||
full_name?: string;
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
created?: string;
|
||||
};
|
||||
|
||||
@@ -55,9 +55,9 @@ export function AdminManagementPage() {
|
||||
const [sshTarget, setSshTarget] = useState({
|
||||
admin: null as AdminAccount | null,
|
||||
label: '',
|
||||
giteaUsername: '',
|
||||
publicKey: ''
|
||||
});
|
||||
const [sshDeleteTarget, setSshDeleteTarget] = useState<{ admin: AdminAccount; keyId: string } | null>(null);
|
||||
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
@@ -119,7 +119,7 @@ export function AdminManagementPage() {
|
||||
setMenuAdmin(null);
|
||||
const account = admin.email.split('@')[0] || 'admin';
|
||||
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');
|
||||
setSshError('');
|
||||
};
|
||||
@@ -207,12 +207,11 @@ export function AdminManagementPage() {
|
||||
setSshError('');
|
||||
createAdminSshKey(sshTarget.admin.id, {
|
||||
label: sshTarget.label.trim() || undefined,
|
||||
giteaUsername: sshTarget.giteaUsername.trim() || undefined,
|
||||
publicKey: sshTarget.publicKey.trim()
|
||||
})
|
||||
.then(() => {
|
||||
setSshState('idle');
|
||||
setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' });
|
||||
setSshTarget({ admin: null, label: '', publicKey: '' });
|
||||
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
|
||||
refresh();
|
||||
})
|
||||
@@ -223,9 +222,12 @@ export function AdminManagementPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const removeSshKey = (admin: AdminAccount, keyId: string) => {
|
||||
deleteAdminSshKey(admin.id, keyId)
|
||||
const removeSshKey = () => {
|
||||
if (!sshDeleteTarget) return;
|
||||
|
||||
deleteAdminSshKey(sshDeleteTarget.admin.id, sshDeleteTarget.keyId)
|
||||
.then(() => {
|
||||
setSshDeleteTarget(null);
|
||||
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
|
||||
refresh();
|
||||
})
|
||||
@@ -240,9 +242,12 @@ export function AdminManagementPage() {
|
||||
header: '관리자',
|
||||
className: 'w-[260px] px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="font-semibold">{row.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.email}</p>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AdminAvatar admin={row} />
|
||||
<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>
|
||||
)
|
||||
},
|
||||
@@ -278,7 +283,7 @@ export function AdminManagementPage() {
|
||||
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
|
||||
onClick={() => openSshModal(row)}
|
||||
>
|
||||
<StatusBadge status={`${keyCount}개`} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
||||
<StatusBadge status={keyCount > 0 ? '등록' : '미등록'} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -355,13 +360,26 @@ export function AdminManagementPage() {
|
||||
if (!row) return null;
|
||||
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%)' }}>
|
||||
<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>
|
||||
{row.sshKeys?.length ? (
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMenuAdmin(null);
|
||||
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
|
||||
type="button"
|
||||
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
|
||||
open={Boolean(sshTarget.admin)}
|
||||
title="SSH 키 등록"
|
||||
onClose={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}
|
||||
onClose={() => setSshTarget({ admin: null, label: '', publicKey: '' })}
|
||||
className="max-w-2xl"
|
||||
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>
|
||||
</>
|
||||
}
|
||||
@@ -465,10 +483,9 @@ export function AdminManagementPage() {
|
||||
<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 }))} />
|
||||
</label>
|
||||
<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={sshTarget.giteaUsername} onChange={(event) => setSshTarget((prev) => ({ ...prev, giteaUsername: 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={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 font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -492,6 +509,20 @@ export function AdminManagementPage() {
|
||||
</div>
|
||||
</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
|
||||
open={Boolean(permissionTarget.admin)}
|
||||
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: '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: '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',
|
||||
header: '',
|
||||
|
||||
@@ -15,6 +15,7 @@ export type AdminAccount = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
|
||||
isActive: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
@@ -135,7 +136,7 @@ export function resetAdminPassword(adminId: string, temporaryPassword: string) {
|
||||
|
||||
export function createAdminSshKey(
|
||||
adminId: string,
|
||||
payload: { label?: string; giteaUsername?: string; publicKey: string }
|
||||
payload: { label?: string; publicKey: string }
|
||||
) {
|
||||
return apiRequest(`/admins/${adminId}/ssh-keys`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user