Files
maternal-app/maternal-web/components/settings/SessionsManagement.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

304 lines
10 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,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
Devices,
Computer,
PhoneAndroid,
Tablet,
Delete,
CheckCircle,
ExpandMore,
} from '@mui/icons-material';
import { sessionsApi, type SessionInfo } from '@/lib/api/sessions';
import { motion } from 'framer-motion';
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
import { useAuth } from '@/lib/auth/AuthContext';
export function SessionsManagement() {
const { formatDistanceToNow } = useLocalizedDate();
const { logout } = useAuth();
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Revoke session dialog
const [revokeDialogOpen, setRevokeDialogOpen] = useState(false);
const [sessionToRevoke, setSessionToRevoke] = useState<SessionInfo | null>(null);
const [isRevoking, setIsRevoking] = useState(false);
// Revoke all dialog
const [revokeAllDialogOpen, setRevokeAllDialogOpen] = useState(false);
const [isRevokingAll, setIsRevokingAll] = useState(false);
useEffect(() => {
loadSessions();
}, []);
const loadSessions = async () => {
try {
setIsLoading(true);
const response = await sessionsApi.getSessions();
setSessions(response.sessions);
setError(null);
} catch (err: any) {
console.error('Failed to load sessions:', err);
setError('Failed to load sessions');
} finally {
setIsLoading(false);
}
};
const handleRevokeSession = async () => {
if (!sessionToRevoke) return;
try {
setIsRevoking(true);
await sessionsApi.revokeSession(sessionToRevoke.id);
setSuccessMessage('Session revoked successfully');
setRevokeDialogOpen(false);
setSessionToRevoke(null);
await loadSessions();
} catch (err: any) {
console.error('Failed to revoke session:', err);
setError(err.response?.data?.message || 'Failed to revoke session');
} finally {
setIsRevoking(false);
}
};
const handleRevokeAllSessions = async () => {
try {
setIsRevokingAll(true);
const response = await sessionsApi.revokeAllSessions();
setSuccessMessage(`${response.revokedCount} session(s) revoked successfully`);
setRevokeAllDialogOpen(false);
// Logout the current user since all sessions are revoked
// This clears tokens and redirects to login
await logout();
} catch (err: any) {
console.error('Failed to revoke all sessions:', err);
setError(err.response?.data?.message || 'Failed to revoke sessions');
setIsRevokingAll(false);
}
// Note: Don't set isRevokingAll 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 />;
};
if (isLoading) {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
<Devices color="primary" />
<Typography variant="h6" fontWeight="600">
Active Sessions
</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%' }}>
<Devices color="primary" />
<Typography variant="h6" fontWeight="600">
Active Sessions
</Typography>
<Chip label={`${sessions.length}`} size="small" sx={{ ml: 'auto', mr: 1 }} />
</Box>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Manage your active sessions across different devices. You can revoke access from any device.
</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>
)}
{sessions.length === 0 ? (
<Alert severity="info">No active sessions found.</Alert>
) : (
<>
<List>
{sessions.map((session, index) => (
<Box key={session.id}>
{index > 0 && <Divider />}
<ListItem>
<Box sx={{ mr: 2 }}>{getPlatformIcon(session.platform)}</Box>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body1">
{session.platform || 'Unknown Platform'}
</Typography>
{session.isCurrent && (
<Chip label="Current" color="success" size="small" icon={<CheckCircle />} />
)}
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
Device: {session.deviceFingerprint}
</Typography>
<Typography variant="body2" color="text.secondary">
Last active: {formatDistanceToNow(new Date(session.lastUsed))} ago
</Typography>
<Typography variant="body2" color="text.secondary">
Created: {formatDistanceToNow(new Date(session.createdAt))} ago
</Typography>
</Box>
}
/>
<ListItemSecondaryAction>
{!session.isCurrent && (
<IconButton
edge="end"
onClick={() => {
setSessionToRevoke(session);
setRevokeDialogOpen(true);
}}
color="error"
>
<Delete />
</IconButton>
)}
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
{sessions.length > 1 && (
<Box sx={{ mt: 3 }}>
<Button
variant="outlined"
color="error"
onClick={() => setRevokeAllDialogOpen(true)}
fullWidth
>
Revoke All Other Sessions
</Button>
</Box>
)}
</>
)}
</AccordionDetails>
</Accordion>
{/* Revoke Session Dialog */}
<Dialog open={revokeDialogOpen} onClose={() => setRevokeDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Revoke Session?</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
This device will be logged out and will need to log in again.
</Alert>
{sessionToRevoke && (
<Box>
<Typography variant="body2" gutterBottom>
<strong>Device:</strong> {sessionToRevoke.deviceFingerprint}
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Platform:</strong> {sessionToRevoke.platform || 'Unknown'}
</Typography>
<Typography variant="body2">
<strong>Last active:</strong> {formatDistanceToNow(new Date(sessionToRevoke.lastUsed))} ago
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setRevokeDialogOpen(false)} disabled={isRevoking}>
Cancel
</Button>
<Button onClick={handleRevokeSession} variant="contained" color="error" disabled={isRevoking}>
{isRevoking ? <CircularProgress size={20} /> : 'Revoke Session'}
</Button>
</DialogActions>
</Dialog>
{/* Revoke All Sessions Dialog */}
<Dialog open={revokeAllDialogOpen} onClose={() => setRevokeAllDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Revoke All Other Sessions?</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
This will log out all devices except your current one. They will need to log in again.
</Alert>
<Typography variant="body2">
You are about to revoke {sessions.filter((s) => !s.isCurrent).length} session(s).
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setRevokeAllDialogOpen(false)} disabled={isRevokingAll}>
Cancel
</Button>
<Button
onClick={handleRevokeAllSessions}
variant="contained"
color="error"
disabled={isRevokingAll}
>
{isRevokingAll ? <CircularProgress size={20} /> : 'Revoke All'}
</Button>
</DialogActions>
</Dialog>
</>
);
}