Files
studio-admin-fe/src/pages/operations/LicensesPage.tsx
T
chyoo ac075a1fea
ci/woodpecker/push/woodpecker Pipeline was successful
improve app registration and component service ports
2026-07-09 14:26:43 +09:00

639 lines
26 KiB
TypeScript

import { MoreHorizontal, Pencil, Plus, ShieldCheck, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
import { Button, Card, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { formatDateOnly } from '../../lib/date-format';
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
import {
assignUserLicense,
loadLicensePlans,
loadLicenseUsers,
loadLauncherUserAppPermissions,
revokeUserLicense,
updateLauncherUserAppPermissions,
updateUserLicense,
type AuthLicenseUser,
type LauncherUserAppPermission,
type LicensePlan
} from './operations-api';
type LicenseAssignmentRow = {
id: string;
no: number;
userId: string;
user: string;
name: string;
role: string;
code: string;
licenseName: string;
startsAt: string;
expiresAt?: string | null;
assignedBy?: string | null;
status: string;
};
function today() {
return new Date().toISOString().slice(0, 10);
}
function addDays(dateValue: string, days?: number | null) {
if (!dateValue || !days) return '';
const date = new Date(`${dateValue}T00:00:00`);
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
}
function formatOptionalDate(value?: string | null) {
if (value == null) return '-';
const trimmed = String(value).trim();
if (!trimmed || trimmed === 'null' || trimmed === 'undefined') return '-';
return formatDateOnly(trimmed);
}
function toDateInputValue(value?: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString().slice(0, 10);
}
function assignmentRows(users: AuthLicenseUser[]) {
return users.flatMap((user) =>
user.licenses.map((license) => ({
id: license.id,
userId: user.id,
user: user.email,
name: user.name,
role: user.role,
code: license.license.code,
licenseName: license.license.name,
startsAt: license.startsAt,
expiresAt: license.expiresAt,
assignedBy: license.assignedById,
status: license.status
}))
);
}
export function LicensesPage() {
const { toast } = useToast();
const [plans, setPlans] = useState<LicensePlan[]>([]);
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
const [loading, setLoading] = useState(true);
const [assignOpen, setAssignOpen] = useState(false);
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
const [deleteTarget, setDeleteTarget] = useState<LicenseAssignmentRow | null>(null);
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
const [appPermissionLoading, setAppPermissionLoading] = useState(false);
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
const [saving, setSaving] = useState(false);
const [assignForm, setAssignForm] = useState({
userId: '',
licenseCode: '',
startsAt: today(),
expiresAt: ''
});
const [userKeyword, setUserKeyword] = useState('');
const loadingVisible = useMinimumLoading(loading);
const rows = useMemo(() => {
const assignments = assignmentRows(users);
return assignments.map((assignment, index) => ({ ...assignment, no: assignments.length - index }));
}, [users]);
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
const plan = plans.find((item) => item.code === licenseCode);
return addDays(startsAt, plan?.defaultDurationDays);
};
const filteredUsers = useMemo(() => {
const keyword = userKeyword.trim().toLowerCase();
if (!keyword) return [];
return users.filter((user) => `${user.email} ${user.name}`.toLowerCase().includes(keyword));
}, [userKeyword, users]);
const selectedUser = useMemo(() => users.find((user) => user.id === assignForm.userId), [assignForm.userId, users]);
const selectedUserCurrentLicense = selectedUser?.currentLicense ?? null;
const cannotAssignBecauseActive = Boolean(!editingRow && selectedUserCurrentLicense);
const refresh = async () => {
setLoading(true);
try {
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
setPlans(nextPlans);
setUsers(nextUsers);
setAssignForm((prev) => ({
...prev,
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
expiresAt: prev.expiresAt || addDays(prev.startsAt, nextPlans.find((plan) => plan.code === (prev.licenseCode || nextPlans[0]?.code || ''))?.defaultDurationDays)
}));
} catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 정보를 불러오지 못했습니다.' });
} finally {
setLoading(false);
}
};
useEffect(() => {
void refresh();
}, []);
useEffect(() => {
if (!menuRow) return;
const close = () => setMenuRow(null);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMenuRow(null);
};
window.addEventListener('click', close);
window.addEventListener('keydown', closeOnEscape);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('keydown', closeOnEscape);
};
}, [menuRow]);
const openAssignModal = () => {
const startsAt = today();
const licenseCode = assignForm.licenseCode || plans[0]?.code || '';
setEditingRow(null);
setAssignForm((prev) => ({
userId: '',
licenseCode,
startsAt,
expiresAt: calculateExpiresAt(licenseCode, startsAt)
}));
setUserKeyword('');
setAssignOpen(true);
};
const openEditModal = (row: LicenseAssignmentRow) => {
const startsAt = toDateInputValue(row.startsAt) || today();
setMenuRow(null);
setEditingRow(row);
setAssignForm({
userId: row.userId,
licenseCode: row.code,
startsAt,
expiresAt: toDateInputValue(row.expiresAt)
});
setUserKeyword('');
setAssignOpen(true);
};
const closeAssignModal = () => {
setAssignOpen(false);
setEditingRow(null);
setUserKeyword('');
};
const submitAssign = async () => {
if (!assignForm.userId || !assignForm.licenseCode) return;
setSaving(true);
try {
const payload = {
licenseCode: assignForm.licenseCode,
startsAt: assignForm.startsAt || undefined,
expiresAt: assignForm.expiresAt || undefined
};
if (editingRow) {
await updateUserLicense(editingRow.id, payload);
} else {
await assignUserLicense(assignForm.userId, payload);
}
toast({ type: 'success', message: editingRow ? '라이센스를 수정했습니다.' : '라이센스를 부여했습니다.' });
closeAssignModal();
await refresh();
} catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : editingRow ? '라이센스 수정에 실패했습니다.' : '라이센스 부여에 실패했습니다.' });
} finally {
setSaving(false);
}
};
const revoke = async () => {
if (!deleteTarget) return;
setSaving(true);
try {
await revokeUserLicense(deleteTarget.id);
toast({ type: 'success', message: '라이센스를 삭제했습니다.' });
setDeleteTarget(null);
await refresh();
} catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 삭제에 실패했습니다.' });
} finally {
setSaving(false);
}
};
const openAppPermissionModal = async (row: LicenseAssignmentRow) => {
setMenuRow(null);
setAppPermissionTarget(row);
setAppPermissions([]);
setAppPermissionLoading(true);
try {
setAppPermissions(await loadLauncherUserAppPermissions(row.userId));
} catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : '앱 권한을 불러오지 못했습니다.' });
} finally {
setAppPermissionLoading(false);
}
};
const closeAppPermissionModal = () => {
setAppPermissionTarget(null);
setAppPermissions([]);
};
const toggleAppPermission = (appKey: string, field: 'canView' | 'canLaunch') => {
setAppPermissions((prev) => prev.map((permission) => {
if (permission.appKey !== appKey) return permission;
if (field === 'canView') {
const canView = !permission.canView;
return { ...permission, canView, canLaunch: canView ? permission.canLaunch : false };
}
const canLaunch = !permission.canLaunch;
return { ...permission, canView: canLaunch ? true : permission.canView, canLaunch };
}));
};
const saveAppPermissions = async () => {
if (!appPermissionTarget) return;
setSaving(true);
try {
const result = await updateLauncherUserAppPermissions(
appPermissionTarget.userId,
appPermissions.map((permission) => ({
appKey: permission.appKey,
canView: permission.canView,
canLaunch: permission.canView && permission.canLaunch
}))
);
setAppPermissions(result.permissions ?? []);
toast({ type: 'success', message: '앱 권한을 저장했습니다.' });
closeAppPermissionModal();
} catch (error) {
toast({ type: 'error', message: error instanceof Error ? error.message : '앱 권한 저장에 실패했습니다.' });
} finally {
setSaving(false);
}
};
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
() => [
{ 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-[260px] px-5 py-3 font-medium',
cell: (row) => (
<div>
<p className="text-foreground">{row.user}</p>
<p className="mt-1 text-xs text-muted-foreground">{row.name}</p>
</div>
)
},
{ key: 'licenseName', header: '라이선스명', cell: (row) => <span className="text-muted-foreground">{row.licenseName}</span> },
{ key: 'startsAt', header: '시작일', cell: (row) => <span className="text-muted-foreground">{formatDateOnly(row.startsAt)}</span> },
{ key: 'expiresAt', header: '만료일', cell: (row) => <span className="text-muted-foreground">{formatOptionalDate(row.expiresAt)}</span> },
{ key: 'assignedBy', header: '부여자', cell: (row) => <span className="text-muted-foreground">{row.assignedBy || '-'}</span> },
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status} /> },
{
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();
setMenuRow((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
}}
>
<MoreHorizontal className="size-4" />
</Button>
</div>
)
}
],
[]
);
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
() => [
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.name })), getValue: (row) => row.licenseName },
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status }
],
[plans, rows]
);
return (
<>
<PageTitle
eyebrow="운영"
title="라이센스"
description="공통 인증 DB 기준으로 라이센스 플랜과 사용자 부여 이력을 관리합니다."
actions={
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
<Plus className="size-4" />
등록
</Button>
}
/>
<div className="grid gap-4 xl:grid-cols-3">
{plans.map((plan) => (
<Card key={plan.code} className="p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-foreground">{plan.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
</div>
<StatusBadge status={plan.isActive ? '활성' : '비활성'} />
</div>
<div className="mt-5 grid grid-cols-3 gap-3">
<div>
<p className="text-xs text-muted-foreground">부여</p>
<p className="mt-1 text-xl font-semibold">{plan.assignedCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">활성</p>
<p className="mt-1 text-xl font-semibold">{plan.activeUserCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">만료임박</p>
<p className="mt-1 text-xl font-semibold">{plan.expiringSoonCount}</p>
</div>
</div>
<p className="mt-4 text-xs text-muted-foreground">기본 기간 {plan.defaultDurationDays ?? '-'}일</p>
</Card>
))}
{!loading && plans.length === 0 ? (
<Card className="flex min-h-40 items-center justify-center p-5 text-sm text-muted-foreground xl:col-span-3">
등록된 라이센스 플랜이 없습니다.
</Card>
) : null}
</div>
<div className="mt-4">
<DataTableCard
title="목록"
rows={rows}
columns={columns}
filters={filters}
searchPlaceholder="사용자, 라이선스, 부여자 검색"
getSearchText={(row) => `${row.user} ${row.name} ${row.licenseName} ${row.assignedBy ?? ''} ${row.status}`}
getRowKey={(row) => row.id}
pageSize={6}
minWidth={980}
loading={loading}
emptyMessage="부여된 라이센스가 없습니다."
/>
</div>
{menuRow ? (() => {
const row = rows.find((item) => item.id === menuRow.id);
if (!row) return null;
return (
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuRow.x, top: menuRow.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={() => openEditModal(row)}
>
수정
</button>
<button
type="button"
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
onClick={() => void openAppPermissionModal(row)}
>
앱 권한 설정
</button>
<button
type="button"
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10"
onClick={() => {
setMenuRow(null);
setDeleteTarget(row);
}}
>
삭제
</button>
</div>
);
})() : null}
<Modal
open={assignOpen}
title={
<span className="inline-flex items-center gap-2">
<Pencil className="size-4 text-primary" />
{editingRow ? '라이센스 수정' : '라이센스 추가'}
</span>
}
onClose={closeAssignModal}
footer={
<>
<Button variant="outline" onClick={closeAssignModal} disabled={saving}>취소</Button>
<Button disabled={saving || !assignForm.userId || !assignForm.licenseCode || cannotAssignBecauseActive} onClick={submitAssign}>
{saving ? '저장 중' : editingRow ? '저장' : '부여'}
</Button>
</>
}
>
<div className="space-y-4">
{editingRow ? (
<div>
<span className="text-xs font-medium text-muted-foreground">사용자 <b className="text-foreground">*</b></span>
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
<p className="text-foreground">{editingRow.user}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
</div>
</div>
) : (
<div>
<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={userKeyword}
onChange={(event) => setUserKeyword(event.target.value)}
placeholder="이메일 또는 이름 검색"
/>
{selectedUser ? (
<div className="mt-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm">
<p className="text-foreground">{selectedUser.email}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{selectedUser.name || '-'}</p>
{selectedUserCurrentLicense ? (
<p className="mt-2 rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-200">
이미 활성 라이센스가 있습니다. 기존 이력에서 수정하세요.
</p>
) : null}
</div>
) : null}
{userKeyword.trim() ? (
<div className="mt-2 max-h-44 overflow-y-auto rounded-md border border-border bg-background/80">
{filteredUsers.map((user) => {
const selected = assignForm.userId === user.id;
return (
<button
key={user.id}
type="button"
className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition hover:bg-white/5 ${selected ? 'bg-primary/15 text-foreground' : 'text-muted-foreground'}`}
onClick={() => {
setAssignForm((prev) => ({ ...prev, userId: user.id }));
setUserKeyword('');
}}
>
<span>
<span className="block text-foreground">{user.email}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{user.name || '-'}</span>
</span>
{selected ? <span className="text-xs text-primary">선택됨</span> : null}
</button>
);
})}
{filteredUsers.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">검색된 사용자가 없습니다.</div>
) : null}
</div>
) : null}
</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={assignForm.licenseCode}
onChange={(event) => {
const licenseCode = event.target.value;
setAssignForm((prev) => ({
...prev,
licenseCode,
expiresAt: calculateExpiresAt(licenseCode, prev.startsAt)
}));
}}
>
{plans.map((plan) => <option key={plan.code} value={plan.code}>{plan.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">시작일 <b className="text-foreground">*</b></span>
<input
type="date"
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={assignForm.startsAt}
onChange={(event) => {
const startsAt = event.target.value;
setAssignForm((prev) => ({
...prev,
startsAt,
expiresAt: calculateExpiresAt(prev.licenseCode, startsAt)
}));
}}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">만료일</span>
<input
type="date"
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={assignForm.expiresAt}
onChange={(event) => setAssignForm((prev) => ({ ...prev, expiresAt: event.target.value }))}
/>
</label>
</div>
</div>
</Modal>
<Modal
open={Boolean(deleteTarget)}
title={
<span className="inline-flex items-center gap-2">
<Trash2 className="size-4 text-rose-300" />
라이센스 삭제
</span>
}
onClose={() => setDeleteTarget(null)}
footer={
<>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={revoke} disabled={saving}>
{saving ? '삭제 중' : '삭제'}
</Button>
</>
}
>
<p className="pb-6 text-sm text-foreground">라이센스를 삭제할까요?</p>
</Modal>
<Modal
open={Boolean(appPermissionTarget)}
title={
<span className="inline-flex items-center gap-2">
<ShieldCheck className="size-4 text-primary" />
앱 권한 설정
</span>
}
onClose={closeAppPermissionModal}
className="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={closeAppPermissionModal} disabled={saving}>취소</Button>
<Button onClick={saveAppPermissions} disabled={saving || appPermissionLoading}>
{saving ? '저장 중' : '저장'}
</Button>
</>
}
>
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
<p className="text-sm font-medium text-foreground">{appPermissionTarget?.user ?? '-'}</p>
<p className="mt-1 text-xs text-muted-foreground">{appPermissionTarget?.name || '-'}</p>
</div>
<div className="max-h-80 overflow-y-auto rounded-md border border-border">
{appPermissionLoading ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">앱 권한을 불러오는 중입니다.</div>
) : appPermissions.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">런처에 연결된 앱이 없습니다.</div>
) : (
appPermissions.map((permission) => (
<div key={permission.appKey} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
<div className="min-w-0">
<p className="truncate text-sm text-foreground">{permission.displayName}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{permission.appKey}</p>
</div>
<div className="flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="size-4 accent-primary"
checked={permission.canView}
onChange={() => toggleAppPermission(permission.appKey, 'canView')}
/>
보기
</label>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="size-4 accent-primary"
checked={permission.canLaunch}
onChange={() => toggleAppPermission(permission.appKey, 'canLaunch')}
/>
실행
</label>
</div>
</div>
))
)}
</div>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
}