merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-10 13:14:26 +09:00
7 changed files with 620 additions and 97 deletions
+38 -11
View File
@@ -48,7 +48,8 @@ export function DataTableCard<T>({
pageSize = 8, pageSize = 8,
minWidth = 980, minWidth = 980,
loading = false, loading = false,
emptyMessage = '데이터가 없습니다.' emptyMessage = '데이터가 없습니다.',
serverPagination
}: { }: {
title: string; title: string;
description?: string; description?: string;
@@ -62,12 +63,23 @@ export function DataTableCard<T>({
minWidth?: number; minWidth?: number;
loading?: boolean; loading?: boolean;
emptyMessage?: string; 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 [search, setSearch] = useState('');
const [filterValues, setFilterValues] = useState<Record<string, string>>({}); const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const serverMode = Boolean(serverPagination);
const activeFilterValues = serverPagination?.filterValues ?? filterValues;
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
if (serverMode) return rows;
const normalizedSearch = search.trim().toLowerCase(); const normalizedSearch = search.trim().toLowerCase();
return rows.filter((row) => { return rows.filter((row) => {
@@ -75,24 +87,37 @@ export function DataTableCard<T>({
? getSearchText(row).toLowerCase().includes(normalizedSearch) ? getSearchText(row).toLowerCase().includes(normalizedSearch)
: true; : true;
const matchesFilters = filters.every((filter) => { const matchesFilters = filters.every((filter) => {
const selected = filterValues[filter.key]; const selected = activeFilterValues[filter.key];
return !selected || filter.getValue(row) === selected; return !selected || filter.getValue(row) === selected;
}); });
return matchesSearch && matchesFilters; 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 totalCount = serverPagination?.total ?? filteredRows.length;
const currentPage = Math.min(page, totalPages); const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * 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 flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
const updateFilter = (key: string, value: string) => { const updateFilter = (key: string, value: string) => {
if (serverPagination?.onFilterChange) {
serverPagination.onFilterChange(key, value);
return;
}
setFilterValues((prev) => ({ ...prev, [key]: value })); setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1); setPage(1);
}; };
const movePage = (nextPage: number) => {
if (serverPagination) {
serverPagination.onPageChange(nextPage);
return;
}
setPage(nextPage);
};
return ( return (
<Card className="min-w-0 overflow-hidden"> <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"> <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} placeholder={searchPlaceholder}
value={search} value={search}
onChange={(event) => { onChange={(event) => {
setSearch(event.target.value); const value = event.target.value;
setSearch(value);
serverPagination?.onSearchChange?.(value);
setPage(1); setPage(1);
}} }}
/> />
@@ -120,7 +147,7 @@ export function DataTableCard<T>({
{filter.label} {filter.label}
<select <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" 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)} onChange={(event) => updateFilter(filter.key, event.target.value)}
> >
<option value="">전체</option> <option value="">전체</option>
@@ -210,14 +237,14 @@ export function DataTableCard<T>({
</div> </div>
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm"> <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"> <span className="text-xs text-muted-foreground">
{loading ? '불러오는 중' : `총 ${filteredRows.length}건 · ${currentPage} / ${totalPages}`} {loading ? '불러오는 중' : `총 ${totalCount}건 · ${currentPage} / ${totalPages}`}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
className="h-8 px-3 text-[13px]" className="h-8 px-3 text-[13px]"
disabled={currentPage <= 1} disabled={currentPage <= 1}
onClick={() => setPage((prev) => Math.max(1, prev - 1))} onClick={() => movePage(Math.max(1, currentPage - 1))}
> >
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
이전 이전
@@ -226,7 +253,7 @@ export function DataTableCard<T>({
variant="outline" variant="outline"
className="h-8 px-3 text-[13px]" className="h-8 px-3 text-[13px]"
disabled={currentPage >= totalPages} disabled={currentPage >= totalPages}
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))} onClick={() => movePage(Math.min(totalPages, currentPage + 1))}
> >
다음 다음
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
+31 -9
View File
@@ -13,7 +13,7 @@ import {
deleteAdminSshKey, deleteAdminSshKey,
loadAdminAppPermissions, loadAdminAppPermissions,
loadAdminRoles, loadAdminRoles,
loadAdmins, loadAdminsPage,
resetAdminPassword, resetAdminPassword,
updateAdminAppPermissions, updateAdminAppPermissions,
updateAdminRole, updateAdminRole,
@@ -44,6 +44,10 @@ const fallbackAdminRoles: AdminRoleOption[] = [
export function AdminManagementPage() { export function AdminManagementPage() {
const { toast } = useToast(); const { toast } = useToast();
const [admins, setAdmins] = useState<AdminAccount[]>([]); 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 [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -75,9 +79,13 @@ export function AdminManagementPage() {
const refresh = () => { const refresh = () => {
setLoading(true); setLoading(true);
Promise.all([loadAdmins(), loadAdminRoles().catch(() => fallbackAdminRoles)]) Promise.all([
.then(([nextAdmins, nextRoles]) => { loadAdminsPage({ page, limit: 6, keyword, role: filterValues.role, state: filterValues.state }),
setAdmins(nextAdmins); loadAdminRoles().catch(() => fallbackAdminRoles)
])
.then(([result, nextRoles]) => {
setAdmins(result.items);
setTotal(result.total);
setAdminRoles(nextRoles.filter((role) => role.isActive)); setAdminRoles(nextRoles.filter((role) => role.isActive));
setFailed(false); setFailed(false);
}) })
@@ -91,7 +99,7 @@ export function AdminManagementPage() {
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); }, [page, keyword, filterValues.role, filterValues.state]);
useEffect(() => { useEffect(() => {
if (!menuAdmin) return; if (!menuAdmin) return;
@@ -110,7 +118,7 @@ export function AdminManagementPage() {
}, [menuAdmin]); }, [menuAdmin]);
const rows = useMemo<AdminRow[]>( 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] [admins]
); );
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles; const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
@@ -318,10 +326,10 @@ export function AdminManagementPage() {
const filters = useMemo<Array<DataTableFilter<AdminRow>>>( 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: 'role', label: '권한', options: roleOptions.map((role) => ({ label: role.name, value: role.code })), 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: 'state', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '비활성', value: 'inactive' }], getValue: (row) => row.isActive ? 'active' : 'inactive' }
], ],
[rows] [roleOptions]
); );
return ( return (
@@ -354,6 +362,20 @@ export function AdminManagementPage() {
minWidth={1040} minWidth={1040}
loading={loading} loading={loading}
emptyMessage="등록된 관리자가 없습니다." emptyMessage="등록된 관리자가 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
{menuAdmin ? (() => { {menuAdmin ? (() => {
const row = rows.find((item) => item.id === menuAdmin.id); const row = rows.find((item) => item.id === menuAdmin.id);
+267 -45
View File
@@ -8,31 +8,23 @@ import { formatDateOnly } from '../../lib/date-format';
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils'; import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
import { import {
assignUserLicense, assignUserLicense,
loadLicenseAppPermissions,
loadLicenseAssignments,
loadLicensePlans, loadLicensePlans,
loadLicenseUsers, loadLicenseUsers,
loadLauncherUserAppPermissions, loadLauncherUserAppPermissions,
revokeUserLicense, revokeUserLicense,
updateLicensePlan,
updateLauncherUserAppPermissions, updateLauncherUserAppPermissions,
updateUserLicense, updateUserLicense,
type AuthLicenseUser, type AuthLicenseUser,
type LauncherUserAppPermission, type LauncherUserAppPermission,
type LicenseAppPermission,
type LicenseAssignment,
type LicensePlan type LicensePlan
} from './operations-api'; } from './operations-api';
type LicenseAssignmentRow = { type LicenseAssignmentRow = 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;
};
function today() { function today() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
@@ -59,28 +51,15 @@ function toDateInputValue(value?: string | null) {
return date.toISOString().slice(0, 10); 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() { export function LicensesPage() {
const { toast } = useToast(); const { toast } = useToast();
const [plans, setPlans] = useState<LicensePlan[]>([]); const [plans, setPlans] = useState<LicensePlan[]>([]);
const [users, setUsers] = useState<AuthLicenseUser[]>([]); 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 [loading, setLoading] = useState(true);
const [assignOpen, setAssignOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false);
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null); const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
@@ -88,8 +67,18 @@ export function LicensesPage() {
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null); const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]); const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
const [appPermissionLoading, setAppPermissionLoading] = useState(false); 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 [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 [saving, setSaving] = useState(false);
const [planForm, setPlanForm] = useState({
name: '',
description: '',
defaultDurationDays: ''
});
const [assignForm, setAssignForm] = useState({ const [assignForm, setAssignForm] = useState({
userId: '', userId: '',
licenseCode: '', licenseCode: '',
@@ -99,10 +88,6 @@ export function LicensesPage() {
const [userKeyword, setUserKeyword] = useState(''); const [userKeyword, setUserKeyword] = useState('');
const loadingVisible = useMinimumLoading(loading); 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 calculateExpiresAt = (licenseCode: string, startsAt: string) => {
const plan = plans.find((item) => item.code === licenseCode); const plan = plans.find((item) => item.code === licenseCode);
return addDays(startsAt, plan?.defaultDurationDays); return addDays(startsAt, plan?.defaultDurationDays);
@@ -120,8 +105,17 @@ export function LicensesPage() {
setLoading(true); setLoading(true);
try { try {
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]); const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
const nextAssignments = await loadLicenseAssignments({
page,
limit: 6,
keyword,
licenseCode: filterValues.licenseName,
status: filterValues.status,
});
setPlans(nextPlans); setPlans(nextPlans);
setUsers(nextUsers); setUsers(nextUsers);
setRows(nextAssignments.items);
setTotal(nextAssignments.total);
setAssignForm((prev) => ({ setAssignForm((prev) => ({
...prev, ...prev,
licenseCode: prev.licenseCode || nextPlans[0]?.code || '', licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
@@ -136,14 +130,17 @@ export function LicensesPage() {
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
}, []); }, [page, keyword, filterValues.licenseName, filterValues.status]);
useEffect(() => { useEffect(() => {
if (!menuRow) return; if (!menuRow && !planMenu) return;
const close = () => setMenuRow(null); const close = () => {
setMenuRow(null);
setPlanMenu(null);
};
const closeOnEscape = (event: KeyboardEvent) => { const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMenuRow(null); if (event.key === 'Escape') close();
}; };
window.addEventListener('click', close); window.addEventListener('click', close);
@@ -152,7 +149,7 @@ export function LicensesPage() {
window.removeEventListener('click', close); window.removeEventListener('click', close);
window.removeEventListener('keydown', closeOnEscape); window.removeEventListener('keydown', closeOnEscape);
}; };
}, [menuRow]); }, [menuRow, planMenu]);
const openAssignModal = () => { const openAssignModal = () => {
const startsAt = today(); 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>>>( 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: '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>>>( const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
() => [ () => [
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.name })), getValue: (row) => row.licenseName }, { key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.code })), getValue: (row) => row.code },
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status } { key: 'status', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '만료', value: 'expired' }], getValue: (row) => row.status === '활성' ? 'active' : 'expired' }
], ],
[plans, rows] [plans]
); );
return ( return (
@@ -354,7 +418,22 @@ export function LicensesPage() {
<p className="text-sm text-foreground">{plan.name}</p> <p className="text-sm text-foreground">{plan.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p> <p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
</div> </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>
<div className="mt-5 grid grid-cols-3 gap-3"> <div className="mt-5 grid grid-cols-3 gap-3">
<div> <div>
@@ -379,6 +458,31 @@ export function LicensesPage() {
</Card> </Card>
) : null} ) : null}
</div> </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"> <div className="mt-4">
<DataTableCard <DataTableCard
title="목록" title="목록"
@@ -392,6 +496,20 @@ export function LicensesPage() {
minWidth={980} minWidth={980}
loading={loading} loading={loading}
emptyMessage="부여된 라이센스가 없습니다." emptyMessage="부여된 라이센스가 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
</div> </div>
@@ -550,6 +668,110 @@ export function LicensesPage() {
</div> </div>
</div> </div>
</Modal> </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 <Modal
open={Boolean(deleteTarget)} open={Boolean(deleteTarget)}
title={ title={
+32 -8
View File
@@ -9,7 +9,7 @@ import { listApps } from '../applications/application-api';
import { import {
createLauncherNotice, createLauncherNotice,
deleteLauncherNotice, deleteLauncherNotice,
loadLauncherNotices, loadLauncherNoticesPage,
updateLauncherNotice, updateLauncherNotice,
type LauncherNotice type LauncherNotice
} from './operations-api'; } from './operations-api';
@@ -123,6 +123,10 @@ export function NoticesPage() {
const { toast } = useToast(); const { toast } = useToast();
const [notices, setNotices] = useState<LauncherNotice[]>([]); const [notices, setNotices] = useState<LauncherNotice[]>([]);
const [apps, setApps] = useState<AppWithComponents[]>([]); 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 [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -136,11 +140,12 @@ export function NoticesPage() {
const refresh = () => { const refresh = () => {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
loadLauncherNotices(), loadLauncherNoticesPage({ page, limit: 6, keyword, appKey: filterValues.appKey }),
listApps().catch(() => []) listApps().catch(() => [])
]) ])
.then(([items, appItems]) => { .then(([result, appItems]) => {
setNotices(items); setNotices(result.items);
setTotal(result.total);
setApps(appItems); setApps(appItems);
setFailed(false); setFailed(false);
}) })
@@ -154,7 +159,7 @@ export function NoticesPage() {
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); }, [page, keyword, filterValues.appKey]);
useEffect(() => { useEffect(() => {
if (!menuNotice) return; if (!menuNotice) return;
@@ -184,7 +189,7 @@ export function NoticesPage() {
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey; const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
const rows = useMemo( 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] [appNameByKey, notices]
); );
@@ -303,9 +308,14 @@ export function NoticesPage() {
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>( 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 ( return (
@@ -338,6 +348,20 @@ export function NoticesPage() {
minWidth={1040} minWidth={1040}
loading={loading} loading={loading}
emptyMessage="등록된 공지사항이 없습니다." emptyMessage="등록된 공지사항이 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
{menuNotice ? (() => { {menuNotice ? (() => {
+100 -6
View File
@@ -8,8 +8,11 @@ import { formatDateTime } from '../../lib/date-format';
import { import {
createOrganization, createOrganization,
deleteOrganization, deleteOrganization,
loadOrganizations, loadLicensePlans,
loadOrganizationsPage,
updateOrganization, updateOrganization,
updateOrganizationLicense,
type LicensePlan,
type Organization, type Organization,
type OrganizationType type OrganizationType
} from './operations-api'; } from './operations-api';
@@ -36,11 +39,17 @@ function organizationTypeLabel(type: OrganizationType) {
export function OrganizationsPage() { export function OrganizationsPage() {
const { toast } = useToast(); const { toast } = useToast();
const [organizations, setOrganizations] = useState<Organization[]>([]); 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 [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Organization | null>(null); const [editing, setEditing] = useState<Organization | null>(null);
const [deleteTarget, setDeleteTarget] = 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 [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
@@ -48,9 +57,10 @@ export function OrganizationsPage() {
const refresh = () => { const refresh = () => {
setLoading(true); setLoading(true);
loadOrganizations() loadOrganizationsPage({ page, limit: 10, keyword, type: filterValues.type })
.then((items) => { .then((result) => {
setOrganizations(items); setOrganizations(result.items);
setTotal(result.total);
setFailed(false); setFailed(false);
}) })
.catch((error: Error) => { .catch((error: Error) => {
@@ -63,7 +73,10 @@ export function OrganizationsPage() {
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); loadLicensePlans()
.then(setLicensePlans)
.catch(() => setLicensePlans([]));
}, [page, keyword, filterValues.type]);
useEffect(() => { useEffect(() => {
if (!menuOrganization) return; if (!menuOrganization) return;
@@ -82,7 +95,7 @@ export function OrganizationsPage() {
}, [menuOrganization]); }, [menuOrganization]);
const rows = useMemo( const rows = useMemo(
() => organizations.map((organization, index) => ({ ...organization, no: organizations.length - index })), () => organizations.map((organization) => ({ ...organization, no: organization.no ?? 0 })),
[organizations] [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: '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: '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> }, { 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} minWidth={980}
loading={loading} loading={loading}
emptyMessage="표시할 조직 데이터가 없습니다." emptyMessage="표시할 조직 데이터가 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
{menuOrganization ? (() => { {menuOrganization ? (() => {
const row = rows.find((item) => item.id === menuOrganization.id); const row = rows.find((item) => item.id === menuOrganization.id);
@@ -248,6 +276,16 @@ export function OrganizationsPage() {
return ( 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%)' }}> <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 <button
type="button" type="button"
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" 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> </div>
); );
})() : null} })() : 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 <Modal
open={modalOpen} open={modalOpen}
title={editing ? '조직 수정' : '조직 추가'} title={editing ? '조직 수정' : '조직 추가'}
+27 -8
View File
@@ -9,7 +9,7 @@ import {
createManagedUser, createManagedUser,
loadLicensePlans, loadLicensePlans,
loadOrganizations, loadOrganizations,
loadUserSessions, loadUserSessionsPage,
lockUser, lockUser,
terminateUserSession, terminateUserSession,
unlockUser, unlockUser,
@@ -32,6 +32,10 @@ const emptyCreateForm = {
export function UserSessionsPage() { export function UserSessionsPage() {
const { toast } = useToast(); const { toast } = useToast();
const [sessions, setSessions] = useState<UserSession[]>([]); 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 [organizations, setOrganizations] = useState<Organization[]>([]);
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]); const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -45,9 +49,10 @@ export function UserSessionsPage() {
const refresh = () => { const refresh = () => {
setLoading(true); setLoading(true);
loadUserSessions() loadUserSessionsPage({ page, limit: 6, keyword, state: filterValues.state })
.then((nextSessions) => { .then((result) => {
setSessions(nextSessions); setSessions(result.items);
setTotal(result.total);
setFailed(false); setFailed(false);
}) })
.catch(() => { .catch(() => {
@@ -71,7 +76,7 @@ export function UserSessionsPage() {
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO' licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
})); }));
}); });
}, []); }, [page, keyword, filterValues.state]);
useEffect(() => { useEffect(() => {
if (!menuSession) return; if (!menuSession) return;
@@ -89,7 +94,7 @@ export function UserSessionsPage() {
}; };
}, [menuSession]); }, [menuSession]);
const rows = useMemo(() => sessions.map((session, index) => ({ ...session, no: sessions.length - index })), [sessions]); const rows = sessions;
const columns = useMemo<Array<DataTableColumn<UserSession>>>( const columns = useMemo<Array<DataTableColumn<UserSession>>>(
() => [ () => [
@@ -140,9 +145,9 @@ export function UserSessionsPage() {
const filters = useMemo<Array<DataTableFilter<UserSession>>>( 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 = () => { const openCreateModal = () => {
@@ -208,6 +213,20 @@ export function UserSessionsPage() {
minWidth={900} minWidth={900}
loading={loading} loading={loading}
emptyMessage="표시할 사용자/세션 데이터가 없습니다." emptyMessage="표시할 사용자/세션 데이터가 없습니다."
serverPagination={{
page,
total,
onPageChange: setPage,
onSearchChange: (value) => {
setKeyword(value);
setPage(1);
},
filterValues,
onFilterChange: (key, value) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
}
}}
/> />
{menuSession ? (() => { {menuSession ? (() => {
const row = rows.find((item) => item.id === menuSession.id); const row = rows.find((item) => item.id === menuSession.id);
+125 -10
View File
@@ -1,5 +1,28 @@
import { apiRequest } from '../../lib/api'; 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 = { export type AdminSession = {
id?: string; id?: string;
revokedAt?: string | null; revokedAt?: string | null;
@@ -13,6 +36,7 @@ export type AdminSshKey = {
export type AdminAccount = { export type AdminAccount = {
id: string; id: string;
no?: number;
email: string; email: string;
name: string; name: string;
avatarUrl?: string | null; avatarUrl?: string | null;
@@ -64,6 +88,8 @@ export type Organization = {
name: string; name: string;
type: OrganizationType; type: OrganizationType;
domain?: string | null; domain?: string | null;
defaultLicenseCode?: string | null;
defaultLicenseName?: string | null;
createdAt: string; createdAt: string;
}; };
@@ -86,18 +112,24 @@ export type LauncherNotice = {
updatedAt?: string; updatedAt?: string;
}; };
export async function loadAdmins() { export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & { type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
appPermissions?: Array<string | { appId: string }>; 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 ?? []; const admins = Array.isArray(response) ? response : response.admins ?? [];
return admins.map((admin) => ({ const items = admins.map((admin) => ({
...admin, ...admin,
appPermissions: (admin.appPermissions ?? []).map((permission) => ( appPermissions: (admin.appPermissions ?? []).map((permission) => (
typeof permission === 'string' ? permission : permission.appId 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() { export function loadAdminRoles() {
@@ -165,13 +197,18 @@ export function updateAdminAppPermissions(adminId: string, appIds: string[]) {
}); });
} }
export async function loadUserSessions() { export async function loadUserSessionsPage(params?: PageParams & { state?: string }): Promise<PagedResponse<UserSession>> {
const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions'); const response = await apiRequest<{ sessions?: UserSession[]; total?: number; page?: number; limit?: number }>(`/users/sessions${pageQuery(params)}`);
return (response.sessions ?? []).map((session) => ({ const items = (response.sessions ?? []).map((session) => ({
...session, ...session,
session: session.sessionId ? session.session : '-', session: session.sessionId ? session.session : '-',
state: session.state 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) { 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() { export async function loadOrganizations() {
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations'); return (await loadOrganizationsPage({ limit: 1000 })).items;
return response.organizations ?? [];
} }
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) { 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() { export async function loadLauncherNotices() {
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices'); return (await loadLauncherNoticesPage({ limit: 1000 })).items;
return response.notices ?? [];
} }
export function createLauncherNotice(payload: { export function createLauncherNotice(payload: {
@@ -323,6 +377,19 @@ export async function loadLicensePlans() {
return response.plans ?? []; 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 }) { export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params?.keyword) searchParams.set('keyword', params.keyword); if (params?.keyword) searchParams.set('keyword', params.keyword);
@@ -332,6 +399,27 @@ export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?
return response.users ?? []; 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: { export function assignUserLicense(userId: string, payload: {
licenseCode: string; licenseCode: string;
startsAt?: string; startsAt?: string;
@@ -373,6 +461,33 @@ export type LauncherUserAppPermission = {
assignedAt?: string | null; 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) { export async function loadLauncherUserAppPermissions(userId: string) {
const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`); const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`);
return response.permissions ?? []; return response.permissions ?? [];