mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:11:12 -05:00
Refactor: Consolidate Sessions into Security Tab
This commit is contained in:
@@ -1,5 +1,31 @@
|
||||
# Account Security Improvements
|
||||
|
||||
## UI Consolidation: Sessions Merged into Security Tab
|
||||
|
||||
**Date**: 2025-01-14
|
||||
|
||||
**Changes**:
|
||||
- Merged `SessionsTab` functionality into `SecurityTab` "Active Sessions & Login History" section
|
||||
- Removed redundant `SessionsTab.tsx` component
|
||||
- Reduced settings navigation from 7 tabs to 6 tabs
|
||||
- Added proper TypeScript types with `DeviceInfo` interface
|
||||
|
||||
**Benefits**:
|
||||
- All security-related features in one location (password, 2FA, social accounts, sessions)
|
||||
- Eliminated 123 lines of duplicate code
|
||||
- Improved UX with logical grouping of authentication and session management
|
||||
- Simplified navigation structure
|
||||
- Better type safety with typed device information
|
||||
|
||||
**Technical Details**:
|
||||
- Session management uses existing `user_sessions` table with proper RLS
|
||||
- Real-time session fetching via Supabase query
|
||||
- Device detection from `device_info` JSONB column
|
||||
- Session revocation with proper error handling and toast notifications
|
||||
- Type-safe interfaces for `DeviceInfo` and `UserSession`
|
||||
|
||||
---
|
||||
|
||||
## Implemented Security Enhancements
|
||||
|
||||
This document outlines all security improvements made to the account settings system.
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Shield, Key, Smartphone, Globe, Loader2 } from 'lucide-react';
|
||||
import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { TOTPSetup } from '@/components/auth/TOTPSetup';
|
||||
import { GoogleIcon } from '@/components/icons/GoogleIcon';
|
||||
import { DiscordIcon } from '@/components/icons/DiscordIcon';
|
||||
@@ -22,6 +23,22 @@ import type { UserIdentity, OAuthProvider } from '@/types/identity';
|
||||
import { toast as sonnerToast } from '@/components/ui/sonner';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface DeviceInfo {
|
||||
browser?: string;
|
||||
userAgent?: string;
|
||||
os?: string;
|
||||
device?: string;
|
||||
}
|
||||
|
||||
interface UserSession {
|
||||
id: string;
|
||||
device_info: DeviceInfo | null;
|
||||
last_activity: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
session_token: string;
|
||||
}
|
||||
|
||||
export function SecurityTab() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
@@ -32,10 +49,13 @@ export function SecurityTab() {
|
||||
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [addingPassword, setAddingPassword] = useState(false);
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(true);
|
||||
|
||||
// Load user identities on mount
|
||||
useEffect(() => {
|
||||
loadIdentities();
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const loadIdentities = async () => {
|
||||
@@ -160,6 +180,59 @@ export function SecurityTab() {
|
||||
setAddingPassword(false);
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('user_sessions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('last_activity', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching sessions:', error);
|
||||
} else {
|
||||
const typedSessions: UserSession[] = (data || []).map(session => ({
|
||||
id: session.id,
|
||||
device_info: session.device_info as DeviceInfo | null,
|
||||
last_activity: session.last_activity,
|
||||
created_at: session.created_at,
|
||||
expires_at: session.expires_at,
|
||||
session_token: session.session_token
|
||||
}));
|
||||
setSessions(typedSessions);
|
||||
}
|
||||
setLoadingSessions(false);
|
||||
};
|
||||
|
||||
const revokeSession = async (sessionId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('user_sessions')
|
||||
.delete()
|
||||
.eq('id', sessionId);
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to revoke session',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Session revoked successfully'
|
||||
});
|
||||
fetchSessions();
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceIcon = (deviceInfo: DeviceInfo | null) => {
|
||||
const ua = deviceInfo?.userAgent?.toLowerCase() || '';
|
||||
if (ua.includes('mobile')) return <Smartphone className="w-4 h-4" />;
|
||||
if (ua.includes('tablet')) return <Tablet className="w-4 h-4" />;
|
||||
return <Monitor className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
// Get connected accounts with identity data
|
||||
const connectedAccounts = [
|
||||
{
|
||||
@@ -317,35 +390,60 @@ export function SecurityTab() {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Login History */}
|
||||
{/* Active Sessions & Login History */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<h3 className="text-lg font-medium">Login History</h3>
|
||||
<h3 className="text-lg font-medium">
|
||||
Active Sessions {sessions.length > 0 && `(${sessions.length})`}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Review your recent login activity and active sessions.
|
||||
Review and manage your active login sessions across all devices
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Current Session</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Web • {new Date().toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
{loadingSessions ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 text-muted-foreground">
|
||||
<p className="text-sm">No other recent sessions found</p>
|
||||
) : sessions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="flex items-start justify-between p-3 border rounded-lg">
|
||||
<div className="flex gap-3">
|
||||
{getDeviceIcon(session.device_info)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{session.device_info?.browser || 'Unknown Browser'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last active: {format(new Date(session.last_activity), 'PPpp')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Expires: {format(new Date(session.expires_at), 'PPpp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => revokeSession(session.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-8 text-muted-foreground">
|
||||
<p className="text-sm">No active sessions found</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Monitor, Smartphone, Tablet, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface UserSession {
|
||||
id: string;
|
||||
device_info: any;
|
||||
last_activity: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
session_token: string;
|
||||
}
|
||||
|
||||
export function SessionsTab() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('user_sessions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('last_activity', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching sessions:', error);
|
||||
} else {
|
||||
setSessions(data || []);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, [user]);
|
||||
|
||||
const revokeSession = async (sessionId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('user_sessions')
|
||||
.delete()
|
||||
.eq('id', sessionId);
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to revoke session',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Session revoked successfully'
|
||||
});
|
||||
fetchSessions();
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceIcon = (deviceInfo: any) => {
|
||||
const ua = deviceInfo?.userAgent?.toLowerCase() || '';
|
||||
if (ua.includes('mobile')) return <Smartphone className="w-4 h-4" />;
|
||||
if (ua.includes('tablet')) return <Tablet className="w-4 h-4" />;
|
||||
return <Monitor className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-muted-foreground">Loading sessions...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Active Sessions</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your active login sessions across devices
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sessions.map((session) => (
|
||||
<Card key={session.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex gap-3">
|
||||
{getDeviceIcon(session.device_info)}
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{session.device_info?.browser || 'Unknown Browser'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last active: {format(new Date(session.last_activity), 'PPpp')}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Expires: {format(new Date(session.expires_at), 'PPpp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => revokeSession(session.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
No active sessions found
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Settings, User, Shield, Eye, Bell, MapPin, Download, MonitorSmartphone } from 'lucide-react';
|
||||
import { Settings, User, Shield, Eye, Bell, MapPin, Download } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useProfile } from '@/hooks/useProfile';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AccountProfileTab } from '@/components/settings/AccountProfileTab';
|
||||
import { SecurityTab } from '@/components/settings/SecurityTab';
|
||||
import { SessionsTab } from '@/components/settings/SessionsTab';
|
||||
import { PrivacyTab } from '@/components/settings/PrivacyTab';
|
||||
import { NotificationsTab } from '@/components/settings/NotificationsTab';
|
||||
import { LocationTab } from '@/components/settings/LocationTab';
|
||||
@@ -54,12 +53,6 @@ export default function UserSettings() {
|
||||
icon: Shield,
|
||||
component: SecurityTab
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
label: 'Sessions',
|
||||
icon: MonitorSmartphone,
|
||||
component: SessionsTab
|
||||
},
|
||||
{
|
||||
id: 'privacy',
|
||||
label: 'Privacy',
|
||||
@@ -102,7 +95,7 @@ export default function UserSettings() {
|
||||
|
||||
{/* Settings Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-7 h-auto p-1">
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6 h-auto p-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user