This commit is contained in:
@@ -48,7 +48,8 @@ export function DataTableCard<T>({
|
||||
pageSize = 8,
|
||||
minWidth = 980,
|
||||
loading = false,
|
||||
emptyMessage = '데이터가 없습니다.'
|
||||
emptyMessage = '데이터가 없습니다.',
|
||||
serverPagination
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -62,12 +63,23 @@ export function DataTableCard<T>({
|
||||
minWidth?: number;
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
serverPagination?: {
|
||||
page: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onSearchChange?: (keyword: string) => void;
|
||||
filterValues?: Record<string, string>;
|
||||
onFilterChange?: (key: string, value: string) => void;
|
||||
};
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const serverMode = Boolean(serverPagination);
|
||||
const activeFilterValues = serverPagination?.filterValues ?? filterValues;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (serverMode) return rows;
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
|
||||
return rows.filter((row) => {
|
||||
@@ -75,24 +87,37 @@ export function DataTableCard<T>({
|
||||
? getSearchText(row).toLowerCase().includes(normalizedSearch)
|
||||
: true;
|
||||
const matchesFilters = filters.every((filter) => {
|
||||
const selected = filterValues[filter.key];
|
||||
const selected = activeFilterValues[filter.key];
|
||||
return !selected || filter.getValue(row) === selected;
|
||||
});
|
||||
|
||||
return matchesSearch && matchesFilters;
|
||||
});
|
||||
}, [filterValues, filters, getSearchText, rows, search]);
|
||||
}, [activeFilterValues, filters, getSearchText, rows, search, serverMode]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
const totalCount = serverPagination?.total ?? filteredRows.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const currentPage = Math.min(serverPagination?.page ?? page, totalPages);
|
||||
const pageRows = serverMode ? rows : filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
const flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
if (serverPagination?.onFilterChange) {
|
||||
serverPagination.onFilterChange(key, value);
|
||||
return;
|
||||
}
|
||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const movePage = (nextPage: number) => {
|
||||
if (serverPagination) {
|
||||
serverPagination.onPageChange(nextPage);
|
||||
return;
|
||||
}
|
||||
setPage(nextPage);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
@@ -108,7 +133,9 @@ export function DataTableCard<T>({
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
const value = event.target.value;
|
||||
setSearch(value);
|
||||
serverPagination?.onSearchChange?.(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
@@ -120,7 +147,7 @@ export function DataTableCard<T>({
|
||||
{filter.label}
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={filterValues[filter.key] ?? ''}
|
||||
value={activeFilterValues[filter.key] ?? ''}
|
||||
onChange={(event) => updateFilter(filter.key, event.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
@@ -210,14 +237,14 @@ export function DataTableCard<T>({
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loading ? '불러오는 중' : `총 ${filteredRows.length}건 · ${currentPage} / ${totalPages}`}
|
||||
{loading ? '불러오는 중' : `총 ${totalCount}건 · ${currentPage} / ${totalPages}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
onClick={() => movePage(Math.max(1, currentPage - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
이전
|
||||
@@ -226,7 +253,7 @@ export function DataTableCard<T>({
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
onClick={() => movePage(Math.min(totalPages, currentPage + 1))}
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="size-4" />
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
deleteAdminSshKey,
|
||||
loadAdminAppPermissions,
|
||||
loadAdminRoles,
|
||||
loadAdmins,
|
||||
loadAdminsPage,
|
||||
resetAdminPassword,
|
||||
updateAdminAppPermissions,
|
||||
updateAdminRole,
|
||||
@@ -44,6 +44,10 @@ const fallbackAdminRoles: AdminRoleOption[] = [
|
||||
export function AdminManagementPage() {
|
||||
const { toast } = useToast();
|
||||
const [admins, setAdmins] = useState<AdminAccount[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -75,9 +79,13 @@ export function AdminManagementPage() {
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([loadAdmins(), loadAdminRoles().catch(() => fallbackAdminRoles)])
|
||||
.then(([nextAdmins, nextRoles]) => {
|
||||
setAdmins(nextAdmins);
|
||||
Promise.all([
|
||||
loadAdminsPage({ page, limit: 6, keyword, role: filterValues.role, state: filterValues.state }),
|
||||
loadAdminRoles().catch(() => fallbackAdminRoles)
|
||||
])
|
||||
.then(([result, nextRoles]) => {
|
||||
setAdmins(result.items);
|
||||
setTotal(result.total);
|
||||
setAdminRoles(nextRoles.filter((role) => role.isActive));
|
||||
setFailed(false);
|
||||
})
|
||||
@@ -91,7 +99,7 @@ export function AdminManagementPage() {
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
}, [page, keyword, filterValues.role, filterValues.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuAdmin) return;
|
||||
@@ -110,7 +118,7 @@ export function AdminManagementPage() {
|
||||
}, [menuAdmin]);
|
||||
|
||||
const rows = useMemo<AdminRow[]>(
|
||||
() => admins.map((admin, index) => ({ ...admin, no: admins.length - index, state: admin.isActive ? '활성' : '비활성' })),
|
||||
() => admins.map((admin) => ({ ...admin, no: admin.no ?? 0, state: admin.isActive ? '활성' : '비활성' })),
|
||||
[admins]
|
||||
);
|
||||
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
|
||||
@@ -318,10 +326,10 @@ export function AdminManagementPage() {
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
|
||||
() => [
|
||||
{ key: 'role', label: '권한', options: [...new Set(rows.map((row) => row.role))].map((role) => ({ label: role, value: role })), getValue: (row) => row.role },
|
||||
{ key: 'state', label: '상태', options: [...new Set(rows.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||
{ key: 'role', label: '권한', options: roleOptions.map((role) => ({ label: role.name, value: role.code })), getValue: (row) => row.role },
|
||||
{ key: 'state', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '비활성', value: 'inactive' }], getValue: (row) => row.isActive ? 'active' : 'inactive' }
|
||||
],
|
||||
[rows]
|
||||
[roleOptions]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -354,6 +362,20 @@ export function AdminManagementPage() {
|
||||
minWidth={1040}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{menuAdmin ? (() => {
|
||||
const row = rows.find((item) => item.id === menuAdmin.id);
|
||||
|
||||
@@ -8,31 +8,23 @@ import { formatDateOnly } from '../../lib/date-format';
|
||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
import {
|
||||
assignUserLicense,
|
||||
loadLicenseAppPermissions,
|
||||
loadLicenseAssignments,
|
||||
loadLicensePlans,
|
||||
loadLicenseUsers,
|
||||
loadLauncherUserAppPermissions,
|
||||
revokeUserLicense,
|
||||
updateLicensePlan,
|
||||
updateLauncherUserAppPermissions,
|
||||
updateUserLicense,
|
||||
type AuthLicenseUser,
|
||||
type LauncherUserAppPermission,
|
||||
type LicenseAppPermission,
|
||||
type LicenseAssignment,
|
||||
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;
|
||||
};
|
||||
type LicenseAssignmentRow = LicenseAssignment;
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
@@ -59,28 +51,15 @@ function toDateInputValue(value?: string | null) {
|
||||
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 [rows, setRows] = useState<LicenseAssignmentRow[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
||||
@@ -88,8 +67,18 @@ export function LicensesPage() {
|
||||
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
|
||||
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
|
||||
const [appPermissionLoading, setAppPermissionLoading] = useState(false);
|
||||
const [planEditTarget, setPlanEditTarget] = useState<LicensePlan | null>(null);
|
||||
const [planStatusTarget, setPlanStatusTarget] = useState<LicensePlan | null>(null);
|
||||
const [licenseAppPermissions, setLicenseAppPermissions] = useState<LicenseAppPermission[]>([]);
|
||||
const [licenseAppLoading, setLicenseAppLoading] = useState(false);
|
||||
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [planMenu, setPlanMenu] = useState<{ code: string; x: number; y: number } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [planForm, setPlanForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
defaultDurationDays: ''
|
||||
});
|
||||
const [assignForm, setAssignForm] = useState({
|
||||
userId: '',
|
||||
licenseCode: '',
|
||||
@@ -99,10 +88,6 @@ export function LicensesPage() {
|
||||
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);
|
||||
@@ -120,8 +105,17 @@ export function LicensesPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
||||
const nextAssignments = await loadLicenseAssignments({
|
||||
page,
|
||||
limit: 6,
|
||||
keyword,
|
||||
licenseCode: filterValues.licenseName,
|
||||
status: filterValues.status,
|
||||
});
|
||||
setPlans(nextPlans);
|
||||
setUsers(nextUsers);
|
||||
setRows(nextAssignments.items);
|
||||
setTotal(nextAssignments.total);
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
||||
@@ -136,14 +130,17 @@ export function LicensesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
}, [page, keyword, filterValues.licenseName, filterValues.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuRow) return;
|
||||
if (!menuRow && !planMenu) return;
|
||||
|
||||
const close = () => setMenuRow(null);
|
||||
const close = () => {
|
||||
setMenuRow(null);
|
||||
setPlanMenu(null);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuRow(null);
|
||||
if (event.key === 'Escape') close();
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
@@ -152,7 +149,7 @@ export function LicensesPage() {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuRow]);
|
||||
}, [menuRow, planMenu]);
|
||||
|
||||
const openAssignModal = () => {
|
||||
const startsAt = today();
|
||||
@@ -280,6 +277,73 @@ export function LicensesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openPlanEditModal = async (plan: LicensePlan) => {
|
||||
setPlanMenu(null);
|
||||
setPlanEditTarget(plan);
|
||||
setPlanForm({
|
||||
name: plan.name,
|
||||
description: plan.description ?? '',
|
||||
defaultDurationDays: plan.defaultDurationDays ? String(plan.defaultDurationDays) : ''
|
||||
});
|
||||
setLicenseAppPermissions([]);
|
||||
setLicenseAppLoading(true);
|
||||
try {
|
||||
setLicenseAppPermissions(await loadLicenseAppPermissions(plan.code));
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 앱 설정을 불러오지 못했습니다.' });
|
||||
} finally {
|
||||
setLicenseAppLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closePlanEditModal = () => {
|
||||
setPlanEditTarget(null);
|
||||
setLicenseAppPermissions([]);
|
||||
};
|
||||
|
||||
const savePlanEdit = async () => {
|
||||
if (!planEditTarget || !planForm.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateLicensePlan(planEditTarget.code, {
|
||||
name: planForm.name.trim(),
|
||||
description: planForm.description.trim() || null,
|
||||
defaultDurationDays: planForm.defaultDurationDays ? Number(planForm.defaultDurationDays) : null,
|
||||
permissions: licenseAppPermissions.map((permission) => ({
|
||||
appKey: permission.appKey,
|
||||
canLaunch: permission.canLaunch
|
||||
}))
|
||||
});
|
||||
toast({ type: 'success', message: `라이센스를 수정했습니다. ${result.updatedUserCount ?? 0}명에게 반영했습니다.` });
|
||||
closePlanEditModal();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 수정에 실패했습니다.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlanActive = async (plan: LicensePlan) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateLicensePlan(plan.code, { isActive: !plan.isActive });
|
||||
toast({ type: 'success', message: plan.isActive ? '라이센스를 비활성화했습니다.' : '라이센스를 활성화했습니다.' });
|
||||
setPlanStatusTarget(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 상태 변경에 실패했습니다.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLicenseAppLaunch = (appKey: string) => {
|
||||
setLicenseAppPermissions((prev) => prev.map((permission) =>
|
||||
permission.appKey === appKey ? { ...permission, canLaunch: !permission.canLaunch, canView: true } : permission
|
||||
));
|
||||
};
|
||||
|
||||
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> },
|
||||
@@ -327,10 +391,10 @@ export function LicensesPage() {
|
||||
|
||||
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 }
|
||||
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.code })), getValue: (row) => row.code },
|
||||
{ key: 'status', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '만료', value: 'expired' }], getValue: (row) => row.status === '활성' ? 'active' : 'expired' }
|
||||
],
|
||||
[plans, rows]
|
||||
[plans]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -354,7 +418,22 @@ export function LicensesPage() {
|
||||
<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 className="flex shrink-0 items-center gap-2">
|
||||
{!plan.isActive ? <StatusBadge status="비활성" /> : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setPlanMenu((prev) => (prev?.code === plan.code ? null : { code: plan.code, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
@@ -379,6 +458,31 @@ export function LicensesPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
{planMenu ? (() => {
|
||||
const plan = plans.find((item) => item.code === planMenu.code);
|
||||
if (!plan) 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: planMenu.x, top: planMenu.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={() => void openPlanEditModal(plan)}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
setPlanMenu(null);
|
||||
setPlanStatusTarget(plan);
|
||||
}}
|
||||
>
|
||||
{plan.isActive ? '비활성' : '활성'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
<div className="mt-4">
|
||||
<DataTableCard
|
||||
title="목록"
|
||||
@@ -392,6 +496,20 @@ export function LicensesPage() {
|
||||
minWidth={980}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -550,6 +668,110 @@ export function LicensesPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(planEditTarget)}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
라이센스 수정
|
||||
</span>
|
||||
}
|
||||
onClose={closePlanEditModal}
|
||||
className="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closePlanEditModal} disabled={saving}>취소</Button>
|
||||
<Button onClick={savePlanEdit} disabled={saving || licenseAppLoading || !planForm.name.trim()}>
|
||||
{saving ? '저장 중' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
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={planForm.name}
|
||||
onChange={(event) => setPlanForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">기본 기간</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
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={planForm.defaultDurationDays}
|
||||
onChange={(event) => setPlanForm((prev) => ({ ...prev, defaultDurationDays: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||
<textarea
|
||||
className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={planForm.description}
|
||||
onChange={(event) => setPlanForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">앱 실행 설정</span>
|
||||
<div className="mt-2 max-h-64 overflow-y-auto rounded-md border border-border">
|
||||
{licenseAppLoading ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">앱 설정을 불러오는 중입니다.</div>
|
||||
) : licenseAppPermissions.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">런처에 연결된 앱이 없습니다.</div>
|
||||
) : (
|
||||
licenseAppPermissions.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>
|
||||
<label className="inline-flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-primary"
|
||||
checked={permission.canLaunch}
|
||||
onChange={() => toggleLicenseAppLaunch(permission.appKey)}
|
||||
/>
|
||||
실행
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(planStatusTarget)}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 text-primary" />
|
||||
라이센스 상태 변경
|
||||
</span>
|
||||
}
|
||||
onClose={() => setPlanStatusTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPlanStatusTarget(null)} disabled={saving}>취소</Button>
|
||||
<Button
|
||||
className={planStatusTarget?.isActive ? 'border border-amber-500/40 bg-amber-500/15 text-amber-100 hover:bg-amber-500/25' : undefined}
|
||||
onClick={() => planStatusTarget ? void togglePlanActive(planStatusTarget) : undefined}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '처리 중' : planStatusTarget?.isActive ? '비활성' : '활성'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="pb-6 text-sm text-foreground">
|
||||
{planStatusTarget?.name ?? '라이센스'}를 {planStatusTarget?.isActive ? '비활성화' : '활성화'}할까요?
|
||||
</p>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(deleteTarget)}
|
||||
title={
|
||||
|
||||
@@ -9,7 +9,7 @@ import { listApps } from '../applications/application-api';
|
||||
import {
|
||||
createLauncherNotice,
|
||||
deleteLauncherNotice,
|
||||
loadLauncherNotices,
|
||||
loadLauncherNoticesPage,
|
||||
updateLauncherNotice,
|
||||
type LauncherNotice
|
||||
} from './operations-api';
|
||||
@@ -123,6 +123,10 @@ export function NoticesPage() {
|
||||
const { toast } = useToast();
|
||||
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
||||
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -136,11 +140,12 @@ export function NoticesPage() {
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
loadLauncherNotices(),
|
||||
loadLauncherNoticesPage({ page, limit: 6, keyword, appKey: filterValues.appKey }),
|
||||
listApps().catch(() => [])
|
||||
])
|
||||
.then(([items, appItems]) => {
|
||||
setNotices(items);
|
||||
.then(([result, appItems]) => {
|
||||
setNotices(result.items);
|
||||
setTotal(result.total);
|
||||
setApps(appItems);
|
||||
setFailed(false);
|
||||
})
|
||||
@@ -154,7 +159,7 @@ export function NoticesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
}, [page, keyword, filterValues.appKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuNotice) return;
|
||||
@@ -184,7 +189,7 @@ export function NoticesPage() {
|
||||
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
||||
|
||||
const rows = useMemo(
|
||||
() => notices.map((notice, index) => ({ ...notice, no: notices.length - index, appName: appLabel(notice.appKey) })),
|
||||
() => notices.map((notice) => ({ ...notice, no: notice.no ?? 0, appName: appLabel(notice.appKey) })),
|
||||
[appNameByKey, notices]
|
||||
);
|
||||
|
||||
@@ -303,9 +308,14 @@ export function NoticesPage() {
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||
() => [
|
||||
{ key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey }
|
||||
{
|
||||
key: 'appKey',
|
||||
label: '대상 앱',
|
||||
options: ['Launcher', ...apps.map((app) => app.key)].map((appKey) => ({ label: appLabel(appKey), value: appKey })),
|
||||
getValue: (row) => row.appKey
|
||||
}
|
||||
],
|
||||
[rows]
|
||||
[apps, appNameByKey]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -338,6 +348,20 @@ export function NoticesPage() {
|
||||
minWidth={1040}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{menuNotice ? (() => {
|
||||
|
||||
@@ -8,8 +8,11 @@ import { formatDateTime } from '../../lib/date-format';
|
||||
import {
|
||||
createOrganization,
|
||||
deleteOrganization,
|
||||
loadOrganizations,
|
||||
loadLicensePlans,
|
||||
loadOrganizationsPage,
|
||||
updateOrganization,
|
||||
updateOrganizationLicense,
|
||||
type LicensePlan,
|
||||
type Organization,
|
||||
type OrganizationType
|
||||
} from './operations-api';
|
||||
@@ -36,11 +39,17 @@ function organizationTypeLabel(type: OrganizationType) {
|
||||
export function OrganizationsPage() {
|
||||
const { toast } = useToast();
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
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 [licenseTarget, setLicenseTarget] = useState<{ organization: Organization; licenseCode: string } | null>(null);
|
||||
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
@@ -48,9 +57,10 @@ export function OrganizationsPage() {
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
loadOrganizations()
|
||||
.then((items) => {
|
||||
setOrganizations(items);
|
||||
loadOrganizationsPage({ page, limit: 10, keyword, type: filterValues.type })
|
||||
.then((result) => {
|
||||
setOrganizations(result.items);
|
||||
setTotal(result.total);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
@@ -63,7 +73,10 @@ export function OrganizationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
loadLicensePlans()
|
||||
.then(setLicensePlans)
|
||||
.catch(() => setLicensePlans([]));
|
||||
}, [page, keyword, filterValues.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOrganization) return;
|
||||
@@ -82,7 +95,7 @@ export function OrganizationsPage() {
|
||||
}, [menuOrganization]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => organizations.map((organization, index) => ({ ...organization, no: organizations.length - index })),
|
||||
() => organizations.map((organization) => ({ ...organization, no: organization.no ?? 0 })),
|
||||
[organizations]
|
||||
);
|
||||
|
||||
@@ -101,6 +114,7 @@ export function OrganizationsPage() {
|
||||
)
|
||||
},
|
||||
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
||||
{ key: 'license', header: '라이센스', className: 'w-36 px-5 py-3', cell: (row) => row.defaultLicenseCode ? <StatusBadge status={row.defaultLicenseName ?? row.defaultLicenseCode} tone="green" /> : <span className="text-xs text-muted-foreground">-</span> },
|
||||
{ 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> },
|
||||
{
|
||||
@@ -241,6 +255,20 @@ export function OrganizationsPage() {
|
||||
minWidth={980}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{menuOrganization ? (() => {
|
||||
const row = rows.find((item) => item.id === menuOrganization.id);
|
||||
@@ -248,6 +276,16 @@ export function OrganizationsPage() {
|
||||
|
||||
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);
|
||||
setLicenseTarget({ organization: row, licenseCode: row.defaultLicenseCode ?? '' });
|
||||
}}
|
||||
>
|
||||
라이센스 설정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
@@ -271,6 +309,62 @@ export function OrganizationsPage() {
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
<Modal
|
||||
open={Boolean(licenseTarget)}
|
||||
title="조직 라이센스 설정"
|
||||
onClose={() => setLicenseTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setLicenseTarget(null)} disabled={saving}>취소</Button>
|
||||
<Button
|
||||
disabled={saving || !licenseTarget}
|
||||
onClick={() => {
|
||||
if (!licenseTarget) return;
|
||||
setSaving(true);
|
||||
updateOrganizationLicense(licenseTarget.organization.id, licenseTarget.licenseCode || null)
|
||||
.then((result) => {
|
||||
toast({
|
||||
type: 'success',
|
||||
title: '라이센스 설정 완료',
|
||||
message: `${result.updatedUserCount}명의 사용자 라이센스를 갱신했습니다.`
|
||||
});
|
||||
setLicenseTarget(null);
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '라이센스 설정 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}}
|
||||
>
|
||||
{saving ? '저장 중' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{licenseTarget ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-background/70 px-3 py-2 text-sm">
|
||||
<p className="font-semibold text-foreground">{licenseTarget.organization.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">저장하면 해당 조직에 소속된 사용자의 현재 라이센스가 선택한 라이센스로 갱신됩니다.</p>
|
||||
</div>
|
||||
<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={licenseTarget.licenseCode}
|
||||
onChange={(event) => setLicenseTarget((prev) => prev ? { ...prev, licenseCode: event.target.value } : prev)}
|
||||
>
|
||||
<option value="">없음</option>
|
||||
{licensePlans.map((plan) => (
|
||||
<option key={plan.code} value={plan.code}>{plan.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editing ? '조직 수정' : '조직 추가'}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
createManagedUser,
|
||||
loadLicensePlans,
|
||||
loadOrganizations,
|
||||
loadUserSessions,
|
||||
loadUserSessionsPage,
|
||||
lockUser,
|
||||
terminateUserSession,
|
||||
unlockUser,
|
||||
@@ -32,6 +32,10 @@ const emptyCreateForm = {
|
||||
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);
|
||||
@@ -45,9 +49,10 @@ export function UserSessionsPage() {
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
loadUserSessions()
|
||||
.then((nextSessions) => {
|
||||
setSessions(nextSessions);
|
||||
loadUserSessionsPage({ page, limit: 6, keyword, state: filterValues.state })
|
||||
.then((result) => {
|
||||
setSessions(result.items);
|
||||
setTotal(result.total);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -71,7 +76,7 @@ export function UserSessionsPage() {
|
||||
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
||||
}));
|
||||
});
|
||||
}, []);
|
||||
}, [page, keyword, filterValues.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuSession) return;
|
||||
@@ -89,7 +94,7 @@ export function UserSessionsPage() {
|
||||
};
|
||||
}, [menuSession]);
|
||||
|
||||
const rows = useMemo(() => sessions.map((session, index) => ({ ...session, no: sessions.length - index })), [sessions]);
|
||||
const rows = sessions;
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
||||
() => [
|
||||
@@ -140,9 +145,9 @@ export function UserSessionsPage() {
|
||||
|
||||
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 }
|
||||
{ key: 'state', label: '상태', options: ['유효토큰', '세션 없음', '잠김', '만료', '종료'].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||
],
|
||||
[sessions]
|
||||
[]
|
||||
);
|
||||
|
||||
const openCreateModal = () => {
|
||||
@@ -208,6 +213,20 @@ export function UserSessionsPage() {
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import { apiRequest } from '../../lib/api';
|
||||
|
||||
export type PageParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
|
||||
export type PagedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
function pageQuery(params?: PageParams) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params ?? {}).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') searchParams.set(key, String(value));
|
||||
});
|
||||
const query = searchParams.toString();
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
export type AdminSession = {
|
||||
id?: string;
|
||||
revokedAt?: string | null;
|
||||
@@ -13,6 +36,7 @@ export type AdminSshKey = {
|
||||
|
||||
export type AdminAccount = {
|
||||
id: string;
|
||||
no?: number;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
@@ -64,6 +88,8 @@ export type Organization = {
|
||||
name: string;
|
||||
type: OrganizationType;
|
||||
domain?: string | null;
|
||||
defaultLicenseCode?: string | null;
|
||||
defaultLicenseName?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -86,18 +112,24 @@ export type LauncherNotice = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export async function loadAdmins() {
|
||||
export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
|
||||
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
||||
appPermissions?: Array<string | { appId: string }>;
|
||||
};
|
||||
const response = await apiRequest<RawAdminAccount[] | { admins?: RawAdminAccount[] }>('/admins');
|
||||
const response = await apiRequest<RawAdminAccount[] | { admins?: RawAdminAccount[]; total?: number; page?: number; limit?: number }>(`/admins${pageQuery(params)}`);
|
||||
const admins = Array.isArray(response) ? response : response.admins ?? [];
|
||||
return admins.map((admin) => ({
|
||||
const items = admins.map((admin) => ({
|
||||
...admin,
|
||||
appPermissions: (admin.appPermissions ?? []).map((permission) => (
|
||||
typeof permission === 'string' ? permission : permission.appId
|
||||
))
|
||||
}));
|
||||
if (Array.isArray(response)) return { items, total: items.length, page: params?.page ?? 1, limit: params?.limit ?? items.length };
|
||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||
}
|
||||
|
||||
export async function loadAdmins() {
|
||||
return (await loadAdminsPage({ limit: 1000 })).items;
|
||||
}
|
||||
|
||||
export function loadAdminRoles() {
|
||||
@@ -165,13 +197,18 @@ export function updateAdminAppPermissions(adminId: string, appIds: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadUserSessions() {
|
||||
const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions');
|
||||
return (response.sessions ?? []).map((session) => ({
|
||||
export async function loadUserSessionsPage(params?: PageParams & { state?: string }): Promise<PagedResponse<UserSession>> {
|
||||
const response = await apiRequest<{ sessions?: UserSession[]; total?: number; page?: number; limit?: number }>(`/users/sessions${pageQuery(params)}`);
|
||||
const items = (response.sessions ?? []).map((session) => ({
|
||||
...session,
|
||||
session: session.sessionId ? session.session : '-',
|
||||
state: session.state
|
||||
}));
|
||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||
}
|
||||
|
||||
export async function loadUserSessions() {
|
||||
return (await loadUserSessionsPage({ limit: 1000 })).items;
|
||||
}
|
||||
|
||||
export function lockUser(userId: string) {
|
||||
@@ -207,9 +244,14 @@ export function createManagedUser(payload: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadOrganizationsPage(params?: PageParams & { type?: string }): Promise<PagedResponse<Organization>> {
|
||||
const response = await apiRequest<{ organizations?: Organization[]; total?: number; page?: number; limit?: number }>(`/organizations${pageQuery(params)}`);
|
||||
const items = response.organizations ?? [];
|
||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||
}
|
||||
|
||||
export async function loadOrganizations() {
|
||||
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
|
||||
return response.organizations ?? [];
|
||||
return (await loadOrganizationsPage({ limit: 1000 })).items;
|
||||
}
|
||||
|
||||
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) {
|
||||
@@ -232,9 +274,21 @@ export function deleteOrganization(organizationId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOrganizationLicense(organizationId: string, licenseCode: string | null) {
|
||||
return apiRequest<{ organization: Organization; updatedUserCount: number }>(`/organizations/${organizationId}/license`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ licenseCode })
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadLauncherNoticesPage(params?: PageParams & { appKey?: string }): Promise<PagedResponse<LauncherNotice>> {
|
||||
const response = await apiRequest<{ notices?: LauncherNotice[]; total?: number; page?: number; limit?: number }>(`/notices${pageQuery(params)}`);
|
||||
const items = response.notices ?? [];
|
||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||
}
|
||||
|
||||
export async function loadLauncherNotices() {
|
||||
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
|
||||
return response.notices ?? [];
|
||||
return (await loadLauncherNoticesPage({ limit: 1000 })).items;
|
||||
}
|
||||
|
||||
export function createLauncherNotice(payload: {
|
||||
@@ -323,6 +377,19 @@ export async function loadLicensePlans() {
|
||||
return response.plans ?? [];
|
||||
}
|
||||
|
||||
export function updateLicensePlan(licenseCode: string, payload: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
defaultDurationDays?: number | null;
|
||||
isActive?: boolean;
|
||||
permissions?: Array<{ appKey: string; canLaunch: boolean }>;
|
||||
}) {
|
||||
return apiRequest<{ plan: LicensePlan; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.keyword) searchParams.set('keyword', params.keyword);
|
||||
@@ -332,6 +399,27 @@ export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?
|
||||
return response.users ?? [];
|
||||
}
|
||||
|
||||
export async function loadLicenseAssignments(params?: PageParams & { licenseCode?: string; status?: string }): Promise<PagedResponse<LicenseAssignment>> {
|
||||
const response = await apiRequest<{ assignments?: LicenseAssignment[]; total?: number; page?: number; limit?: number }>(`/licenses/assignments${pageQuery(params)}`);
|
||||
const items = response.assignments ?? [];
|
||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||
}
|
||||
|
||||
export type LicenseAssignment = {
|
||||
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;
|
||||
};
|
||||
|
||||
export function assignUserLicense(userId: string, payload: {
|
||||
licenseCode: string;
|
||||
startsAt?: string;
|
||||
@@ -373,6 +461,33 @@ export type LauncherUserAppPermission = {
|
||||
assignedAt?: string | null;
|
||||
};
|
||||
|
||||
export type LicenseAppPermission = {
|
||||
appId: string;
|
||||
appKey: string;
|
||||
displayName: string;
|
||||
description?: string | null;
|
||||
sortOrder: number;
|
||||
isEnabled: boolean;
|
||||
policyId?: string | null;
|
||||
canView: boolean;
|
||||
canLaunch: boolean;
|
||||
};
|
||||
|
||||
export async function loadLicenseAppPermissions(licenseCode: string) {
|
||||
const response = await apiRequest<{ permissions?: LicenseAppPermission[] }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`);
|
||||
return response.permissions ?? [];
|
||||
}
|
||||
|
||||
export function updateLicenseAppPermissions(licenseCode: string, permissions: Array<{
|
||||
appKey: string;
|
||||
canLaunch: boolean;
|
||||
}>) {
|
||||
return apiRequest<{ permissions?: LicenseAppPermission[]; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ permissions })
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadLauncherUserAppPermissions(userId: string) {
|
||||
const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`);
|
||||
return response.permissions ?? [];
|
||||
|
||||
Reference in New Issue
Block a user