feat: add device management UI in admin user card

Show connected devices in subscription tab with ability to:
- View device platform, model, HWID and connection date
- Delete individual devices
- Reset all devices at once
This commit is contained in:
Fringg
2026-02-08 20:49:07 +03:00
parent 92d206f5b6
commit 6f31fbe6b5
6 changed files with 191 additions and 2 deletions

View File

@@ -563,4 +563,33 @@ export const adminUsersApi = {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`); const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
return response.data; return response.data;
}, },
// Get user devices
getUserDevices: async (
userId: number,
): Promise<{
devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[];
total: number;
device_limit: number;
}> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/devices`);
return response.data;
},
// Delete single device
deleteUserDevice: async (
userId: number,
hwid: string,
): Promise<{ success: boolean; message: string; deleted_hwid: string | null }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices/${hwid}`);
return response.data;
},
// Reset all devices
resetUserDevices: async (
userId: number,
): Promise<{ success: boolean; message: string; deleted_count: number }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices`);
return response.data;
},
}; };

View File

@@ -1909,6 +1909,13 @@
"nodeUsage": "Node usage", "nodeUsage": "Node usage",
"copied": "Copied", "copied": "Copied",
"panelNotFound": "User not found in panel", "panelNotFound": "User not found in panel",
"devices": {
"title": "Devices",
"none": "No connected devices",
"resetAll": "Reset all",
"deleted": "Device removed",
"allDeleted": "All devices reset"
},
"referral": { "referral": {
"title": "Referral program", "title": "Referral program",
"referrals": "Referrals", "referrals": "Referrals",

View File

@@ -1612,6 +1612,13 @@
"nodeUsage": "مصرف نودها", "nodeUsage": "مصرف نودها",
"copied": "کپی شد", "copied": "کپی شد",
"panelNotFound": "کاربر در پنل یافت نشد", "panelNotFound": "کاربر در پنل یافت نشد",
"devices": {
"title": "دستگاه‌ها",
"none": "دستگاه متصلی وجود ندارد",
"resetAll": "بازنشانی همه",
"deleted": "دستگاه حذف شد",
"allDeleted": "همه دستگاه‌ها بازنشانی شدند"
},
"referral": { "referral": {
"title": "برنامه ارجاع", "title": "برنامه ارجاع",
"referrals": "ارجاعات", "referrals": "ارجاعات",

View File

@@ -2435,6 +2435,13 @@
"nodeUsage": "Расход по нодам", "nodeUsage": "Расход по нодам",
"copied": "Скопировано", "copied": "Скопировано",
"panelNotFound": "Пользователь не найден в панели", "panelNotFound": "Пользователь не найден в панели",
"devices": {
"title": "Устройства",
"none": "Нет подключённых устройств",
"resetAll": "Сбросить все",
"deleted": "Устройство удалено",
"allDeleted": "Все устройства сброшены"
},
"referral": { "referral": {
"title": "Реферальная программа", "title": "Реферальная программа",
"referrals": "Рефералов", "referrals": "Рефералов",

View File

@@ -1611,6 +1611,13 @@
"nodeUsage": "节点用量", "nodeUsage": "节点用量",
"copied": "已复制", "copied": "已复制",
"panelNotFound": "面板中未找到用户", "panelNotFound": "面板中未找到用户",
"devices": {
"title": "设备",
"none": "没有已连接的设备",
"resetAll": "重置全部",
"deleted": "设备已删除",
"allDeleted": "所有设备已重置"
},
"referral": { "referral": {
"title": "推荐计划", "title": "推荐计划",
"referrals": "推荐人数", "referrals": "推荐人数",

View File

@@ -195,6 +195,14 @@ export default function AdminUserDetail() {
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24); const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
const [offerSending, setOfferSending] = useState(false); const [offerSending, setOfferSending] = useState(false);
// Devices
const [devices, setDevices] = useState<
{ hwid: string; platform: string; device_model: string; created_at: string | null }[]
>([]);
const [devicesTotal, setDevicesTotal] = useState(0);
const [deviceLimit, setDeviceLimit] = useState(0);
const [devicesLoading, setDevicesLoading] = useState(false);
const userId = id ? parseInt(id, 10) : null; const userId = id ? parseInt(id, 10) : null;
const loadUser = useCallback(async () => { const loadUser = useCallback(async () => {
@@ -293,9 +301,24 @@ export default function AdminUserDetail() {
} }
}, [userId]); }, [userId]);
const loadDevices = useCallback(async () => {
if (!userId) return;
try {
setDevicesLoading(true);
const data = await adminUsersApi.getUserDevices(userId);
setDevices(data.devices);
setDevicesTotal(data.total);
setDeviceLimit(data.device_limit);
} catch {
// ignore
} finally {
setDevicesLoading(false);
}
}, [userId]);
const loadSubscriptionData = useCallback(async () => { const loadSubscriptionData = useCallback(async () => {
await Promise.all([loadPanelInfo(), loadNodeUsage()]); await Promise.all([loadPanelInfo(), loadNodeUsage(), loadDevices()]);
}, [loadPanelInfo, loadNodeUsage]); }, [loadPanelInfo, loadNodeUsage, loadDevices]);
const loadPromoGroups = useCallback(async () => { const loadPromoGroups = useCallback(async () => {
try { try {
@@ -493,6 +516,34 @@ export default function AdminUserDetail() {
} }
}; };
const handleDeleteDevice = async (hwid: string) => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.deleteUserDevice(userId, hwid);
notify.success(t('admin.users.detail.devices.deleted'));
await loadDevices();
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleResetDevices = async () => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.resetUserDevices(userId);
notify.success(t('admin.users.detail.devices.allDeleted'));
await loadDevices();
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleChangePromoGroup = async (groupId: number | null) => { const handleChangePromoGroup = async (groupId: number | null) => {
if (!userId) return; if (!userId) return;
setActionLoading(true); setActionLoading(true);
@@ -1453,6 +1504,87 @@ export default function AdminUserDetail() {
</div> </div>
</> </>
) : null} ) : null}
{/* Devices */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.devices.title')} ({devicesTotal}/{deviceLimit})
</span>
<div className="flex items-center gap-2">
<button
onClick={() => loadDevices()}
className="rounded-lg p-1 text-dark-500 transition-colors hover:text-dark-300"
title={t('common.refresh')}
>
<RefreshIcon className="h-3.5 w-3.5" />
</button>
{devices.length > 0 && (
<button
onClick={() => handleInlineConfirm('resetDevices', handleResetDevices)}
disabled={actionLoading}
className={`rounded-lg px-2 py-1 text-xs font-medium transition-all disabled:opacity-50 ${
confirmingAction === 'resetDevices'
? 'bg-error-500 text-white'
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
}`}
>
{confirmingAction === 'resetDevices'
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.detail.devices.resetAll')}
</button>
)}
</div>
</div>
{devicesLoading ? (
<div className="flex justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : devices.length > 0 ? (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.hwid}
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-dark-200">
{device.platform || device.device_model || device.hwid.slice(0, 12)}
</div>
<div className="flex items-center gap-2 text-[10px] text-dark-500">
{device.device_model && device.platform && (
<span>{device.device_model}</span>
)}
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
{device.created_at && (
<span>{new Date(device.created_at).toLocaleDateString(locale)}</span>
)}
</div>
</div>
<button
onClick={() =>
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
handleDeleteDevice(device.hwid),
)
}
disabled={actionLoading}
className={`ml-2 shrink-0 rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
confirmingAction === `deleteDevice_${device.hwid}`
? 'bg-error-500 text-white'
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
}`}
>
{confirmingAction === `deleteDevice_${device.hwid}` ? '?' : '\u00D7'}
</button>
</div>
))}
</div>
) : (
<div className="py-2 text-center text-xs text-dark-500">
{t('admin.users.detail.devices.none')}
</div>
)}
</div>
</div> </div>
)} )}