diff --git a/maternal-web/app/settings/page.tsx b/maternal-web/app/settings/page.tsx index ec553f0..7e157dc 100644 --- a/maternal-web/app/settings/page.tsx +++ b/maternal-web/app/settings/page.tsx @@ -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() { + {/* Device Trust Management */} + + + + + + {/* Account Actions */} diff --git a/maternal-web/components/settings/DeviceTrustManagement.tsx b/maternal-web/components/settings/DeviceTrustManagement.tsx new file mode 100644 index 0000000..fe36679 --- /dev/null +++ b/maternal-web/components/settings/DeviceTrustManagement.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [filter, setFilter] = useState<'all' | 'trusted' | 'untrusted'>('all'); + + // Remove device dialog + const [removeDialogOpen, setRemoveDialogOpen] = useState(false); + const [deviceToRemove, setDeviceToRemove] = useState(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 ; + const p = platform.toLowerCase(); + if (p.includes('android') || p.includes('mobile')) return ; + if (p.includes('ios') || p.includes('iphone') || p.includes('ipad')) return ; + if (p.includes('tablet')) return ; + return ; + }; + + const filteredDevices = devices.filter((device) => { + if (filter === 'trusted') return device.trusted; + if (filter === 'untrusted') return !device.trusted; + return true; + }); + + if (isLoading) { + return ( + + + + + + ); + } + + return ( + <> + + + + + + Device Trust Management + + + + + + Manage which devices are trusted to access your account. Untrusted devices will require additional verification. + + + {error && ( + setError(null)}> + {error} + + )} + + {successMessage && ( + setSuccessMessage(null)}> + {successMessage} + + )} + + {/* Filter Toggle */} + + newFilter && setFilter(newFilter)} + size="small" + > + All ({devices.length}) + + Trusted ({devices.filter((d) => d.trusted).length}) + + + Untrusted ({devices.filter((d) => !d.trusted).length}) + + + + + {filteredDevices.length === 0 ? ( + No devices found for the selected filter. + ) : ( + <> + + {filteredDevices.map((device, index) => ( + + {index > 0 && } + + {getPlatformIcon(device.platform)} + + + {device.platform || 'Unknown Platform'} + + {device.isCurrent && ( + } /> + )} + {device.trusted ? ( + } /> + ) : ( + } + /> + )} + + } + secondary={ + + + Device: {device.deviceFingerprint} + + + Last seen: {formatDistanceToNow(new Date(device.lastSeen))} ago + + + } + /> + + + + {!device.isCurrent && ( + { + setDeviceToRemove(device); + setRemoveDialogOpen(true); + }} + color="error" + > + + + )} + + + + + ))} + + + {devices.length > 1 && ( + + + + )} + + )} + + + + {/* Remove Device Dialog */} + setRemoveDialogOpen(false)} maxWidth="sm" fullWidth> + Remove Device? + + + This will completely remove the device. All sessions from this device will be terminated. + + {deviceToRemove && ( + + + Device: {deviceToRemove.deviceFingerprint} + + + Platform: {deviceToRemove.platform || 'Unknown'} + + + Last seen: {formatDistanceToNow(new Date(deviceToRemove.lastSeen))} ago + + + )} + + + + + + + + {/* Remove All Devices Dialog */} + setRemoveAllDialogOpen(false)} maxWidth="sm" fullWidth> + Remove All Other Devices? + + + This will remove all devices except your current one. All other sessions will be terminated. + + + You are about to remove {devices.filter((d) => !d.isCurrent).length} device(s). + + + + + + + + + ); +} diff --git a/maternal-web/lib/api/devices.ts b/maternal-web/lib/api/devices.ts new file mode 100644 index 0000000..ab0ae00 --- /dev/null +++ b/maternal-web/lib/api/devices.ts @@ -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; + }, +};