diff --git a/client/src/components/AccountSettings/LinkedDevices.tsx b/client/src/components/AccountSettings/LinkedDevices.tsx index 92b9c733208fa2912ea981e923966f39ac120456..8fc86f2a551de294a1aebf7de7b1289580dade87 100644 --- a/client/src/components/AccountSettings/LinkedDevices.tsx +++ b/client/src/components/AccountSettings/LinkedDevices.tsx @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022-2025 Savoir-faire Linux Inc. + * Copyright (C) 2025 Savoir-faire Linux Inc. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -15,121 +15,405 @@ * License along with this program. If not, see * <https://www.gnu.org/licenses/>. */ - +import DeleteIcon from '@mui/icons-material/Delete' import DevicesIcon from '@mui/icons-material/Devices' -import { Box } from '@mui/material' -import Card from '@mui/material/Card' -import CardContent from '@mui/material/CardContent' -import Grid from '@mui/material/Grid' -import ListItem from '@mui/material/ListItem' -import ListItemText from '@mui/material/ListItemText' +import { useMediaQuery } from '@mui/material' +import Box from '@mui/material/Box' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import Paper from '@mui/material/Paper' +import { alpha, useTheme } from '@mui/material/styles' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' +import TableContainer from '@mui/material/TableContainer' +import TableHead from '@mui/material/TableHead' +import TablePagination from '@mui/material/TablePagination' +import TableRow from '@mui/material/TableRow' +import TableSortLabel from '@mui/material/TableSortLabel' +import Toolbar from '@mui/material/Toolbar' +import Tooltip from '@mui/material/Tooltip' import Typography from '@mui/material/Typography' -import { useMemo, useState } from 'react' +import { visuallyHidden } from '@mui/utils' +import { ChangeEvent, MouseEvent, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useAuthContext } from '../../contexts/AuthProvider' import { useRemoveLinkedDevice } from '../../services/accountQueries' import EllipsisMiddle from '../EllipsisMiddle' -import { TrashBinIcon } from '../SvgIcon' import PasswordRequiredModal from './PasswordRequiredModal' -function LinkedDevices() { +interface DeviceRow { + deviceId: string + deviceName: string +} + +const rowsPerPageOptions = [10, 25, 50, 100] +const paddingValue = '20px' + +function descendingComparator<T>(a: T, b: T, orderBy: keyof T) { + if (b[orderBy] < a[orderBy]) { + return -1 + } + if (b[orderBy] > a[orderBy]) { + return 1 + } + return 0 +} + +type Order = 'asc' | 'desc' + +function getComparator<Key extends keyof any>( + order: Order, + orderBy: Key, +): (a: { [key in Key]: string }, b: { [key in Key]: string }) => number { + return order === 'desc' + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy) +} + +interface HeadCell { + disablePadding: boolean + id: keyof DeviceRow | 'removeDevice' + label: string + numeric: boolean +} + +const headCells: readonly HeadCell[] = [ + { + id: 'deviceName', + numeric: false, + disablePadding: true, + label: 'Device Name', + }, + { + id: 'deviceId', + numeric: false, + disablePadding: false, + label: 'Device ID', + }, +] + +interface EnhancedTableProps { + numSelected: number + onRequestSort: (event: MouseEvent<unknown>, property: keyof DeviceRow) => void + onSelectAllClick: (event: ChangeEvent<HTMLInputElement>) => void + order: Order + orderBy: string + rowCount: number +} + +function EnhancedTableHead(props: EnhancedTableProps) { + const { onSelectAllClick, order, orderBy, numSelected, rowCount, onRequestSort } = props + + const createSortHandler = (property: keyof DeviceRow) => (event: MouseEvent<unknown>) => { + onRequestSort(event, property) + } + + return ( + <TableHead> + <TableRow> + {headCells.map((headCell) => { + const isSortable = headCell.id !== 'removeDevice' + return ( + <TableCell + key={headCell.id} + sx={{ paddingLeft: paddingValue }} + align={headCell.numeric ? 'right' : 'left'} + padding={headCell.disablePadding ? 'none' : 'normal'} + sortDirection={orderBy === headCell.id ? order : false} + > + {isSortable ? ( + <TableSortLabel + active={orderBy === headCell.id} + direction={orderBy === headCell.id ? order : 'asc'} + onClick={createSortHandler(headCell.id as keyof DeviceRow)} + > + {headCell.label} + {orderBy === headCell.id ? ( + <Box component="span" sx={visuallyHidden}> + {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + </Box> + ) : null} + </TableSortLabel> + ) : ( + headCell.label + )} + </TableCell> + ) + })} + <TableCell padding="checkbox"> + <Checkbox + color="primary" + indeterminate={numSelected > 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={onSelectAllClick} + inputProps={{ + 'aria-label': 'select all devices', + }} + /> + </TableCell> + </TableRow> + </TableHead> + ) +} + +interface EnhancedTableToolbarProps { + numSelected: number + devicesNumber: number + onDelete: () => void +} + +function EnhancedTableToolbar(props: EnhancedTableToolbarProps) { + const { numSelected, devicesNumber, onDelete } = props + const { t } = useTranslation() const { account } = useAuthContext() + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + + return ( + <> + <Toolbar + sx={[ + { + pl: { sm: 2 }, + pr: { xs: 1, sm: 1 }, + }, + numSelected > 0 && { + bgcolor: (theme) => alpha(theme.palette.primary.main, theme.palette.action.activatedOpacity), + }, + ]} + > + {numSelected > 0 ? ( + <Typography sx={{ flex: '1 1 100%' }} color="inherit" variant="subtitle1" component="div"> + {numSelected} selected + </Typography> + ) : ( + <Typography variant="h6" id="tableTitle" component="div"> + {t('settings_devices_linked', { count: devicesNumber })} + </Typography> + )} + {numSelected > 0 && ( + <Tooltip title="Delete"> + <IconButton onClick={onDelete}> + <DeleteIcon /> + </IconButton> + </Tooltip> + )} + </Toolbar> + <Box + sx={{ + paddingLeft: paddingValue, + display: 'flex', + flexDirection: 'column', + gap: '10px', + }} + > + <Box + sx={{ display: 'flex', justifyContent: 'start', alignItems: 'center', alignContent: 'center', gap: '5px' }} + > + {t('Current device:')} + </Box> + <Box sx={{ display: 'flex', flexDirection: 'row', gap: '5px', alignItems: 'center', alignContent: 'center' }}> + <Box + sx={{ fontSize: '14px', display: 'flex', justifyContent: 'center', alignContent: 'center', gap: '10px' }} + > + {' '} + <DevicesIcon sx={{ height: '20px', width: '20px', marginTop: '1px' }} />{' '} + <Box sx={{ textWrap: 'nowrap' }}>{account.details['Account.deviceName']}</Box> + </Box>{' '} + <Box + sx={{ fontFamily: 'monospace', fontSize: '12px', marginTop: '2px', maxWidth: isMobile ? '150px' : 'unset' }} + > + <EllipsisMiddle text={account.volatileDetails['Account.deviceID']} /> + </Box> + </Box> + </Box> + </> + ) +} + +export default function LinkedDevices() { + const [order, setOrder] = useState<Order>('asc') + const [orderBy, setOrderBy] = useState<keyof DeviceRow>('deviceName') + const [selected, setSelected] = useState<readonly string[]>([]) + const [page, setPage] = useState(0) + const [rowsPerPage, setRowsPerPage] = useState(rowsPerPageOptions[0]) + const [open, setOpen] = useState(false) const { t } = useTranslation() const removeLinkedDeviceMutation = useRemoveLinkedDevice() - const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null) + const { account } = useAuthContext() - const devices = useMemo(() => { - const deviceList: string[][] = [] - const accountDevices = account.devices + const rows = useMemo<DeviceRow[]>(() => { + const accountDevices = account.devices || {} + const deviceList: DeviceRow[] = [] - for (const deviceId in accountDevices) { - if (deviceId === account.volatileDetails['Account.deviceID']) continue - deviceList.push([deviceId, accountDevices[deviceId]]) + for (const uri in accountDevices) { + if (uri === account.volatileDetails['Account.deviceID']) continue + deviceList.push({ + deviceId: uri, + deviceName: accountDevices[uri], + }) } - return deviceList }, [account.devices, account.volatileDetails]) - function selectDevice(deviceId: string) { - setSelectedDeviceId(deviceId) + const handleRequestSort = (event: MouseEvent<unknown>, property: keyof DeviceRow) => { + const isAsc = orderBy === property && order === 'asc' + setOrder(isAsc ? 'desc' : 'asc') + setOrderBy(property) + } + + const handleSelectAllClick = (event: ChangeEvent<HTMLInputElement>) => { + if (event.target.checked) { + const newSelected = rows.map((r) => r.deviceId) + setSelected(newSelected) + return + } + setSelected([]) + } + + const handleClick = (event: MouseEvent<unknown>, deviceId: string) => { + const selectedIndex = selected.indexOf(deviceId) + let newSelected: readonly string[] = [] + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, deviceId) + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)) + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)) + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)) + } + setSelected(newSelected) + } + + const handleChangePage = (event: unknown, newPage: number) => { + setPage(newPage) + } + + const handleChangeRowsPerPage = (event: ChangeEvent<HTMLInputElement>) => { + setRowsPerPage(parseInt(event.target.value, 10)) + setPage(0) } - function removeDevice(password: string) { - if (selectedDeviceId === null) return - removeLinkedDeviceMutation.mutate({ deviceId: selectedDeviceId, password }) - onClose() + const handleDeleteSelected = () => { + setOpen(true) } - function onClose() { - setSelectedDeviceId(null) + const handleValidateDeleteSelected = (password: string) => { + selected.forEach((deviceId) => { + removeLinkedDeviceMutation.mutate({ deviceId: deviceId, password: password }) + }) + setSelected([]) + setOpen(false) } + const visibleRows = useMemo(() => { + const sorted = [...rows].sort(getComparator(order, orderBy)) + return sorted.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + }, [rows, order, orderBy, page, rowsPerPage]) + + const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0 + return ( - <> - <Grid item xs={12} sm={12} aria-label="linked-device"> - <Card> - <CardContent sx={{ width: '100%' }}> - <Typography sx={{}} color="textSecondary" gutterBottom> - {t('settings_linked_devices')} - </Typography> - <Typography gutterBottom variant="h5" component="h2"> - <ListItem> - <DevicesIcon - sx={{ padding: '4px', margin: '4px', marginRight: '16px', height: '40px', width: '40px' }} - /> - <ListItemText - id="switch-list-label-rendezvous" - primary={ - <> - {account.details['Account.deviceName']} - <span style={{ fontSize: 'small', fontWeight: 'bolder', marginLeft: '3%' }}> - {t('current_device')} - </span> - </> - } - secondary={ - <Typography noWrap sx={{ textOverflow: 'ellipsis', overflow: 'hidden', maxWidth: '100%' }}> - {account.volatileDetails['Account.deviceID']} - </Typography> - } - /> - </ListItem> - {devices.map((device) => ( - <ListItem key={device[0]}> - <DevicesIcon - sx={{ padding: '4px', margin: '4px', marginRight: '16px', height: '40px', width: '40px' }} - /> - <ListItemText - id="switch-list-label-rendezvous" - primary={device[1]} - secondary={ - <Box maxWidth={'90%'}> - <EllipsisMiddle text={device[0]} /> - </Box> - } - /> - <TrashBinIcon sx={{ '&:hover': { cursor: 'pointer' } }} onClick={() => selectDevice(device[0])} /> - </ListItem> - ))} - {/* <ListItemTextsion> */} - </Typography> - </CardContent> - </Card> - </Grid> - <PasswordRequiredModal - open={selectedDeviceId !== null} - onClose={onClose} - title={t('revoke_device')} - onValidation={removeDevice} - buttonText={t('revoke_device')} - info={t('revoke_device_info', { - device: selectedDeviceId, - deviceName: account.devices[selectedDeviceId || ''], - })} - /> - </> + <Box sx={{ display: 'flex', width: '100%', justifyContent: 'center' }}> + <Box sx={{ width: '85%', marginTop: '5%' }}> + <Paper sx={{ width: '100%', mb: 2, borderRadius: '10px', overflow: 'hidden' }}> + <EnhancedTableToolbar + numSelected={selected.length} + devicesNumber={rows.length} + onDelete={handleDeleteSelected} + /> + <TableContainer> + <Table sx={{ minWidth: 750 }} aria-labelledby="tableTitle" size={'medium'}> + <EnhancedTableHead + numSelected={selected.length} + order={order} + orderBy={orderBy} + onSelectAllClick={handleSelectAllClick} + onRequestSort={handleRequestSort} + rowCount={rows.length} + /> + <TableBody> + {visibleRows.map((row, index) => { + const isItemSelected = selected.includes(row.deviceId) + const labelId = `enhanced-table-checkbox-${index}` + + return ( + <TableRow + hover + onClick={(event) => handleClick(event, row.deviceId)} + role="checkbox" + aria-checked={isItemSelected} + tabIndex={-1} + key={row.deviceId} + selected={isItemSelected} + sx={{ cursor: 'pointer' }} + > + {/* Device Name */} + <TableCell + component="th" + id={labelId} + scope="row" + padding="none" + sx={{ paddingLeft: paddingValue }} + > + <Box sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}> + <DevicesIcon sx={{ height: '20px', width: '20px' }} /> + {row.deviceName} + </Box> + </TableCell> + + <TableCell sx={{ fontFamily: 'monospace', fontSize: '12px' }} align="left"> + {row.deviceId} + </TableCell> + + <TableCell padding="checkbox"> + <Checkbox + color="primary" + checked={isItemSelected} + inputProps={{ + 'aria-labelledby': labelId, + }} + /> + </TableCell> + </TableRow> + ) + })} + {emptyRows > 0 && ( + <TableRow style={{ height: 53 * emptyRows }}> + <TableCell colSpan={4} /> + </TableRow> + )} + </TableBody> + </Table> + </TableContainer> + + <TablePagination + rowsPerPageOptions={rowsPerPageOptions} + component="div" + count={rows.length} + rowsPerPage={rowsPerPage} + page={page} + onPageChange={handleChangePage} + onRowsPerPageChange={handleChangeRowsPerPage} + /> + </Paper> + </Box> + {open && ( + <PasswordRequiredModal + open + onClose={() => { + setOpen(false) + }} + onValidation={handleValidateDeleteSelected} + title={t('revoke_device')} + buttonText={t('revoke_device')} + info={t('revoke_device_info')} + /> + )} + </Box> ) } - -export default LinkedDevices diff --git a/server/locale/en/translation.json b/server/locale/en/translation.json index f605ece85a91048e6986ff928b34f79c5fbf3d5d..1d2ea6524e15b476e1ffcc9c7d134ba7bb313eec 100644 --- a/server/locale/en/translation.json +++ b/server/locale/en/translation.json @@ -247,7 +247,7 @@ "reset_nameserver_button": "Reset to default", "ressource_not_available": "Resource unavailable", "revoke_device": "Revoke device", - "revoke_device_info": "You are about to revoke the device {{deviceName}}.\nThis will log out the device and remove it from the linked devices list.", + "revoke_device_info": "You are about to revoke devices.\nThis will log out the devices and remove them from the linked devices list.", "save_button": "Save", "replying_to": "Replying to", "replied_to": "Replied to", @@ -273,6 +273,7 @@ "settings_appearance": "Appearance", "settings_customize_profile": "Customize profile", "settings_dark_theme": "Dark theme", + "settings_devices_linked": "Devices linked - {{count}}", "setting_display_name_success_alert": "Display name updated successfully.", "setting_display_name_error_alert": "An error occurred while updating the display name.", "settings_language": "User interface language",