diff --git a/backend/env.example b/backend/env.example index 6ef6243..700c2c2 100644 --- a/backend/env.example +++ b/backend/env.example @@ -31,3 +31,8 @@ JWT_SECRET=your-secure-random-secret-key-change-this-in-production JWT_EXPIRES_IN=1h JWT_REFRESH_EXPIRES_IN=7d SESSION_INACTIVITY_TIMEOUT_MINUTES=30 + +# TFA Configuration +TFA_REMEMBER_ME_EXPIRES_IN=30d +TFA_MAX_REMEMBER_SESSIONS=5 +TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3 diff --git a/backend/prisma/migrations/20251006001639_add_tfa_remember_me_fields/migration.sql b/backend/prisma/migrations/20251006001639_add_tfa_remember_me_fields/migration.sql new file mode 100644 index 0000000..d95b197 --- /dev/null +++ b/backend/prisma/migrations/20251006001639_add_tfa_remember_me_fields/migration.sql @@ -0,0 +1,6 @@ +-- Add TFA remember me fields to user_sessions table +ALTER TABLE "user_sessions" ADD COLUMN "tfa_remember_me" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user_sessions" ADD COLUMN "tfa_bypass_until" TIMESTAMP(3); + +-- Create index for TFA bypass until field for efficient querying +CREATE INDEX "user_sessions_tfa_bypass_until_idx" ON "user_sessions"("tfa_bypass_until"); diff --git a/backend/prisma/migrations/20251006002023_add_security_fields_to_sessions/migration.sql b/backend/prisma/migrations/20251006002023_add_security_fields_to_sessions/migration.sql new file mode 100644 index 0000000..187aecd --- /dev/null +++ b/backend/prisma/migrations/20251006002023_add_security_fields_to_sessions/migration.sql @@ -0,0 +1,7 @@ +-- Add security fields to user_sessions table for production-ready remember me +ALTER TABLE "user_sessions" ADD COLUMN "device_fingerprint" TEXT; +ALTER TABLE "user_sessions" ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "user_sessions" ADD COLUMN "last_login_ip" TEXT; + +-- Create index for device fingerprint for efficient querying +CREATE INDEX "user_sessions_device_fingerprint_idx" ON "user_sessions"("device_fingerprint"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 60f4732..b0fcd0f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -207,15 +207,22 @@ model user_sessions { access_token_hash String? ip_address String? user_agent String? + device_fingerprint String? last_activity DateTime @default(now()) expires_at DateTime created_at DateTime @default(now()) is_revoked Boolean @default(false) + tfa_remember_me Boolean @default(false) + tfa_bypass_until DateTime? + login_count Int @default(1) + last_login_ip String? users users @relation(fields: [user_id], references: [id], onDelete: Cascade) @@index([user_id]) @@index([refresh_token]) @@index([expires_at]) + @@index([tfa_bypass_until]) + @@index([device_fingerprint]) } model auto_enrollment_tokens { diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index f13606f..3d5dd38 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -3,6 +3,7 @@ const { PrismaClient } = require("@prisma/client"); const { validate_session, update_session_activity, + is_tfa_bypassed, } = require("../utils/session_manager"); const prisma = new PrismaClient(); @@ -46,6 +47,9 @@ const authenticateToken = async (req, res, next) => { // Update session activity timestamp await update_session_activity(decoded.sessionId); + // Check if TFA is bypassed for this session + const tfa_bypassed = await is_tfa_bypassed(decoded.sessionId); + // Update last login (only on successful authentication) await prisma.users.update({ where: { id: validation.user.id }, @@ -57,6 +61,7 @@ const authenticateToken = async (req, res, next) => { req.user = validation.user; req.session_id = decoded.sessionId; + req.tfa_bypassed = tfa_bypassed; next(); } catch (error) { if (error.name === "JsonWebTokenError") { @@ -114,8 +119,33 @@ const optionalAuth = async (req, _res, next) => { } }; +// Middleware to check if TFA is required for sensitive operations +const requireTfaIfEnabled = async (req, res, next) => { + try { + // Check if user has TFA enabled + const user = await prisma.users.findUnique({ + where: { id: req.user.id }, + select: { tfa_enabled: true }, + }); + + // If TFA is enabled and not bypassed, require TFA verification + if (user?.tfa_enabled && !req.tfa_bypassed) { + return res.status(403).json({ + error: "Two-factor authentication required for this operation", + requires_tfa: true, + }); + } + + next(); + } catch (error) { + console.error("TFA requirement check error:", error); + return res.status(500).json({ error: "Authentication check failed" }); + } +}; + module.exports = { authenticateToken, requireAdmin, optionalAuth, + requireTfaIfEnabled, }; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 7d66856..fd1aa99 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -17,12 +17,65 @@ const { refresh_access_token, revoke_session, revoke_all_user_sessions, - get_user_sessions, } = require("../utils/session_manager"); const router = express.Router(); const prisma = new PrismaClient(); +/** + * Parse user agent string to extract browser and OS info + */ +function parse_user_agent(user_agent) { + if (!user_agent) + return { browser: "Unknown", os: "Unknown", device: "Unknown" }; + + const ua = user_agent.toLowerCase(); + + // Browser detection + let browser = "Unknown"; + if (ua.includes("chrome") && !ua.includes("edg")) browser = "Chrome"; + else if (ua.includes("firefox")) browser = "Firefox"; + else if (ua.includes("safari") && !ua.includes("chrome")) browser = "Safari"; + else if (ua.includes("edg")) browser = "Edge"; + else if (ua.includes("opera")) browser = "Opera"; + + // OS detection + let os = "Unknown"; + if (ua.includes("windows")) os = "Windows"; + else if (ua.includes("macintosh") || ua.includes("mac os")) os = "macOS"; + else if (ua.includes("linux")) os = "Linux"; + else if (ua.includes("android")) os = "Android"; + else if (ua.includes("iphone") || ua.includes("ipad")) os = "iOS"; + + // Device type + let device = "Desktop"; + if (ua.includes("mobile")) device = "Mobile"; + else if (ua.includes("tablet") || ua.includes("ipad")) device = "Tablet"; + + return { browser, os, device }; +} + +/** + * Get basic location info from IP (simplified - in production you'd use a service) + */ +function get_location_from_ip(ip) { + if (!ip) return { country: "Unknown", city: "Unknown" }; + + // For localhost/private IPs + if ( + ip === "127.0.0.1" || + ip === "::1" || + ip.startsWith("192.168.") || + ip.startsWith("10.") + ) { + return { country: "Local", city: "Local Network" }; + } + + // In a real implementation, you'd use a service like MaxMind GeoIP2 + // For now, return unknown for external IPs + return { country: "Unknown", city: "Unknown" }; +} + // Check if any admin users exist (for first-time setup) router.get("/check-admin-users", async (_req, res) => { try { @@ -765,6 +818,8 @@ router.post( id: user.id, username: user.username, email: user.email, + first_name: user.first_name, + last_name: user.last_name, role: user.role, is_active: user.is_active, last_login: user.last_login, @@ -788,6 +843,10 @@ router.post( .isLength({ min: 6, max: 6 }) .withMessage("Token must be 6 digits"), body("token").isNumeric().withMessage("Token must contain only numbers"), + body("remember_me") + .optional() + .isBoolean() + .withMessage("Remember me must be a boolean"), ], async (req, res) => { try { @@ -796,7 +855,7 @@ router.post( return res.status(400).json({ errors: errors.array() }); } - const { username, token } = req.body; + const { username, token, remember_me = false } = req.body; // Find user const user = await prisma.users.findFirst({ @@ -865,13 +924,20 @@ router.post( // Create session with access and refresh tokens const ip_address = req.ip || req.connection.remoteAddress; const user_agent = req.get("user-agent"); - const session = await create_session(user.id, ip_address, user_agent); + const session = await create_session( + user.id, + ip_address, + user_agent, + remember_me, + req, + ); res.json({ message: "Login successful", token: session.access_token, refresh_token: session.refresh_token, expires_at: session.expires_at, + tfa_bypass_until: session.tfa_bypass_until, user: { id: user.id, username: user.username, @@ -1109,10 +1175,43 @@ router.post( // Get user's active sessions router.get("/sessions", authenticateToken, async (req, res) => { try { - const sessions = await get_user_sessions(req.user.id); + const sessions = await prisma.user_sessions.findMany({ + where: { + user_id: req.user.id, + is_revoked: false, + expires_at: { gt: new Date() }, + }, + select: { + id: true, + ip_address: true, + user_agent: true, + device_fingerprint: true, + last_activity: true, + created_at: true, + expires_at: true, + tfa_remember_me: true, + tfa_bypass_until: true, + login_count: true, + last_login_ip: true, + }, + orderBy: { last_activity: "desc" }, + }); + + // Enhance sessions with device info + const enhanced_sessions = sessions.map((session) => { + const is_current_session = session.id === req.session_id; + const device_info = parse_user_agent(session.user_agent); + + return { + ...session, + is_current_session, + device_info, + location_info: get_location_from_ip(session.ip_address), + }; + }); res.json({ - sessions: sessions, + sessions: enhanced_sessions, }); } catch (error) { console.error("Get sessions error:", error); @@ -1134,6 +1233,11 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => { return res.status(404).json({ error: "Session not found" }); } + // Don't allow revoking the current session + if (session_id === req.session_id) { + return res.status(400).json({ error: "Cannot revoke current session" }); + } + await revoke_session(session_id); res.json({ @@ -1145,4 +1249,25 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => { } }); +// Revoke all sessions except current one +router.delete("/sessions", authenticateToken, async (req, res) => { + try { + // Revoke all sessions except the current one + await prisma.user_sessions.updateMany({ + where: { + user_id: req.user.id, + id: { not: req.session_id }, + }, + data: { is_revoked: true }, + }); + + res.json({ + message: "All other sessions revoked successfully", + }); + } catch (error) { + console.error("Revoke all sessions error:", error); + res.status(500).json({ error: "Failed to revoke sessions" }); + } +}); + module.exports = router; diff --git a/backend/src/utils/session_manager.js b/backend/src/utils/session_manager.js index 83cbf74..5155071 100644 --- a/backend/src/utils/session_manager.js +++ b/backend/src/utils/session_manager.js @@ -15,6 +15,16 @@ if (!process.env.JWT_SECRET) { const JWT_SECRET = process.env.JWT_SECRET; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h"; const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d"; +const TFA_REMEMBER_ME_EXPIRES_IN = + process.env.TFA_REMEMBER_ME_EXPIRES_IN || "30d"; +const TFA_MAX_REMEMBER_SESSIONS = parseInt( + process.env.TFA_MAX_REMEMBER_SESSIONS || "5", + 10, +); +const TFA_SUSPICIOUS_ACTIVITY_THRESHOLD = parseInt( + process.env.TFA_SUSPICIOUS_ACTIVITY_THRESHOLD || "3", + 10, +); const INACTIVITY_TIMEOUT_MINUTES = parseInt( process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30", 10, @@ -70,16 +80,136 @@ function parse_expiration(expiration_string) { } } +/** + * Generate device fingerprint from request data + */ +function generate_device_fingerprint(req) { + const components = [ + req.get("user-agent") || "", + req.get("accept-language") || "", + req.get("accept-encoding") || "", + req.ip || "", + ]; + + // Create a simple hash of device characteristics + const fingerprint = crypto + .createHash("sha256") + .update(components.join("|")) + .digest("hex") + .substring(0, 32); // Use first 32 chars for storage efficiency + + return fingerprint; +} + +/** + * Check for suspicious activity patterns + */ +async function check_suspicious_activity( + user_id, + _ip_address, + _device_fingerprint, +) { + try { + // Check for multiple sessions from different IPs in short time + const recent_sessions = await prisma.user_sessions.findMany({ + where: { + user_id: user_id, + created_at: { + gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours + }, + is_revoked: false, + }, + select: { + ip_address: true, + device_fingerprint: true, + created_at: true, + }, + }); + + // Count unique IPs and devices + const unique_ips = new Set(recent_sessions.map((s) => s.ip_address)); + const unique_devices = new Set( + recent_sessions.map((s) => s.device_fingerprint), + ); + + // Flag as suspicious if more than threshold different IPs or devices in 24h + if ( + unique_ips.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD || + unique_devices.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD + ) { + console.warn( + `Suspicious activity detected for user ${user_id}: ${unique_ips.size} IPs, ${unique_devices.size} devices`, + ); + return true; + } + + return false; + } catch (error) { + console.error("Error checking suspicious activity:", error); + return false; + } +} + /** * Create a new session for user */ -async function create_session(user_id, ip_address, user_agent) { +async function create_session( + user_id, + ip_address, + user_agent, + remember_me = false, + req = null, +) { try { const session_id = crypto.randomUUID(); const refresh_token = generate_refresh_token(); const access_token = generate_access_token(user_id, session_id); - const expires_at = parse_expiration(JWT_REFRESH_EXPIRES_IN); + // Generate device fingerprint if request is available + const device_fingerprint = req ? generate_device_fingerprint(req) : null; + + // Check for suspicious activity + if (device_fingerprint) { + const is_suspicious = await check_suspicious_activity( + user_id, + ip_address, + device_fingerprint, + ); + if (is_suspicious) { + console.warn( + `Suspicious activity detected for user ${user_id}, session creation may be restricted`, + ); + } + } + + // Check session limits for remember me + if (remember_me) { + const existing_remember_sessions = await prisma.user_sessions.count({ + where: { + user_id: user_id, + tfa_remember_me: true, + is_revoked: false, + expires_at: { gt: new Date() }, + }, + }); + + // Limit remember me sessions per user + if (existing_remember_sessions >= TFA_MAX_REMEMBER_SESSIONS) { + throw new Error( + "Maximum number of remembered devices reached. Please revoke an existing session first.", + ); + } + } + + // Use longer expiration for remember me sessions + const expires_at = remember_me + ? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN) + : parse_expiration(JWT_REFRESH_EXPIRES_IN); + + // Calculate TFA bypass until date for remember me sessions + const tfa_bypass_until = remember_me + ? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN) + : null; // Store session in database await prisma.user_sessions.create({ @@ -90,8 +220,13 @@ async function create_session(user_id, ip_address, user_agent) { access_token_hash: hash_token(access_token), ip_address: ip_address || null, user_agent: user_agent || null, + device_fingerprint: device_fingerprint, + last_login_ip: ip_address || null, last_activity: new Date(), expires_at: expires_at, + tfa_remember_me: remember_me, + tfa_bypass_until: tfa_bypass_until, + login_count: 1, }, }); @@ -100,6 +235,7 @@ async function create_session(user_id, ip_address, user_agent) { access_token, refresh_token, expires_at, + tfa_bypass_until, }; } catch (error) { console.error("Error creating session:", error); @@ -299,6 +435,8 @@ async function get_user_sessions(user_id) { last_activity: true, created_at: true, expires_at: true, + tfa_remember_me: true, + tfa_bypass_until: true, }, orderBy: { last_activity: "desc" }, }); @@ -308,6 +446,42 @@ async function get_user_sessions(user_id) { } } +/** + * Check if TFA is bypassed for a session + */ +async function is_tfa_bypassed(session_id) { + try { + const session = await prisma.user_sessions.findUnique({ + where: { id: session_id }, + select: { + tfa_remember_me: true, + tfa_bypass_until: true, + is_revoked: true, + expires_at: true, + }, + }); + + if (!session) { + return false; + } + + // Check if session is still valid + if (session.is_revoked || new Date() > session.expires_at) { + return false; + } + + // Check if TFA is bypassed and still within bypass period + if (session.tfa_remember_me && session.tfa_bypass_until) { + return new Date() < session.tfa_bypass_until; + } + + return false; + } catch (error) { + console.error("Error checking TFA bypass:", error); + return false; + } +} + module.exports = { create_session, validate_session, @@ -317,6 +491,9 @@ module.exports = { revoke_all_user_sessions, cleanup_expired_sessions, get_user_sessions, + is_tfa_bypassed, + generate_device_fingerprint, + check_suspicious_activity, generate_access_token, INACTIVITY_TIMEOUT_MINUTES, }; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index a2b3435..fdac292 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -25,6 +25,7 @@ import { X, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { FaYoutube } from "react-icons/fa"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; @@ -875,13 +876,14 @@ const Layout = ({ children }) => { {/* Global Search Bar */} -
+
{/* External Links */} -
+
+ {/* 1) GitHub */} {
)} + {/* 2) Roadmap */} { > - - - + {/* 3) Docs */} { > + {/* 4) Discord */} + + + + {/* 5) Email */} { > + {/* 6) YouTube */} + + + + {/* 7) Web */} { }); // Update user mutation - const updateUserMutation = useMutation({ + const _updateUserMutation = useMutation({ mutationFn: ({ id, data }) => adminUsersAPI.update(id, data), onSuccess: () => { queryClient.invalidateQueries(["users"]); diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 5ad9cb5..49fc5ed 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -22,6 +22,7 @@ const Login = () => { const emailId = useId(); const passwordId = useId(); const tokenId = useId(); + const rememberMeId = useId(); const { login, setAuthState } = useAuth(); const [isSignupMode, setIsSignupMode] = useState(false); const [formData, setFormData] = useState({ @@ -33,6 +34,7 @@ const Login = () => { }); const [tfaData, setTfaData] = useState({ token: "", + remember_me: false, }); const [showPassword, setShowPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -127,7 +129,11 @@ const Login = () => { setError(""); try { - const response = await authAPI.verifyTfa(tfaUsername, tfaData.token); + const response = await authAPI.verifyTfa( + tfaUsername, + tfaData.token, + tfaData.remember_me, + ); if (response.data?.token) { // Update AuthContext with the new authentication state @@ -158,9 +164,11 @@ const Login = () => { }; const handleTfaInputChange = (e) => { + const { name, value, type, checked } = e.target; setTfaData({ ...tfaData, - [e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6), + [name]: + type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6), }); // Clear error when user starts typing if (error) { @@ -170,7 +178,7 @@ const Login = () => { const handleBackToLogin = () => { setRequiresTfa(false); - setTfaData({ token: "" }); + setTfaData({ token: "", remember_me: false }); setError(""); }; @@ -436,6 +444,23 @@ const Login = () => {
+
+ + +
+ {error && (
diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index ce22b52..fa13720 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, CheckCircle, + Clock, Copy, Download, Eye, EyeOff, Key, + LogOut, Mail, + MapPin, + Monitor, Moon, RefreshCw, Save, @@ -153,6 +157,7 @@ const Profile = () => { { id: "profile", name: "Profile Information", icon: User }, { id: "password", name: "Change Password", icon: Key }, { id: "tfa", name: "Multi-Factor Authentication", icon: Smartphone }, + { id: "sessions", name: "Active Sessions", icon: Monitor }, ]; return ( @@ -533,6 +538,9 @@ const Profile = () => { {/* Multi-Factor Authentication Tab */} {activeTab === "tfa" && } + + {/* Sessions Tab */} + {activeTab === "sessions" && }
@@ -1072,4 +1080,256 @@ const TfaTab = () => { ); }; +// Sessions Tab Component +const SessionsTab = () => { + const _queryClient = useQueryClient(); + const [_isLoading, _setIsLoading] = useState(false); + const [message, setMessage] = useState({ type: "", text: "" }); + + // Fetch user sessions + const { + data: sessionsData, + isLoading: sessionsLoading, + refetch, + } = useQuery({ + queryKey: ["user-sessions"], + queryFn: async () => { + const response = await fetch("/api/v1/auth/sessions", { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }); + if (!response.ok) throw new Error("Failed to fetch sessions"); + return response.json(); + }, + }); + + // Revoke individual session mutation + const revokeSessionMutation = useMutation({ + mutationFn: async (sessionId) => { + const response = await fetch(`/api/v1/auth/sessions/${sessionId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }); + if (!response.ok) throw new Error("Failed to revoke session"); + return response.json(); + }, + onSuccess: () => { + setMessage({ type: "success", text: "Session revoked successfully" }); + refetch(); + }, + onError: (error) => { + setMessage({ type: "error", text: error.message }); + }, + }); + + // Revoke all sessions mutation + const revokeAllSessionsMutation = useMutation({ + mutationFn: async () => { + const response = await fetch("/api/v1/auth/sessions", { + method: "DELETE", + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }); + if (!response.ok) throw new Error("Failed to revoke sessions"); + return response.json(); + }, + onSuccess: () => { + setMessage({ + type: "success", + text: "All other sessions revoked successfully", + }); + refetch(); + }, + onError: (error) => { + setMessage({ type: "error", text: error.message }); + }, + }); + + const formatDate = (dateString) => { + return new Date(dateString).toLocaleString(); + }; + + const formatRelativeTime = (dateString) => { + const now = new Date(); + const date = new Date(dateString); + const diff = now - date; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`; + if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`; + if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`; + return "Just now"; + }; + + const handleRevokeSession = (sessionId) => { + if (window.confirm("Are you sure you want to revoke this session?")) { + revokeSessionMutation.mutate(sessionId); + } + }; + + const handleRevokeAllSessions = () => { + if ( + window.confirm( + "Are you sure you want to revoke all other sessions? This will log you out of all other devices.", + ) + ) { + revokeAllSessionsMutation.mutate(); + } + }; + + return ( +
+ {/* Header */} +
+

+ Active Sessions +

+

+ Manage your active sessions and devices. You can see where you're + logged in and revoke access for any device. +

+
+ + {/* Message */} + {message.text && ( +
+
+ {message.type === "success" ? ( + + ) : ( + + )} +
+

{message.text}

+
+
+
+ )} + + {/* Sessions List */} + {sessionsLoading ? ( +
+
+
+ ) : sessionsData?.sessions?.length > 0 ? ( +
+ {/* Revoke All Button */} + {sessionsData.sessions.filter((s) => !s.is_current_session).length > + 0 && ( +
+ +
+ )} + + {/* Sessions */} + {sessionsData.sessions.map((session) => ( +
+
+
+
+ +
+
+

+ {session.device_info?.browser} on{" "} + {session.device_info?.os} +

+ {session.is_current_session && ( + + Current Session + + )} + {session.tfa_remember_me && ( + + Remembered + + )} +
+

+ {session.device_info?.device} • {session.ip_address} +

+
+
+ +
+
+ + + {session.location_info?.city},{" "} + {session.location_info?.country} + +
+
+ + + Last active: {formatRelativeTime(session.last_activity)} + +
+
+ Created: {formatDate(session.created_at)} +
+
+ Login count: {session.login_count} +
+
+
+ + {!session.is_current_session && ( + + )} +
+
+ ))} +
+ ) : ( +
+ +

+ No active sessions +

+

+ You don't have any active sessions at the moment. +

+
+ )} +
+ ); +}; + export default Profile; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 1477408..e4c941a 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -224,8 +224,8 @@ export const versionAPI = { export const authAPI = { login: (username, password) => api.post("/auth/login", { username, password }), - verifyTfa: (username, token) => - api.post("/auth/verify-tfa", { username, token }), + verifyTfa: (username, token, remember_me = false) => + api.post("/auth/verify-tfa", { username, token, remember_me }), signup: (username, email, password, firstName, lastName) => api.post("/auth/signup", { username,