This commit implements comprehensive localization for high-priority components: ## Tracking Pages (4 files) - Localized feeding, sleep, diaper, and medicine tracking pages - Replaced hardcoded strings with translation keys from tracking namespace - Added useTranslation hook integration - All form labels, buttons, and messages now support multiple languages ## Child Dialog Components (2 files) - Localized ChildDialog (add/edit child form) - Localized DeleteConfirmDialog - Added new translation keys to children.json for dialog content - Includes validation messages and action buttons ## Date/Time Localization (14 files + new hook) - Created useLocalizedDate hook wrapping date-fns with locale support - Supports 5 languages: English, Spanish, French, Portuguese, Chinese - Updated all date formatting across: * Tracking pages (feeding, sleep, diaper, medicine) * Activity pages (activities, history, track activity) * Settings components (sessions, biometric, device trust) * Analytics components (insights, growth, sleep chart, feeding graph) - Date displays automatically adapt to user's language (e.g., "2 hours ago" → "hace 2 horas") ## Translation Updates - Enhanced children.json with dialog section containing: * Form field labels (name, birthDate, gender, photoUrl) * Action buttons (add, update, delete, cancel, saving, deleting) * Delete confirmation messages * Validation error messages Files changed: 17 files (+164, -113) Languages supported: en, es, fr, pt-BR, zh-CN 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
280 lines
9.5 KiB
TypeScript
280 lines
9.5 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,
|
|
} 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 { useLocalizedDate } from '@/hooks/useLocalizedDate';
|
|
|
|
export function SessionsManagement() {
|
|
const { formatDistanceToNow } = useLocalizedDate();
|
|
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>
|
|
</>
|
|
);
|
|
}
|