447 lines
19 KiB
TypeScript
447 lines
19 KiB
TypeScript
import { MoreHorizontal, Plus } from 'lucide-react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
|
import { Button, Modal } from '../../components/ui';
|
|
import { useToast } from '../../components/toast';
|
|
import { PageTitle } from '../../layout/AppLayout';
|
|
import { cn } from '../../lib/utils';
|
|
import {
|
|
createManagedUser,
|
|
loadLicensePlans,
|
|
loadOrganizations,
|
|
loadUserSessionsPage,
|
|
lockUser,
|
|
terminateUserSession,
|
|
unlockUser,
|
|
type LicensePlan,
|
|
type Organization,
|
|
type UserSession
|
|
} from './operations-api';
|
|
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
|
|
|
const emptyCreateForm = {
|
|
email: '',
|
|
name: '',
|
|
password: '',
|
|
organizationId: '',
|
|
department: '',
|
|
position: '',
|
|
licenseCode: 'EDU' as 'FREE' | 'EDU' | 'PRO'
|
|
};
|
|
|
|
export function UserSessionsPage() {
|
|
const { toast } = useToast();
|
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [keyword, setKeyword] = useState('');
|
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
|
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [failed, setFailed] = useState(false);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
|
const [createSaving, setCreateSaving] = 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 = () => {
|
|
setLoading(true);
|
|
loadUserSessionsPage({ page, limit: 6, keyword, state: filterValues.state })
|
|
.then((result) => {
|
|
setSessions(result.items);
|
|
setTotal(result.total);
|
|
setFailed(false);
|
|
})
|
|
.catch(() => {
|
|
setSessions([]);
|
|
setFailed(true);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
Promise.all([
|
|
loadOrganizations().catch(() => []),
|
|
loadLicensePlans().catch(() => [])
|
|
]).then(([nextOrganizations, nextLicensePlans]) => {
|
|
setOrganizations(nextOrganizations);
|
|
setLicensePlans(nextLicensePlans);
|
|
setCreateForm((current) => ({
|
|
...current,
|
|
organizationId: current.organizationId || '',
|
|
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
|
}));
|
|
});
|
|
}, [page, keyword, filterValues.state]);
|
|
|
|
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 = sessions;
|
|
|
|
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
|
() => [
|
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
|
{
|
|
key: 'user',
|
|
header: '사용자',
|
|
className: 'w-[280px] px-5 py-3 font-medium',
|
|
cell: (row) => (
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<UserAvatar session={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>
|
|
</div>
|
|
)
|
|
},
|
|
{ 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-32 px-5 py-3', 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: ['유효토큰', '세션 없음', '잠김', '만료', '종료'].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
|
],
|
|
[]
|
|
);
|
|
|
|
const openCreateModal = () => {
|
|
setCreateForm({
|
|
...emptyCreateForm,
|
|
organizationId: '',
|
|
licenseCode: (licensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
|
});
|
|
setCreateOpen(true);
|
|
};
|
|
|
|
const submitCreateUser = () => {
|
|
if (!createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8) return;
|
|
|
|
setCreateSaving(true);
|
|
createManagedUser({
|
|
email: createForm.email.trim(),
|
|
name: createForm.name.trim(),
|
|
password: createForm.password,
|
|
organizationId: createForm.organizationId || null,
|
|
department: createForm.department.trim() || null,
|
|
position: createForm.position.trim() || null,
|
|
licenseCode: createForm.licenseCode
|
|
})
|
|
.then(() => {
|
|
toast({ type: 'success', title: '사용자 등록', message: '사용자가 등록되었습니다.' });
|
|
setCreateOpen(false);
|
|
refresh();
|
|
})
|
|
.catch((error: Error) => {
|
|
toast({ type: 'error', title: '사용자 등록 실패', message: error.message });
|
|
})
|
|
.finally(() => setCreateSaving(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.user} ${row.email ?? ''} ${row.session} ${row.state}`}
|
|
getRowKey={(row) => row.id}
|
|
pageSize={6}
|
|
minWidth={900}
|
|
loading={loading}
|
|
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
|
serverPagination={{
|
|
page,
|
|
total,
|
|
onPageChange: setPage,
|
|
onSearchChange: (value) => {
|
|
setKeyword(value);
|
|
setPage(1);
|
|
},
|
|
filterValues,
|
|
onFilterChange: (key, value) => {
|
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
|
setPage(1);
|
|
}
|
|
}}
|
|
/>
|
|
{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);
|
|
setDetailSession(row);
|
|
}}
|
|
>
|
|
상세보기
|
|
</button>
|
|
<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}
|
|
<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="flex min-w-0 items-center gap-3">
|
|
<UserAvatar session={detailSession} size="lg" />
|
|
<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>
|
|
</div>
|
|
<StatusBadge status={detailSession.state} />
|
|
</div>
|
|
<div className="grid gap-3 text-sm">
|
|
<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>
|
|
<Modal
|
|
open={createOpen}
|
|
title="사용자 추가"
|
|
onClose={() => {
|
|
if (!createSaving) setCreateOpen(false);
|
|
}}
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={createSaving}>취소</Button>
|
|
<Button
|
|
onClick={submitCreateUser}
|
|
disabled={createSaving || !createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8}
|
|
>
|
|
{createSaving ? '등록 중' : '등록'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">이메일 <b className="text-foreground">*</b></span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={createForm.email}
|
|
onChange={(event) => setCreateForm((current) => ({ ...current, email: event.target.value }))}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">이름 <b className="text-foreground">*</b></span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={createForm.name}
|
|
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호 <b className="text-foreground">*</b></span>
|
|
<input
|
|
type="password"
|
|
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={createForm.password}
|
|
onChange={(event) => setCreateForm((current) => ({ ...current, password: event.target.value }))}
|
|
/>
|
|
</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={createForm.organizationId}
|
|
onChange={(event) => setCreateForm((current) => ({ ...current, organizationId: event.target.value }))}
|
|
>
|
|
<option value="">없음</option>
|
|
{organizations.map((organization) => (
|
|
<option key={organization.id} value={organization.id}>{organization.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">부서</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={createForm.department}
|
|
onChange={(event) => setCreateForm((current) => ({ ...current, department: event.target.value }))}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">직책</span>
|
|
<input
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={createForm.position}
|
|
onChange={(event) => setCreateForm((current) => ({ ...current, position: event.target.value }))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">라이센스 <b className="text-foreground">*</b></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={createForm.licenseCode}
|
|
onChange={(event) => setCreateForm((current) => ({ ...current, licenseCode: event.target.value as 'FREE' | 'EDU' | 'PRO' }))}
|
|
>
|
|
{licensePlans.map((plan) => (
|
|
<option key={plan.code} value={plan.code}>{plan.name}</option>
|
|
))}
|
|
{licensePlans.length === 0 ? <option value="EDU">EDU</option> : null}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</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 UserAvatar({ session, size = 'sm' }: { session: UserSession; size?: 'sm' | 'lg' }) {
|
|
const [failed, setFailed] = useState(false);
|
|
const initial = (session.user || session.email || '?').trim().charAt(0).toUpperCase() || '?';
|
|
const sizeClassName = size === 'lg' ? 'size-14 text-lg' : 'size-9 text-sm';
|
|
|
|
if (session.avatarUrl && !failed) {
|
|
return (
|
|
<img
|
|
src={session.avatarUrl}
|
|
alt=""
|
|
className={cn(sizeClassName, 'shrink-0 rounded-full border border-border bg-muted object-cover')}
|
|
onError={() => setFailed(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span className={cn(sizeClassName, 'inline-flex shrink-0 items-center justify-center rounded-full border border-border bg-muted font-semibold text-muted-foreground')}>
|
|
{initial}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function DetailItem({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
|
return (
|
|
<label className="block min-w-0">
|
|
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
|
<div className={cn('mt-2 min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-medium text-foreground', mono && 'break-all font-mono text-xs font-normal')}>
|
|
{value || '-'}
|
|
</div>
|
|
</label>
|
|
);
|
|
}
|