From 5d8a1e71d648659a641e281f90c538559407fcf6 Mon Sep 17 00:00:00 2001
From: Muhammad Ibrahim
Date: Wed, 1 Oct 2025 08:38:40 +0100
Subject: [PATCH 1/3] Made changes to the host details area to add notes
Reconfigured JWT session timeouts
---
backend/env.example | 6 +
.../migration.sql | 3 +
.../add_user_sessions/migration.sql | 31 +
backend/prisma/schema.prisma | 20 +
backend/src/middleware/auth.js | 49 +-
backend/src/routes/authRoutes.js | 136 +++-
backend/src/routes/dashboardRoutes.js | 1 +
backend/src/routes/hostRoutes.js | 75 +++
backend/src/server.js | 27 +
backend/src/utils/session_manager.js | 319 +++++++++
frontend/src/pages/HostDetail.jsx | 606 ++++++++++--------
frontend/src/pages/Hosts.jsx | 26 +-
frontend/src/utils/api.js | 4 +
13 files changed, 1004 insertions(+), 299 deletions(-)
create mode 100644 backend/prisma/migrations/20250930234123_add_host_notes/migration.sql
create mode 100644 backend/prisma/migrations/add_user_sessions/migration.sql
create mode 100644 backend/src/utils/session_manager.js
diff --git a/backend/env.example b/backend/env.example
index e0d713c..1db1fe7 100644
--- a/backend/env.example
+++ b/backend/env.example
@@ -23,3 +23,9 @@ ENABLE_LOGGING=true
# User Registration
DEFAULT_USER_ROLE=user
+
+# JWT Configuration
+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
diff --git a/backend/prisma/migrations/20250930234123_add_host_notes/migration.sql b/backend/prisma/migrations/20250930234123_add_host_notes/migration.sql
new file mode 100644
index 0000000..3683c64
--- /dev/null
+++ b/backend/prisma/migrations/20250930234123_add_host_notes/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "hosts" ADD COLUMN "notes" TEXT;
+
diff --git a/backend/prisma/migrations/add_user_sessions/migration.sql b/backend/prisma/migrations/add_user_sessions/migration.sql
new file mode 100644
index 0000000..04a45a9
--- /dev/null
+++ b/backend/prisma/migrations/add_user_sessions/migration.sql
@@ -0,0 +1,31 @@
+-- CreateTable
+CREATE TABLE "user_sessions" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "refresh_token" TEXT NOT NULL,
+ "access_token_hash" TEXT,
+ "ip_address" TEXT,
+ "user_agent" TEXT,
+ "last_activity" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expires_at" TIMESTAMP(3) NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "is_revoked" BOOLEAN NOT NULL DEFAULT false,
+
+ CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "user_sessions_refresh_token_key" ON "user_sessions"("refresh_token");
+
+-- CreateIndex
+CREATE INDEX "user_sessions_user_id_idx" ON "user_sessions"("user_id");
+
+-- CreateIndex
+CREATE INDEX "user_sessions_refresh_token_idx" ON "user_sessions"("refresh_token");
+
+-- CreateIndex
+CREATE INDEX "user_sessions_expires_at_idx" ON "user_sessions"("expires_at");
+
+-- AddForeignKey
+ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 662018e..160aa1c 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -86,6 +86,7 @@ model hosts {
selinux_status String?
swap_size Int?
system_uptime String?
+ notes String?
host_packages host_packages[]
host_repositories host_repositories[]
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
@@ -186,4 +187,23 @@ model users {
first_name String?
last_name String?
dashboard_preferences dashboard_preferences[]
+ user_sessions user_sessions[]
+}
+
+model user_sessions {
+ id String @id
+ user_id String
+ refresh_token String @unique
+ access_token_hash String?
+ ip_address String?
+ user_agent String?
+ last_activity DateTime @default(now())
+ expires_at DateTime
+ created_at DateTime @default(now())
+ is_revoked Boolean @default(false)
+ users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
+
+ @@index([user_id])
+ @@index([refresh_token])
+ @@index([expires_at])
}
diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js
index e49ae81..d0d82cb 100644
--- a/backend/src/middleware/auth.js
+++ b/backend/src/middleware/auth.js
@@ -1,9 +1,13 @@
const jwt = require("jsonwebtoken");
const { PrismaClient } = require("@prisma/client");
+const {
+ validate_session,
+ update_session_activity,
+} = require("../utils/session_manager");
const prisma = new PrismaClient();
-// Middleware to verify JWT token
+// Middleware to verify JWT token with session validation
const authenticateToken = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
@@ -19,35 +23,40 @@ const authenticateToken = async (req, res, next) => {
process.env.JWT_SECRET || "your-secret-key",
);
- // Get user from database
- const user = await prisma.users.findUnique({
- where: { id: decoded.userId },
- select: {
- id: true,
- username: true,
- email: true,
- role: true,
- is_active: true,
- last_login: true,
- created_at: true,
- updated_at: true,
- },
- });
+ // Validate session and check inactivity timeout
+ const validation = await validate_session(decoded.sessionId, token);
- if (!user || !user.is_active) {
- return res.status(401).json({ error: "Invalid or inactive user" });
+ if (!validation.valid) {
+ const error_messages = {
+ "Session not found": "Session not found",
+ "Session revoked": "Session has been revoked",
+ "Session expired": "Session has expired",
+ "Session inactive":
+ validation.message || "Session timed out due to inactivity",
+ "Token mismatch": "Invalid token",
+ "User inactive": "User account is inactive",
+ };
+
+ return res.status(401).json({
+ error: error_messages[validation.reason] || "Authentication failed",
+ reason: validation.reason,
+ });
}
- // Update last login
+ // Update session activity timestamp
+ await update_session_activity(decoded.sessionId);
+
+ // Update last login (only on successful authentication)
await prisma.users.update({
- where: { id: user.id },
+ where: { id: validation.user.id },
data: {
last_login: new Date(),
updated_at: new Date(),
},
});
- req.user = user;
+ req.user = validation.user;
+ req.session_id = decoded.sessionId;
next();
} catch (error) {
if (error.name === "JsonWebTokenError") {
diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js
index bcffc02..9d04c58 100644
--- a/backend/src/routes/authRoutes.js
+++ b/backend/src/routes/authRoutes.js
@@ -12,6 +12,13 @@ const { v4: uuidv4 } = require("uuid");
const {
createDefaultDashboardPreferences,
} = require("./dashboardPreferencesRoutes");
+const {
+ create_session,
+ refresh_access_token,
+ revoke_session,
+ revoke_all_user_sessions,
+ get_user_sessions,
+} = require("../utils/session_manager");
const router = express.Router();
const prisma = new PrismaClient();
@@ -118,12 +125,16 @@ router.post(
// Create default dashboard preferences for the new admin user
await createDefaultDashboardPreferences(user.id, "admin");
- // Generate token for immediate login
- const token = generateToken(user.id);
+ // Create session for immediate login
+ 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);
res.status(201).json({
message: "Admin user created successfully",
- token,
+ token: session.access_token,
+ refresh_token: session.refresh_token,
+ expires_at: session.expires_at,
user: {
id: user.id,
username: user.username,
@@ -722,12 +733,16 @@ router.post(
},
});
- // Generate token
- const token = generateToken(user.id);
+ // 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);
res.json({
message: "Login successful",
- token,
+ token: session.access_token,
+ refresh_token: session.refresh_token,
+ expires_at: session.expires_at,
user: {
id: user.id,
username: user.username,
@@ -829,12 +844,16 @@ router.post(
data: { last_login: new Date() },
});
- // Generate token
- const jwtToken = generateToken(user.id);
+ // 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);
res.json({
message: "Login successful",
- token: jwtToken,
+ token: session.access_token,
+ refresh_token: session.refresh_token,
+ expires_at: session.expires_at,
user: {
id: user.id,
username: user.username,
@@ -1001,9 +1020,14 @@ router.put(
},
);
-// Logout (client-side token removal)
-router.post("/logout", authenticateToken, async (_req, res) => {
+// Logout (revoke current session)
+router.post("/logout", authenticateToken, async (req, res) => {
try {
+ // Revoke the current session
+ if (req.session_id) {
+ await revoke_session(req.session_id);
+ }
+
res.json({
message: "Logout successful",
});
@@ -1013,4 +1037,94 @@ router.post("/logout", authenticateToken, async (_req, res) => {
}
});
+// Logout all sessions (revoke all user sessions)
+router.post("/logout-all", authenticateToken, async (req, res) => {
+ try {
+ await revoke_all_user_sessions(req.user.id);
+
+ res.json({
+ message: "All sessions logged out successfully",
+ });
+ } catch (error) {
+ console.error("Logout all error:", error);
+ res.status(500).json({ error: "Logout all failed" });
+ }
+});
+
+// Refresh access token using refresh token
+router.post(
+ "/refresh-token",
+ [body("refresh_token").notEmpty().withMessage("Refresh token is required")],
+ async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
+ const { refresh_token } = req.body;
+
+ const result = await refresh_access_token(refresh_token);
+
+ if (!result.success) {
+ return res.status(401).json({ error: result.error });
+ }
+
+ res.json({
+ message: "Token refreshed successfully",
+ token: result.access_token,
+ user: {
+ id: result.user.id,
+ username: result.user.username,
+ email: result.user.email,
+ role: result.user.role,
+ is_active: result.user.is_active,
+ },
+ });
+ } catch (error) {
+ console.error("Refresh token error:", error);
+ res.status(500).json({ error: "Token refresh failed" });
+ }
+ },
+);
+
+// Get user's active sessions
+router.get("/sessions", authenticateToken, async (req, res) => {
+ try {
+ const sessions = await get_user_sessions(req.user.id);
+
+ res.json({
+ sessions: sessions,
+ });
+ } catch (error) {
+ console.error("Get sessions error:", error);
+ res.status(500).json({ error: "Failed to fetch sessions" });
+ }
+});
+
+// Revoke a specific session
+router.delete("/sessions/:session_id", authenticateToken, async (req, res) => {
+ try {
+ const { session_id } = req.params;
+
+ // Verify the session belongs to the user
+ const session = await prisma.user_sessions.findUnique({
+ where: { id: session_id },
+ });
+
+ if (!session || session.user_id !== req.user.id) {
+ return res.status(404).json({ error: "Session not found" });
+ }
+
+ await revoke_session(session_id);
+
+ res.json({
+ message: "Session revoked successfully",
+ });
+ } catch (error) {
+ console.error("Revoke session error:", error);
+ res.status(500).json({ error: "Failed to revoke session" });
+ }
+});
+
module.exports = router;
diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js
index 48edf2e..fb54447 100644
--- a/backend/src/routes/dashboardRoutes.js
+++ b/backend/src/routes/dashboardRoutes.js
@@ -194,6 +194,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
status: true,
agent_version: true,
auto_update: true,
+ notes: true,
host_groups: {
select: {
id: true,
diff --git a/backend/src/routes/hostRoutes.js b/backend/src/routes/hostRoutes.js
index 8a36a64..842f286 100644
--- a/backend/src/routes/hostRoutes.js
+++ b/backend/src/routes/hostRoutes.js
@@ -858,6 +858,7 @@ router.get(
auto_update: true,
created_at: true,
host_group_id: true,
+ notes: true,
host_groups: {
select: {
id: true,
@@ -1491,4 +1492,78 @@ router.patch(
},
);
+// Update host notes (admin only)
+router.patch(
+ "/:hostId/notes",
+ authenticateToken,
+ requireManageHosts,
+ [
+ body("notes")
+ .optional()
+ .isLength({ max: 1000 })
+ .withMessage("Notes must be less than 1000 characters"),
+ ],
+ async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
+ const { hostId } = req.params;
+ const { notes } = req.body;
+
+ // Check if host exists
+ const existingHost = await prisma.hosts.findUnique({
+ where: { id: hostId },
+ });
+
+ if (!existingHost) {
+ return res.status(404).json({ error: "Host not found" });
+ }
+
+ // Update the notes
+ const updatedHost = await prisma.hosts.update({
+ where: { id: hostId },
+ data: {
+ notes: notes || null,
+ updated_at: new Date(),
+ },
+ select: {
+ id: true,
+ friendly_name: true,
+ hostname: true,
+ ip: true,
+ os_type: true,
+ os_version: true,
+ architecture: true,
+ last_update: true,
+ status: true,
+ host_group_id: true,
+ agent_version: true,
+ auto_update: true,
+ created_at: true,
+ updated_at: true,
+ notes: true,
+ host_groups: {
+ select: {
+ id: true,
+ name: true,
+ color: true,
+ },
+ },
+ },
+ });
+
+ res.json({
+ message: "Notes updated successfully",
+ host: updatedHost,
+ });
+ } catch (error) {
+ console.error("Update notes error:", error);
+ res.status(500).json({ error: "Failed to update notes" });
+ }
+ },
+);
+
module.exports = router;
diff --git a/backend/src/server.js b/backend/src/server.js
index 832cefc..78fc180 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -26,6 +26,7 @@ const versionRoutes = require("./routes/versionRoutes");
const tfaRoutes = require("./routes/tfaRoutes");
const updateScheduler = require("./services/updateScheduler");
const { initSettings } = require("./services/settingsService");
+const { cleanup_expired_sessions } = require("./utils/session_manager");
// Initialize Prisma client with optimized connection pooling for multiple instances
const prisma = createPrismaClient();
@@ -399,6 +400,9 @@ process.on("SIGINT", async () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info("SIGINT received, shutting down gracefully");
}
+ if (app.locals.session_cleanup_interval) {
+ clearInterval(app.locals.session_cleanup_interval);
+ }
updateScheduler.stop();
await disconnectPrisma(prisma);
process.exit(0);
@@ -408,6 +412,9 @@ process.on("SIGTERM", async () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info("SIGTERM received, shutting down gracefully");
}
+ if (app.locals.session_cleanup_interval) {
+ clearInterval(app.locals.session_cleanup_interval);
+ }
updateScheduler.stop();
await disconnectPrisma(prisma);
process.exit(0);
@@ -671,15 +678,35 @@ async function startServer() {
// Initialize dashboard preferences for all users
await initializeDashboardPreferences();
+
+ // Initial session cleanup
+ await cleanup_expired_sessions();
+
+ // Schedule session cleanup every hour
+ const session_cleanup_interval = setInterval(
+ async () => {
+ try {
+ await cleanup_expired_sessions();
+ } catch (error) {
+ console.error("Session cleanup error:", error);
+ }
+ },
+ 60 * 60 * 1000,
+ ); // Every hour
+
app.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
+ logger.info("✅ Session cleanup scheduled (every hour)");
}
// Start update scheduler
updateScheduler.start();
});
+
+ // Store interval for cleanup on shutdown
+ app.locals.session_cleanup_interval = session_cleanup_interval;
} catch (error) {
console.error("❌ Failed to start server:", error.message);
process.exit(1);
diff --git a/backend/src/utils/session_manager.js b/backend/src/utils/session_manager.js
new file mode 100644
index 0000000..b941ec5
--- /dev/null
+++ b/backend/src/utils/session_manager.js
@@ -0,0 +1,319 @@
+const jwt = require("jsonwebtoken");
+const crypto = require("crypto");
+const { PrismaClient } = require("@prisma/client");
+
+const prisma = new PrismaClient();
+
+/**
+ * Session Manager - Handles secure session management with inactivity timeout
+ */
+
+// Configuration
+const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
+const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
+const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
+const INACTIVITY_TIMEOUT_MINUTES = parseInt(
+ process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30",
+ 10,
+);
+
+/**
+ * Generate access token (short-lived)
+ */
+function generate_access_token(user_id, session_id) {
+ return jwt.sign({ userId: user_id, sessionId: session_id }, JWT_SECRET, {
+ expiresIn: JWT_EXPIRES_IN,
+ });
+}
+
+/**
+ * Generate refresh token (long-lived)
+ */
+function generate_refresh_token() {
+ return crypto.randomBytes(64).toString("hex");
+}
+
+/**
+ * Hash token for storage
+ */
+function hash_token(token) {
+ return crypto.createHash("sha256").update(token).digest("hex");
+}
+
+/**
+ * Parse expiration string to Date
+ */
+function parse_expiration(expiration_string) {
+ const match = expiration_string.match(/^(\d+)([smhd])$/);
+ if (!match) {
+ throw new Error("Invalid expiration format");
+ }
+
+ const value = parseInt(match[1], 10);
+ const unit = match[2];
+
+ const now = new Date();
+ switch (unit) {
+ case "s":
+ return new Date(now.getTime() + value * 1000);
+ case "m":
+ return new Date(now.getTime() + value * 60 * 1000);
+ case "h":
+ return new Date(now.getTime() + value * 60 * 60 * 1000);
+ case "d":
+ return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
+ default:
+ throw new Error("Invalid time unit");
+ }
+}
+
+/**
+ * Create a new session for user
+ */
+async function create_session(user_id, ip_address, user_agent) {
+ 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);
+
+ // Store session in database
+ await prisma.user_sessions.create({
+ data: {
+ id: session_id,
+ user_id: user_id,
+ refresh_token: hash_token(refresh_token),
+ access_token_hash: hash_token(access_token),
+ ip_address: ip_address || null,
+ user_agent: user_agent || null,
+ last_activity: new Date(),
+ expires_at: expires_at,
+ },
+ });
+
+ return {
+ session_id,
+ access_token,
+ refresh_token,
+ expires_at,
+ };
+ } catch (error) {
+ console.error("Error creating session:", error);
+ throw error;
+ }
+}
+
+/**
+ * Validate session and check for inactivity timeout
+ */
+async function validate_session(session_id, access_token) {
+ try {
+ const session = await prisma.user_sessions.findUnique({
+ where: { id: session_id },
+ include: { users: true },
+ });
+
+ if (!session) {
+ return { valid: false, reason: "Session not found" };
+ }
+
+ // Check if session is revoked
+ if (session.is_revoked) {
+ return { valid: false, reason: "Session revoked" };
+ }
+
+ // Check if session has expired
+ if (new Date() > session.expires_at) {
+ await revoke_session(session_id);
+ return { valid: false, reason: "Session expired" };
+ }
+
+ // Check for inactivity timeout
+ const inactivity_threshold = new Date(
+ Date.now() - INACTIVITY_TIMEOUT_MINUTES * 60 * 1000,
+ );
+ if (session.last_activity < inactivity_threshold) {
+ await revoke_session(session_id);
+ return {
+ valid: false,
+ reason: "Session inactive",
+ message: `Session timed out after ${INACTIVITY_TIMEOUT_MINUTES} minutes of inactivity`,
+ };
+ }
+
+ // Validate access token hash (optional security check)
+ if (session.access_token_hash) {
+ const provided_hash = hash_token(access_token);
+ if (session.access_token_hash !== provided_hash) {
+ return { valid: false, reason: "Token mismatch" };
+ }
+ }
+
+ // Check if user is still active
+ if (!session.users.is_active) {
+ await revoke_session(session_id);
+ return { valid: false, reason: "User inactive" };
+ }
+
+ return {
+ valid: true,
+ session,
+ user: session.users,
+ };
+ } catch (error) {
+ console.error("Error validating session:", error);
+ return { valid: false, reason: "Validation error" };
+ }
+}
+
+/**
+ * Update session activity timestamp
+ */
+async function update_session_activity(session_id) {
+ try {
+ await prisma.user_sessions.update({
+ where: { id: session_id },
+ data: { last_activity: new Date() },
+ });
+ return true;
+ } catch (error) {
+ console.error("Error updating session activity:", error);
+ return false;
+ }
+}
+
+/**
+ * Refresh access token using refresh token
+ */
+async function refresh_access_token(refresh_token) {
+ try {
+ const hashed_token = hash_token(refresh_token);
+
+ const session = await prisma.user_sessions.findUnique({
+ where: { refresh_token: hashed_token },
+ include: { users: true },
+ });
+
+ if (!session) {
+ return { success: false, error: "Invalid refresh token" };
+ }
+
+ // Validate session
+ const validation = await validate_session(session.id, "");
+ if (!validation.valid) {
+ return { success: false, error: validation.reason };
+ }
+
+ // Generate new access token
+ const new_access_token = generate_access_token(session.user_id, session.id);
+
+ // Update access token hash
+ await prisma.user_sessions.update({
+ where: { id: session.id },
+ data: {
+ access_token_hash: hash_token(new_access_token),
+ last_activity: new Date(),
+ },
+ });
+
+ return {
+ success: true,
+ access_token: new_access_token,
+ user: session.users,
+ };
+ } catch (error) {
+ console.error("Error refreshing access token:", error);
+ return { success: false, error: "Token refresh failed" };
+ }
+}
+
+/**
+ * Revoke a session
+ */
+async function revoke_session(session_id) {
+ try {
+ await prisma.user_sessions.update({
+ where: { id: session_id },
+ data: { is_revoked: true },
+ });
+ return true;
+ } catch (error) {
+ console.error("Error revoking session:", error);
+ return false;
+ }
+}
+
+/**
+ * Revoke all sessions for a user
+ */
+async function revoke_all_user_sessions(user_id) {
+ try {
+ await prisma.user_sessions.updateMany({
+ where: { user_id: user_id },
+ data: { is_revoked: true },
+ });
+ return true;
+ } catch (error) {
+ console.error("Error revoking user sessions:", error);
+ return false;
+ }
+}
+
+/**
+ * Clean up expired sessions (should be run periodically)
+ */
+async function cleanup_expired_sessions() {
+ try {
+ const result = await prisma.user_sessions.deleteMany({
+ where: {
+ OR: [{ expires_at: { lt: new Date() } }, { is_revoked: true }],
+ },
+ });
+ console.log(`Cleaned up ${result.count} expired sessions`);
+ return result.count;
+ } catch (error) {
+ console.error("Error cleaning up sessions:", error);
+ return 0;
+ }
+}
+
+/**
+ * Get active sessions for a user
+ */
+async function get_user_sessions(user_id) {
+ try {
+ return await prisma.user_sessions.findMany({
+ where: {
+ user_id: user_id,
+ is_revoked: false,
+ expires_at: { gt: new Date() },
+ },
+ select: {
+ id: true,
+ ip_address: true,
+ user_agent: true,
+ last_activity: true,
+ created_at: true,
+ expires_at: true,
+ },
+ orderBy: { last_activity: "desc" },
+ });
+ } catch (error) {
+ console.error("Error getting user sessions:", error);
+ return [];
+ }
+}
+
+module.exports = {
+ create_session,
+ validate_session,
+ update_session_activity,
+ refresh_access_token,
+ revoke_session,
+ revoke_all_user_sessions,
+ cleanup_expired_sessions,
+ get_user_sessions,
+ generate_access_token,
+ INACTIVITY_TIMEOUT_MINUTES,
+};
diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx
index 6e7a998..8f2b222 100644
--- a/frontend/src/pages/HostDetail.jsx
+++ b/frontend/src/pages/HostDetail.jsx
@@ -102,6 +102,15 @@ const HostDetail = () => {
},
});
+ const updateNotesMutation = useMutation({
+ mutationFn: ({ hostId, notes }) =>
+ adminHostsAPI.updateNotes(hostId, notes).then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["host", hostId]);
+ queryClient.invalidateQueries(["hosts"]);
+ },
+ });
+
const handleDeleteHost = async () => {
if (
window.confirm(
@@ -315,17 +324,6 @@ const HostDetail = () => {
>
System
-
+
@@ -506,55 +515,279 @@ const HostDetail = () => {
)}
{/* System Information */}
- {activeTab === "system" &&
- (host.kernel_version ||
- host.selinux_status ||
- host.architecture) && (
-
-
- {host.architecture && (
-
-
- Architecture
-
-
- {host.architecture}
-
-
- )}
+ {activeTab === "system" && (
+
+ {/* Basic System Information */}
+ {(host.kernel_version ||
+ host.selinux_status ||
+ host.architecture) && (
+
+
+
+ System Information
+
+
+ {host.architecture && (
+
+
+ Architecture
+
+
+ {host.architecture}
+
+
+ )}
- {host.kernel_version && (
-
-
- Kernel Version
-
-
- {host.kernel_version}
-
-
- )}
+ {host.kernel_version && (
+
+
+ Kernel Version
+
+
+ {host.kernel_version}
+
+
+ )}
- {host.selinux_status && (
-
-
- SELinux Status
-
-
- {host.selinux_status}
-
-
- )}
+ {/* Empty div to push SELinux status to the right */}
+
+
+ {host.selinux_status && (
+
+
+ SELinux Status
+
+
+ {host.selinux_status}
+
+
+ )}
+
-
- )}
+ )}
+
+ {/* Resource Information */}
+ {(host.system_uptime ||
+ host.cpu_model ||
+ host.cpu_cores ||
+ host.ram_installed ||
+ host.swap_size !== undefined ||
+ (host.load_average &&
+ Array.isArray(host.load_average) &&
+ host.load_average.length > 0 &&
+ host.load_average.some((load) => load != null)) ||
+ (host.disk_details &&
+ Array.isArray(host.disk_details) &&
+ host.disk_details.length > 0)) && (
+
+
+
+ Resource Information
+
+
+ {/* System Overview */}
+
+ {/* System Uptime */}
+ {host.system_uptime && (
+
+
+
+ {host.system_uptime}
+
+
+ )}
+
+ {/* CPU Model */}
+ {host.cpu_model && (
+
+
+
+ {host.cpu_model}
+
+
+ )}
+
+ {/* CPU Cores */}
+ {host.cpu_cores && (
+
+
+
+ {host.cpu_cores}
+
+
+ )}
+
+ {/* RAM Installed */}
+ {host.ram_installed && (
+
+
+
+ {host.ram_installed} GB
+
+
+ )}
+
+ {/* Swap Size */}
+ {host.swap_size !== undefined &&
+ host.swap_size !== null && (
+
+
+
+ {host.swap_size} GB
+
+
+ )}
+
+ {/* Load Average */}
+ {host.load_average &&
+ Array.isArray(host.load_average) &&
+ host.load_average.length > 0 &&
+ host.load_average.some((load) => load != null) && (
+
+
+
+ {host.load_average
+ .filter((load) => load != null)
+ .map((load, index) => (
+
+ {typeof load === "number"
+ ? load.toFixed(2)
+ : String(load)}
+ {index <
+ host.load_average.filter(
+ (load) => load != null,
+ ).length -
+ 1 && ", "}
+
+ ))}
+
+
+ )}
+
+
+ {/* Disk Information */}
+ {host.disk_details &&
+ Array.isArray(host.disk_details) &&
+ host.disk_details.length > 0 && (
+
+
+
+ Disk Usage
+
+
+ {host.disk_details.map((disk, index) => (
+
+
+
+
+ {disk.name || `Disk ${index + 1}`}
+
+
+ {disk.size && (
+
+ Size: {disk.size}
+
+ )}
+ {disk.mountpoint && (
+
+ Mount: {disk.mountpoint}
+
+ )}
+ {disk.usage &&
+ typeof disk.usage === "number" && (
+
+
+ Usage
+ {disk.usage}%
+
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* No Data State */}
+ {!host.kernel_version &&
+ !host.selinux_status &&
+ !host.architecture &&
+ !host.system_uptime &&
+ !host.cpu_model &&
+ !host.cpu_cores &&
+ !host.ram_installed &&
+ host.swap_size === undefined &&
+ (!host.load_average ||
+ !Array.isArray(host.load_average) ||
+ host.load_average.length === 0 ||
+ !host.load_average.some((load) => load != null)) &&
+ (!host.disk_details ||
+ !Array.isArray(host.disk_details) ||
+ host.disk_details.length === 0) && (
+
+
+
+ No system information available
+
+
+ System information will appear once the agent collects
+ data from this host
+
+
+ )}
+
+ )}
{activeTab === "network" &&
!(
@@ -570,213 +803,6 @@ const HostDetail = () => {
)}
- {activeTab === "system" &&
- !(
- host.kernel_version ||
- host.selinux_status ||
- host.architecture
- ) && (
-
-
-
- No system information available
-
-
- )}
-
- {/* System Monitoring */}
- {activeTab === "monitoring" && (
-
- {/* System Overview */}
-
- {/* System Uptime */}
- {host.system_uptime && (
-
-
-
- {host.system_uptime}
-
-
- )}
-
- {/* CPU Model */}
- {host.cpu_model && (
-
-
-
- {host.cpu_model}
-
-
- )}
-
- {/* CPU Cores */}
- {host.cpu_cores && (
-
-
-
- {host.cpu_cores}
-
-
- )}
-
- {/* RAM Installed */}
- {host.ram_installed && (
-
-
-
- {host.ram_installed} GB
-
-
- )}
-
- {/* Swap Size */}
- {host.swap_size !== undefined &&
- host.swap_size !== null && (
-
-
-
- {host.swap_size} GB
-
-
- )}
-
- {/* Load Average */}
- {host.load_average &&
- Array.isArray(host.load_average) &&
- host.load_average.length > 0 &&
- host.load_average.some((load) => load != null) && (
-
-
-
- {host.load_average
- .filter((load) => load != null)
- .map((load, index) => (
-
- {typeof load === "number"
- ? load.toFixed(2)
- : String(load)}
- {index <
- host.load_average.filter(
- (load) => load != null,
- ).length -
- 1 && ", "}
-
- ))}
-
-
- )}
-
-
- {/* Disk Information */}
- {host.disk_details &&
- Array.isArray(host.disk_details) &&
- host.disk_details.length > 0 && (
-
-
-
- Disk Usage
-
-
- {host.disk_details.map((disk, index) => (
-
-
-
-
- {disk.name || `Disk ${index + 1}`}
-
-
- {disk.size && (
-
- Size: {disk.size}
-
- )}
- {disk.mountpoint && (
-
- Mount: {disk.mountpoint}
-
- )}
- {disk.usage && typeof disk.usage === "number" && (
-
-
- Usage
- {disk.usage}%
-
-
-
- )}
-
- ))}
-
-
- )}
-
- {/* No Data State */}
- {!host.system_uptime &&
- !host.cpu_model &&
- !host.cpu_cores &&
- !host.ram_installed &&
- host.swap_size === undefined &&
- (!host.load_average ||
- !Array.isArray(host.load_average) ||
- host.load_average.length === 0 ||
- !host.load_average.some((load) => load != null)) &&
- (!host.disk_details ||
- !Array.isArray(host.disk_details) ||
- host.disk_details.length === 0) && (
-
-
-
- No monitoring data available
-
-
- Monitoring data will appear once the agent collects
- system information
-
-
- )}
-
- )}
-
{/* Update History */}
{activeTab === "history" && (
@@ -883,6 +909,56 @@ const HostDetail = () => {
)}
)}
+
+ {/* Notes */}
+ {activeTab === "notes" && (
+
+ )}
diff --git a/frontend/src/pages/Hosts.jsx b/frontend/src/pages/Hosts.jsx
index fbb94cb..bc74124 100644
--- a/frontend/src/pages/Hosts.jsx
+++ b/frontend/src/pages/Hosts.jsx
@@ -341,8 +341,9 @@ const Hosts = () => {
},
{ id: "status", label: "Status", visible: true, order: 8 },
{ id: "updates", label: "Updates", visible: true, order: 9 },
- { id: "last_update", label: "Last Update", visible: true, order: 10 },
- { id: "actions", label: "Actions", visible: true, order: 11 },
+ { id: "notes", label: "Notes", visible: false, order: 10 },
+ { id: "last_update", label: "Last Update", visible: true, order: 11 },
+ { id: "actions", label: "Actions", visible: true, order: 12 },
];
const saved = localStorage.getItem("hosts-column-config");
@@ -542,7 +543,8 @@ const Hosts = () => {
searchTerm === "" ||
host.friendly_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
- host.os_type?.toLowerCase().includes(searchTerm.toLowerCase());
+ host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
// Group filter
const matchesGroup =
@@ -628,6 +630,10 @@ const Hosts = () => {
aValue = new Date(a.last_update);
bValue = new Date(b.last_update);
break;
+ case "notes":
+ aValue = (a.notes || "").toLowerCase();
+ bValue = (b.notes || "").toLowerCase();
+ break;
default:
aValue = a[sortField];
bValue = b[sortField];
@@ -877,6 +883,20 @@ const Hosts = () => {
{formatRelativeTime(host.last_update)}
);
+ case "notes":
+ return (
+
+ {host.notes ? (
+
+ {host.notes}
+
+ ) : (
+
+ No notes
+
+ )}
+
+ );
case "actions":
return (
+ api.patch(`/hosts/${hostId}/notes`, {
+ notes: notes,
+ }),
};
// Host Groups API
From 0742c4b05cd3cbc50bab4b65a88c5d4e1baab2d5 Mon Sep 17 00:00:00 2001
From: Muhammad Ibrahim
Date: Wed, 1 Oct 2025 09:08:27 +0100
Subject: [PATCH 2/3] fixed TFA setting up and login redirect issue
---
frontend/src/pages/Login.jsx | 7 +++----
frontend/src/pages/Profile.jsx | 1 +
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
index a0c76a3..5ad9cb5 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 { login, setAuthState } = useAuth();
const [isSignupMode, setIsSignupMode] = useState(false);
const [formData, setFormData] = useState({
username: "",
@@ -41,7 +42,6 @@ const Login = () => {
const [signupEnabled, setSignupEnabled] = useState(false);
const navigate = useNavigate();
- const { login, setAuthState } = useAuth();
// Check if signup is enabled
useEffect(() => {
@@ -130,9 +130,8 @@ const Login = () => {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
if (response.data?.token) {
- // Store token and user data
- localStorage.setItem("token", response.data.token);
- localStorage.setItem("user", JSON.stringify(response.data.user));
+ // Update AuthContext with the new authentication state
+ setAuthState(response.data.token, response.data.user);
// Redirect to dashboard
navigate("/");
diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx
index 50635f1..5fc7d68 100644
--- a/frontend/src/pages/Profile.jsx
+++ b/frontend/src/pages/Profile.jsx
@@ -529,6 +529,7 @@ const Profile = () => {
// TFA Tab Component
const TfaTab = () => {
+ const verificationTokenId = useId();
const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
const [verificationToken, setVerificationToken] = useState("");
const [password, setPassword] = useState("");
From 02f9899b237d5a48f6d051b638fc6edba397432e Mon Sep 17 00:00:00 2001
From: Muhammad Ibrahim
Date: Wed, 1 Oct 2025 09:33:34 +0100
Subject: [PATCH 3/3] Added pages for coming soon features
---
frontend/src/App.jsx | 22 +++++
frontend/src/components/SettingsLayout.jsx | 28 ++++++
frontend/src/pages/settings/AlertChannels.jsx | 12 +--
frontend/src/pages/settings/Integrations.jsx | 49 ++++++++++
frontend/src/pages/settings/Notifications.jsx | 55 +----------
.../src/pages/settings/PatchManagement.jsx | 98 +++++++++++++++++++
6 files changed, 200 insertions(+), 64 deletions(-)
create mode 100644 frontend/src/pages/settings/Integrations.jsx
create mode 100644 frontend/src/pages/settings/PatchManagement.jsx
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index af5b350..a9642ca 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -17,7 +17,9 @@ import Profile from "./pages/Profile";
import Repositories from "./pages/Repositories";
import RepositoryDetail from "./pages/RepositoryDetail";
import AlertChannels from "./pages/settings/AlertChannels";
+import Integrations from "./pages/settings/Integrations";
import Notifications from "./pages/settings/Notifications";
+import PatchManagement from "./pages/settings/PatchManagement";
import SettingsAgentConfig from "./pages/settings/SettingsAgentConfig";
import SettingsHostGroups from "./pages/settings/SettingsHostGroups";
import SettingsServerConfig from "./pages/settings/SettingsServerConfig";
@@ -248,6 +250,26 @@ function AppRoutes() {
}
/>
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
{
});
}
+ // Patch Management
+ if (canManageSettings()) {
+ nav.push({
+ section: "Patch Management",
+ items: [
+ {
+ name: "Policies",
+ href: "/settings/patch-management",
+ icon: Settings,
+ comingSoon: true,
+ },
+ ],
+ });
+ }
+
// Server Config
if (canManageSettings()) {
+ // Integrations section
+ nav.push({
+ section: "Integrations",
+ items: [
+ {
+ name: "Integrations",
+ href: "/settings/integrations",
+ icon: Wrench,
+ comingSoon: true,
+ },
+ ],
+ });
+
nav.push({
section: "Server",
items: [
diff --git a/frontend/src/pages/settings/AlertChannels.jsx b/frontend/src/pages/settings/AlertChannels.jsx
index 3fdf935..05af5d1 100644
--- a/frontend/src/pages/settings/AlertChannels.jsx
+++ b/frontend/src/pages/settings/AlertChannels.jsx
@@ -1,4 +1,4 @@
-import { Bell, Plus } from "lucide-react";
+import { Bell } from "lucide-react";
const AlertChannels = () => {
return (
@@ -13,10 +13,6 @@ const AlertChannels = () => {
Configure how PatchMon sends notifications and alerts
-
{/* Coming Soon Card */}
@@ -62,12 +58,6 @@ const AlertChannels = () => {
Get started by adding your first alert channel.
-
diff --git a/frontend/src/pages/settings/Integrations.jsx b/frontend/src/pages/settings/Integrations.jsx
new file mode 100644
index 0000000..200372e
--- /dev/null
+++ b/frontend/src/pages/settings/Integrations.jsx
@@ -0,0 +1,49 @@
+import { Plug } from "lucide-react";
+import SettingsLayout from "../../components/SettingsLayout";
+
+const Integrations = () => {
+ return (
+
+
+ {/* Header */}
+
+
+
+ Integrations
+
+
+ Connect PatchMon to third-party services
+
+
+
+
+ {/* Coming Soon Card */}
+
+
+
+
+
+ Integrations Coming Soon
+
+
+ We are building integrations for Slack, Discord, email, and
+ webhooks to streamline alerts and workflows.
+
+
+
+ In Development
+
+
+
+
+
+
+
+ );
+};
+
+export default Integrations;
diff --git a/frontend/src/pages/settings/Notifications.jsx b/frontend/src/pages/settings/Notifications.jsx
index 3d035fe..c3d761a 100644
--- a/frontend/src/pages/settings/Notifications.jsx
+++ b/frontend/src/pages/settings/Notifications.jsx
@@ -1,4 +1,4 @@
-import { Bell, Plus, Settings } from "lucide-react";
+import { Bell, Settings } from "lucide-react";
const Notifications = () => {
return (
@@ -13,10 +13,6 @@ const Notifications = () => {
Configure notification preferences and alert rules
-
{/* Coming Soon Card */}
@@ -46,7 +42,7 @@ const Notifications = () => {
{/* Notification Settings Preview */}
-
+
{/* Package Updates */}
@@ -87,47 +83,6 @@ const Notifications = () => {
-
- {/* System Events */}
-
-
-
-
-
-
- System Events
-
-
-
- Monitor system health and performance events
-
-
-
-
- Host Offline
-
-
- Coming Soon
-
-
-
-
- High CPU Usage
-
-
- Coming Soon
-
-
-
-
- Disk Space Low
-
-
- Coming Soon
-
-
-
-
{/* Empty State */}
@@ -148,12 +103,6 @@ const Notifications = () => {
Get started by creating your first notification rule.
-
diff --git a/frontend/src/pages/settings/PatchManagement.jsx b/frontend/src/pages/settings/PatchManagement.jsx
new file mode 100644
index 0000000..582a23a
--- /dev/null
+++ b/frontend/src/pages/settings/PatchManagement.jsx
@@ -0,0 +1,98 @@
+import { Settings } from "lucide-react";
+import SettingsLayout from "../../components/SettingsLayout";
+
+const PatchManagement = () => {
+ return (
+
+
+ {/* Header */}
+
+
+
+ Patch Management
+
+
+ Define and enforce policies for applying and monitoring patches
+
+
+
+
+ {/* Coming Soon Card */}
+
+
+
+
+
+ Patch Management Coming Soon
+
+
+ We are designing rule sets, approval workflows, and automated
+ patch policies to give you granular control over updates.
+
+
+
+ In Development
+
+
+
+
+
+
+ {/* Patch Policy */}
+
+
+
+
+
+
+ Patch Policy
+
+
+
+ Configure rule sets for patch management and monitoring
+
+
+
+
+ Rule Sets
+
+
+ Coming Soon
+
+
+
+
+ Patch Approval Workflows
+
+
+ Coming Soon
+
+
+
+
+ Security Patch Priority
+
+
+ Coming Soon
+
+
+
+
+ Auto-Update Policies
+
+
+ Coming Soon
+
+
+
+
+
+
+ );
+};
+
+export default PatchManagement;