Add Device Trust Management UI
- Create DeviceTrustManagement component with trust/untrust/remove device functionality - Add devices API client for device management endpoints - Integrate DeviceTrustManagement into settings page - Add filter toggle for all/trusted/untrusted devices - Implement current device protection and indicators - Add platform-specific device icons - Include confirmation dialogs for device removal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { usersApi } from '@/lib/api/users';
|
||||
import { MFASettings } from '@/components/settings/MFASettings';
|
||||
import { SessionsManagement } from '@/components/settings/SessionsManagement';
|
||||
import { DeviceTrustManagement } from '@/components/settings/DeviceTrustManagement';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -241,11 +242,22 @@ export default function SettingsPage() {
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* Device Trust Management */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.33 }}
|
||||
>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<DeviceTrustManagement />
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* Account Actions */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.35 }}
|
||||
transition={{ duration: 0.4, delay: 0.4 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
||||
340
maternal-web/components/settings/DeviceTrustManagement.tsx
Normal file
340
maternal-web/components/settings/DeviceTrustManagement.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
'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,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Devices,
|
||||
Computer,
|
||||
PhoneAndroid,
|
||||
Tablet,
|
||||
Delete,
|
||||
CheckCircle,
|
||||
Shield,
|
||||
ShieldOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { devicesApi, type DeviceInfo } from '@/lib/api/devices';
|
||||
import { motion } from 'framer-motion';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export function DeviceTrustManagement() {
|
||||
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);
|
||||
await loadDevices();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove all devices:', err);
|
||||
setError(err.response?.data?.message || 'Failed to remove devices');
|
||||
} finally {
|
||||
setIsRemovingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardContent sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Shield color="primary" />
|
||||
<Typography variant="h6" fontWeight="600">
|
||||
Device Trust Management
|
||||
</Typography>
|
||||
<Chip label={`${devices.length} devices`} size="small" sx={{ ml: 'auto' }} />
|
||||
</Box>
|
||||
|
||||
<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={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body1">
|
||||
{device.platform || 'Unknown Platform'}
|
||||
</Typography>
|
||||
{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>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Device: {device.deviceFingerprint}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Last seen: {formatDistanceToNow(new Date(device.lastSeen))} ago
|
||||
</Typography>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
maternal-web/lib/api/devices.ts
Normal file
88
maternal-web/lib/api/devices.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3020';
|
||||
|
||||
export interface DeviceInfo {
|
||||
id: string;
|
||||
deviceFingerprint: string;
|
||||
platform?: string;
|
||||
trusted: boolean;
|
||||
lastSeen: Date;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
export const devicesApi = {
|
||||
// Get all devices
|
||||
async getDevices(): Promise<{ success: boolean; devices: DeviceInfo[]; totalCount: number }> {
|
||||
const response = await axios.get(`${API_BASE_URL}/api/v1/auth/devices`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get trusted devices only
|
||||
async getTrustedDevices(): Promise<{ success: boolean; devices: DeviceInfo[]; totalCount: number }> {
|
||||
const response = await axios.get(`${API_BASE_URL}/api/v1/auth/devices/trusted`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get device counts
|
||||
async getDeviceCount(): Promise<{ success: boolean; total: number; trusted: number; untrusted: number }> {
|
||||
const response = await axios.get(`${API_BASE_URL}/api/v1/auth/devices/count`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Trust a device
|
||||
async trustDevice(deviceId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/api/v1/auth/devices/${deviceId}/trust`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Revoke device trust
|
||||
async revokeDeviceTrust(deviceId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await axios.delete(`${API_BASE_URL}/api/v1/auth/devices/${deviceId}/trust`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Remove device completely
|
||||
async removeDevice(deviceId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await axios.delete(`${API_BASE_URL}/api/v1/auth/devices/${deviceId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Remove all devices except current
|
||||
async removeAllDevices(): Promise<{ success: boolean; message: string; removedCount: number }> {
|
||||
const response = await axios.delete(`${API_BASE_URL}/api/v1/auth/devices`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user