mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-05 14:35:35 +00:00
Theme settings per user
This commit is contained in:
@@ -196,7 +196,6 @@ model settings {
|
|||||||
metrics_enabled Boolean @default(true)
|
metrics_enabled Boolean @default(true)
|
||||||
metrics_anonymous_id String?
|
metrics_anonymous_id String?
|
||||||
metrics_last_sent DateTime?
|
metrics_last_sent DateTime?
|
||||||
color_theme String @default("default")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model update_history {
|
model update_history {
|
||||||
@@ -228,6 +227,8 @@ model users {
|
|||||||
tfa_secret String?
|
tfa_secret String?
|
||||||
first_name String?
|
first_name String?
|
||||||
last_name String?
|
last_name String?
|
||||||
|
theme_preference String? @default("dark")
|
||||||
|
color_theme String? @default("cyber_blue")
|
||||||
dashboard_preferences dashboard_preferences[]
|
dashboard_preferences dashboard_preferences[]
|
||||||
user_sessions user_sessions[]
|
user_sessions user_sessions[]
|
||||||
auto_enrollment_tokens auto_enrollment_tokens[]
|
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||||
|
|||||||
105
backend/src/routes/userPreferencesRoutes.js
Normal file
105
backend/src/routes/userPreferencesRoutes.js
Normal 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;
|
||||||
@@ -70,6 +70,7 @@ const integrationRoutes = require("./routes/integrationRoutes");
|
|||||||
const wsRoutes = require("./routes/wsRoutes");
|
const wsRoutes = require("./routes/wsRoutes");
|
||||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
||||||
const metricsRoutes = require("./routes/metricsRoutes");
|
const metricsRoutes = require("./routes/metricsRoutes");
|
||||||
|
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
|
||||||
const { initSettings } = require("./services/settingsService");
|
const { initSettings } = require("./services/settingsService");
|
||||||
const { queueManager } = require("./services/automation");
|
const { queueManager } = require("./services/automation");
|
||||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
|
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}/ws`, wsRoutes);
|
||||||
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
|
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
|
||||||
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
||||||
|
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
|
||||||
|
|
||||||
// Bull Board - will be populated after queue manager initializes
|
// Bull Board - will be populated after queue manager initializes
|
||||||
let bullBoardRouter = null;
|
let bullBoardRouter = null;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SettingsLayout from "./components/SettingsLayout";
|
|||||||
import { isAuthPhase } from "./constants/authPhases";
|
import { isAuthPhase } from "./constants/authPhases";
|
||||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||||
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
|
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
|
||||||
|
import { SettingsProvider } from "./contexts/SettingsContext";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
||||||
|
|
||||||
@@ -450,15 +451,17 @@ function AppRoutes() {
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ColorThemeProvider>
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<SettingsProvider>
|
||||||
|
<ColorThemeProvider>
|
||||||
<UpdateNotificationProvider>
|
<UpdateNotificationProvider>
|
||||||
<LogoProvider>
|
<LogoProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</LogoProvider>
|
</LogoProvider>
|
||||||
</UpdateNotificationProvider>
|
</UpdateNotificationProvider>
|
||||||
</AuthProvider>
|
|
||||||
</ColorThemeProvider>
|
</ColorThemeProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { isAuthReady } from "../constants/authPhases";
|
import { useSettings } from "../contexts/SettingsContext";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
|
||||||
import { settingsAPI } from "../utils/api";
|
|
||||||
|
|
||||||
const LogoProvider = ({ children }) => {
|
const LogoProvider = ({ children }) => {
|
||||||
const { authPhase, isAuthenticated } = useAuth();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const { data: settings } = useQuery({
|
|
||||||
queryKey: ["settings"],
|
|
||||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
|
||||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use custom favicon or fallback to default
|
// Use custom favicon or fallback to default
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
Image,
|
|
||||||
Palette,
|
|
||||||
RotateCcw,
|
|
||||||
Upload,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
|
|
||||||
import { settingsAPI } from "../../utils/api";
|
import { settingsAPI } from "../../utils/api";
|
||||||
|
|
||||||
const BrandingTab = () => {
|
const BrandingTab = () => {
|
||||||
@@ -20,7 +12,6 @@ const BrandingTab = () => {
|
|||||||
});
|
});
|
||||||
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
||||||
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
||||||
const { colorTheme, setColorTheme } = useColorTheme();
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -137,93 +112,11 @@ const BrandingTab = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||||
Customize your PatchMon installation with custom logos, favicon, and
|
Customize your PatchMon installation with custom logos and favicon.
|
||||||
color themes. These will be displayed throughout the application.
|
These will be displayed throughout the application.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Logo Section Header */}
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<Image className="h-5 w-5 text-primary-600 mr-2" />
|
<Image className="h-5 w-5 text-primary-600 mr-2" />
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { userPreferencesAPI } from "../utils/api";
|
||||||
|
|
||||||
const ColorThemeContext = createContext();
|
const ColorThemeContext = createContext();
|
||||||
|
|
||||||
@@ -121,54 +123,40 @@ export const THEME_PRESETS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ColorThemeProvider = ({ children }) => {
|
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);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Fetch theme from settings on mount
|
// Fetch user preferences from backend
|
||||||
useEffect(() => {
|
const { data: userPreferences } = useQuery({
|
||||||
const fetchTheme = async () => {
|
queryKey: ["userPreferences"],
|
||||||
try {
|
queryFn: () => userPreferencesAPI.get().then((res) => res.data),
|
||||||
// Check localStorage first for unauthenticated pages (login)
|
retry: 1,
|
||||||
const cachedTheme = localStorage.getItem("colorTheme");
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
if (cachedTheme) {
|
|
||||||
setColorTheme(cachedTheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
// Update theme when preferences are loaded
|
||||||
const data = await response.json();
|
useEffect(() => {
|
||||||
if (data.color_theme) {
|
if (userPreferences?.color_theme) {
|
||||||
setColorTheme(data.color_theme);
|
setColorTheme(userPreferences.color_theme);
|
||||||
localStorage.setItem("colorTheme", data.color_theme);
|
localStorage.setItem("colorTheme", userPreferences.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);
|
setIsLoading(false);
|
||||||
}
|
}, [userPreferences]);
|
||||||
};
|
|
||||||
|
|
||||||
fetchTheme();
|
const updateColorTheme = async (theme) => {
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateColorTheme = (theme) => {
|
|
||||||
setColorTheme(theme);
|
setColorTheme(theme);
|
||||||
localStorage.setItem("colorTheme", 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 = {
|
const value = {
|
||||||
|
|||||||
45
frontend/src/contexts/SettingsContext.jsx
Normal file
45
frontend/src/contexts/SettingsContext.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { userPreferencesAPI } from "../utils/api";
|
||||||
|
|
||||||
const ThemeContext = createContext();
|
const ThemeContext = createContext();
|
||||||
|
|
||||||
@@ -12,7 +14,7 @@ export const useTheme = () => {
|
|||||||
|
|
||||||
export const ThemeProvider = ({ children }) => {
|
export const ThemeProvider = ({ children }) => {
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setTheme] = useState(() => {
|
||||||
// Check localStorage first, then system preference
|
// Check localStorage first for immediate render
|
||||||
const savedTheme = localStorage.getItem("theme");
|
const savedTheme = localStorage.getItem("theme");
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
return savedTheme;
|
return savedTheme;
|
||||||
@@ -24,6 +26,22 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
return "light";
|
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(() => {
|
useEffect(() => {
|
||||||
// Apply theme to document
|
// Apply theme to document
|
||||||
if (theme === "dark") {
|
if (theme === "dark") {
|
||||||
@@ -36,8 +54,17 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
localStorage.setItem("theme", theme);
|
localStorage.setItem("theme", theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = async () => {
|
||||||
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
|
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 = {
|
const value = {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { createContext, useContext, useState } from "react";
|
import { createContext, useContext, useState } from "react";
|
||||||
import { isAuthReady } from "../constants/authPhases";
|
import { useSettings } from "./SettingsContext";
|
||||||
import { settingsAPI } from "../utils/api";
|
|
||||||
import { useAuth } from "./AuthContext";
|
|
||||||
|
|
||||||
const UpdateNotificationContext = createContext();
|
const UpdateNotificationContext = createContext();
|
||||||
|
|
||||||
@@ -18,17 +15,7 @@ export const useUpdateNotification = () => {
|
|||||||
|
|
||||||
export const UpdateNotificationProvider = ({ children }) => {
|
export const UpdateNotificationProvider = ({ children }) => {
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
const { authPhase, isAuthenticated } = useAuth();
|
const { settings, isLoading: settingsLoading } = useSettings();
|
||||||
|
|
||||||
// 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()),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read cached update information from settings (no GitHub API calls)
|
// Read cached update information from settings (no GitHub API calls)
|
||||||
// The backend scheduler updates this data periodically
|
// The backend scheduler updates this data periodically
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { useEffect, useId, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { THEME_PRESETS, useColorTheme } from "../contexts/ColorThemeContext";
|
||||||
import { useTheme } from "../contexts/ThemeContext";
|
import { useTheme } from "../contexts/ThemeContext";
|
||||||
import { isCorsError, tfaAPI } from "../utils/api";
|
import { isCorsError, tfaAPI } from "../utils/api";
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ const Profile = () => {
|
|||||||
const confirmPasswordId = useId();
|
const confirmPasswordId = useId();
|
||||||
const { user, updateProfile, changePassword } = useAuth();
|
const { user, updateProfile, changePassword } = useAuth();
|
||||||
const { toggleTheme, isDark } = useTheme();
|
const { toggleTheme, isDark } = useTheme();
|
||||||
|
const { colorTheme, setColorTheme } = useColorTheme();
|
||||||
const [activeTab, setActiveTab] = useState("profile");
|
const [activeTab, setActiveTab] = useState("profile");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [message, setMessage] = useState({ type: "", text: "" });
|
const [message, setMessage] = useState({ type: "", text: "" });
|
||||||
@@ -411,6 +413,68 @@ const Profile = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -178,6 +179,19 @@ const SettingsMetrics = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Metrics Toggle */}
|
{/* Metrics Toggle */}
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ export const settingsAPI = {
|
|||||||
getServerUrl: () => api.get("/settings/server-url"),
|
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
|
// Agent File Management API
|
||||||
export const agentFileAPI = {
|
export const agentFileAPI = {
|
||||||
getInfo: () => api.get("/hosts/agent/info"),
|
getInfo: () => api.get("/hosts/agent/info"),
|
||||||
|
|||||||
Reference in New Issue
Block a user