From 50353d8fc1f811bc1103c3d5bc39ef65ef05da5a Mon Sep 17 00:00:00 2001 From: Andrei Date: Wed, 1 Oct 2025 21:11:30 +0000 Subject: [PATCH] Add Session Management UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- maternal-web/app/settings/page.tsx | 14 +- .../settings/SessionsManagement.tsx | 278 ++++++++++++++++++ maternal-web/lib/api/sessions.ts | 55 ++++ 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 maternal-web/components/settings/SessionsManagement.tsx create mode 100644 maternal-web/lib/api/sessions.ts diff --git a/maternal-web/app/settings/page.tsx b/maternal-web/app/settings/page.tsx index fa035f2..ec553f0 100644 --- a/maternal-web/app/settings/page.tsx +++ b/maternal-web/app/settings/page.tsx @@ -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() { - {/* Account Actions */} + {/* Sessions Management */} + + + + + + {/* Account Actions */} + diff --git a/maternal-web/components/settings/SessionsManagement.tsx b/maternal-web/components/settings/SessionsManagement.tsx new file mode 100644 index 0000000..7183ca6 --- /dev/null +++ b/maternal-web/components/settings/SessionsManagement.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // Revoke session dialog + const [revokeDialogOpen, setRevokeDialogOpen] = useState(false); + const [sessionToRevoke, setSessionToRevoke] = useState(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 ; + 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 ; + }; + + if (isLoading) { + return ( + + + + + + ); + } + + return ( + <> + + + + + + Active Sessions + + + + + + Manage your active sessions across different devices. You can revoke access from any device. + + + {error && ( + setError(null)}> + {error} + + )} + + {successMessage && ( + setSuccessMessage(null)}> + {successMessage} + + )} + + {sessions.length === 0 ? ( + No active sessions found. + ) : ( + <> + + {sessions.map((session, index) => ( + + {index > 0 && } + + {getPlatformIcon(session.platform)} + + + {session.platform || 'Unknown Platform'} + + {session.isCurrent && ( + } /> + )} + + } + secondary={ + + + Device: {session.deviceFingerprint} + + + Last active: {formatDistanceToNow(new Date(session.lastUsed))} ago + + + Created: {formatDistanceToNow(new Date(session.createdAt))} ago + + + } + /> + + {!session.isCurrent && ( + { + setSessionToRevoke(session); + setRevokeDialogOpen(true); + }} + color="error" + > + + + )} + + + + ))} + + + {sessions.length > 1 && ( + + + + )} + + )} + + + + {/* Revoke Session Dialog */} + setRevokeDialogOpen(false)} maxWidth="sm" fullWidth> + Revoke Session? + + + This device will be logged out and will need to log in again. + + {sessionToRevoke && ( + + + Device: {sessionToRevoke.deviceFingerprint} + + + Platform: {sessionToRevoke.platform || 'Unknown'} + + + Last active: {formatDistanceToNow(new Date(sessionToRevoke.lastUsed))} ago + + + )} + + + + + + + + {/* Revoke All Sessions Dialog */} + setRevokeAllDialogOpen(false)} maxWidth="sm" fullWidth> + Revoke All Other Sessions? + + + This will log out all devices except your current one. They will need to log in again. + + + You are about to revoke {sessions.filter((s) => !s.isCurrent).length} session(s). + + + + + + + + + ); +} diff --git a/maternal-web/lib/api/sessions.ts b/maternal-web/lib/api/sessions.ts new file mode 100644 index 0000000..323a30f --- /dev/null +++ b/maternal-web/lib/api/sessions.ts @@ -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; + }, +};