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