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:
@@ -253,15 +253,6 @@ export function AdminManagementPage() {
|
||||
</select>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'session',
|
||||
header: '최근 세션',
|
||||
cell: (row) => {
|
||||
const session = row.sessions?.[0];
|
||||
const state = session ? (session.revokedAt ? '종료' : '활성') : '세션 없음';
|
||||
return <StatusBadge status={state} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'sshKeys',
|
||||
header: 'SSH 키',
|
||||
@@ -342,7 +333,7 @@ export function AdminManagementPage() {
|
||||
getSearchText={(row) => `${row.name} ${row.email} ${row.role} ${row.state}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1180}
|
||||
minWidth={1040}
|
||||
loading={loading}
|
||||
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 { useEffect, useMemo, useState } from 'react';
|
||||
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 { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
|
||||
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
@@ -11,6 +11,7 @@ export function UserSessionsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
@@ -63,11 +64,10 @@ export function UserSessionsPage() {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'role', header: '권한', cell: (row) => <span className="text-muted-foreground">{row.role}</span> },
|
||||
{ key: 'session', header: '세션', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
|
||||
{ key: 'expiresAt', header: '만료', cell: (row) => <span className="text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
|
||||
{ key: 'lastSeen', header: '최근 접속', cell: (row) => <span className="text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
|
||||
{ key: 'state', header: '상태', cell: (row) => <StatusBadge status={row.state} /> },
|
||||
{ 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: 'actions',
|
||||
header: '',
|
||||
@@ -114,11 +114,11 @@ export function UserSessionsPage() {
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="사용자, 이메일, 권한, 세션 검색"
|
||||
getSearchText={(row) => `${row.user} ${row.email ?? ''} ${row.role} ${row.session} ${row.state}`}
|
||||
searchPlaceholder="사용자, 이메일, 세션 검색"
|
||||
getSearchText={(row) => `${row.user} ${row.email ?? ''} ${row.session} ${row.state}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1180}
|
||||
minWidth={900}
|
||||
loading={loading}
|
||||
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
||||
/>
|
||||
@@ -128,6 +128,16 @@ export function UserSessionsPage() {
|
||||
const locked = row.state === '잠김';
|
||||
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%)' }}>
|
||||
<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
|
||||
type="button"
|
||||
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>
|
||||
);
|
||||
})() : 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
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 (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
||||
if (['없음'].includes(status)) return 'neutral';
|
||||
if (['런처'].includes(status)) return 'violet';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
@@ -43,6 +43,25 @@ export type UserSession = {
|
||||
expiresAt?: string | null;
|
||||
lastSeen?: string | null;
|
||||
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 = {
|
||||
@@ -121,7 +140,11 @@ export function deleteAdminSshKey(adminId: string, keyId: string) {
|
||||
|
||||
export async function loadUserSessions() {
|
||||
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) {
|
||||
@@ -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() {
|
||||
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
|
||||
return response.notices ?? [];
|
||||
|
||||
Reference in New Issue
Block a user