- 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>
362 lines
12 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|