From 94bfffd88262a386a0960a1d89ceee2533e3e3cf Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 31 Oct 2025 17:33:47 +0000 Subject: [PATCH] Theme settings per user --- backend/prisma/schema.prisma | 3 +- backend/src/routes/userPreferencesRoutes.js | 105 ++++++++++++++++ backend/src/server.js | 2 + frontend/src/App.jsx | 21 ++-- frontend/src/components/LogoProvider.jsx | 13 +- .../src/components/settings/BrandingTab.jsx | 113 +----------------- frontend/src/contexts/ColorThemeContext.jsx | 72 +++++------ frontend/src/contexts/SettingsContext.jsx | 45 +++++++ frontend/src/contexts/ThemeContext.jsx | 33 ++++- .../contexts/UpdateNotificationContext.jsx | 17 +-- frontend/src/pages/Profile.jsx | 64 ++++++++++ .../src/pages/settings/SettingsMetrics.jsx | 14 +++ frontend/src/utils/api.js | 6 + 13 files changed, 317 insertions(+), 191 deletions(-) create mode 100644 backend/src/routes/userPreferencesRoutes.js create mode 100644 frontend/src/contexts/SettingsContext.jsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f703d58..c2344d8 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -196,7 +196,6 @@ model settings { metrics_enabled Boolean @default(true) metrics_anonymous_id String? metrics_last_sent DateTime? - color_theme String @default("default") } model update_history { @@ -228,6 +227,8 @@ model users { tfa_secret String? first_name String? last_name String? + theme_preference String? @default("dark") + color_theme String? @default("cyber_blue") dashboard_preferences dashboard_preferences[] user_sessions user_sessions[] auto_enrollment_tokens auto_enrollment_tokens[] diff --git a/backend/src/routes/userPreferencesRoutes.js b/backend/src/routes/userPreferencesRoutes.js new file mode 100644 index 0000000..2085ff7 --- /dev/null +++ b/backend/src/routes/userPreferencesRoutes.js @@ -0,0 +1,105 @@ +const express = require("express"); +const { getPrismaClient } = require("../config/prisma"); +const { authenticateToken } = require("../middleware/auth"); + +const router = express.Router(); +const prisma = getPrismaClient(); + +/** + * GET /api/v1/user/preferences + * Get current user's preferences (theme and color theme) + */ +router.get("/", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + + const user = await prisma.users.findUnique({ + where: { id: userId }, + select: { + theme_preference: true, + color_theme: true, + }, + }); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + res.json({ + theme_preference: user.theme_preference || "dark", + color_theme: user.color_theme || "cyber_blue", + }); + } catch (error) { + console.error("Error fetching user preferences:", error); + res.status(500).json({ error: "Failed to fetch user preferences" }); + } +}); + +/** + * PATCH /api/v1/user/preferences + * Update current user's preferences + */ +router.patch("/", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { theme_preference, color_theme } = req.body; + + // Validate inputs + const updateData = {}; + if (theme_preference !== undefined) { + if (!["light", "dark"].includes(theme_preference)) { + return res.status(400).json({ + error: "Invalid theme preference. Must be 'light' or 'dark'", + }); + } + updateData.theme_preference = theme_preference; + } + + if (color_theme !== undefined) { + const validColorThemes = [ + "default", + "cyber_blue", + "neon_purple", + "matrix_green", + "ocean_blue", + "sunset_gradient", + ]; + if (!validColorThemes.includes(color_theme)) { + return res.status(400).json({ + error: `Invalid color theme. Must be one of: ${validColorThemes.join(", ")}`, + }); + } + updateData.color_theme = color_theme; + } + + if (Object.keys(updateData).length === 0) { + return res + .status(400) + .json({ error: "No preferences provided to update" }); + } + + updateData.updated_at = new Date(); + + const updatedUser = await prisma.users.update({ + where: { id: userId }, + data: updateData, + select: { + theme_preference: true, + color_theme: true, + }, + }); + + res.json({ + message: "Preferences updated successfully", + preferences: { + theme_preference: updatedUser.theme_preference, + color_theme: updatedUser.color_theme, + }, + }); + } catch (error) { + console.error("Error updating user preferences:", error); + res.status(500).json({ error: "Failed to update user preferences" }); + } +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 81bbc6d..9315e45 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -70,6 +70,7 @@ const integrationRoutes = require("./routes/integrationRoutes"); const wsRoutes = require("./routes/wsRoutes"); const agentVersionRoutes = require("./routes/agentVersionRoutes"); const metricsRoutes = require("./routes/metricsRoutes"); +const userPreferencesRoutes = require("./routes/userPreferencesRoutes"); const { initSettings } = require("./services/settingsService"); const { queueManager } = require("./services/automation"); const { authenticateToken, requireAdmin } = require("./middleware/auth"); @@ -477,6 +478,7 @@ app.use(`/api/${apiVersion}/integrations`, integrationRoutes); app.use(`/api/${apiVersion}/ws`, wsRoutes); app.use(`/api/${apiVersion}/agent`, agentVersionRoutes); app.use(`/api/${apiVersion}/metrics`, metricsRoutes); +app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes); // Bull Board - will be populated after queue manager initializes let bullBoardRouter = null; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d254f29..6eaf7b1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ import SettingsLayout from "./components/SettingsLayout"; import { isAuthPhase } from "./constants/authPhases"; import { AuthProvider, useAuth } from "./contexts/AuthContext"; import { ColorThemeProvider } from "./contexts/ColorThemeContext"; +import { SettingsProvider } from "./contexts/SettingsContext"; import { ThemeProvider } from "./contexts/ThemeContext"; import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext"; @@ -450,15 +451,17 @@ function AppRoutes() { function App() { return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/frontend/src/components/LogoProvider.jsx b/frontend/src/components/LogoProvider.jsx index 49e6343..bfef259 100644 --- a/frontend/src/components/LogoProvider.jsx +++ b/frontend/src/components/LogoProvider.jsx @@ -1,17 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; import { useEffect } from "react"; -import { isAuthReady } from "../constants/authPhases"; -import { useAuth } from "../contexts/AuthContext"; -import { settingsAPI } from "../utils/api"; +import { useSettings } from "../contexts/SettingsContext"; const LogoProvider = ({ children }) => { - const { authPhase, isAuthenticated } = useAuth(); - - const { data: settings } = useQuery({ - queryKey: ["settings"], - queryFn: () => settingsAPI.get().then((res) => res.data), - enabled: isAuthReady(authPhase, isAuthenticated()), - }); + const { settings } = useSettings(); useEffect(() => { // Use custom favicon or fallback to default diff --git a/frontend/src/components/settings/BrandingTab.jsx b/frontend/src/components/settings/BrandingTab.jsx index e022d93..49451b2 100644 --- a/frontend/src/components/settings/BrandingTab.jsx +++ b/frontend/src/components/settings/BrandingTab.jsx @@ -1,14 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - AlertCircle, - Image, - Palette, - RotateCcw, - Upload, - X, -} from "lucide-react"; +import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react"; import { useState } from "react"; -import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext"; import { settingsAPI } from "../../utils/api"; const BrandingTab = () => { @@ -20,7 +12,6 @@ const BrandingTab = () => { }); const [showLogoUploadModal, setShowLogoUploadModal] = useState(false); const [selectedLogoType, setSelectedLogoType] = useState("dark"); - const { colorTheme, setColorTheme } = useColorTheme(); const queryClient = useQueryClient(); @@ -84,22 +75,6 @@ const BrandingTab = () => { }, }); - // Theme update mutation - const updateThemeMutation = useMutation({ - mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }), - onSuccess: (_data, theme) => { - queryClient.invalidateQueries(["settings"]); - setColorTheme(theme); - }, - onError: (error) => { - console.error("Update theme error:", error); - }, - }); - - const handleThemeChange = (theme) => { - updateThemeMutation.mutate(theme); - }; - if (isLoading) { return (
@@ -137,93 +112,11 @@ const BrandingTab = () => {

- Customize your PatchMon installation with custom logos, favicon, and - color themes. These will be displayed throughout the application. + Customize your PatchMon installation with custom logos and favicon. + These will be displayed throughout the application.

- {/* Color Theme Selector */} -
-
- -

- Color Theme -

-
-

- Choose a color theme that will be applied to the login page and - background areas throughout the app. -

- -
- {Object.entries(THEME_PRESETS).map(([themeKey, theme]) => { - const isSelected = colorTheme === themeKey; - const gradientColors = theme.login.xColors; - - return ( - - ); - })} -
- - {updateThemeMutation.isPending && ( -
-
- Updating theme... -
- )} - - {updateThemeMutation.isError && ( -
-

- Failed to update theme: {updateThemeMutation.error?.message} -

-
- )} -
- {/* Logo Section Header */}
diff --git a/frontend/src/contexts/ColorThemeContext.jsx b/frontend/src/contexts/ColorThemeContext.jsx index 34647cb..1781343 100644 --- a/frontend/src/contexts/ColorThemeContext.jsx +++ b/frontend/src/contexts/ColorThemeContext.jsx @@ -1,4 +1,6 @@ +import { useQuery } from "@tanstack/react-query"; import { createContext, useContext, useEffect, useState } from "react"; +import { userPreferencesAPI } from "../utils/api"; const ColorThemeContext = createContext(); @@ -121,54 +123,40 @@ export const THEME_PRESETS = { }; export const ColorThemeProvider = ({ children }) => { - const [colorTheme, setColorTheme] = useState("default"); + const [colorTheme, setColorTheme] = useState(() => { + // Initialize from localStorage for immediate render + return localStorage.getItem("colorTheme") || "cyber_blue"; + }); const [isLoading, setIsLoading] = useState(true); - // Fetch theme from settings on mount + // Fetch user preferences from backend + const { data: userPreferences } = useQuery({ + queryKey: ["userPreferences"], + queryFn: () => userPreferencesAPI.get().then((res) => res.data), + retry: 1, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + // Update theme when preferences are loaded useEffect(() => { - const fetchTheme = async () => { - try { - // Check localStorage first for unauthenticated pages (login) - const cachedTheme = localStorage.getItem("colorTheme"); - if (cachedTheme) { - setColorTheme(cachedTheme); - } + if (userPreferences?.color_theme) { + setColorTheme(userPreferences.color_theme); + localStorage.setItem("colorTheme", userPreferences.color_theme); + } + setIsLoading(false); + }, [userPreferences]); - // Try to fetch from API (will fail on login page, that's ok) - try { - const token = localStorage.getItem("token"); - if (token) { - const response = await fetch("/api/v1/settings", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (response.ok) { - const data = await response.json(); - if (data.color_theme) { - setColorTheme(data.color_theme); - localStorage.setItem("colorTheme", data.color_theme); - } - } - } - } catch (_apiError) { - // Silent fail - use cached or default theme - console.log("Could not fetch theme from API, using cached/default"); - } - } catch (error) { - console.error("Error loading color theme:", error); - } finally { - setIsLoading(false); - } - }; - - fetchTheme(); - }, []); - - const updateColorTheme = (theme) => { + const updateColorTheme = async (theme) => { setColorTheme(theme); localStorage.setItem("colorTheme", theme); + + // Save to backend + try { + await userPreferencesAPI.update({ color_theme: theme }); + } catch (error) { + console.error("Failed to save color theme preference:", error); + // Theme is already set locally, so user still sees the change + } }; const value = { diff --git a/frontend/src/contexts/SettingsContext.jsx b/frontend/src/contexts/SettingsContext.jsx new file mode 100644 index 0000000..faff109 --- /dev/null +++ b/frontend/src/contexts/SettingsContext.jsx @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { createContext, useContext } from "react"; +import { isAuthReady } from "../constants/authPhases"; +import { settingsAPI } from "../utils/api"; +import { useAuth } from "./AuthContext"; + +const SettingsContext = createContext(); + +export const useSettings = () => { + const context = useContext(SettingsContext); + if (!context) { + throw new Error("useSettings must be used within a SettingsProvider"); + } + return context; +}; + +export const SettingsProvider = ({ children }) => { + const { authPhase, isAuthenticated } = useAuth(); + + const { + data: settings, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ["settings"], + queryFn: () => settingsAPI.get().then((res) => res.data), + staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes + refetchOnWindowFocus: false, + enabled: isAuthReady(authPhase, isAuthenticated()), + }); + + const value = { + settings, + isLoading, + error, + refetch, + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/contexts/ThemeContext.jsx b/frontend/src/contexts/ThemeContext.jsx index 3056c6a..161279b 100644 --- a/frontend/src/contexts/ThemeContext.jsx +++ b/frontend/src/contexts/ThemeContext.jsx @@ -1,4 +1,6 @@ +import { useQuery } from "@tanstack/react-query"; import { createContext, useContext, useEffect, useState } from "react"; +import { userPreferencesAPI } from "../utils/api"; const ThemeContext = createContext(); @@ -12,7 +14,7 @@ export const useTheme = () => { export const ThemeProvider = ({ children }) => { const [theme, setTheme] = useState(() => { - // Check localStorage first, then system preference + // Check localStorage first for immediate render const savedTheme = localStorage.getItem("theme"); if (savedTheme) { return savedTheme; @@ -24,6 +26,22 @@ export const ThemeProvider = ({ children }) => { return "light"; }); + // Fetch user preferences from backend + const { data: userPreferences } = useQuery({ + queryKey: ["userPreferences"], + queryFn: () => userPreferencesAPI.get().then((res) => res.data), + retry: 1, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + // Sync with user preferences from backend + useEffect(() => { + if (userPreferences?.theme_preference) { + setTheme(userPreferences.theme_preference); + localStorage.setItem("theme", userPreferences.theme_preference); + } + }, [userPreferences]); + useEffect(() => { // Apply theme to document if (theme === "dark") { @@ -36,8 +54,17 @@ export const ThemeProvider = ({ children }) => { localStorage.setItem("theme", theme); }, [theme]); - const toggleTheme = () => { - setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); + const toggleTheme = async () => { + const newTheme = theme === "light" ? "dark" : "light"; + setTheme(newTheme); + + // Save to backend + try { + await userPreferencesAPI.update({ theme_preference: newTheme }); + } catch (error) { + console.error("Failed to save theme preference:", error); + // Theme is already set locally, so user still sees the change + } }; const value = { diff --git a/frontend/src/contexts/UpdateNotificationContext.jsx b/frontend/src/contexts/UpdateNotificationContext.jsx index 09a10ae..a860e2e 100644 --- a/frontend/src/contexts/UpdateNotificationContext.jsx +++ b/frontend/src/contexts/UpdateNotificationContext.jsx @@ -1,8 +1,5 @@ -import { useQuery } from "@tanstack/react-query"; import { createContext, useContext, useState } from "react"; -import { isAuthReady } from "../constants/authPhases"; -import { settingsAPI } from "../utils/api"; -import { useAuth } from "./AuthContext"; +import { useSettings } from "./SettingsContext"; const UpdateNotificationContext = createContext(); @@ -18,17 +15,7 @@ export const useUpdateNotification = () => { export const UpdateNotificationProvider = ({ children }) => { const [dismissed, setDismissed] = useState(false); - const { authPhase, isAuthenticated } = useAuth(); - - // Ensure settings are loaded - but only after auth is fully ready - // This reads cached update info from backend (updated by scheduler) - const { data: settings, isLoading: settingsLoading } = useQuery({ - queryKey: ["settings"], - queryFn: () => settingsAPI.get().then((res) => res.data), - staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes - refetchOnWindowFocus: false, - enabled: isAuthReady(authPhase, isAuthenticated()), - }); + const { settings, isLoading: settingsLoading } = useSettings(); // Read cached update information from settings (no GitHub API calls) // The backend scheduler updates this data periodically diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index 2404af4..d545d07 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -25,6 +25,7 @@ import { import { useEffect, useId, useState } from "react"; import { useAuth } from "../contexts/AuthContext"; +import { THEME_PRESETS, useColorTheme } from "../contexts/ColorThemeContext"; import { useTheme } from "../contexts/ThemeContext"; import { isCorsError, tfaAPI } from "../utils/api"; @@ -38,6 +39,7 @@ const Profile = () => { const confirmPasswordId = useId(); const { user, updateProfile, changePassword } = useAuth(); const { toggleTheme, isDark } = useTheme(); + const { colorTheme, setColorTheme } = useColorTheme(); const [activeTab, setActiveTab] = useState("profile"); const [isLoading, setIsLoading] = useState(false); const [message, setMessage] = useState({ type: "", text: "" }); @@ -411,6 +413,68 @@ const Profile = () => {
+ + {/* Color Theme Settings */} +
+

+ Color Theme +

+

+ Choose your preferred color scheme for the application +

+ +
+ {Object.entries(THEME_PRESETS).map(([themeKey, theme]) => { + const isSelected = colorTheme === themeKey; + const gradientColors = theme.login.xColors; + + return ( + + ); + })} +
+
diff --git a/frontend/src/pages/settings/SettingsMetrics.jsx b/frontend/src/pages/settings/SettingsMetrics.jsx index 27ba9ec..98ccde7 100644 --- a/frontend/src/pages/settings/SettingsMetrics.jsx +++ b/frontend/src/pages/settings/SettingsMetrics.jsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, BarChart3, + BookOpen, CheckCircle, Eye, EyeOff, @@ -178,6 +179,19 @@ const SettingsMetrics = () => {
+ + {/* More Information Button */} +
+ + + More Information + +
{/* Metrics Toggle */} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5fe509b..f2bf4aa 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -169,6 +169,12 @@ export const settingsAPI = { getServerUrl: () => api.get("/settings/server-url"), }; +// User Preferences API +export const userPreferencesAPI = { + get: () => api.get("/user/preferences"), + update: (preferences) => api.patch("/user/preferences", preferences), +}; + // Agent File Management API export const agentFileAPI = { getInfo: () => api.get("/hosts/agent/info"),