add organization management and refine source repository view
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+13
-1
@@ -22,10 +22,12 @@ import { BuildManagementPage } from './pages/applications/BuildManagementPage';
|
|||||||
import { DeploymentManagementPage } from './pages/applications/DeploymentManagementPage';
|
import { DeploymentManagementPage } from './pages/applications/DeploymentManagementPage';
|
||||||
import { MigrationManagementPage } from './pages/applications/MigrationManagementPage';
|
import { MigrationManagementPage } from './pages/applications/MigrationManagementPage';
|
||||||
import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPage';
|
import { ReleaseManagementPage } from './pages/applications/ReleaseManagementPage';
|
||||||
|
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 { 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 { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage';
|
import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage';
|
||||||
import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
|
import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
|
||||||
import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage';
|
import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage';
|
||||||
@@ -61,6 +63,7 @@ type ViewKey =
|
|||||||
| 'launcherCommonSettings'
|
| 'launcherCommonSettings'
|
||||||
| 'notices'
|
| 'notices'
|
||||||
| 'appManagement'
|
| 'appManagement'
|
||||||
|
| 'appRepositories'
|
||||||
| 'appSources'
|
| 'appSources'
|
||||||
| 'appBuilds'
|
| 'appBuilds'
|
||||||
| 'appReleases'
|
| 'appReleases'
|
||||||
@@ -82,6 +85,7 @@ type ViewKey =
|
|||||||
| 'runtimeLogs'
|
| 'runtimeLogs'
|
||||||
| 'runtimeDeviceControl'
|
| 'runtimeDeviceControl'
|
||||||
| 'adminManagement'
|
| 'adminManagement'
|
||||||
|
| 'organizations'
|
||||||
| 'userSessions'
|
| 'userSessions'
|
||||||
| 'licenses';
|
| 'licenses';
|
||||||
|
|
||||||
@@ -93,6 +97,7 @@ const viewByLabel: Record<string, ViewKey> = {
|
|||||||
'공통 설정': 'launcherCommonSettings',
|
'공통 설정': 'launcherCommonSettings',
|
||||||
공지사항: 'notices',
|
공지사항: 'notices',
|
||||||
'앱 관리': 'appManagement',
|
'앱 관리': 'appManagement',
|
||||||
|
'저장소 관리': 'appRepositories',
|
||||||
'소스 관리': 'appSources',
|
'소스 관리': 'appSources',
|
||||||
'빌드 관리': 'appBuilds',
|
'빌드 관리': 'appBuilds',
|
||||||
'릴리즈 관리': 'appReleases',
|
'릴리즈 관리': 'appReleases',
|
||||||
@@ -114,6 +119,7 @@ const viewByLabel: Record<string, ViewKey> = {
|
|||||||
'로그/이벤트': 'runtimeLogs',
|
'로그/이벤트': 'runtimeLogs',
|
||||||
'장비 제어': 'runtimeDeviceControl',
|
'장비 제어': 'runtimeDeviceControl',
|
||||||
'관리자 관리': 'adminManagement',
|
'관리자 관리': 'adminManagement',
|
||||||
|
'조직 관리': 'organizations',
|
||||||
'사용자/세션': 'userSessions',
|
'사용자/세션': 'userSessions',
|
||||||
라이센스: 'licenses'
|
라이센스: 'licenses'
|
||||||
};
|
};
|
||||||
@@ -128,6 +134,7 @@ const pathByView: Record<ViewKey, string> = {
|
|||||||
launcherCommonSettings: '/launcher/common-settings',
|
launcherCommonSettings: '/launcher/common-settings',
|
||||||
notices: '/operations/notices',
|
notices: '/operations/notices',
|
||||||
appManagement: '/applications/apps',
|
appManagement: '/applications/apps',
|
||||||
|
appRepositories: '/applications/repositories',
|
||||||
appSources: '/applications/sources',
|
appSources: '/applications/sources',
|
||||||
appBuilds: '/applications/builds',
|
appBuilds: '/applications/builds',
|
||||||
appReleases: '/applications/releases',
|
appReleases: '/applications/releases',
|
||||||
@@ -149,6 +156,7 @@ const pathByView: Record<ViewKey, string> = {
|
|||||||
runtimeLogs: '/runtime/logs',
|
runtimeLogs: '/runtime/logs',
|
||||||
runtimeDeviceControl: '/runtime/devices',
|
runtimeDeviceControl: '/runtime/devices',
|
||||||
adminManagement: '/operations/admins',
|
adminManagement: '/operations/admins',
|
||||||
|
organizations: '/operations/organizations',
|
||||||
userSessions: '/operations/users',
|
userSessions: '/operations/users',
|
||||||
licenses: '/operations/licenses'
|
licenses: '/operations/licenses'
|
||||||
};
|
};
|
||||||
@@ -332,7 +340,7 @@ function loadAppReleases() {
|
|||||||
|
|
||||||
function loadUserSessions() {
|
function loadUserSessions() {
|
||||||
return apiRequest<{ sessions?: UserSessionRow[] }>('/users/sessions')
|
return apiRequest<{ sessions?: UserSessionRow[] }>('/users/sessions')
|
||||||
.then((response) => response.sessions ?? []);
|
.then((response) => (response.sessions ?? []).filter((session) => session.sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadNotices() {
|
function loadNotices() {
|
||||||
@@ -1490,12 +1498,16 @@ function App() {
|
|||||||
case 'adminManagement':
|
case 'adminManagement':
|
||||||
if (!isOwner) return null;
|
if (!isOwner) return null;
|
||||||
return <AdminManagementApiPage />;
|
return <AdminManagementApiPage />;
|
||||||
|
case 'organizations':
|
||||||
|
return <OrganizationsApiPage />;
|
||||||
case 'userSessions':
|
case 'userSessions':
|
||||||
return <UserSessionsApiPage />;
|
return <UserSessionsApiPage />;
|
||||||
case 'licenses':
|
case 'licenses':
|
||||||
return <LicensesApiPage />;
|
return <LicensesApiPage />;
|
||||||
case 'appManagement':
|
case 'appManagement':
|
||||||
return <AppManagementApiPage />;
|
return <AppManagementApiPage />;
|
||||||
|
case 'appRepositories':
|
||||||
|
return <RepositoryManagementPage />;
|
||||||
case 'appSources':
|
case 'appSources':
|
||||||
return <AppSourcesApiPage />;
|
return <AppSourcesApiPage />;
|
||||||
case 'appBuilds':
|
case 'appBuilds':
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const navGroups = [
|
|||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
children: [
|
children: [
|
||||||
{ label: '앱 관리' },
|
{ label: '앱 관리' },
|
||||||
|
{ label: '저장소 관리' },
|
||||||
{ label: '소스 관리' },
|
{ label: '소스 관리' },
|
||||||
{ label: '빌드 관리' },
|
{ label: '빌드 관리' },
|
||||||
{ label: '릴리즈 관리' },
|
{ label: '릴리즈 관리' },
|
||||||
@@ -81,6 +82,7 @@ export const navGroups = [
|
|||||||
label: '운영',
|
label: '운영',
|
||||||
items: [
|
items: [
|
||||||
{ label: '관리자 관리', icon: ShieldCheck },
|
{ label: '관리자 관리', icon: ShieldCheck },
|
||||||
|
{ label: '조직 관리', icon: Boxes },
|
||||||
{ label: '사용자/세션', icon: Users },
|
{ label: '사용자/세션', icon: Users },
|
||||||
{ label: '라이센스', icon: FileKey },
|
{ label: '라이센스', icon: FileKey },
|
||||||
{ label: '공지사항', icon: Megaphone }
|
{ label: '공지사항', icon: Megaphone }
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
children: [
|
children: [
|
||||||
{ label: '앱 관리', path: '/applications/apps' },
|
{ label: '앱 관리', path: '/applications/apps' },
|
||||||
|
{ label: '저장소 관리', path: '/applications/repositories' },
|
||||||
{ label: '소스 관리', path: '/applications/sources' },
|
{ label: '소스 관리', path: '/applications/sources' },
|
||||||
{ label: '빌드 관리', path: '/applications/builds' },
|
{ label: '빌드 관리', path: '/applications/builds' },
|
||||||
{ label: '릴리즈 관리', path: '/applications/releases' },
|
{ label: '릴리즈 관리', path: '/applications/releases' },
|
||||||
@@ -97,6 +98,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
label: '운영',
|
label: '운영',
|
||||||
items: [
|
items: [
|
||||||
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
||||||
|
{ label: '조직 관리', path: '/operations/organizations', icon: Boxes },
|
||||||
{ label: '사용자/세션', path: '/operations/users', icon: Users },
|
{ label: '사용자/세션', path: '/operations/users', icon: Users },
|
||||||
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
||||||
{ label: '공지사항', path: '/operations/notices', icon: Megaphone }
|
{ label: '공지사항', path: '/operations/notices', icon: Megaphone }
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link2, Plus } from 'lucide-react';
|
||||||
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import type { GiteaRepo } from '../../types/application';
|
||||||
|
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
||||||
|
import { LoadingModal } from '../operations/operation-utils';
|
||||||
|
import { createGiteaOrgRepo, linkComponentSource, listGiteaRepos, listSources } from './application-api';
|
||||||
|
import { AppSelector, useApps } from './ApplicationControls';
|
||||||
|
|
||||||
|
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222';
|
||||||
|
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? import.meta.env.GITEA_APP_ORG ?? 'platform';
|
||||||
|
|
||||||
|
type PlatformRepo = {
|
||||||
|
id: number;
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
description?: string;
|
||||||
|
private: boolean;
|
||||||
|
cloneUrl: string;
|
||||||
|
sshUrl: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
language?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SourceComponent = ApiAppComponent & {
|
||||||
|
app?: ApiApp;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useMinimumLoading(loading: boolean, minimumMs = 500) {
|
||||||
|
const [visible, setVisible] = useState(loading);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => setVisible(false), minimumMs);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [loading, minimumMs]);
|
||||||
|
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPlatformRepo(repo: GiteaRepo): PlatformRepo {
|
||||||
|
const fullName = (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? repo.name;
|
||||||
|
const owner =
|
||||||
|
typeof repo.owner === 'string'
|
||||||
|
? repo.owner
|
||||||
|
: repo.owner?.login ?? fullName.split('/')[0] ?? 'platform';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: repo.id ?? Number(`${owner.length}${repo.name.length}`),
|
||||||
|
owner,
|
||||||
|
name: repo.name,
|
||||||
|
fullName,
|
||||||
|
description: (repo as GiteaRepo & { description?: string }).description,
|
||||||
|
private: Boolean(repo.private),
|
||||||
|
cloneUrl: (repo as GiteaRepo & { cloneUrl?: string }).cloneUrl ?? repo.clone_url ?? `${giteaBaseUrl}/${fullName}.git`,
|
||||||
|
sshUrl: (repo as GiteaRepo & { sshUrl?: string }).sshUrl ?? repo.ssh_url ?? `${giteaSshBaseUrl.replace(/\/$/, '')}/${fullName}.git`,
|
||||||
|
defaultBranch: (repo as GiteaRepo & { defaultBranch?: string }).defaultBranch ?? 'main',
|
||||||
|
language: (repo as GiteaRepo & { language?: string }).language
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRepositoryName(repository: string) {
|
||||||
|
const trimmed = repository.trim().replace(/^\/+/, '').replace(/\.git$/, '');
|
||||||
|
return trimmed.includes('/') ? trimmed : `${giteaAppOrg}/${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositoryKey(repository: string) {
|
||||||
|
return normalizeRepositoryName(repository).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSourceLinkComponent(source: SourceComponent) {
|
||||||
|
return (source.imageSource ?? 'SOURCE') === 'SOURCE' && source.type !== 'DATABASE' && source.type !== 'MIGRATION';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLauncherRepo(repo: PlatformRepo) {
|
||||||
|
const name = repo.name.toLowerCase();
|
||||||
|
const fullName = repo.fullName.toLowerCase();
|
||||||
|
return name === 'studio-launcher' || name === 'studio-launcher-fe' || name === 'studio-launcher-be' || fullName.includes('/studio-launcher');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepositoryManagementPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||||
|
const [giteaRepos, setGiteaRepos] = useState<PlatformRepo[]>([]);
|
||||||
|
const [apiSources, setApiSources] = useState<SourceComponent[]>([]);
|
||||||
|
const [sourcesLoading, setSourcesLoading] = useState(true);
|
||||||
|
const [giteaStatus, setGiteaStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||||
|
const sourcesLoadingVisible = useMinimumLoading(giteaStatus === 'loading' || sourcesLoading);
|
||||||
|
const [isCreateRepoOpen, setIsCreateRepoOpen] = useState(false);
|
||||||
|
const [repoName, setRepoName] = useState('');
|
||||||
|
const [repoDescription, setRepoDescription] = useState('');
|
||||||
|
const [repoPrivate, setRepoPrivate] = useState(true);
|
||||||
|
const [createDevelopBranch, setCreateDevelopBranch] = useState(true);
|
||||||
|
const [createReleaseBranch, setCreateReleaseBranch] = useState(true);
|
||||||
|
const [repoCreateStatus, setRepoCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||||
|
const [sourceModalOpen, setSourceModalOpen] = useState(false);
|
||||||
|
const [sourceTarget, setSourceTarget] = useState<SourceComponent | null>(null);
|
||||||
|
const [sourceRepo, setSourceRepo] = useState('');
|
||||||
|
const [sourceBranch, setSourceBranch] = useState('develop');
|
||||||
|
const [sourceBuildTarget, setSourceBuildTarget] = useState('');
|
||||||
|
const [sourceSaveStatus, setSourceSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||||
|
|
||||||
|
const refreshRepos = () => {
|
||||||
|
setGiteaStatus('loading');
|
||||||
|
listGiteaRepos(giteaAppOrg, 100)
|
||||||
|
.then((repos) => {
|
||||||
|
setGiteaRepos(repos.map(toPlatformRepo));
|
||||||
|
setGiteaStatus('ready');
|
||||||
|
})
|
||||||
|
.catch(() => setGiteaStatus('error'));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
setSourcesLoading(true);
|
||||||
|
listSources()
|
||||||
|
.then((sources) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setApiSources(sources);
|
||||||
|
setSourcesLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setApiSources([]);
|
||||||
|
setSourcesLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
listGiteaRepos(giteaAppOrg, 100)
|
||||||
|
.then((repos) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setGiteaRepos(repos.map(toPlatformRepo));
|
||||||
|
setGiteaStatus('ready');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setGiteaStatus('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createRepo = () => {
|
||||||
|
if (!repoName.trim()) return;
|
||||||
|
|
||||||
|
setRepoCreateStatus('saving');
|
||||||
|
createGiteaOrgRepo(giteaAppOrg, {
|
||||||
|
name: repoName.trim(),
|
||||||
|
description: repoDescription.trim() || undefined,
|
||||||
|
private: repoPrivate,
|
||||||
|
autoInit: true,
|
||||||
|
createDevelopBranch,
|
||||||
|
createReleaseBranch
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setRepoName('');
|
||||||
|
setRepoDescription('');
|
||||||
|
setRepoPrivate(true);
|
||||||
|
setCreateDevelopBranch(true);
|
||||||
|
setCreateReleaseBranch(true);
|
||||||
|
setRepoCreateStatus('idle');
|
||||||
|
setIsCreateRepoOpen(false);
|
||||||
|
refreshRepos();
|
||||||
|
toast({ type: 'success', message: '저장소가 생성되었습니다.' });
|
||||||
|
})
|
||||||
|
.catch(() => setRepoCreateStatus('error'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshSources = () => {
|
||||||
|
setSourcesLoading(true);
|
||||||
|
listSources()
|
||||||
|
.then((sources) => {
|
||||||
|
setApiSources(sources);
|
||||||
|
setSourcesLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setApiSources([]);
|
||||||
|
setSourcesLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSourceModal = (source: SourceComponent) => {
|
||||||
|
setSourceTarget(source);
|
||||||
|
setSourceRepo(source.repository ?? '');
|
||||||
|
setSourceBranch(source.branch ?? 'develop');
|
||||||
|
setSourceBuildTarget(source.buildTarget ?? '');
|
||||||
|
setSourceSaveStatus('idle');
|
||||||
|
setSourceModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSourceModal = () => {
|
||||||
|
setSourceModalOpen(false);
|
||||||
|
setSourceTarget(null);
|
||||||
|
setSourceRepo('');
|
||||||
|
setSourceBranch('develop');
|
||||||
|
setSourceBuildTarget('');
|
||||||
|
setSourceSaveStatus('idle');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSourceLink = () => {
|
||||||
|
if (!sourceTarget || !sourceRepo.trim()) return;
|
||||||
|
|
||||||
|
setSourceSaveStatus('saving');
|
||||||
|
linkComponentSource(sourceTarget.id, {
|
||||||
|
repository: sourceRepo,
|
||||||
|
branch: sourceBranch || undefined,
|
||||||
|
buildTarget: sourceBuildTarget || undefined
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
closeSourceModal();
|
||||||
|
refreshSources();
|
||||||
|
toast({ type: 'success', message: '소스 연결이 저장되었습니다.' });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setSourceSaveStatus('error');
|
||||||
|
toast({ type: 'error', message: '소스 연결 저장에 실패했습니다.' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredSources = apiSources.filter((source) => isSourceLinkComponent(source) && (!selectedAppId || source.appId === selectedAppId));
|
||||||
|
const selectedAppRepoKeys = useMemo(
|
||||||
|
() => new Set(filteredSources.map((source) => source.repository).filter((repository): repository is string => Boolean(repository)).map(repositoryKey)),
|
||||||
|
[filteredSources]
|
||||||
|
);
|
||||||
|
const visibleRepos = useMemo(
|
||||||
|
() => giteaRepos
|
||||||
|
.filter((repo) => !isLauncherRepo(repo))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aSelected = selectedAppRepoKeys.has(repositoryKey(a.fullName));
|
||||||
|
const bSelected = selectedAppRepoKeys.has(repositoryKey(b.fullName));
|
||||||
|
if (aSelected !== bSelected) return aSelected ? -1 : 1;
|
||||||
|
return a.fullName.localeCompare(b.fullName);
|
||||||
|
}),
|
||||||
|
[giteaRepos, selectedAppRepoKeys]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="어플리케이션"
|
||||||
|
title="저장소 관리"
|
||||||
|
description="앱 구성요소에 연결할 저장소를 생성하고 연결 정보를 관리합니다."
|
||||||
|
actions={
|
||||||
|
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateRepoOpen(true)}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
저장소 생성
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
||||||
|
|
||||||
|
<Card className="mb-4 min-w-0 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
title="저장소"
|
||||||
|
description="앱과 런처 소스를 구성요소에 연결할 수 있도록 관리합니다."
|
||||||
|
/>
|
||||||
|
<div className="max-h-[300px] overflow-x-auto overflow-y-auto">
|
||||||
|
<table className="w-full min-w-[900px] text-left text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 font-medium">저장소</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Owner</th>
|
||||||
|
<th className="px-5 py-3 font-medium">기본 브랜치</th>
|
||||||
|
<th className="px-5 py-3 font-medium">언어</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Clone URL</th>
|
||||||
|
<th className="px-5 py-3 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{giteaStatus === 'error' ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>백엔드 연결 후 저장소가 표시됩니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : giteaStatus !== 'loading' && visibleRepos.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>등록된 저장소가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
visibleRepos.map((repo) => {
|
||||||
|
const isSelectedAppRepo = selectedAppRepoKeys.has(repositoryKey(repo.fullName));
|
||||||
|
return (
|
||||||
|
<tr key={repo.id} className="hover:bg-muted/20">
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">{repo.fullName}</span>
|
||||||
|
{isSelectedAppRepo ? <Badge tone="green">선택 앱</Badge> : null}
|
||||||
|
</div>
|
||||||
|
{repo.description ? <p className="mt-1 text-xs text-muted-foreground">{repo.description}</p> : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">{repo.owner}</td>
|
||||||
|
<td className="px-5 py-3">{repo.defaultBranch}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{repo.language || '-'}</td>
|
||||||
|
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{repo.cloneUrl}</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<Badge tone={repo.private ? 'amber' : 'blue'}>{repo.private ? 'Private' : 'Public'}</Badge>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader title="저장소 연결" description="앱 구성요소별 저장소와 빌드 대상을 확인합니다." />
|
||||||
|
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||||
|
<table className="w-full min-w-[920px] text-left text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 font-medium">앱</th>
|
||||||
|
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||||
|
<th className="w-72 px-5 py-3 font-medium">저장소</th>
|
||||||
|
<th className="px-5 py-3 font-medium">기본 브랜치</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Commit</th>
|
||||||
|
<th className="px-5 py-3 font-medium">유형</th>
|
||||||
|
<th className="px-5 py-3 font-medium">빌드 대상</th>
|
||||||
|
<th className="w-24 px-5 py-3 font-medium">상태</th>
|
||||||
|
<th className="w-24 px-5 py-3 text-right font-medium">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{!sourcesLoading && filteredSources.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="px-5 py-6 text-center text-muted-foreground">등록된 앱 구성요소가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
!sourcesLoading && filteredSources.map((source) => (
|
||||||
|
<tr key={source.id} className="hover:bg-muted/20">
|
||||||
|
<td className="px-5 py-3 font-semibold">{source.app?.name ?? '-'}</td>
|
||||||
|
<td className="px-5 py-3">{source.name}</td>
|
||||||
|
<td className="max-w-72 px-5 py-3 text-muted-foreground">
|
||||||
|
<span className="block truncate whitespace-nowrap">{source.repository || '미연결'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">{source.branch || '-'}</td>
|
||||||
|
<td className="px-5 py-3 font-medium">-</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{source.type}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{source.buildTarget || '-'}</td>
|
||||||
|
<td className="whitespace-nowrap px-5 py-3"><Badge tone={source.repository ? 'green' : 'neutral'}>{source.repository ? '연결됨' : '미연결'}</Badge></td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" className="size-8 px-0" title="소스 연결" aria-label="소스 연결" onClick={() => openSourceModal(source)}>
|
||||||
|
<Link2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isCreateRepoOpen ? (
|
||||||
|
<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="border-b border-border px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">저장소 생성</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 px-5 py-5">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
|
||||||
|
<input
|
||||||
|
value={repoName}
|
||||||
|
onChange={(event) => setRepoName(event.target.value)}
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||||
|
<textarea
|
||||||
|
value={repoDescription}
|
||||||
|
onChange={(event) => setRepoDescription(event.target.value)}
|
||||||
|
className="mt-2 min-h-20 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={repoPrivate}
|
||||||
|
onChange={(event) => setRepoPrivate(event.target.checked)}
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
Private 저장소로 생성
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createDevelopBranch}
|
||||||
|
onChange={(event) => setCreateDevelopBranch(event.target.checked)}
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
develop 브랜치 자동 생성
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createReleaseBranch}
|
||||||
|
onChange={(event) => setCreateReleaseBranch(event.target.checked)}
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
release 브랜치 자동 생성
|
||||||
|
</label>
|
||||||
|
{repoCreateStatus === 'error' ? (
|
||||||
|
<p className="text-xs text-rose-300">저장소 생성에 실패했습니다. 권한과 중복 저장소명을 확인하세요.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
|
<Button variant="ghost" onClick={() => setIsCreateRepoOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={createRepo} disabled={!repoName.trim() || repoCreateStatus === 'saving'}>
|
||||||
|
{repoCreateStatus === 'saving' ? '생성 중' : '생성'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sourceModalOpen && sourceTarget ? (
|
||||||
|
<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="border-b border-border px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">소스 연결</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 px-5 py-5">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">구성요소</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold">{sourceTarget.app?.name ?? '-'} / {sourceTarget.name}</p>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">저장소</span>
|
||||||
|
<select
|
||||||
|
value={sourceRepo}
|
||||||
|
onChange={(event) => setSourceRepo(event.target.value)}
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="">저장소 선택</option>
|
||||||
|
{visibleRepos.map((repo) => (
|
||||||
|
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">기본 브랜치</span>
|
||||||
|
<select
|
||||||
|
value={sourceBranch}
|
||||||
|
onChange={(event) => setSourceBranch(event.target.value)}
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="develop">develop</option>
|
||||||
|
<option value="release">release</option>
|
||||||
|
<option value="main">main</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">빌드 대상</span>
|
||||||
|
<input
|
||||||
|
value={sourceBuildTarget}
|
||||||
|
onChange={(event) => setSourceBuildTarget(event.target.value)}
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{sourceSaveStatus === 'error' ? <p className="text-xs text-rose-300">소스 연결 저장에 실패했습니다.</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
|
<Button variant="ghost" onClick={closeSourceModal}>취소</Button>
|
||||||
|
<Button onClick={saveSourceLink} disabled={!sourceRepo.trim() || sourceSaveStatus === 'saving'}>
|
||||||
|
{sourceSaveStatus === 'saving' ? '저장 중' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<LoadingModal open={sourcesLoadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,34 @@ import type {
|
|||||||
} from '../../types/application';
|
} from '../../types/application';
|
||||||
import type { ApiAppComponent } from '../../types/runtime';
|
import type { ApiAppComponent } from '../../types/runtime';
|
||||||
|
|
||||||
|
export type GiteaCommit = {
|
||||||
|
sha: string;
|
||||||
|
commit?: {
|
||||||
|
message?: string;
|
||||||
|
author?: {
|
||||||
|
name?: string;
|
||||||
|
date?: string;
|
||||||
|
};
|
||||||
|
committer?: {
|
||||||
|
name?: string;
|
||||||
|
date?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
author?: {
|
||||||
|
login?: string;
|
||||||
|
username?: string;
|
||||||
|
full_name?: string;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
committer?: {
|
||||||
|
login?: string;
|
||||||
|
username?: string;
|
||||||
|
full_name?: string;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
created?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function listApps() {
|
export function listApps() {
|
||||||
return apiRequest<AppWithComponents[]>('/apps').then(filterAppsByCurrentAdmin);
|
return apiRequest<AppWithComponents[]>('/apps').then(filterAppsByCurrentAdmin);
|
||||||
}
|
}
|
||||||
@@ -94,6 +122,29 @@ export function createGiteaOrgRepo(
|
|||||||
}).then((result) => result.repo);
|
}).then((result) => result.repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listGiteaCommitsPage(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
branch = 'develop',
|
||||||
|
options?: { page?: number; limit?: number; since?: string }
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sha: branch,
|
||||||
|
page: String(options?.page ?? 1),
|
||||||
|
limit: String(options?.limit ?? 5)
|
||||||
|
});
|
||||||
|
if (options?.since) params.set('since', options.since);
|
||||||
|
return apiRequest<{ commits: GiteaCommit[]; total: number | null; page: number; limit: number }>(
|
||||||
|
`/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?${params.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGiteaCommitDiff(owner: string, repo: string, sha: string) {
|
||||||
|
return apiRequest<{ diff: string }>(
|
||||||
|
`/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(sha)}/diff`
|
||||||
|
).then((result) => result.diff);
|
||||||
|
}
|
||||||
|
|
||||||
export function listBuildJobs(appKey?: string) {
|
export function listBuildJobs(appKey?: string) {
|
||||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||||
return apiRequest<BuildJob[]>(`/apps/builds${query}`);
|
return apiRequest<BuildJob[]>(`/apps/builds${query}`);
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ export function LauncherSourcePage() {
|
|||||||
'launcher-backend': emptyForm()
|
'launcher-backend': emptyForm()
|
||||||
});
|
});
|
||||||
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
||||||
|
const [sourceBranch, setSourceBranch] = useState('release');
|
||||||
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
||||||
const [commitPage, setCommitPage] = useState(1);
|
const [commitPage, setCommitPage] = useState(1);
|
||||||
const [hasNextCommitPage, setHasNextCommitPage] = useState(false);
|
const [hasNextCommitPage, setHasNextCommitPage] = useState(false);
|
||||||
@@ -391,7 +392,7 @@ export function LauncherSourcePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
const sourceBranch = 'develop';
|
const branchOptions = ['release', 'develop', 'main'];
|
||||||
|
|
||||||
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
||||||
const activeForm = forms[activeTarget];
|
const activeForm = forms[activeTarget];
|
||||||
@@ -446,7 +447,7 @@ export function LauncherSourcePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCommitPage(1);
|
setCommitPage(1);
|
||||||
}, [activeForm.repository, activeTarget, commitRange]);
|
}, [activeForm.repository, activeTarget, sourceBranch, commitRange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg);
|
const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg);
|
||||||
@@ -605,6 +606,18 @@ export function LauncherSourcePage() {
|
|||||||
{activeTarget === 'launcher-client' ? activeVersion?.version ?? '-' : activeForm.image || '-'}
|
{activeTarget === 'launcher-client' ? activeVersion?.version ?? '-' : activeForm.image || '-'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
||||||
|
<select
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={sourceBranch}
|
||||||
|
onChange={(event) => setSourceBranch(event.target.value)}
|
||||||
|
>
|
||||||
|
{branchOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>{option}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">HTTP Clone URL</p>
|
<p className="text-xs text-muted-foreground">HTTP Clone URL</p>
|
||||||
<div className="mt-2 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
<div className="mt-2 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
||||||
@@ -630,7 +643,7 @@ export function LauncherSourcePage() {
|
|||||||
<Card className="mt-4 min-w-0 overflow-hidden">
|
<Card className="mt-4 min-w-0 overflow-hidden">
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="커밋 목록"
|
title="커밋 목록"
|
||||||
description={`${activeTargetMeta.title} 저장소의 develop 브랜치 기준 커밋 내역입니다.`}
|
description={`${activeTargetMeta.title} 저장소의 ${sourceBranch} 브랜치 기준 커밋 내역입니다.`}
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => (
|
{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => (
|
||||||
|
|||||||
@@ -253,15 +253,6 @@ export function AdminManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'session',
|
|
||||||
header: '최근 세션',
|
|
||||||
cell: (row) => {
|
|
||||||
const session = row.sessions?.[0];
|
|
||||||
const state = session ? (session.revokedAt ? '종료' : '활성') : '세션 없음';
|
|
||||||
return <StatusBadge status={state} />;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'sshKeys',
|
key: 'sshKeys',
|
||||||
header: 'SSH 키',
|
header: 'SSH 키',
|
||||||
@@ -342,7 +333,7 @@ export function AdminManagementPage() {
|
|||||||
getSearchText={(row) => `${row.name} ${row.email} ${row.role} ${row.state}`}
|
getSearchText={(row) => `${row.name} ${row.email} ${row.role} ${row.state}`}
|
||||||
getRowKey={(row) => row.id}
|
getRowKey={(row) => row.id}
|
||||||
pageSize={6}
|
pageSize={6}
|
||||||
minWidth={1180}
|
minWidth={1040}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="등록된 관리자가 없습니다."
|
emptyMessage="등록된 관리자가 없습니다."
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { MoreHorizontal, Plus } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
|
import { Button, Modal } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
|
import {
|
||||||
|
createOrganization,
|
||||||
|
deleteOrganization,
|
||||||
|
loadOrganizations,
|
||||||
|
updateOrganization,
|
||||||
|
type Organization,
|
||||||
|
type OrganizationType
|
||||||
|
} from './operations-api';
|
||||||
|
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
|
|
||||||
|
const organizationTypes: Array<{ value: OrganizationType; label: string }> = [
|
||||||
|
{ value: 'SCHOOL', label: '학교' },
|
||||||
|
{ value: 'COMPANY', label: '회사' },
|
||||||
|
{ value: 'GOVERNMENT', label: '공공기관' },
|
||||||
|
{ value: 'LAB', label: '연구소' },
|
||||||
|
{ value: 'OTHER', label: '기타' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
name: '',
|
||||||
|
type: 'SCHOOL' as OrganizationType,
|
||||||
|
domain: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
function organizationTypeLabel(type: OrganizationType) {
|
||||||
|
return organizationTypes.find((item) => item.value === type)?.label ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Organization | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null);
|
||||||
|
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
loadOrganizations()
|
||||||
|
.then((items) => {
|
||||||
|
setOrganizations(items);
|
||||||
|
setFailed(false);
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
setOrganizations([]);
|
||||||
|
setFailed(true);
|
||||||
|
toast({ type: 'error', title: '조직 조회 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOrganization) return;
|
||||||
|
|
||||||
|
const close = () => setMenuOrganization(null);
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setMenuOrganization(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('click', close);
|
||||||
|
window.addEventListener('keydown', closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('click', close);
|
||||||
|
window.removeEventListener('keydown', closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [menuOrganization]);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => organizations.map((organization, index) => ({ ...organization, no: organizations.length - index })),
|
||||||
|
[organizations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<Organization>>>(
|
||||||
|
() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: '조직명',
|
||||||
|
className: 'px-5 py-3 font-medium',
|
||||||
|
cell: (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.id}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
||||||
|
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
||||||
|
{ key: 'createdAt', header: '등록일', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.createdAt)}</span> },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="relative flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="메뉴"
|
||||||
|
aria-label="메뉴"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setMenuOrganization((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[menuOrganization?.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filters = useMemo<Array<DataTableFilter<Organization>>>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: '유형',
|
||||||
|
options: organizationTypes.map((type) => ({ label: type.label, value: type.value })),
|
||||||
|
getValue: (row) => row.type
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (organization: Organization) => {
|
||||||
|
setEditing(organization);
|
||||||
|
setForm({
|
||||||
|
name: organization.name,
|
||||||
|
type: organization.type,
|
||||||
|
domain: organization.domain ?? ''
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!form.name.trim()) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
type: form.type,
|
||||||
|
domain: form.domain.trim() || null
|
||||||
|
};
|
||||||
|
const request = editing
|
||||||
|
? updateOrganization(editing.id, payload)
|
||||||
|
: createOrganization(payload);
|
||||||
|
|
||||||
|
request
|
||||||
|
.then(({ organization }) => {
|
||||||
|
setOrganizations((prev) => editing
|
||||||
|
? prev.map((item) => item.id === organization.id ? organization : item)
|
||||||
|
: [organization, ...prev]);
|
||||||
|
toast({
|
||||||
|
type: 'success',
|
||||||
|
title: editing ? '조직 수정 완료' : '조직 등록 완료',
|
||||||
|
message: `${organization.name} 조직이 ${editing ? '수정' : '등록'}되었습니다.`
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
setSaving(false);
|
||||||
|
toast({ type: 'error', title: editing ? '조직 수정 실패' : '조직 등록 실패', message: error.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
deleteOrganization(deleteTarget.id)
|
||||||
|
.then(() => {
|
||||||
|
setOrganizations((prev) => prev.filter((organization) => organization.id !== deleteTarget.id));
|
||||||
|
toast({ type: 'success', title: '조직 삭제 완료', message: `${deleteTarget.name} 조직이 삭제되었습니다.` });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '조직 삭제 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="운영"
|
||||||
|
title="조직 관리"
|
||||||
|
description="가입 사용자에게 연결할 회사, 학교, 기관, 연구소 정보를 관리합니다."
|
||||||
|
actions={
|
||||||
|
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
조직 등록
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{failed ? (
|
||||||
|
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
|
조직 데이터를 불러오지 못했습니다.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DataTableCard
|
||||||
|
title="목록"
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
filters={filters}
|
||||||
|
searchPlaceholder="조직명, 도메인 검색"
|
||||||
|
getSearchText={(row) => `${row.name} ${row.type} ${organizationTypeLabel(row.type)} ${row.domain ?? ''}`}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
pageSize={10}
|
||||||
|
minWidth={980}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="표시할 조직 데이터가 없습니다."
|
||||||
|
/>
|
||||||
|
{menuOrganization ? (() => {
|
||||||
|
const row = rows.find((item) => item.id === menuOrganization.id);
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.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={() => {
|
||||||
|
setMenuOrganization(null);
|
||||||
|
openEditModal(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-muted hover:text-rose-200"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOrganization(null);
|
||||||
|
setDeleteTarget(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
|
<Modal
|
||||||
|
open={modalOpen}
|
||||||
|
title={editing ? '조직 수정' : '조직 등록'}
|
||||||
|
onClose={closeModal}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button>
|
||||||
|
<Button onClick={submit} disabled={!form.name.trim() || saving}>{saving ? '저장 중' : editing ? '수정' : '등록'}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-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 placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
placeholder="예: 영남대학교"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">유형</span>
|
||||||
|
<select
|
||||||
|
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={form.type}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, type: event.target.value }))}
|
||||||
|
>
|
||||||
|
{organizationTypes.map((type) => (
|
||||||
|
<option key={type.value} value={type.value}>{type.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={form.domain}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
|
||||||
|
placeholder="yu.ac.kr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title="조직 삭제"
|
||||||
|
onClose={() => {
|
||||||
|
if (!saving) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
|
||||||
|
<Button onClick={confirmDelete} disabled={saving}>{saving ? '삭제 중' : '삭제'}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{deleteTarget?.name ?? '-'} 조직을 삭제하시겠습니까? 삭제된 조직은 목록과 선택 항목에서 숨겨집니다.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MoreHorizontal } from 'lucide-react';
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
import { Button } from '../../components/ui';
|
import { Button, Modal } from '../../components/ui';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
|
import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
|
||||||
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
@@ -11,6 +11,7 @@ export function UserSessionsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
@@ -63,11 +64,10 @@ export function UserSessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ key: 'role', header: '권한', cell: (row) => <span className="text-muted-foreground">{row.role}</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: 'session', header: '세션', 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: '만료', cell: (row) => <span className="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: '최근 접속', cell: (row) => <span className="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: '상태', cell: (row) => <StatusBadge status={row.state} /> },
|
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -114,11 +114,11 @@ export function UserSessionsPage() {
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
searchPlaceholder="사용자, 이메일, 권한, 세션 검색"
|
searchPlaceholder="사용자, 이메일, 세션 검색"
|
||||||
getSearchText={(row) => `${row.user} ${row.email ?? ''} ${row.role} ${row.session} ${row.state}`}
|
getSearchText={(row) => `${row.user} ${row.email ?? ''} ${row.session} ${row.state}`}
|
||||||
getRowKey={(row) => row.id}
|
getRowKey={(row) => row.id}
|
||||||
pageSize={6}
|
pageSize={6}
|
||||||
minWidth={1180}
|
minWidth={900}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
||||||
/>
|
/>
|
||||||
@@ -128,6 +128,16 @@ export function UserSessionsPage() {
|
|||||||
const locked = row.state === '잠김';
|
const locked = row.state === '잠김';
|
||||||
return (
|
return (
|
||||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuSession.x, top: menuSession.y, transform: 'translateX(-100%)' }}>
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuSession.x, top: menuSession.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={() => {
|
||||||
|
setMenuSession(null);
|
||||||
|
setDetailSession(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
상세보기
|
||||||
|
</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"
|
||||||
@@ -154,7 +164,59 @@ export function UserSessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})() : null}
|
})() : null}
|
||||||
|
<Modal
|
||||||
|
open={Boolean(detailSession)}
|
||||||
|
title="사용자 상세 정보"
|
||||||
|
onClose={() => setDetailSession(null)}
|
||||||
|
className="!max-w-2xl"
|
||||||
|
footer={<Button variant="outline" onClick={() => setDetailSession(null)}>닫기</Button>}
|
||||||
|
>
|
||||||
|
{detailSession ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-4 rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">{detailSession.user}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{detailSession.email ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={detailSession.state} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||||
|
<DetailItem label="사용자 ID" value={detailSession.userId} mono />
|
||||||
|
<DetailItem label="조직" value={detailSession.primaryAffiliation?.organizationName ?? '-'} />
|
||||||
|
<DetailItem label="조직 유형" value={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
||||||
|
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
||||||
|
<DetailItem label="직책" value={detailSession.primaryAffiliation?.position ?? '-'} />
|
||||||
|
<DetailItem label="세션 ID" value={detailSession.sessionId ?? '-'} mono />
|
||||||
|
<DetailItem label="세션" value={detailSession.session} mono />
|
||||||
|
<DetailItem label="상태" value={detailSession.state} />
|
||||||
|
<DetailItem label="만료" value={formatDateTime(detailSession.expiresAt)} />
|
||||||
|
<DetailItem label="최근 접속" value={formatDateTime(detailSession.lastSeen)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
<LoadingModal open={loadingVisible} />
|
<LoadingModal open={loadingVisible} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function organizationTypeLabel(type?: string | null) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
COMPANY: '회사',
|
||||||
|
SCHOOL: '학교',
|
||||||
|
GOVERNMENT: '공공기관',
|
||||||
|
LAB: '연구소',
|
||||||
|
OTHER: '기타'
|
||||||
|
};
|
||||||
|
|
||||||
|
return type ? labels[type] ?? type : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailItem({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 rounded-md border border-border bg-background px-3 py-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
|
<p className={`mt-1 truncate text-sm text-foreground ${mono ? 'font-mono text-xs' : 'font-medium'}`}>{value || '-'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ export type BadgeTone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet
|
|||||||
|
|
||||||
export function statusTone(status?: string | null): BadgeTone {
|
export function statusTone(status?: string | null): BadgeTone {
|
||||||
if (!status) return 'neutral';
|
if (!status) return 'neutral';
|
||||||
if (['활성', '유효토큰', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
||||||
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
||||||
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
||||||
|
if (['없음'].includes(status)) return 'neutral';
|
||||||
if (['런처'].includes(status)) return 'violet';
|
if (['런처'].includes(status)) return 'violet';
|
||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,25 @@ export type UserSession = {
|
|||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
lastSeen?: string | null;
|
lastSeen?: string | null;
|
||||||
state: string;
|
state: string;
|
||||||
|
primaryAffiliation?: {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
organizationName: string;
|
||||||
|
organizationType: string;
|
||||||
|
department?: string | null;
|
||||||
|
position?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationType = 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER' | string;
|
||||||
|
|
||||||
|
export type Organization = {
|
||||||
|
id: string;
|
||||||
|
no?: number;
|
||||||
|
name: string;
|
||||||
|
type: OrganizationType;
|
||||||
|
domain?: string | null;
|
||||||
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LauncherNotice = {
|
export type LauncherNotice = {
|
||||||
@@ -121,7 +140,11 @@ export function deleteAdminSshKey(adminId: string, keyId: string) {
|
|||||||
|
|
||||||
export async function loadUserSessions() {
|
export async function loadUserSessions() {
|
||||||
const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions');
|
const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions');
|
||||||
return response.sessions ?? [];
|
return (response.sessions ?? []).map((session) => ({
|
||||||
|
...session,
|
||||||
|
session: session.sessionId ? session.session : '-',
|
||||||
|
state: session.state === '세션 없음' ? '없음' : session.state
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lockUser(userId: string) {
|
export function lockUser(userId: string) {
|
||||||
@@ -142,6 +165,31 @@ export function terminateUserSession(sessionId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadOrganizations() {
|
||||||
|
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
|
||||||
|
return response.organizations ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) {
|
||||||
|
return apiRequest<{ organization: Organization }>('/organizations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOrganization(organizationId: string, payload: { name?: string; type?: OrganizationType; domain?: string | null }) {
|
||||||
|
return apiRequest<{ organization: Organization }>(`/organizations/${organizationId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOrganization(organizationId: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/organizations/${organizationId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadLauncherNotices() {
|
export async function loadLauncherNotices() {
|
||||||
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
|
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
|
||||||
return response.notices ?? [];
|
return response.notices ?? [];
|
||||||
|
|||||||
Reference in New Issue
Block a user