Add Session Management UI
Implements user interface for viewing and managing active sessions: Session Management Features: - SessionsManagement component with full session management UI - List all active sessions with device information - Platform-specific icons (Computer, Phone, Tablet) - Current session indicator with green chip - Session details: device fingerprint, platform, last used, created date - Revoke individual sessions with confirmation dialog - Revoke all sessions except current with bulk action - Real-time session count display User Experience: - Visual device type indicators - Human-readable time formatting (e.g., "2 hours ago") - Current session clearly marked and protected from removal - Warning dialogs before revoking sessions - Success/error feedback with alerts - Loading states for all operations - Empty state handling API Integration: - Sessions API client in lib/api/sessions.ts - Get all sessions - Get session count - Revoke specific session - Revoke all sessions except current - Proper error handling and user feedback Settings Page Integration: - Added Sessions Management section - Placed after Security/MFA settings - Animated transitions with staggered delays - Maintains consistent settings page layout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
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 { motion } from 'framer-motion';
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -229,11 +230,22 @@ export default function SettingsPage() {
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* Account Actions */}
|
||||
{/* Sessions Management */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.3 }}
|
||||
>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<SessionsManagement />
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* Account Actions */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.35 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
||||
278
maternal-web/components/settings/SessionsManagement.tsx
Normal file
278
maternal-web/components/settings/SessionsManagement.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
'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,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Devices,
|
||||
Computer,
|
||||
PhoneAndroid,
|
||||
Tablet,
|
||||
Delete,
|
||||
CheckCircle,
|
||||
} from '@mui/icons-material';
|
||||
import { sessionsApi, type SessionInfo } from '@/lib/api/sessions';
|
||||
import { motion } from 'framer-motion';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export function SessionsManagement() {
|
||||
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);
|
||||
await loadSessions();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to revoke all sessions:', err);
|
||||
setError(err.response?.data?.message || 'Failed to revoke sessions');
|
||||
} finally {
|
||||
setIsRevokingAll(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 />;
|
||||
};
|
||||
|
||||
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 }}>
|
||||
<Devices color="primary" />
|
||||
<Typography variant="h6" fontWeight="600">
|
||||
Active Sessions
|
||||
</Typography>
|
||||
<Chip label={`${sessions.length} active`} size="small" sx={{ ml: 'auto' }} />
|
||||
</Box>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
maternal-web/lib/api/sessions.ts
Normal file
55
maternal-web/lib/api/sessions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3020';
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
deviceFingerprint: string;
|
||||
platform?: string;
|
||||
lastUsed: Date;
|
||||
createdAt: Date;
|
||||
ipAddress?: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export const sessionsApi = {
|
||||
// Get all sessions
|
||||
async getSessions(): Promise<{ success: boolean; sessions: SessionInfo[]; totalCount: number }> {
|
||||
const response = await axios.get(`${API_BASE_URL}/api/v1/auth/sessions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get session count
|
||||
async getSessionCount(): Promise<{ success: boolean; activeSessionCount: number }> {
|
||||
const response = await axios.get(`${API_BASE_URL}/api/v1/auth/sessions/count`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Revoke specific session
|
||||
async revokeSession(sessionId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await axios.delete(`${API_BASE_URL}/api/v1/auth/sessions/${sessionId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Revoke all sessions except current
|
||||
async revokeAllSessions(): Promise<{ success: boolean; message: string; revokedCount: number }> {
|
||||
const response = await axios.delete(`${API_BASE_URL}/api/v1/auth/sessions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user