Skip to content

Commit a5ad16a

Browse files
committed
feat: add device selection and removal functionality in SecurityDevicesPage
1 parent 6a1a835 commit a5ad16a

11 files changed

Lines changed: 163 additions & 4 deletions

File tree

webapp/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1959,6 +1959,7 @@ export default function App() {
19591959
lockTimeoutMinutes,
19601960
sessionTimeoutAction,
19611961
authorizedDevices: authorizedDevicesQuery.data || [],
1962+
currentDeviceIdentifier: getCurrentDeviceIdentifier(),
19621963
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
19631964
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
19641965
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
@@ -2031,6 +2032,7 @@ export default function App() {
20312032
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
20322033
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
20332034
onRemoveDevice: accountSecurityActions.openRemoveDevice,
2035+
onRemoveSelectedDevices: accountSecurityActions.openRemoveSelectedDevices,
20342036
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
20352037
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
20362038
onRefreshAdmin: adminActions.refreshAdmin,

webapp/src/components/AppMainRoutes.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export interface AppMainRoutesProps {
5757
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
5858
sessionTimeoutAction: 'lock' | 'logout';
5959
authorizedDevices: AuthorizedDevice[];
60+
currentDeviceIdentifier: string;
6061
authorizedDevicesLoading: boolean;
6162
authorizedDevicesError: string;
6263
domainRules: DomainRules | null;
@@ -130,6 +131,7 @@ export interface AppMainRoutesProps {
130131
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
131132
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
132133
onRemoveDevice: (device: AuthorizedDevice) => void;
134+
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
133135
onRevokeAllDeviceTrust: () => void;
134136
onRemoveAllDevices: () => void;
135137
onCreateInvite: (hours: number) => Promise<void>;
@@ -347,6 +349,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
347349
<Suspense fallback={<RouteContentFallback />}>
348350
<SecurityDevicesPage
349351
devices={props.authorizedDevices}
352+
currentDeviceIdentifier={props.currentDeviceIdentifier}
350353
loading={props.authorizedDevicesLoading}
351354
error={props.authorizedDevicesError}
352355
pendingAuthRequests={props.pendingAuthRequests}
@@ -359,6 +362,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
359362
onRevokeTrust={props.onRevokeDeviceTrust}
360363
onTrustPermanently={props.onTrustDevicePermanently}
361364
onRemoveDevice={props.onRemoveDevice}
365+
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
362366
onRevokeAll={props.onRevokeAllDeviceTrust}
363367
onRemoveAll={props.onRemoveAllDevices}
364368
/>

webapp/src/components/SecurityDevicesPage.tsx

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from 'preact/hooks';
2-
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
2+
import { CheckSquare, Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
33
import ConfirmDialog from '@/components/ConfirmDialog';
44
import LoadingState from '@/components/LoadingState';
55
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
@@ -8,6 +8,7 @@ import { t } from '@/lib/i18n';
88

99
interface SecurityDevicesPageProps {
1010
devices: AuthorizedDevice[];
11+
currentDeviceIdentifier: string;
1112
loading: boolean;
1213
error: string;
1314
pendingAuthRequests: AuthRequest[];
@@ -20,6 +21,7 @@ interface SecurityDevicesPageProps {
2021
onRevokeTrust: (device: AuthorizedDevice) => void;
2122
onTrustPermanently: (device: AuthorizedDevice) => void;
2223
onRemoveDevice: (device: AuthorizedDevice) => void;
24+
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
2325
onRevokeAll: () => void;
2426
onRemoveAll: () => void;
2527
}
@@ -62,6 +64,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
6264
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
6365
const [deviceNote, setDeviceNote] = useState('');
6466
const [savingNote, setSavingNote] = useState(false);
67+
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
68+
const currentDeviceIdentifier = props.currentDeviceIdentifier;
69+
const selectableDevices = props.devices.filter((device) => (
70+
device.identifier !== currentDeviceIdentifier
71+
));
72+
const selectedDeviceIdSet = new Set(selectedDeviceIds);
73+
const selectedDevices = selectableDevices.filter((device) => selectedDeviceIdSet.has(device.identifier));
74+
const allSelectableSelected = selectableDevices.length > 0 && selectedDevices.length === selectableDevices.length;
6575

6676
async function handleSaveDeviceNote(): Promise<void> {
6777
if (!editingDevice || savingNote) return;
@@ -75,6 +85,19 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
7585
}
7686
}
7787

88+
function toggleSelectAllDevices(): void {
89+
setSelectedDeviceIds(allSelectableSelected ? [] : selectableDevices.map((device) => device.identifier));
90+
}
91+
92+
function toggleSelectedDevice(device: AuthorizedDevice): void {
93+
if (device.identifier === currentDeviceIdentifier) return;
94+
setSelectedDeviceIds((current) => (
95+
current.includes(device.identifier)
96+
? current.filter((id) => id !== device.identifier)
97+
: [...current, device.identifier]
98+
));
99+
}
100+
78101
return (
79102
<>
80103
<div className="stack">
@@ -101,6 +124,27 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
101124
<RefreshCw size={14} className="btn-icon" />
102125
{t('txt_refresh')}
103126
</button>
127+
<button
128+
type="button"
129+
className="btn btn-secondary small"
130+
disabled={props.loading || selectableDevices.length === 0}
131+
onClick={toggleSelectAllDevices}
132+
>
133+
<CheckSquare size={14} className="btn-icon" />
134+
{allSelectableSelected ? t('txt_clear_selection') : t('txt_select_all')}
135+
</button>
136+
<button
137+
type="button"
138+
className="btn btn-danger small"
139+
disabled={selectedDevices.length === 0}
140+
onClick={() => {
141+
props.onRemoveSelectedDevices(selectedDevices);
142+
setSelectedDeviceIds([]);
143+
}}
144+
>
145+
<Trash2 size={14} className="btn-icon" />
146+
{t('txt_remove_selected_devices', { count: selectedDevices.length })}
147+
</button>
104148
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
105149
<ShieldOff size={14} className="btn-icon" />
106150
{t('txt_revoke_all_trusted')}
@@ -122,6 +166,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
122166
)}
123167
<table className="table authorized-devices-table">
124168
<colgroup>
169+
<col className="authorized-devices-col-select" />
125170
<col className="authorized-devices-col-device" />
126171
<col className="authorized-devices-col-type" />
127172
<col className="authorized-devices-col-status" />
@@ -132,6 +177,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
132177
</colgroup>
133178
<thead>
134179
<tr>
180+
<th>{t('txt_select')}</th>
135181
<th>{t('txt_device')}</th>
136182
<th>{t('txt_type')}</th>
137183
<th>{t('txt_status')}</th>
@@ -144,6 +190,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
144190
<tbody>
145191
{props.devices.map((device) => (
146192
<tr key={device.identifier}>
193+
<td data-label={t('txt_select')}>
194+
<input
195+
type="checkbox"
196+
className="authorized-device-checkbox"
197+
checked={selectedDeviceIdSet.has(device.identifier)}
198+
disabled={device.identifier === currentDeviceIdentifier}
199+
aria-label={t('txt_select_device_name', { name: device.name || t('txt_unknown_device') })}
200+
onChange={() => toggleSelectedDevice(device)}
201+
/>
202+
</td>
147203
<td data-label={t('txt_device')}>
148204
<div>{device.name || t('txt_unknown_device')}</div>
149205
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
@@ -216,14 +272,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
216272
))}
217273
{props.loading && props.devices.length === 0 && (
218274
<tr>
219-
<td colSpan={7}>
275+
<td colSpan={8}>
220276
<LoadingState lines={5} compact />
221277
</td>
222278
</tr>
223279
)}
224280
{!props.loading && props.devices.length === 0 && (
225281
<tr>
226-
<td colSpan={7}>
282+
<td colSpan={8}>
227283
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
228284
</td>
229285
</tr>

webapp/src/hooks/useAccountSecurityActions.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
changeMasterPassword,
44
deleteAllAuthorizedDevices,
55
deleteAuthorizedDevice,
6+
deleteAuthorizedDevices,
67
deriveLoginHash,
78
deleteAccountPasskey as deleteAccountPasskeyApi,
89
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
@@ -389,6 +390,38 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
389390
});
390391
},
391392

393+
openRemoveSelectedDevices(devices: AuthorizedDevice[]) {
394+
const selectedDevices = devices.filter((device) => String(device.identifier || '').trim());
395+
if (selectedDevices.length === 0) {
396+
onNotify('warning', t('txt_no_devices_selected'));
397+
return;
398+
}
399+
const includesCurrentDevice = selectedDevices.some((device) => device.identifier === getCurrentDeviceIdentifier());
400+
onSetConfirm({
401+
title: t('txt_remove_selected_devices', { count: selectedDevices.length }),
402+
message: includesCurrentDevice
403+
? t('txt_remove_selected_devices_and_sign_out_current', { count: selectedDevices.length })
404+
: t('txt_remove_selected_devices_confirm', { count: selectedDevices.length }),
405+
danger: true,
406+
onConfirm: () => {
407+
onSetConfirm(null);
408+
void (async () => {
409+
try {
410+
await deleteAuthorizedDevices(authedFetch, selectedDevices);
411+
onNotify('success', t('txt_selected_devices_removed', { count: selectedDevices.length }));
412+
if (includesCurrentDevice) {
413+
onLogoutNow();
414+
return;
415+
}
416+
await refetchAuthorizedDevices();
417+
} catch (error) {
418+
onNotify('error', error instanceof Error ? error.message : t('txt_remove_selected_devices_failed'));
419+
}
420+
})();
421+
},
422+
});
423+
},
424+
392425
openRevokeAllDeviceTrust() {
393426
onSetConfirm({
394427
title: t('txt_revoke_all_trusted_devices'),

webapp/src/lib/api/auth.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,20 @@ export async function deleteAuthorizedDevice(
885885
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
886886
}
887887

888+
export async function deleteAuthorizedDevices(
889+
authedFetch: AuthedFetch,
890+
devices: Array<Pick<AuthorizedDevice, 'identifier' | 'hasStoredDevice'>>
891+
): Promise<void> {
892+
const uniqueDevices = Array.from(
893+
new Map(devices.map((device) => [String(device.identifier || '').trim(), device])).values()
894+
).filter((device) => String(device.identifier || '').trim());
895+
await Promise.all(uniqueDevices.map((device) => (
896+
device.hasStoredDevice === false
897+
? revokeAuthorizedDeviceTrust(authedFetch, device.identifier)
898+
: deleteAuthorizedDevice(authedFetch, device.identifier)
899+
)));
900+
}
901+
888902
export async function updateAuthorizedDeviceName(
889903
authedFetch: AuthedFetch,
890904
deviceIdentifier: string,

webapp/src/lib/i18n/locales/en.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,11 @@ const en: Record<string, string> = {
838838
"txt_remove_all_devices": "Remove all devices",
839839
"txt_remove_all_devices_and_clear_all_2fa_trust": "Remove all devices and clear all 2FA trust?",
840840
"txt_remove_all_devices_and_sign_out_all_sessions": "Remove all devices, clear all trust, and sign out every device?",
841+
"txt_remove_selected_devices": "Remove selected ({count})",
842+
"txt_remove_selected_devices_confirm": "Remove {count} selected devices, clear their trust, and sign them out?",
843+
"txt_remove_selected_devices_and_sign_out_current": "Remove {count} selected devices, clear their trust, and sign out this device too?",
844+
"txt_selected_devices_removed": "Selected devices removed",
845+
"txt_remove_selected_devices_failed": "Failed to remove selected devices",
841846
"txt_remove_device_name_and_clear_its_2fa_trust": "Remove device \"{name}\" and clear its 2FA trust?",
842847
"txt_remove_device_and_sign_out_name": "Remove device \"{name}\", clear its trust, and sign it out?",
843848
"txt_reveal": "Reveal",
@@ -879,6 +884,9 @@ const en: Record<string, string> = {
879884
"txt_security_code": "Security Code",
880885
"txt_security_code_cvv": "Security Code (CVV)",
881886
"txt_select_all": "Select All",
887+
"txt_clear_selection": "Clear selection",
888+
"txt_select_device_name": "Select {name}",
889+
"txt_no_devices_selected": "No devices selected",
882890
"txt_select": "Select",
883891
"txt_select_duplicate_items": "Select Duplicates",
884892
"txt_select_an_item": "Select an item",

webapp/src/lib/i18n/locales/es.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,11 @@ const es: Record<string, string> = {
838838
"txt_remove_all_devices": "Quitar todos los dispositivos",
839839
"txt_remove_all_devices_and_clear_all_2fa_trust": "¿Quitar todos los dispositivos y limpiar toda la confianza 2FA?",
840840
"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?",
841+
"txt_remove_selected_devices": "Quitar seleccionados ({count})",
842+
"txt_remove_selected_devices_confirm": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar sesión?",
843+
"txt_remove_selected_devices_and_sign_out_current": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar también esta sesión?",
844+
"txt_selected_devices_removed": "Dispositivos seleccionados quitados",
845+
"txt_remove_selected_devices_failed": "Error al quitar los dispositivos seleccionados",
841846
"txt_remove_device_name_and_clear_its_2fa_trust": "¿Quitar dispositivo \"{name}\" y limpiar su confianza 2FA?",
842847
"txt_remove_device_and_sign_out_name": "¿Quitar dispositivo \"{name}\", limpiar su confianza y cerrar sesión?",
843848
"txt_reveal": "Mostrar",
@@ -879,6 +884,9 @@ const es: Record<string, string> = {
879884
"txt_security_code": "Código de seguridad",
880885
"txt_security_code_cvv": "Código de seguridad (CVV)",
881886
"txt_select_all": "Seleccionar todo",
887+
"txt_clear_selection": "Borrar selección",
888+
"txt_select_device_name": "Seleccionar {name}",
889+
"txt_no_devices_selected": "No hay dispositivos seleccionados",
882890
"txt_select": "Seleccionar",
883891
"txt_select_duplicate_items": "Seleccionar duplicados",
884892
"txt_select_an_item": "Seleccione un elemento",

webapp/src/lib/i18n/locales/ru.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,11 @@ const ru: Record<string, string> = {
838838
"txt_remove_all_devices": "Удалить все устройства",
839839
"txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?",
840840
"txt_remove_all_devices_and_sign_out_all_sessions": "Удалить все устройства, отменить все доверительные отношения и выйти из системы на каждом устройстве?",
841+
"txt_remove_selected_devices": "Удалить выбранные ({count})",
842+
"txt_remove_selected_devices_confirm": "Удалить {count} выбранных устройств, очистить их доверие и выйти из системы на них?",
843+
"txt_remove_selected_devices_and_sign_out_current": "Удалить {count} выбранных устройств, очистить их доверие и также выйти из системы на этом устройстве?",
844+
"txt_selected_devices_removed": "Выбранные устройства удалены",
845+
"txt_remove_selected_devices_failed": "Не удалось удалить выбранные устройства",
841846
"txt_remove_device_name_and_clear_its_2fa_trust": "Удалить устройство «{name}» и очистить его доверие 2FA?",
842847
"txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?",
843848
"txt_reveal": "Раскрыть",
@@ -879,6 +884,9 @@ const ru: Record<string, string> = {
879884
"txt_security_code": "Код безопасности",
880885
"txt_security_code_cvv": "Код безопасности (CVV)",
881886
"txt_select_all": "Выбрать все",
887+
"txt_clear_selection": "Очистить выбор",
888+
"txt_select_device_name": "Выбрать {name}",
889+
"txt_no_devices_selected": "Устройства не выбраны",
882890
"txt_select": "Выбрать",
883891
"txt_select_duplicate_items": "Выберите дубликаты",
884892
"txt_select_an_item": "Выберите элемент",

webapp/src/lib/i18n/locales/zh-CN.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,11 @@ const zhCN: Record<string, string> = {
838838
"txt_remove_all_devices": "移除所有设备",
839839
"txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?",
840840
"txt_remove_all_devices_and_sign_out_all_sessions": "确认移除所有设备、清除全部信任,并让所有设备重新登录吗?",
841+
"txt_remove_selected_devices": "移除已选({count})",
842+
"txt_remove_selected_devices_confirm": "确认移除选中的 {count} 台设备、清除其信任,并让它们重新登录吗?",
843+
"txt_remove_selected_devices_and_sign_out_current": "确认移除选中的 {count} 台设备、清除其信任,并同时退出本设备吗?",
844+
"txt_selected_devices_removed": "已移除选中设备",
845+
"txt_remove_selected_devices_failed": "移除选中设备失败",
841846
"txt_remove_device_name_and_clear_its_2fa_trust": "确认移除设备“{name}”并清除其 2FA 信任吗?",
842847
"txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?",
843848
"txt_reveal": "显示",
@@ -879,6 +884,9 @@ const zhCN: Record<string, string> = {
879884
"txt_security_code": "安全码",
880885
"txt_security_code_cvv": "安全码 (CVV)",
881886
"txt_select_all": "全选",
887+
"txt_clear_selection": "取消选择",
888+
"txt_select_device_name": "选择 {name}",
889+
"txt_no_devices_selected": "未选择设备",
882890
"txt_select": "请选择",
883891
"txt_select_duplicate_items": "选择重复项",
884892
"txt_select_an_item": "请选择一个项目",

0 commit comments

Comments
 (0)