161 lines
6.6 KiB
TypeScript
161 lines
6.6 KiB
TypeScript
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 { PageTitle } from '../../layout/AppLayout';
|
|
import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
|
|
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
|
|
|
export function UserSessionsPage() {
|
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [failed, setFailed] = useState(false);
|
|
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
|
const loadingVisible = useMinimumLoading(loading);
|
|
|
|
const refresh = () => {
|
|
setLoading(true);
|
|
loadUserSessions()
|
|
.then((nextSessions) => {
|
|
setSessions(nextSessions);
|
|
setFailed(false);
|
|
})
|
|
.catch(() => {
|
|
setSessions([]);
|
|
setFailed(true);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!menuSession) return;
|
|
|
|
const close = () => setMenuSession(null);
|
|
const closeOnEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setMenuSession(null);
|
|
};
|
|
|
|
window.addEventListener('click', close);
|
|
window.addEventListener('keydown', closeOnEscape);
|
|
return () => {
|
|
window.removeEventListener('click', close);
|
|
window.removeEventListener('keydown', closeOnEscape);
|
|
};
|
|
}, [menuSession]);
|
|
|
|
const rows = useMemo(() => sessions.map((session, index) => ({ ...session, no: sessions.length - index })), [sessions]);
|
|
|
|
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
|
() => [
|
|
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
|
{
|
|
key: 'user',
|
|
header: '사용자',
|
|
className: 'w-[240px] px-5 py-3 font-medium',
|
|
cell: (row) => (
|
|
<div className="min-w-0">
|
|
<p className="truncate font-semibold">{row.user}</p>
|
|
{row.email ? <p className="mt-1 truncate text-xs font-medium text-muted-foreground">{row.email}</p> : null}
|
|
</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: '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();
|
|
setMenuSession((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
|
}}
|
|
>
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
],
|
|
[menuSession?.id]
|
|
);
|
|
|
|
const filters = useMemo<Array<DataTableFilter<UserSession>>>(
|
|
() => [
|
|
{ key: 'state', label: '상태', options: [...new Set(sessions.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
|
],
|
|
[sessions]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageTitle eyebrow="운영" title="사용자/세션" description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다." />
|
|
{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.user} ${row.email ?? ''} ${row.role} ${row.session} ${row.state}`}
|
|
getRowKey={(row) => row.id}
|
|
pageSize={6}
|
|
minWidth={1180}
|
|
loading={loading}
|
|
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
|
/>
|
|
{menuSession ? (() => {
|
|
const row = rows.find((item) => item.id === menuSession.id);
|
|
if (!row) return null;
|
|
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);
|
|
const request = locked ? unlockUser(row.userId) : lockUser(row.userId);
|
|
request.then(refresh).catch(() => setFailed(true));
|
|
}}
|
|
>
|
|
{locked ? '잠금 해제' : '잠금'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:bg-transparent"
|
|
disabled={!row.sessionId || row.state === '종료' || row.state === '만료'}
|
|
onClick={() => {
|
|
if (!row.sessionId) return;
|
|
setMenuSession(null);
|
|
terminateUserSession(row.sessionId).then(refresh).catch(() => setFailed(true));
|
|
}}
|
|
>
|
|
유효토큰 종료
|
|
</button>
|
|
</div>
|
|
);
|
|
})() : null}
|
|
<LoadingModal open={loadingVisible} />
|
|
</>
|
|
);
|
|
}
|