diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index c348e46..388a79b 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -719,7 +719,13 @@ export const adminUsersApi = { userId: number, subscriptionId?: number, ): Promise<{ - devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[]; + devices: { + hwid: string; + platform: string; + device_model: string; + created_at: string | null; + local_name?: string | null; + }[]; total: number; device_limit: number; }> => { @@ -729,6 +735,19 @@ export const adminUsersApi = { return response.data; }, + // Set / clear local device alias on behalf of the user (admin override). + renameUserDevice: async ( + userId: number, + hwid: string, + name: string | null, + ): Promise<{ hwid: string; local_name: string | null }> => { + const response = await apiClient.patch( + `/cabinet/admin/users/${userId}/devices/${encodeURIComponent(hwid)}/name`, + { name }, + ); + return response.data; + }, + // Delete single device deleteUserDevice: async ( userId: number, diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 832954d..c80bad4 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -264,6 +264,7 @@ export const subscriptionApi = { platform: string; device_model: string; created_at: string | null; + local_name?: string | null; }>; total: number; device_limit: number; @@ -275,6 +276,27 @@ export const subscriptionApi = { return response.data; }, + /** + * Persist a user-local alias for a device. Pass an empty/null `name` to + * clear the alias. Returns the final `local_name` after server-side + * normalization (trimmed + length-capped). + * + * Scope is per-(user, hwid), so the same alias appears across all of + * the user's subscriptions in multi-tariff mode. + */ + renameDevice: async ( + hwid: string, + name: string | null, + subscriptionId?: number, + ): Promise<{ hwid: string; local_name: string | null }> => { + const response = await apiClient.patch( + `/cabinet/subscription/devices/${encodeURIComponent(hwid)}/name`, + { name }, + withSubId(subscriptionId), + ); + return response.data; + }, + deleteDevice: async ( hwid: string, subscriptionId?: number, diff --git a/src/locales/ru.json b/src/locales/ru.json index 394b2a0..f6c5cac 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -492,6 +492,11 @@ "confirmDeleteAllDevices": "Удалить все устройства?", "deviceDeleted": "Устройство удалено", "allDevicesDeleted": "Все устройства удалены", + "renameDevice": "Переименовать", + "renameDeviceSave": "Сохранить", + "renameDeviceCancel": "Отмена", + "renameDevicePlaceholder": "Имя устройства", + "deviceRenamed": "Имя устройства обновлено", "platform": "Платформа", "model": "Модель", "connectedAt": "Подключено", @@ -3581,7 +3586,10 @@ "none": "Нет подключённых устройств", "resetAll": "Сбросить все", "deleted": "Устройство удалено", - "allDeleted": "Все устройства сброшены" + "allDeleted": "Все устройства сброшены", + "rename": "Переименовать", + "renameSave": "Сохранить", + "renamed": "Имя устройства обновлено" }, "referral": { "title": "Реферальная программа", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index d1fc4f5..59d8e33 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -338,11 +338,20 @@ export default function AdminUserDetail() { // Devices const [devices, setDevices] = useState< - { hwid: string; platform: string; device_model: string; created_at: string | null }[] + { + hwid: string; + platform: string; + device_model: string; + created_at: string | null; + local_name?: string | null; + }[] >([]); const [devicesTotal, setDevicesTotal] = useState(0); const [deviceLimit, setDeviceLimit] = useState(0); const [devicesLoading, setDevicesLoading] = useState(false); + const [editingDeviceHwid, setEditingDeviceHwid] = useState(null); + const [editingDeviceName, setEditingDeviceName] = useState(''); + const [renameSaving, setRenameSaving] = useState(false); // Gifts const [giftsData, setGiftsData] = useState(null); @@ -760,6 +769,25 @@ export default function AdminUserDetail() { } }; + // Admin renames a device on behalf of the user. Empty/whitespace input + // clears the alias and falls back to the platform/model default. + const handleRenameDevice = async (hwid: string) => { + if (!userId) return; + setRenameSaving(true); + try { + const trimmed = editingDeviceName.trim(); + await adminUsersApi.renameUserDevice(userId, hwid, trimmed || null); + notify.success(t('admin.users.detail.devices.renamed', 'Имя устройства обновлено')); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + await loadDevices(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setRenameSaving(false); + } + }; + const handleResetDevices = async () => { if (!userId) return; setActionLoading(true); @@ -2427,44 +2455,135 @@ export default function AdminUserDetail() { ) : devices.length > 0 ? (
- {devices.map((device) => ( -
-
-
- {device.platform || device.device_model || device.hwid.slice(0, 12)} -
-
- {device.device_model && device.platform && ( - {device.device_model} + {devices.map((device) => { + const isEditing = editingDeviceHwid === device.hwid; + // Display priority: alias \u2192 model \u2192 platform \u2192 hwid prefix. + const displayName = + (device.local_name && device.local_name.trim()) || + device.platform || + device.device_model || + device.hwid.slice(0, 12); + + return ( +
+
+ {isEditing ? ( + setEditingDeviceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleRenameDevice(device.hwid); + } else if (e.key === 'Escape') { + e.preventDefault(); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + } + }} + className="w-full rounded-md bg-dark-900/70 px-2 py-1 text-xs font-medium text-dark-50 outline-none ring-1 ring-dark-600/60 focus:ring-accent-500/50" + /> + ) : ( +
+ {displayName} +
)} - {device.hwid.slice(0, 8)}... - {device.created_at && ( - - {new Date(device.created_at).toLocaleDateString(locale)} - +
+ {device.device_model && device.platform && ( + {device.device_model} + )} + {device.hwid.slice(0, 8)}... + {device.created_at && ( + + {new Date(device.created_at).toLocaleDateString(locale)} + + )} +
+
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + )}
- -
- ))} + ); + })}
) : (
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 4e367cf..bd8f2d5 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -308,6 +308,23 @@ export default function Subscription() { }, }); + // Local device alias (rename) state. Only one device can be in edit-mode + // at a time — `editingDeviceHwid` doubles as both the toggle and the + // identifier of the row being edited. + const [editingDeviceHwid, setEditingDeviceHwid] = useState(null); + const [editingDeviceName, setEditingDeviceName] = useState(''); + const DEVICE_ALIAS_MAX_LENGTH = 64; + + const renameDeviceMutation = useMutation({ + mutationFn: ({ hwid, name }: { hwid: string; name: string | null }) => + subscriptionApi.renameDevice(hwid, name, subscriptionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + }, + }); + // Pause subscription mutation const pauseMutation = useMutation({ mutationFn: () => subscriptionApi.togglePause(subscriptionId), @@ -2403,73 +2420,203 @@ export default function Subscription() { ? `${devicesData.total} · ∞` : `${devicesData.total} / ${t('subscription.devices', { count: devicesData.device_limit })}`}
- {devicesData.devices.map((device) => ( -
-
-
-
+
+
- - + +
+
+ {isEditing ? ( + setEditingDeviceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const trimmed = editingDeviceName.trim(); + renameDeviceMutation.mutate({ + hwid: device.hwid, + name: trimmed || null, + }); + } else if (e.key === 'Escape') { + e.preventDefault(); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + } + }} + className="w-full rounded-md border-none bg-transparent px-2 py-1 text-sm font-semibold text-dark-50 outline-none focus:ring-1" + style={{ + background: g.trackBg, + boxShadow: `inset 0 0 0 1px ${g.innerBorder}`, + }} + /> + ) : ( +
+ {displayName} +
+ )} +
+ {device.platform} + + {device.hwid.slice(0, 8).toUpperCase()} + +
+
-
-
- {device.device_model || device.platform} -
-
- {device.platform} - - {device.hwid.slice(0, 8).toUpperCase()} - -
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )}
- -
- ))} + ); + })}
) : (
diff --git a/src/types/index.ts b/src/types/index.ts index 5b1abde..2208030 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -141,6 +141,12 @@ export interface Device { platform: string; device_model: string; created_at: string | null; + /** + * User-set local alias persisted in the bot DB (`user_device_aliases`). + * `null` when the user hasn't renamed the device — clients fall back + * to `device_model` / `platform` for display. + */ + local_name?: string | null; } export interface DevicesResponse {