mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-23 07:42:05 +00:00
Added TFA timeout env variables
Added profile session management Added "Remember me" to bypass TFA using device fingerprint Fixed profile name not being persistent after logout and login
This commit is contained in:
@@ -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
|
||||
|
@@ -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");
|
@@ -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");
|
@@ -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 {
|
||||
|
@@ -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,
|
||||
};
|
||||
|
@@ -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;
|
||||
|
@@ -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,
|
||||
};
|
||||
|
@@ -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 }) => {
|
||||
</div>
|
||||
|
||||
{/* Global Search Bar */}
|
||||
<div className="hidden md:flex items-center max-w-sm">
|
||||
<div className="flex items-center max-w-sm">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-4 lg:gap-x-6 justify-end">
|
||||
{/* External Links */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{/* 1) GitHub */}
|
||||
<a
|
||||
href="https://github.com/PatchMon/PatchMon"
|
||||
target="_blank"
|
||||
@@ -896,6 +898,7 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
{/* 2) Roadmap */}
|
||||
<a
|
||||
href="https://github.com/orgs/PatchMon/projects/2/views/1"
|
||||
target="_blank"
|
||||
@@ -905,15 +908,7 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<Route className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://patchmon.net/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
title="Discord"
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 3) Docs */}
|
||||
<a
|
||||
href="https://docs.patchmon.net"
|
||||
target="_blank"
|
||||
@@ -923,6 +918,17 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 4) Discord */}
|
||||
<a
|
||||
href="https://patchmon.net/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
title="Discord"
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 5) Email */}
|
||||
<a
|
||||
href="mailto:support@patchmon.net"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
@@ -930,6 +936,17 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<Mail className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 6) YouTube */}
|
||||
<a
|
||||
href="https://youtube.com/@patchmonTV"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
title="YouTube Channel"
|
||||
>
|
||||
<FaYoutube className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 7) Web */}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
|
@@ -54,7 +54,7 @@ const UsersTab = () => {
|
||||
});
|
||||
|
||||
// Update user mutation
|
||||
const updateUserMutation = useMutation({
|
||||
const _updateUserMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
|
@@ -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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={rememberMeId}
|
||||
name="remember_me"
|
||||
type="checkbox"
|
||||
checked={tfaData.remember_me}
|
||||
onChange={handleTfaInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor={rememberMeId}
|
||||
className="ml-2 block text-sm text-secondary-700"
|
||||
>
|
||||
Remember me on this computer (skip TFA for 30 days)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
|
@@ -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" && <TfaTab />}
|
||||
|
||||
{/* Sessions Tab */}
|
||||
{activeTab === "sessions" && <SessionsTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
Active Sessions
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
Manage your active sessions and devices. You can see where you're
|
||||
logged in and revoke access for any device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message.text && (
|
||||
<div
|
||||
className={`rounded-md p-4 ${
|
||||
message.type === "success"
|
||||
? "bg-success-50 border border-success-200 text-success-700"
|
||||
: "bg-danger-50 border border-danger-200 text-danger-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex">
|
||||
{message.type === "success" ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<p className="text-sm">{message.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions List */}
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : sessionsData?.sessions?.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* Revoke All Button */}
|
||||
{sessionsData.sessions.filter((s) => !s.is_current_session).length >
|
||||
0 && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={revokeAllSessionsMutation.isPending}
|
||||
className="inline-flex items-center px-4 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{revokeAllSessionsMutation.isPending
|
||||
? "Revoking..."
|
||||
: "Revoke All Other Sessions"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions */}
|
||||
{sessionsData.sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`border rounded-lg p-4 ${
|
||||
session.is_current_session
|
||||
? "border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
: "border-secondary-200 bg-white dark:border-secondary-700 dark:bg-secondary-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Monitor className="h-5 w-5 text-secondary-500" />
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{session.device_info?.browser} on{" "}
|
||||
{session.device_info?.os}
|
||||
</h4>
|
||||
{session.is_current_session && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
|
||||
Current Session
|
||||
</span>
|
||||
)}
|
||||
{session.tfa_remember_me && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200">
|
||||
Remembered
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{session.device_info?.device} • {session.ip_address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span>
|
||||
{session.location_info?.city},{" "}
|
||||
{session.location_info?.country}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
Last active: {formatRelativeTime(session.last_activity)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Created: {formatDate(session.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Login count: {session.login_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!session.is_current_session && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={revokeSessionMutation.isPending}
|
||||
className="ml-4 inline-flex items-center px-3 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Monitor className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
No active sessions
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
You don't have any active sessions at the moment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
|
@@ -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,
|
||||
|
Reference in New Issue
Block a user