mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
feat: add device selection and removal functionality in SecurityDevicesPage
This commit is contained in:
@@ -1959,6 +1959,7 @@ export default function App() {
|
||||
lockTimeoutMinutes,
|
||||
sessionTimeoutAction,
|
||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||
currentDeviceIdentifier: getCurrentDeviceIdentifier(),
|
||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
||||
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
|
||||
@@ -2031,6 +2032,7 @@ export default function App() {
|
||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
|
||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||
onRemoveSelectedDevices: accountSecurityActions.openRemoveSelectedDevices,
|
||||
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
|
||||
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
|
||||
onRefreshAdmin: adminActions.refreshAdmin,
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface AppMainRoutesProps {
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
currentDeviceIdentifier: string;
|
||||
authorizedDevicesLoading: boolean;
|
||||
authorizedDevicesError: string;
|
||||
domainRules: DomainRules | null;
|
||||
@@ -130,6 +131,7 @@ export interface AppMainRoutesProps {
|
||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
|
||||
onRevokeAllDeviceTrust: () => void;
|
||||
onRemoveAllDevices: () => void;
|
||||
onCreateInvite: (hours: number) => Promise<void>;
|
||||
@@ -347,6 +349,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SecurityDevicesPage
|
||||
devices={props.authorizedDevices}
|
||||
currentDeviceIdentifier={props.currentDeviceIdentifier}
|
||||
loading={props.authorizedDevicesLoading}
|
||||
error={props.authorizedDevicesError}
|
||||
pendingAuthRequests={props.pendingAuthRequests}
|
||||
@@ -359,6 +362,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||
onTrustPermanently={props.onTrustDevicePermanently}
|
||||
onRemoveDevice={props.onRemoveDevice}
|
||||
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
|
||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||
onRemoveAll={props.onRemoveAllDevices}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import { CheckSquare, Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
||||
@@ -8,6 +8,7 @@ import { t } from '@/lib/i18n';
|
||||
|
||||
interface SecurityDevicesPageProps {
|
||||
devices: AuthorizedDevice[];
|
||||
currentDeviceIdentifier: string;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
@@ -20,6 +21,7 @@ interface SecurityDevicesPageProps {
|
||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||
onTrustPermanently: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
|
||||
onRevokeAll: () => void;
|
||||
onRemoveAll: () => void;
|
||||
}
|
||||
@@ -62,6 +64,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
|
||||
const [deviceNote, setDeviceNote] = useState('');
|
||||
const [savingNote, setSavingNote] = useState(false);
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||
const currentDeviceIdentifier = props.currentDeviceIdentifier;
|
||||
const selectableDevices = props.devices.filter((device) => (
|
||||
device.identifier !== currentDeviceIdentifier
|
||||
));
|
||||
const selectedDeviceIdSet = new Set(selectedDeviceIds);
|
||||
const selectedDevices = selectableDevices.filter((device) => selectedDeviceIdSet.has(device.identifier));
|
||||
const allSelectableSelected = selectableDevices.length > 0 && selectedDevices.length === selectableDevices.length;
|
||||
|
||||
async function handleSaveDeviceNote(): Promise<void> {
|
||||
if (!editingDevice || savingNote) return;
|
||||
@@ -75,6 +85,19 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAllDevices(): void {
|
||||
setSelectedDeviceIds(allSelectableSelected ? [] : selectableDevices.map((device) => device.identifier));
|
||||
}
|
||||
|
||||
function toggleSelectedDevice(device: AuthorizedDevice): void {
|
||||
if (device.identifier === currentDeviceIdentifier) return;
|
||||
setSelectedDeviceIds((current) => (
|
||||
current.includes(device.identifier)
|
||||
? current.filter((id) => id !== device.identifier)
|
||||
: [...current, device.identifier]
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="stack">
|
||||
@@ -101,6 +124,27 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={props.loading || selectableDevices.length === 0}
|
||||
onClick={toggleSelectAllDevices}
|
||||
>
|
||||
<CheckSquare size={14} className="btn-icon" />
|
||||
{allSelectableSelected ? t('txt_clear_selection') : t('txt_select_all')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small"
|
||||
disabled={selectedDevices.length === 0}
|
||||
onClick={() => {
|
||||
props.onRemoveSelectedDevices(selectedDevices);
|
||||
setSelectedDeviceIds([]);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_remove_selected_devices', { count: selectedDevices.length })}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
{t('txt_revoke_all_trusted')}
|
||||
@@ -122,6 +166,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
)}
|
||||
<table className="table authorized-devices-table">
|
||||
<colgroup>
|
||||
<col className="authorized-devices-col-select" />
|
||||
<col className="authorized-devices-col-device" />
|
||||
<col className="authorized-devices-col-type" />
|
||||
<col className="authorized-devices-col-status" />
|
||||
@@ -132,6 +177,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('txt_select')}</th>
|
||||
<th>{t('txt_device')}</th>
|
||||
<th>{t('txt_type')}</th>
|
||||
<th>{t('txt_status')}</th>
|
||||
@@ -144,6 +190,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<tbody>
|
||||
{props.devices.map((device) => (
|
||||
<tr key={device.identifier}>
|
||||
<td data-label={t('txt_select')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="authorized-device-checkbox"
|
||||
checked={selectedDeviceIdSet.has(device.identifier)}
|
||||
disabled={device.identifier === currentDeviceIdentifier}
|
||||
aria-label={t('txt_select_device_name', { name: device.name || t('txt_unknown_device') })}
|
||||
onChange={() => toggleSelectedDevice(device)}
|
||||
/>
|
||||
</td>
|
||||
<td data-label={t('txt_device')}>
|
||||
<div>{device.name || t('txt_unknown_device')}</div>
|
||||
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
|
||||
@@ -216,14 +272,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
))}
|
||||
{props.loading && props.devices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<td colSpan={8}>
|
||||
<LoadingState lines={5} compact />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!props.loading && props.devices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<td colSpan={8}>
|
||||
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
changeMasterPassword,
|
||||
deleteAllAuthorizedDevices,
|
||||
deleteAuthorizedDevice,
|
||||
deleteAuthorizedDevices,
|
||||
deriveLoginHash,
|
||||
deleteAccountPasskey as deleteAccountPasskeyApi,
|
||||
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
||||
@@ -389,6 +390,38 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
});
|
||||
},
|
||||
|
||||
openRemoveSelectedDevices(devices: AuthorizedDevice[]) {
|
||||
const selectedDevices = devices.filter((device) => String(device.identifier || '').trim());
|
||||
if (selectedDevices.length === 0) {
|
||||
onNotify('warning', t('txt_no_devices_selected'));
|
||||
return;
|
||||
}
|
||||
const includesCurrentDevice = selectedDevices.some((device) => device.identifier === getCurrentDeviceIdentifier());
|
||||
onSetConfirm({
|
||||
title: t('txt_remove_selected_devices', { count: selectedDevices.length }),
|
||||
message: includesCurrentDevice
|
||||
? t('txt_remove_selected_devices_and_sign_out_current', { count: selectedDevices.length })
|
||||
: t('txt_remove_selected_devices_confirm', { count: selectedDevices.length }),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteAuthorizedDevices(authedFetch, selectedDevices);
|
||||
onNotify('success', t('txt_selected_devices_removed', { count: selectedDevices.length }));
|
||||
if (includesCurrentDevice) {
|
||||
onLogoutNow();
|
||||
return;
|
||||
}
|
||||
await refetchAuthorizedDevices();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_remove_selected_devices_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
openRevokeAllDeviceTrust() {
|
||||
onSetConfirm({
|
||||
title: t('txt_revoke_all_trusted_devices'),
|
||||
|
||||
@@ -885,6 +885,20 @@ export async function deleteAuthorizedDevice(
|
||||
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
||||
}
|
||||
|
||||
export async function deleteAuthorizedDevices(
|
||||
authedFetch: AuthedFetch,
|
||||
devices: Array<Pick<AuthorizedDevice, 'identifier' | 'hasStoredDevice'>>
|
||||
): Promise<void> {
|
||||
const uniqueDevices = Array.from(
|
||||
new Map(devices.map((device) => [String(device.identifier || '').trim(), device])).values()
|
||||
).filter((device) => String(device.identifier || '').trim());
|
||||
await Promise.all(uniqueDevices.map((device) => (
|
||||
device.hasStoredDevice === false
|
||||
? revokeAuthorizedDeviceTrust(authedFetch, device.identifier)
|
||||
: deleteAuthorizedDevice(authedFetch, device.identifier)
|
||||
)));
|
||||
}
|
||||
|
||||
export async function updateAuthorizedDeviceName(
|
||||
authedFetch: AuthedFetch,
|
||||
deviceIdentifier: string,
|
||||
|
||||
@@ -838,6 +838,11 @@ const en: Record<string, string> = {
|
||||
"txt_remove_all_devices": "Remove all devices",
|
||||
"txt_remove_all_devices_and_clear_all_2fa_trust": "Remove all devices and clear all 2FA trust?",
|
||||
"txt_remove_all_devices_and_sign_out_all_sessions": "Remove all devices, clear all trust, and sign out every device?",
|
||||
"txt_remove_selected_devices": "Remove selected ({count})",
|
||||
"txt_remove_selected_devices_confirm": "Remove {count} selected devices, clear their trust, and sign them out?",
|
||||
"txt_remove_selected_devices_and_sign_out_current": "Remove {count} selected devices, clear their trust, and sign out this device too?",
|
||||
"txt_selected_devices_removed": "Selected devices removed",
|
||||
"txt_remove_selected_devices_failed": "Failed to remove selected devices",
|
||||
"txt_remove_device_name_and_clear_its_2fa_trust": "Remove device \"{name}\" and clear its 2FA trust?",
|
||||
"txt_remove_device_and_sign_out_name": "Remove device \"{name}\", clear its trust, and sign it out?",
|
||||
"txt_reveal": "Reveal",
|
||||
@@ -879,6 +884,9 @@ const en: Record<string, string> = {
|
||||
"txt_security_code": "Security Code",
|
||||
"txt_security_code_cvv": "Security Code (CVV)",
|
||||
"txt_select_all": "Select All",
|
||||
"txt_clear_selection": "Clear selection",
|
||||
"txt_select_device_name": "Select {name}",
|
||||
"txt_no_devices_selected": "No devices selected",
|
||||
"txt_select": "Select",
|
||||
"txt_select_duplicate_items": "Select Duplicates",
|
||||
"txt_select_an_item": "Select an item",
|
||||
|
||||
@@ -838,6 +838,11 @@ const es: Record<string, string> = {
|
||||
"txt_remove_all_devices": "Quitar todos los dispositivos",
|
||||
"txt_remove_all_devices_and_clear_all_2fa_trust": "¿Quitar todos los dispositivos y limpiar toda la confianza 2FA?",
|
||||
"txt_remove_all_devices_and_sign_out_all_sessions": "¿Quitar todos los dispositivos, limpiar toda la confianza y cerrar sesión en todos los dispositivos?",
|
||||
"txt_remove_selected_devices": "Quitar seleccionados ({count})",
|
||||
"txt_remove_selected_devices_confirm": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar sesión?",
|
||||
"txt_remove_selected_devices_and_sign_out_current": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar también esta sesión?",
|
||||
"txt_selected_devices_removed": "Dispositivos seleccionados quitados",
|
||||
"txt_remove_selected_devices_failed": "Error al quitar los dispositivos seleccionados",
|
||||
"txt_remove_device_name_and_clear_its_2fa_trust": "¿Quitar dispositivo \"{name}\" y limpiar su confianza 2FA?",
|
||||
"txt_remove_device_and_sign_out_name": "¿Quitar dispositivo \"{name}\", limpiar su confianza y cerrar sesión?",
|
||||
"txt_reveal": "Mostrar",
|
||||
@@ -879,6 +884,9 @@ const es: Record<string, string> = {
|
||||
"txt_security_code": "Código de seguridad",
|
||||
"txt_security_code_cvv": "Código de seguridad (CVV)",
|
||||
"txt_select_all": "Seleccionar todo",
|
||||
"txt_clear_selection": "Borrar selección",
|
||||
"txt_select_device_name": "Seleccionar {name}",
|
||||
"txt_no_devices_selected": "No hay dispositivos seleccionados",
|
||||
"txt_select": "Seleccionar",
|
||||
"txt_select_duplicate_items": "Seleccionar duplicados",
|
||||
"txt_select_an_item": "Seleccione un elemento",
|
||||
|
||||
@@ -838,6 +838,11 @@ const ru: Record<string, string> = {
|
||||
"txt_remove_all_devices": "Удалить все устройства",
|
||||
"txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?",
|
||||
"txt_remove_all_devices_and_sign_out_all_sessions": "Удалить все устройства, отменить все доверительные отношения и выйти из системы на каждом устройстве?",
|
||||
"txt_remove_selected_devices": "Удалить выбранные ({count})",
|
||||
"txt_remove_selected_devices_confirm": "Удалить {count} выбранных устройств, очистить их доверие и выйти из системы на них?",
|
||||
"txt_remove_selected_devices_and_sign_out_current": "Удалить {count} выбранных устройств, очистить их доверие и также выйти из системы на этом устройстве?",
|
||||
"txt_selected_devices_removed": "Выбранные устройства удалены",
|
||||
"txt_remove_selected_devices_failed": "Не удалось удалить выбранные устройства",
|
||||
"txt_remove_device_name_and_clear_its_2fa_trust": "Удалить устройство «{name}» и очистить его доверие 2FA?",
|
||||
"txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?",
|
||||
"txt_reveal": "Раскрыть",
|
||||
@@ -879,6 +884,9 @@ const ru: Record<string, string> = {
|
||||
"txt_security_code": "Код безопасности",
|
||||
"txt_security_code_cvv": "Код безопасности (CVV)",
|
||||
"txt_select_all": "Выбрать все",
|
||||
"txt_clear_selection": "Очистить выбор",
|
||||
"txt_select_device_name": "Выбрать {name}",
|
||||
"txt_no_devices_selected": "Устройства не выбраны",
|
||||
"txt_select": "Выбрать",
|
||||
"txt_select_duplicate_items": "Выберите дубликаты",
|
||||
"txt_select_an_item": "Выберите элемент",
|
||||
|
||||
@@ -838,6 +838,11 @@ const zhCN: Record<string, string> = {
|
||||
"txt_remove_all_devices": "移除所有设备",
|
||||
"txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?",
|
||||
"txt_remove_all_devices_and_sign_out_all_sessions": "确认移除所有设备、清除全部信任,并让所有设备重新登录吗?",
|
||||
"txt_remove_selected_devices": "移除已选({count})",
|
||||
"txt_remove_selected_devices_confirm": "确认移除选中的 {count} 台设备、清除其信任,并让它们重新登录吗?",
|
||||
"txt_remove_selected_devices_and_sign_out_current": "确认移除选中的 {count} 台设备、清除其信任,并同时退出本设备吗?",
|
||||
"txt_selected_devices_removed": "已移除选中设备",
|
||||
"txt_remove_selected_devices_failed": "移除选中设备失败",
|
||||
"txt_remove_device_name_and_clear_its_2fa_trust": "确认移除设备“{name}”并清除其 2FA 信任吗?",
|
||||
"txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?",
|
||||
"txt_reveal": "显示",
|
||||
@@ -879,6 +884,9 @@ const zhCN: Record<string, string> = {
|
||||
"txt_security_code": "安全码",
|
||||
"txt_security_code_cvv": "安全码 (CVV)",
|
||||
"txt_select_all": "全选",
|
||||
"txt_clear_selection": "取消选择",
|
||||
"txt_select_device_name": "选择 {name}",
|
||||
"txt_no_devices_selected": "未选择设备",
|
||||
"txt_select": "请选择",
|
||||
"txt_select_duplicate_items": "选择重复项",
|
||||
"txt_select_an_item": "请选择一个项目",
|
||||
|
||||
@@ -838,6 +838,11 @@ const zhTW: Record<string, string> = {
|
||||
"txt_remove_all_devices": "移除所有設備",
|
||||
"txt_remove_all_devices_and_clear_all_2fa_trust": "確認移除所有設備並清除全部 2FA 信任嗎?",
|
||||
"txt_remove_all_devices_and_sign_out_all_sessions": "確認移除所有設備、清除全部信任,並讓所有設備重新登錄嗎?",
|
||||
"txt_remove_selected_devices": "移除已選({count})",
|
||||
"txt_remove_selected_devices_confirm": "確認移除選中的 {count} 臺設備、清除其信任,並讓它們重新登錄嗎?",
|
||||
"txt_remove_selected_devices_and_sign_out_current": "確認移除選中的 {count} 臺設備、清除其信任,並同時退出本設備嗎?",
|
||||
"txt_selected_devices_removed": "已移除選中設備",
|
||||
"txt_remove_selected_devices_failed": "移除選中設備失敗",
|
||||
"txt_remove_device_name_and_clear_its_2fa_trust": "確認移除設備“{name}”並清除其 2FA 信任嗎?",
|
||||
"txt_remove_device_and_sign_out_name": "確認移除設備“{name}”,清除其信任,並讓它重新登錄嗎?",
|
||||
"txt_reveal": "顯示",
|
||||
@@ -879,6 +884,9 @@ const zhTW: Record<string, string> = {
|
||||
"txt_security_code": "安全碼",
|
||||
"txt_security_code_cvv": "安全碼 (CVV)",
|
||||
"txt_select_all": "全選",
|
||||
"txt_clear_selection": "取消選擇",
|
||||
"txt_select_device_name": "選擇 {name}",
|
||||
"txt_no_devices_selected": "未選擇設備",
|
||||
"txt_select": "請選擇",
|
||||
"txt_select_duplicate_items": "選擇重複項",
|
||||
"txt_select_an_item": "請選擇一個項目",
|
||||
|
||||
@@ -1520,8 +1520,12 @@
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.authorized-devices-col-select {
|
||||
width: 4%;
|
||||
}
|
||||
|
||||
.authorized-devices-col-device {
|
||||
width: 28%;
|
||||
width: 26%;
|
||||
}
|
||||
|
||||
.authorized-devices-col-type {
|
||||
@@ -1544,6 +1548,12 @@
|
||||
width: 26%;
|
||||
}
|
||||
|
||||
.authorized-device-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #2563eb;
|
||||
}
|
||||
|
||||
.authorized-devices-table td:first-child {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user