Skip to content
Snippets Groups Projects
Commit 5eba0b04 authored by Léopold Chappuis's avatar Léopold Chappuis Committed by Léopold Chappuis
Browse files

linked-devices: Refactor UI with tab component

Moved linked devices display to a tab component for better organization.

Change-Id: Icc8ae7d590970b316fd626daec69e7f6b21df8ea
parent 8aaddb29
No related branches found
No related tags found
No related merge requests found
/*
* 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() {
const { account } = useAuthContext()
const { t } = useTranslation()
const removeLinkedDeviceMutation = useRemoveLinkedDevice()
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null)
interface DeviceRow {
deviceId: string
deviceName: string
}
const devices = useMemo(() => {
const deviceList: string[][] = []
const accountDevices = account.devices
const rowsPerPageOptions = [10, 25, 50, 100]
const paddingValue = '20px'
for (const deviceId in accountDevices) {
if (deviceId === account.volatileDetails['Account.deviceID']) continue
deviceList.push([deviceId, accountDevices[deviceId]])
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
}
return deviceList
}, [account.devices, account.volatileDetails])
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)
}
function selectDevice(deviceId: string) {
setSelectedDeviceId(deviceId)
interface HeadCell {
disablePadding: boolean
id: keyof DeviceRow | 'removeDevice'
label: string
numeric: boolean
}
function removeDevice(password: string) {
if (selectedDeviceId === null) return
removeLinkedDeviceMutation.mutate({ deviceId: selectedDeviceId, password })
onClose()
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 onClose() {
setSelectedDeviceId(null)
function EnhancedTableHead(props: EnhancedTableProps) {
const { onSelectAllClick, order, orderBy, numSelected, rowCount, onRequestSort } = props
const createSortHandler = (property: keyof DeviceRow) => (event: MouseEvent<unknown>) => {
onRequestSort(event, property)
}
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' }}
<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',
}}
/>
<ListItemText
id="switch-list-label-rendezvous"
primary={
</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 (
<>
{account.details['Account.deviceName']}
<span style={{ fontSize: 'small', fontWeight: 'bolder', marginLeft: '3%' }}>
{t('current_device')}
</span>
<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>
</>
)
}
secondary={
<Typography noWrap sx={{ textOverflow: 'ellipsis', overflow: 'hidden', maxWidth: '100%' }}>
{account.volatileDetails['Account.deviceID']}
</Typography>
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 { account } = useAuthContext()
const rows = useMemo<DeviceRow[]>(() => {
const accountDevices = account.devices || {}
const deviceList: DeviceRow[] = []
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])
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)
}
const handleDeleteSelected = () => {
setOpen(true)
}
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 (
<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}
/>
</ListItem>
{devices.map((device) => (
<ListItem key={device[0]}>
<DevicesIcon
sx={{ padding: '4px', margin: '4px', marginRight: '16px', height: '40px', width: '40px' }}
<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}
/>
<ListItemText
id="switch-list-label-rendezvous"
primary={device[1]}
secondary={
<Box maxWidth={'90%'}>
<EllipsisMiddle text={device[0]} />
<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,
}}
/>
<TrashBinIcon sx={{ '&:hover': { cursor: 'pointer' } }} onClick={() => selectDevice(device[0])} />
</ListItem>
))}
{/* <ListItemTextsion> */}
</Typography>
</CardContent>
</Card>
</Grid>
</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={selectedDeviceId !== null}
onClose={onClose}
open
onClose={() => {
setOpen(false)
}}
onValidation={handleValidateDeleteSelected}
title={t('revoke_device')}
onValidation={removeDevice}
buttonText={t('revoke_device')}
info={t('revoke_device_info', {
device: selectedDeviceId,
deviceName: account.devices[selectedDeviceId || ''],
})}
info={t('revoke_device_info')}
/>
</>
)}
</Box>
)
}
export default LinkedDevices
......@@ -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",
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment