add organization management and refine source repository view
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-07 11:56:29 +09:00
parent cb185abfe3
commit 1de085790d
12 changed files with 1566 additions and 485 deletions
+71 -9
View File
@@ -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>
);
}