Theme settings per user

This commit is contained in:
Muhammad Ibrahim
2025-10-31 17:33:47 +00:00
parent 37462f4831
commit 94bfffd882
13 changed files with 317 additions and 191 deletions

View File

@@ -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[]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 (
<ThemeProvider>
<ColorThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</AuthProvider>
</ColorThemeProvider>
<AuthProvider>
<SettingsProvider>
<ColorThemeProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</ColorThemeProvider>
</SettingsProvider>
</AuthProvider>
</ThemeProvider>
);
}

View File

@@ -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

View File

@@ -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 (
<div className="flex items-center justify-center h-64">
@@ -137,93 +112,11 @@ const BrandingTab = () => {
</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
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.
</p>
</div>
{/* Color Theme Selector */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center mb-4">
<Palette className="h-5 w-5 text-primary-600 mr-2" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Color Theme
</h3>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Choose a color theme that will be applied to the login page and
background areas throughout the app.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
const isSelected = colorTheme === themeKey;
const gradientColors = theme.login.xColors;
return (
<button
key={themeKey}
type="button"
onClick={() => handleThemeChange(themeKey)}
disabled={updateThemeMutation.isPending}
className={`relative p-4 rounded-lg border-2 transition-all ${
isSelected
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
>
{/* Theme Preview */}
<div
className="h-20 rounded-md mb-3 overflow-hidden"
style={{
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
}}
/>
{/* Theme Name */}
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
{theme.name}
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
aria-label="Selected theme"
>
<title>Selected</title>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</button>
);
})}
</div>
{updateThemeMutation.isPending && (
<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Updating theme...
</div>
)}
{updateThemeMutation.isError && (
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
Failed to update theme: {updateThemeMutation.error?.message}
</p>
</div>
)}
</div>
{/* Logo Section Header */}
<div className="flex items-center mb-4">
<Image className="h-5 w-5 text-primary-600 mr-2" />

View File

@@ -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 = {

View File

@@ -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 (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
};

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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 = () => {
</button>
</div>
</div>
{/* Color Theme Settings */}
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
Color Theme
</h4>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mb-4">
Choose your preferred color scheme for the application
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
const isSelected = colorTheme === themeKey;
const gradientColors = theme.login.xColors;
return (
<button
key={themeKey}
type="button"
onClick={() => setColorTheme(themeKey)}
className={`relative p-4 rounded-lg border-2 transition-all ${
isSelected
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
} cursor-pointer`}
>
{/* Theme Preview */}
<div
className="h-20 rounded-md mb-3 overflow-hidden"
style={{
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
}}
/>
{/* Theme Name */}
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
{theme.name}
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
aria-label="Selected theme"
>
<title>Selected</title>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</button>
);
})}
</div>
</div>
</div>
<div className="flex justify-end">

View File

@@ -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 = () => {
</div>
</div>
</div>
{/* More Information Button */}
<div className="mt-4 pt-4 border-t border-blue-200 dark:border-blue-700">
<a
href="https://docs.patchmon.net/books/patchmon-application-documentation/page/metrics-collection-information"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
>
<BookOpen className="h-4 w-4 mr-2" />
More Information
</a>
</div>
</div>
{/* Metrics Toggle */}

View File

@@ -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"),