Files
maternal-app/maternal-web/components/settings/DeviceTrustManagement.tsx
Andrei 426b5a309e
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
feat: Add collapsible sections and mobile grid layout
- Convert Active Sessions and Trusted Devices to collapsible Accordion components
- Display count badge in collapsed state
- Show loading state in accordion header
- Implement 2-card grid layout on mobile (xs=6)
- Responsive card sizing and spacing
- Centered layout on mobile, horizontal on desktop
- Hide full birthdate on mobile, show age only

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 08:08:24 +00:00

362 lines
12 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Card,
CardContent,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
CircularProgress,
Chip,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Divider,
ToggleButton,
ToggleButtonGroup,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
Devices,
Computer,
PhoneAndroid,
Tablet,
Delete,
CheckCircle,
Shield,
ShieldOutlined,
ExpandMore,
} from '@mui/icons-material';
import { devicesApi, type DeviceInfo } from '@/lib/api/devices';
import { motion } from 'framer-motion';
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
import { useAuth } from '@/lib/auth/AuthContext';
export function DeviceTrustManagement() {
const { logout } = useAuth();
const { formatDistanceToNow } = useLocalizedDate();
const [devices, setDevices] = useState<DeviceInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'trusted' | 'untrusted'>('all');
// Remove device dialog
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
const [deviceToRemove, setDeviceToRemove] = useState<DeviceInfo | null>(null);
const [isRemoving, setIsRemoving] = useState(false);
// Remove all dialog
const [removeAllDialogOpen, setRemoveAllDialogOpen] = useState(false);
const [isRemovingAll, setIsRemovingAll] = useState(false);
useEffect(() => {
loadDevices();
}, []);
const loadDevices = async () => {
try {
setIsLoading(true);
const response = await devicesApi.getDevices();
setDevices(response.devices);
setError(null);
} catch (err: any) {
console.error('Failed to load devices:', err);
setError('Failed to load devices');
} finally {
setIsLoading(false);
}
};
const handleTrustToggle = async (device: DeviceInfo) => {
try {
if (device.trusted) {
await devicesApi.revokeDeviceTrust(device.id);
setSuccessMessage('Device trust revoked');
} else {
await devicesApi.trustDevice(device.id);
setSuccessMessage('Device marked as trusted');
}
await loadDevices();
} catch (err: any) {
console.error('Failed to toggle device trust:', err);
setError(err.response?.data?.message || 'Failed to update device trust');
}
};
const handleRemoveDevice = async () => {
if (!deviceToRemove) return;
try {
setIsRemoving(true);
await devicesApi.removeDevice(deviceToRemove.id);
setSuccessMessage('Device removed successfully');
setRemoveDialogOpen(false);
setDeviceToRemove(null);
await loadDevices();
} catch (err: any) {
console.error('Failed to remove device:', err);
setError(err.response?.data?.message || 'Failed to remove device');
} finally {
setIsRemoving(false);
}
};
const handleRemoveAllDevices = async () => {
try {
setIsRemovingAll(true);
const response = await devicesApi.removeAllDevices();
setSuccessMessage(`${response.removedCount} device(s) removed successfully`);
setRemoveAllDialogOpen(false);
// Logout the current user since all devices are removed
// This terminates all sessions and redirects to login
await logout();
} catch (err: any) {
console.error('Failed to remove all devices:', err);
setError(err.response?.data?.message || 'Failed to remove devices');
setIsRemovingAll(false);
}
// Note: Don't set isRemovingAll to false here, as we're logging out
};
const getPlatformIcon = (platform?: string) => {
if (!platform) return <Devices />;
const p = platform.toLowerCase();
if (p.includes('android') || p.includes('mobile')) return <PhoneAndroid />;
if (p.includes('ios') || p.includes('iphone') || p.includes('ipad')) return <PhoneAndroid />;
if (p.includes('tablet')) return <Tablet />;
return <Computer />;
};
const filteredDevices = devices.filter((device) => {
if (filter === 'trusted') return device.trusted;
if (filter === 'untrusted') return !device.trusted;
return true;
});
if (isLoading) {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
<Shield color="primary" />
<Typography variant="h6" fontWeight="600">
Trusted Devices
</Typography>
<CircularProgress size={20} sx={{ ml: 'auto' }} />
</Box>
</AccordionSummary>
</Accordion>
);
}
return (
<>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMore />}
sx={{
'& .MuiAccordionSummary-content': {
margin: '12px 0',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
<Shield color="primary" />
<Typography variant="h6" fontWeight="600">
Trusted Devices
</Typography>
<Chip label={`${devices.length}`} size="small" sx={{ ml: 'auto', mr: 1 }} />
</Box>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Manage which devices are trusted to access your account. Untrusted devices will require additional verification.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{successMessage && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessMessage(null)}>
{successMessage}
</Alert>
)}
{/* Filter Toggle */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<ToggleButtonGroup
value={filter}
exclusive
onChange={(e, newFilter) => newFilter && setFilter(newFilter)}
size="small"
>
<ToggleButton value="all">All ({devices.length})</ToggleButton>
<ToggleButton value="trusted">
Trusted ({devices.filter((d) => d.trusted).length})
</ToggleButton>
<ToggleButton value="untrusted">
Untrusted ({devices.filter((d) => !d.trusted).length})
</ToggleButton>
</ToggleButtonGroup>
</Box>
{filteredDevices.length === 0 ? (
<Alert severity="info">No devices found for the selected filter.</Alert>
) : (
<>
<List>
{filteredDevices.map((device, index) => (
<Box key={device.id}>
{index > 0 && <Divider />}
<ListItem>
<Box sx={{ mr: 2 }}>{getPlatformIcon(device.platform)}</Box>
<ListItemText
primary={device.platform || 'Unknown Platform'}
secondary={
<>
Device: {device.deviceFingerprint}
<br />
Last seen: {formatDistanceToNow(new Date(device.lastSeen))} ago
</>
}
primaryTypographyProps={{
sx: { display: 'inline', mr: 1 }
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap', mr: 2 }}>
{device.isCurrent && (
<Chip label="Current" color="primary" size="small" icon={<CheckCircle />} />
)}
{device.trusted ? (
<Chip label="Trusted" color="success" size="small" icon={<Shield />} />
) : (
<Chip
label="Untrusted"
color="warning"
size="small"
icon={<ShieldOutlined />}
/>
)}
</Box>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant={device.trusted ? 'outlined' : 'contained'}
color={device.trusted ? 'warning' : 'success'}
onClick={() => handleTrustToggle(device)}
>
{device.trusted ? 'Untrust' : 'Trust'}
</Button>
{!device.isCurrent && (
<IconButton
edge="end"
onClick={() => {
setDeviceToRemove(device);
setRemoveDialogOpen(true);
}}
color="error"
>
<Delete />
</IconButton>
)}
</Box>
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
{devices.length > 1 && (
<Box sx={{ mt: 3 }}>
<Button
variant="outlined"
color="error"
onClick={() => setRemoveAllDialogOpen(true)}
fullWidth
>
Remove All Other Devices
</Button>
</Box>
)}
</>
)}
</AccordionDetails>
</Accordion>
{/* Remove Device Dialog */}
<Dialog open={removeDialogOpen} onClose={() => setRemoveDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Remove Device?</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
This will completely remove the device. All sessions from this device will be terminated.
</Alert>
{deviceToRemove && (
<Box>
<Typography variant="body2" gutterBottom>
<strong>Device:</strong> {deviceToRemove.deviceFingerprint}
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Platform:</strong> {deviceToRemove.platform || 'Unknown'}
</Typography>
<Typography variant="body2">
<strong>Last seen:</strong> {formatDistanceToNow(new Date(deviceToRemove.lastSeen))} ago
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setRemoveDialogOpen(false)} disabled={isRemoving}>
Cancel
</Button>
<Button onClick={handleRemoveDevice} variant="contained" color="error" disabled={isRemoving}>
{isRemoving ? <CircularProgress size={20} /> : 'Remove Device'}
</Button>
</DialogActions>
</Dialog>
{/* Remove All Devices Dialog */}
<Dialog open={removeAllDialogOpen} onClose={() => setRemoveAllDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Remove All Other Devices?</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
This will remove all devices except your current one. All other sessions will be terminated.
</Alert>
<Typography variant="body2">
You are about to remove {devices.filter((d) => !d.isCurrent).length} device(s).
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setRemoveAllDialogOpen(false)} disabled={isRemovingAll}>
Cancel
</Button>
<Button
onClick={handleRemoveAllDevices}
variant="contained"
color="error"
disabled={isRemovingAll}
>
{isRemovingAll ? <CircularProgress size={20} /> : 'Remove All'}
</Button>
</DialogActions>
</Dialog>
</>
);
}