Final release on 1.3.7

This commit is contained in:
9technologygroup
2025-12-25 10:19:45 +00:00
parent 0e2829b52b
commit 6aa30351fd
21 changed files with 3148 additions and 445 deletions

View File

@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "release_notes_acceptances" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"version" TEXT NOT NULL,
"accepted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "release_notes_acceptances_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "release_notes_acceptances_user_id_version_key" ON "release_notes_acceptances"("user_id", "version");
-- CreateIndex
CREATE INDEX "release_notes_acceptances_user_id_idx" ON "release_notes_acceptances"("user_id");
-- CreateIndex
CREATE INDEX "release_notes_acceptances_version_idx" ON "release_notes_acceptances"("version");
-- AddForeignKey
ALTER TABLE "release_notes_acceptances" ADD CONSTRAINT "release_notes_acceptances_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "show_github_version_on_login" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -202,6 +202,7 @@ model settings {
metrics_enabled Boolean @default(true)
metrics_anonymous_id String?
metrics_last_sent DateTime?
show_github_version_on_login Boolean @default(true)
}
model update_history {
@@ -250,6 +251,7 @@ model users {
dashboard_preferences dashboard_preferences[]
user_sessions user_sessions[]
auto_enrollment_tokens auto_enrollment_tokens[]
release_notes_acceptances release_notes_acceptances[]
}
model user_sessions {
@@ -437,3 +439,15 @@ model job_history {
@@index([status])
@@index([created_at])
}
model release_notes_acceptances {
id String @id @default(uuid())
user_id String
version String
accepted_at DateTime @default(now())
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@unique([user_id, version])
@@index([user_id])
@@index([version])
}

View File

@@ -0,0 +1,34 @@
## 📝 ALERT : Auto-update of Agent issue
Versions <1.3.6 have an issue where the service does not restart after auto-update. OpenRC systems are unaffected and work correctly.
This means you will unfortunately have to use `systemctl start patchmon-agent` on your systems to load up 1.3.7 agent when it auto-updates shortly.
Very sorry for this, future versions are fixed - I built this release notes notification feature specifically to notify you of this.
---
## 🎉 New Features & Improvements :
**Mobile UI**: Mobile user interface improvements are mostly complete, providing a better experience on mobile devices.
**Systemctl Helper Script**: In future versions (1.3.7+), a systemctl helper script will be available to assist with auto-update service restarts.
**Staggered Agent Intervals**: Agents now report at staggered times to prevent overwhelming the PatchMon server. If the agent report interval is set to 60 minutes, different hosts will report at different times. This is in the `config.yml` as "report_offset: nxyz"
**Reboot Detection Information**: Reboot detection information is now stored in the database. When the "Reboot Required" flag is displayed, hovering over it will show the specific reason why a reboot is needed (Reboot feature still needs work and it will be much better in 1.3.8)
**JSON Report Output**: The `patchmon-agent report --json` command now outputs the complete report payload to the console in JSON format instead of sending it to the PatchMon server. This is very useful for integrating PatchMon agent data with other tools and for diagnostic purposes.
**Persistent Docker Toggle**: Docker integration toggle state is now persisted in the database, eliminating in-memory configuration issues. No more losing Docker settings on container restarts (thanks to the community for initiating this feature).
**Config.yml Synchronization**: The agent now writes and compares the `config.yml` file with the server configuration upon startup, ensuring better synchronization of settings between the agent and server.
**Network Information Page**: Enhanced network information page to display IPv6 addresses and support multiple network interfaces, providing more comprehensive network details.
**Auto-Update Logic Fix**: Fixed an issue where agents would auto-update even when per-host auto-update was disabled. The logic now properly honors both server-wide auto-update settings and per-host auto-update settings.
**Prisma Version Fix**: Fixed Prisma version issues affecting Kubernetes deployments by statically setting the Prisma version in package.json files.
**Hiding Github Version**: Added a toggle in Server Version settings to disable showing the github release notes on the login screen
---

View File

@@ -844,6 +844,24 @@ router.post(
req,
);
// Get accepted release notes versions
let acceptedVersions = [];
try {
if (prisma.release_notes_acceptances) {
acceptedVersions = await prisma.release_notes_acceptances.findMany({
where: { user_id: user.id },
select: { version: true },
});
}
} catch (error) {
// If table doesn't exist yet or Prisma client not regenerated, use empty array
console.warn(
"Could not fetch release notes acceptances:",
error.message,
);
acceptedVersions = [];
}
res.json({
message: "Login successful",
token: session.access_token,
@@ -863,6 +881,9 @@ router.post(
// Include user preferences so they're available immediately after login
theme_preference: user.theme_preference,
color_theme: user.color_theme,
accepted_release_notes_versions: acceptedVersions.map(
(a) => a.version,
),
},
});
} catch (error) {
@@ -986,13 +1007,36 @@ router.post(
req,
);
// Get accepted release notes versions
let acceptedVersions = [];
try {
if (prisma.release_notes_acceptances) {
acceptedVersions = await prisma.release_notes_acceptances.findMany({
where: { user_id: user.id },
select: { version: true },
});
}
} catch (error) {
// If table doesn't exist yet or Prisma client not regenerated, use empty array
console.warn(
"Could not fetch release notes acceptances:",
error.message,
);
acceptedVersions = [];
}
res.json({
message: "Login successful",
token: session.access_token,
refresh_token: session.refresh_token,
expires_at: session.expires_at,
tfa_bypass_until: session.tfa_bypass_until,
user: updatedUser,
user: {
...updatedUser,
accepted_release_notes_versions: acceptedVersions.map(
(a) => a.version,
),
},
});
} catch (error) {
console.error("TFA verification error:", error);

View File

@@ -0,0 +1,206 @@
const express = require("express");
const axios = require("axios");
const { authenticateToken } = require("../middleware/auth");
const router = express.Router();
// Cache for supporter count (5 minute cache)
const supporterCountCache = {
count: null,
timestamp: null,
cacheDuration: 5 * 60 * 1000, // 5 minutes
};
// Get supporter count from Buy Me a Coffee page
router.get("/supporter-count", authenticateToken, async (_req, res) => {
try {
// Check cache first
const now = Date.now();
if (
supporterCountCache.count !== null &&
supporterCountCache.timestamp !== null &&
now - supporterCountCache.timestamp < supporterCountCache.cacheDuration
) {
return res.json({ count: supporterCountCache.count, cached: true });
}
// Fetch the Buy Me a Coffee page
const response = await axios.get("https://buymeacoffee.com/iby___", {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
timeout: 10000, // 10 second timeout
});
const html = response.data;
// Try multiple patterns to find the supporter count
let supporterCount = null;
// Pattern 1: Look for "X supporters" or "X supporter" text (most common)
// Try various formats: "25 supporters", "1 supporter", "25 people", etc.
const textPatterns = [
/(\d+)\s+supporters?/i,
/(\d+)\s+people\s+(have\s+)?(bought|supported)/i,
/supporter[^>]*>.*?(\d+)/i,
/(\d+)[^<]*supporter/i,
/>(\d+)<[^>]*supporter/i,
/supporter[^<]*<[^>]*>(\d+)/i,
];
for (const pattern of textPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseInt(match[1], 10);
if (count > 0 && count < 1000000) {
// Reasonable upper limit
supporterCount = count;
break;
}
}
}
// Pattern 2: Look for data attributes or specific class names
// Buy Me a Coffee might use data attributes like data-count, data-supporters, etc.
if (!supporterCount) {
const dataPatterns = [
/data-supporters?=["'](\d+)["']/i,
/data-count=["'](\d+)["']/i,
/supporter[^>]*data-[^=]*=["'](\d+)["']/i,
];
for (const pattern of dataPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseInt(match[1], 10);
if (count > 0 && count < 1000000) {
supporterCount = count;
break;
}
}
}
}
// Pattern 3: Look for JSON-LD structured data or meta tags
if (!supporterCount) {
const jsonLdMatches = html.matchAll(
/<script[^>]*type=["']application\/ld\+json["'][^>]*>(.*?)<\/script>/gis,
);
for (const jsonLdMatch of jsonLdMatches) {
try {
const jsonLd = JSON.parse(jsonLdMatch[1]);
// Look for supporter count in structured data (could be nested)
const findCount = (obj) => {
if (typeof obj !== "object" || obj === null) return null;
if (obj.supporterCount || obj.supporter_count || obj.supporters) {
return parseInt(
obj.supporterCount || obj.supporter_count || obj.supporters,
10,
);
}
for (const value of Object.values(obj)) {
if (typeof value === "object") {
const found = findCount(value);
if (found) return found;
}
}
return null;
};
const count = findCount(jsonLd);
if (count && count > 0 && count < 1000000) {
supporterCount = count;
break;
}
} catch (_e) {
// Ignore JSON parse errors
}
}
}
// Pattern 4: Look for specific class names or IDs that Buy Me a Coffee uses
if (!supporterCount) {
const classPatterns = [
/class="[^"]*supporter[^"]*"[^>]*>.*?(\d+)/i,
/id="[^"]*supporter[^"]*"[^>]*>.*?(\d+)/i,
/<span[^>]*class="[^"]*count[^"]*"[^>]*>(\d+)<\/span>/i,
];
for (const pattern of classPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseInt(match[1], 10);
if (count > 0 && count < 1000000) {
supporterCount = count;
break;
}
}
}
}
// Pattern 5: Look for numbers near common supporter-related text
if (!supporterCount) {
// Find all numbers in the HTML and check context around them
const numberMatches = html.matchAll(/\b(\d{1,6})\b/g);
for (const match of numberMatches) {
const num = parseInt(match[1], 10);
if (num > 0 && num < 1000000) {
// Check context around this number (200 chars before and after)
const start = Math.max(0, match.index - 200);
const end = Math.min(
html.length,
match.index + match[0].length + 200,
);
const context = html.substring(start, end).toLowerCase();
if (
context.includes("supporter") ||
context.includes("coffee") ||
context.includes("donation")
) {
supporterCount = num;
break;
}
}
}
}
// Update cache
if (supporterCount !== null && supporterCount > 0) {
supporterCountCache.count = supporterCount;
supporterCountCache.timestamp = now;
}
if (supporterCount === null) {
// If we couldn't parse it, return the cached value if available, or 0
if (supporterCountCache.count !== null) {
return res.json({
count: supporterCountCache.count,
cached: true,
error: "Could not parse current count, returning cached value",
});
}
return res.status(500).json({
error: "Could not retrieve supporter count",
});
}
res.json({ count: supporterCount, cached: false });
} catch (error) {
console.error("Error fetching Buy Me a Coffee supporter count:", error);
// Return cached value if available
if (supporterCountCache.count !== null) {
return res.json({
count: supporterCountCache.count,
cached: true,
error: "Failed to fetch, returning cached value",
});
}
res.status(500).json({
error: "Failed to fetch supporter count",
});
}
});
module.exports = router;

View File

@@ -0,0 +1,53 @@
const express = require("express");
const { authenticateToken } = require("../middleware/auth");
const { getPrismaClient } = require("../config/prisma");
const prisma = getPrismaClient();
const router = express.Router();
// Mark release notes as accepted for current user
router.post("/accept", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const { version } = req.body;
if (!version) {
return res.status(400).json({ error: "Version is required" });
}
// Check if the model exists (Prisma client might not be regenerated yet)
if (!prisma.release_notes_acceptances) {
console.warn(
"release_notes_acceptances model not available - Prisma client may need regeneration",
);
return res.status(503).json({
error:
"Release notes acceptance feature not available. Please regenerate Prisma client.",
});
}
await prisma.release_notes_acceptances.upsert({
where: {
user_id_version: {
user_id: userId,
version: version,
},
},
update: {
accepted_at: new Date(),
},
create: {
user_id: userId,
version: version,
accepted_at: new Date(),
},
});
res.json({ success: true });
} catch (error) {
console.error("Error accepting release notes:", error);
res.status(500).json({ error: "Failed to accept release notes" });
}
});
module.exports = router;

View File

@@ -0,0 +1,104 @@
const express = require("express");
const fs = require("node:fs").promises;
const path = require("node:path");
const { authenticateToken } = require("../middleware/auth");
const router = express.Router();
// Helper to get current version
function getCurrentVersion() {
try {
const packageJson = require("../../package.json");
return packageJson?.version || "unknown";
} catch (error) {
console.error("Could not read version from package.json:", error);
return "unknown";
}
}
// Get release notes for a specific version
router.get("/:version", authenticateToken, async (req, res) => {
try {
const { version } = req.params;
const releaseNotesPath = path.join(
__dirname,
"../../release-notes",
`RELEASE_NOTES_${version}.md`,
);
try {
const content = await fs.readFile(releaseNotesPath, "utf-8");
res.json({
version,
content,
exists: true,
});
} catch (_fileError) {
// File doesn't exist for this version
res.json({
version,
content: null,
exists: false,
});
}
} catch (error) {
console.error("Error fetching release notes:", error);
res.status(500).json({ error: "Failed to fetch release notes" });
}
});
// Get release notes for current version
router.get("/current", authenticateToken, async (_req, res) => {
try {
const currentVersion = getCurrentVersion();
const releaseNotesPath = path.join(
__dirname,
"../../release-notes",
`RELEASE_NOTES_${currentVersion}.md`,
);
try {
const content = await fs.readFile(releaseNotesPath, "utf-8");
res.json({
version: currentVersion,
content,
exists: true,
});
} catch (_fileError) {
// No release notes for current version
res.json({
version: currentVersion,
content: null,
exists: false,
});
}
} catch (error) {
console.error("Error fetching current release notes:", error);
res.status(500).json({ error: "Failed to fetch release notes" });
}
});
// List all available release notes versions
router.get("/", authenticateToken, async (_req, res) => {
try {
const releaseNotesDir = path.join(__dirname, "../../release-notes");
const files = await fs.readdir(releaseNotesDir);
const versions = files
.filter(
(file) => file.startsWith("RELEASE_NOTES_") && file.endsWith(".md"),
)
.map((file) => file.replace("RELEASE_NOTES_", "").replace(".md", ""))
.sort((a, b) => {
// Simple version comparison (you might want a more robust one)
return b.localeCompare(a, undefined, { numeric: true });
});
res.json({ versions });
} catch (error) {
console.error("Error listing release notes:", error);
res.status(500).json({ error: "Failed to list release notes" });
}
});
module.exports = router;

View File

@@ -134,6 +134,10 @@ router.put(
.optional()
.isLength({ min: 1 })
.withMessage("Favicon path must be a non-empty string"),
body("showGithubVersionOnLogin")
.optional()
.isBoolean()
.withMessage("Show GitHub version on login must be a boolean"),
],
async (req, res) => {
try {
@@ -159,6 +163,7 @@ router.put(
logoLight,
favicon,
colorTheme,
showGithubVersionOnLogin,
} = req.body;
// Get current settings to check for update interval changes
@@ -191,6 +196,8 @@ router.put(
if (logoLight !== undefined) updateData.logo_light = logoLight;
if (favicon !== undefined) updateData.favicon = favicon;
if (colorTheme !== undefined) updateData.color_theme = colorTheme;
if (showGithubVersionOnLogin !== undefined)
updateData.show_github_version_on_login = showGithubVersionOnLogin;
const updatedSettings = await updateSettings(
currentSettings.id,
@@ -254,6 +261,21 @@ router.get("/server-url", async (_req, res) => {
}
});
// Get login settings for public use (used by login screen)
router.get("/login-settings", async (_req, res) => {
try {
const settings = await getSettings();
res.json({
show_github_version_on_login:
settings.show_github_version_on_login !== false,
signup_enabled: settings.signup_enabled || false,
});
} catch (error) {
console.error("Failed to fetch login settings:", error);
res.status(500).json({ error: "Failed to fetch login settings" });
}
});
// Get update interval policy for agents (requires API authentication)
router.get("/update-interval", async (req, res) => {
try {

View File

@@ -72,6 +72,9 @@ const agentVersionRoutes = require("./routes/agentVersionRoutes");
const metricsRoutes = require("./routes/metricsRoutes");
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
const apiHostsRoutes = require("./routes/apiHostsRoutes");
const releaseNotesRoutes = require("./routes/releaseNotesRoutes");
const releaseNotesAcceptanceRoutes = require("./routes/releaseNotesAcceptanceRoutes");
const buyMeACoffeeRoutes = require("./routes/buyMeACoffeeRoutes");
const { initSettings } = require("./services/settingsService");
const { queueManager } = require("./services/automation");
const { authenticateToken, requireAdmin } = require("./middleware/auth");
@@ -482,6 +485,12 @@ app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
app.use(`/api/${apiVersion}/api`, authLimiter, apiHostsRoutes);
app.use(`/api/${apiVersion}/release-notes`, releaseNotesRoutes);
app.use(
`/api/${apiVersion}/release-notes-acceptance`,
releaseNotesAcceptanceRoutes,
);
app.use(`/api/${apiVersion}/buy-me-a-coffee`, buyMeACoffeeRoutes);
// Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null;

View File

@@ -1,4 +1,6 @@
// Common utilities for automation jobs
const path = require("node:path");
const fs = require("node:fs");
/**
* Compare two semantic versions
@@ -36,7 +38,10 @@ async function checkPublicRepo(owner, repo) {
// Get current version for User-Agent (or use generic if unavailable)
let currentVersion = "unknown";
try {
const packageJson = require("../../../package.json");
// Use __dirname to construct absolute path to package.json
const packageJsonPath = path.join(__dirname, "../../../package.json");
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonContent);
if (packageJson?.version) {
currentVersion = packageJson.version;
}

View File

@@ -27,6 +27,7 @@
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.30.1"
},
"devDependencies": {

View File

@@ -35,6 +35,7 @@ import { dashboardAPI, versionAPI } from "../utils/api";
import DiscordIcon from "./DiscordIcon";
import GlobalSearch from "./GlobalSearch";
import Logo from "./Logo";
import ReleaseNotesModal from "./ReleaseNotesModal";
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
const Layout = ({ children }) => {
@@ -47,6 +48,7 @@ const Layout = ({ children }) => {
const [_userMenuOpen, setUserMenuOpen] = useState(false);
const [githubStars, setGithubStars] = useState(null);
const [mobileLinksOpen, setMobileLinksOpen] = useState(false);
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const {
@@ -86,6 +88,37 @@ const Layout = ({ children }) => {
staleTime: 300000, // Consider data stale after 5 minutes
});
// Check for new release notes when user or version changes
useEffect(() => {
if (!user || !versionInfo?.version) return;
// Get accepted versions from user object (loaded on login)
const acceptedVersions = user.accepted_release_notes_versions || [];
const currentVersion = versionInfo.version;
// If already accepted, don't show
if (acceptedVersions.includes(currentVersion)) {
return;
}
// Check if release notes exist for this version
fetch(`/api/v1/release-notes/${currentVersion}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
})
.then((res) => res.json())
.then((data) => {
// Only show if release notes exist and version not accepted
if (data.exists && !acceptedVersions.includes(currentVersion)) {
setShowReleaseNotes(true);
}
})
.catch((error) => {
console.error("Error checking release notes:", error);
});
}, [user, versionInfo]);
// Build navigation based on permissions
const buildNavigation = () => {
const nav = [];
@@ -1536,6 +1569,12 @@ const Layout = ({ children }) => {
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
</main>
</div>
{/* Release Notes Modal */}
<ReleaseNotesModal
isOpen={showReleaseNotes}
onAccept={() => setShowReleaseNotes(false)}
/>
</div>
);
};

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +1,15 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle, Save, Shield, X } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { permissionsAPI, settingsAPI } from "../../utils/api";
import { settingsAPI } from "../../utils/api";
const AgentUpdatesTab = () => {
const updateIntervalId = useId();
const autoUpdateId = useId();
const signupEnabledId = useId();
const defaultRoleId = useId();
const ignoreSslId = useId();
const [formData, setFormData] = useState({
updateInterval: 60,
autoUpdate: false,
signupEnabled: false,
defaultUserRole: "user",
ignoreSslSelfSigned: false,
});
const [errors, setErrors] = useState({});
@@ -82,20 +78,12 @@ const AgentUpdatesTab = () => {
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Fetch available roles for default user role dropdown
const { data: roles, isLoading: rolesLoading } = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
const newFormData = {
updateInterval: settings.update_interval || 60,
autoUpdate: settings.auto_update || false,
signupEnabled: settings.signup_enabled === true,
defaultUserRole: settings.default_user_role || "user",
ignoreSslSelfSigned: settings.ignore_ssl_self_signed === true,
};
setFormData(newFormData);
@@ -425,85 +413,6 @@ const AgentUpdatesTab = () => {
</p>
</div>
{/* User Signup Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={signupEnabledId}
type="checkbox"
checked={formData.signupEnabled}
onChange={(e) =>
handleInputChange("signupEnabled", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
<label htmlFor={signupEnabledId}>
Enable User Self-Registration
</label>
</div>
</label>
{/* Default User Role Dropdown */}
{formData.signupEnabled && (
<div className="mt-3 ml-6">
<label
htmlFor={defaultRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Default Role for New Users
</label>
<select
id={defaultRoleId}
value={formData.defaultUserRole}
onChange={(e) =>
handleInputChange("defaultUserRole", e.target.value)
}
className="w-full max-w-xs border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
disabled={rolesLoading}
>
{rolesLoading ? (
<option>Loading roles...</option>
) : roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1)}
</option>
))
) : (
<option value="user">User</option>
)}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
New users will be assigned this role when they register.
</p>
</div>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, users can create their own accounts through the signup
page. When disabled, only administrators can create user accounts.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Security Notice
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
When enabling user self-registration, exercise caution on
internal networks. Consider restricting access to trusted
networks only and ensure proper role assignments to prevent
unauthorized access to sensitive systems.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button

View File

@@ -5,6 +5,7 @@ import {
Edit,
Key,
Mail,
Save,
Shield,
Trash2,
User,
@@ -12,7 +13,7 @@ import {
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { adminUsersAPI, permissionsAPI } from "../../utils/api";
import { adminUsersAPI, permissionsAPI, settingsAPI } from "../../utils/api";
const UsersTab = () => {
const [showAddModal, setShowAddModal] = useState(false);
@@ -20,6 +21,13 @@ const UsersTab = () => {
const [resetPasswordUser, setResetPasswordUser] = useState(null);
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const signupEnabledId = useId();
const defaultRoleId = useId();
const [signupFormData, setSignupFormData] = useState({
signupEnabled: false,
defaultUserRole: "user",
});
const [isSignupDirty, setIsSignupDirty] = useState(false);
// Listen for the header button event to open add modal
useEffect(() => {
@@ -40,11 +48,28 @@ const UsersTab = () => {
});
// Fetch available roles
const { data: roles } = useQuery({
const { data: roles, isLoading: rolesLoading } = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Fetch current settings for user registration
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Update signup form data when settings are loaded
useEffect(() => {
if (settings) {
setSignupFormData({
signupEnabled: settings.signup_enabled === true,
defaultUserRole: settings.default_user_role || "user",
});
setIsSignupDirty(false);
}
}, [settings]);
// Delete user mutation
const deleteUserMutation = useMutation({
mutationFn: adminUsersAPI.delete,
@@ -72,6 +97,32 @@ const UsersTab = () => {
},
});
// Update settings mutation for user registration
const updateSignupSettingsMutation = useMutation({
mutationFn: (data) => {
return settingsAPI.update(data).then((res) => res.data);
},
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
setIsSignupDirty(false);
},
});
const handleSignupInputChange = (field, value) => {
setSignupFormData((prev) => ({
...prev,
[field]: value,
}));
setIsSignupDirty(true);
};
const handleSignupSave = () => {
updateSignupSettingsMutation.mutate({
signupEnabled: signupFormData.signupEnabled,
defaultUserRole: signupFormData.defaultUserRole,
});
};
const handleDeleteUser = async (userId, username) => {
if (
window.confirm(
@@ -474,6 +525,138 @@ const UsersTab = () => {
isLoading={resetPasswordMutation.isPending}
/>
)}
{/* User Registration Settings */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
User Registration Settings
</h3>
</div>
<div className="px-6 py-4 space-y-4">
{/* User Signup Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={signupEnabledId}
type="checkbox"
checked={signupFormData.signupEnabled}
onChange={(e) =>
handleSignupInputChange("signupEnabled", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
disabled={settingsLoading}
/>
<label htmlFor={signupEnabledId}>
Enable User Self-Registration
</label>
</div>
</label>
{/* Default User Role Dropdown */}
{signupFormData.signupEnabled && (
<div className="mt-3 ml-6">
<label
htmlFor={defaultRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Default Role for New Users
</label>
<select
id={defaultRoleId}
value={signupFormData.defaultUserRole}
onChange={(e) =>
handleSignupInputChange("defaultUserRole", e.target.value)
}
className="w-full max-w-xs border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
disabled={rolesLoading}
>
{rolesLoading ? (
<option>Loading roles...</option>
) : roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1)}
</option>
))
) : (
<option value="user">User</option>
)}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
New users will be assigned this role when they register.
</p>
</div>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, users can create their own accounts through the
signup page. When disabled, only administrators can create user
accounts.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Security Notice
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
When enabling user self-registration, exercise caution on
internal networks. Consider restricting access to trusted
networks only and ensure proper role assignments to prevent
unauthorized access to sensitive systems.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSignupSave}
disabled={
!isSignupDirty || updateSignupSettingsMutation.isPending
}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isSignupDirty || updateSignupSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSignupSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{updateSignupSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle,
@@ -8,9 +9,30 @@ import {
GitCommit,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { versionAPI } from "../../utils/api";
import { settingsAPI, versionAPI } from "../../utils/api";
const VersionUpdateTab = () => {
const queryClient = useQueryClient();
// Fetch current settings
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
return settingsAPI.update(data).then((res) => res.data);
},
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
},
onError: (error) => {
console.error("Failed to update settings:", error);
},
});
// Version checking state
const [versionInfo, setVersionInfo] = useState({
currentVersion: null,
@@ -101,6 +123,50 @@ const VersionUpdateTab = () => {
)}
</p>
{/* Toggle for showing GitHub version on login */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600 mb-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<label
htmlFor="show-github-version-toggle"
className="text-sm font-medium text-secondary-900 dark:text-white cursor-pointer"
>
Show GitHub Version / Release Notes on Login Screen
</label>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
When enabled, the login screen will display the latest GitHub
release version and release notes information.
</p>
</div>
<button
type="button"
id="show-github-version-toggle"
onClick={() => {
const newValue = !settings?.show_github_version_on_login;
updateSettingsMutation.mutate({
showGithubVersionOnLogin: newValue,
});
}}
disabled={settingsLoading || updateSettingsMutation.isPending}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
settings?.show_github_version_on_login !== false
? "bg-primary-600"
: "bg-secondary-300 dark:bg-secondary-600"
} ${settingsLoading || updateSettingsMutation.isPending ? "opacity-50 cursor-not-allowed" : ""}`}
role="switch"
aria-checked={settings?.show_github_version_on_login !== false}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
settings?.show_github_version_on_login !== false
? "translate-x-5"
: "translate-x-0"
}`}
/>
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* My Version */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">

View File

@@ -71,7 +71,12 @@ export const AuthProvider = ({ children }) => {
if (storedToken && storedUser) {
try {
setToken(storedToken);
setUser(JSON.parse(storedUser));
const parsedUser = JSON.parse(storedUser);
setUser({
...parsedUser,
accepted_release_notes_versions:
parsedUser.accepted_release_notes_versions || [],
});
// Fetch permissions from backend
fetchPermissions(storedToken);
// User is authenticated, skip setup check
@@ -128,9 +133,20 @@ export const AuthProvider = ({ children }) => {
// Regular successful login
setToken(data.token);
setUser(data.user);
setUser({
...data.user,
accepted_release_notes_versions:
data.user.accepted_release_notes_versions || [],
});
localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user));
localStorage.setItem(
"user",
JSON.stringify({
...data.user,
accepted_release_notes_versions:
data.user.accepted_release_notes_versions || [],
}),
);
// Fetch user permissions after successful login
const userPermissions = await fetchPermissions(data.token);
@@ -237,8 +253,19 @@ export const AuthProvider = ({ children }) => {
}
// Update both state and localStorage atomically
setUser(data.user);
localStorage.setItem("user", JSON.stringify(data.user));
setUser({
...data.user,
accepted_release_notes_versions:
data.user.accepted_release_notes_versions || [],
});
localStorage.setItem(
"user",
JSON.stringify({
...data.user,
accepted_release_notes_versions:
data.user.accepted_release_notes_versions || [],
}),
);
return { success: true, user: data.user };
} else {
@@ -349,6 +376,48 @@ export const AuthProvider = ({ children }) => {
}
};
const acceptReleaseNotes = async (version) => {
try {
const response = await fetch("/api/v1/release-notes-acceptance/accept", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ version }),
});
const data = await response.json();
if (response.ok) {
// Update user state immediately with new accepted version
const updatedAcceptedVersions = [
...(user?.accepted_release_notes_versions || []),
version,
];
const updatedUser = {
...user,
accepted_release_notes_versions: updatedAcceptedVersions,
};
// Update both state and localStorage atomically
setUser(updatedUser);
localStorage.setItem("user", JSON.stringify(updatedUser));
return { success: true };
} else {
return {
success: false,
error: data.error || "Failed to accept release notes",
};
}
} catch (error) {
console.error("Error accepting release notes:", error);
return { success: false, error: "Network error occurred" };
}
};
const isAdmin = () => {
return user?.role === "admin";
};
@@ -411,14 +480,25 @@ export const AuthProvider = ({ children }) => {
// Use flushSync to ensure all state updates are applied synchronously
flushSync(() => {
setToken(authToken);
setUser(authUser);
setUser({
...authUser,
accepted_release_notes_versions:
authUser.accepted_release_notes_versions || [],
});
setNeedsFirstTimeSetup(false);
setAuthPhase(AUTH_PHASES.READY);
});
// Store in localStorage after state is updated
localStorage.setItem("token", authToken);
localStorage.setItem("user", JSON.stringify(authUser));
localStorage.setItem(
"user",
JSON.stringify({
...authUser,
accepted_release_notes_versions:
authUser.accepted_release_notes_versions || [],
}),
);
// Fetch permissions immediately for the new authenticated user
fetchPermissions(authToken);
@@ -458,6 +538,7 @@ export const AuthProvider = ({ children }) => {
canViewReports,
canExportData,
canManageSettings,
acceptReleaseNotes,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -49,6 +49,8 @@ const Login = () => {
const [requiresTfa, setRequiresTfa] = useState(false);
const [tfaUsername, setTfaUsername] = useState("");
const [signupEnabled, setSignupEnabled] = useState(false);
const [showGithubVersionOnLogin, setShowGithubVersionOnLogin] =
useState(true);
const [latestRelease, setLatestRelease] = useState(null);
const [githubStars, setGithubStars] = useState(null);
const canvasRef = useRef(null);
@@ -151,26 +153,35 @@ const Login = () => {
return () => window.removeEventListener("resize", handleResize);
}, [themeConfig]);
// Check if signup is enabled
// Check login settings (signup enabled and show github version)
useEffect(() => {
const checkSignupEnabled = async () => {
const checkLoginSettings = async () => {
try {
const response = await fetch("/api/v1/auth/signup-enabled");
const response = await fetch("/api/v1/settings/login-settings");
if (response.ok) {
const data = await response.json();
setSignupEnabled(data.signupEnabled);
setSignupEnabled(data.signup_enabled || false);
setShowGithubVersionOnLogin(
data.show_github_version_on_login !== false,
);
}
} catch (error) {
console.error("Failed to check signup status:", error);
console.error("Failed to check login settings:", error);
// Default to disabled on error for security
setSignupEnabled(false);
setShowGithubVersionOnLogin(true); // Default to showing on error
}
};
checkSignupEnabled();
checkLoginSettings();
}, []);
// Fetch latest release and stars from GitHub
useEffect(() => {
// Only fetch if the setting allows it
if (!showGithubVersionOnLogin) {
return;
}
const fetchGitHubData = async () => {
try {
// Try to get cached data first
@@ -261,7 +272,7 @@ const Login = () => {
};
fetchGitHubData();
}, []); // Run once on mount
}, [showGithubVersionOnLogin]); // Run once on mount
const handleSubmit = async (e) => {
e.preventDefault();
@@ -449,209 +460,213 @@ const Login = () => {
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-black/60" />
{/* Left side - Info Panel (hidden on mobile) */}
<div className="hidden lg:flex lg:w-1/2 xl:w-3/5 relative z-10">
<div className="flex flex-col justify-between text-white p-12 h-full w-full">
<div className="flex-1 flex flex-col justify-center items-start max-w-xl mx-auto">
<div className="space-y-6">
<div>
<img
src="/assets/logo_dark.png"
alt="PatchMon"
className="h-16 mb-4"
/>
<p className="text-sm text-blue-200 font-medium tracking-wide uppercase">
Linux Patch Monitoring
</p>
</div>
{/* Left side - Info Panel (hidden on mobile or when GitHub version is disabled) */}
{showGithubVersionOnLogin && (
<div className="hidden lg:flex lg:w-1/2 xl:w-3/5 relative z-10">
<div className="flex flex-col justify-between text-white p-12 h-full w-full">
<div className="flex-1 flex flex-col justify-center items-start max-w-xl mx-auto">
<div className="space-y-6">
<div>
<img
src="/assets/logo_dark.png"
alt="PatchMon"
className="h-16 mb-4"
/>
<p className="text-sm text-blue-200 font-medium tracking-wide uppercase">
Linux Patch Monitoring
</p>
</div>
{latestRelease ? (
<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
<span className="text-green-300 text-sm font-semibold">
Latest Release
{showGithubVersionOnLogin && latestRelease ? (
<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
<span className="text-green-300 text-sm font-semibold">
Latest Release
</span>
</div>
<span className="text-2xl font-bold text-white">
{latestRelease.version}
</span>
</div>
<span className="text-2xl font-bold text-white">
{latestRelease.version}
</span>
</div>
{latestRelease.name && (
<h3 className="text-lg font-semibold text-white">
{latestRelease.name}
</h3>
)}
{latestRelease.name && (
<h3 className="text-lg font-semibold text-white">
{latestRelease.name}
</h3>
)}
<div className="flex items-center gap-2 text-sm text-gray-300">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-label="Release date"
<div className="flex items-center gap-2 text-sm text-gray-300">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-label="Release date"
>
<title>Release date</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Released {latestRelease.publishedAt}</span>
</div>
{latestRelease.body && (
<p className="text-sm text-gray-300 leading-relaxed line-clamp-3">
{latestRelease.body}
</p>
)}
<a
href="https://github.com/PatchMon/PatchMon/releases/latest"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-blue-300 hover:text-blue-200 transition-colors font-medium"
>
<title>Release date</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Released {latestRelease.publishedAt}</span>
View Release Notes
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-label="External link"
>
<title>External link</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
) : showGithubVersionOnLogin ? (
<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10">
<div className="animate-pulse space-y-3">
<div className="h-6 bg-white/20 rounded w-3/4" />
<div className="h-4 bg-white/20 rounded w-1/2" />
<div className="h-4 bg-white/20 rounded w-full" />
</div>
</div>
) : null}
</div>
</div>
{latestRelease.body && (
<p className="text-sm text-gray-300 leading-relaxed line-clamp-3">
{latestRelease.body}
</p>
)}
{/* Social Links Footer */}
<div className="max-w-xl mx-auto w-full">
<div className="border-t border-white/10 pt-6">
<p className="text-sm text-gray-400 mb-4">Connect with us</p>
<div className="flex flex-wrap items-center gap-2">
{/* GitHub */}
<a
href="https://github.com/PatchMon/PatchMon/releases/latest"
href="https://github.com/PatchMon/PatchMon"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-blue-300 hover:text-blue-200 transition-colors font-medium"
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="GitHub Repository"
>
View Release Notes
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-label="External link"
>
<title>External link</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
<Github className="h-5 w-5 text-white" />
{githubStars !== null && (
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current text-yellow-400" />
<span className="text-sm font-medium text-white">
{githubStars}
</span>
</div>
)}
</a>
{/* Roadmap */}
<a
href="https://github.com/orgs/PatchMon/projects/2/views/1"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Roadmap"
>
<Route className="h-5 w-5 text-white" />
</a>
{/* Docs */}
<a
href="https://docs.patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Documentation"
>
<BookOpen className="h-5 w-5 text-white" />
</a>
{/* Discord */}
<a
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Discord Community"
>
<DiscordIcon className="h-5 w-5 text-white" />
</a>
{/* Email */}
<a
href="mailto:support@patchmon.net"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Email Support"
>
<Mail className="h-5 w-5 text-white" />
</a>
{/* YouTube */}
<a
href="https://youtube.com/@patchmonTV"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="YouTube Channel"
>
<FaYoutube className="h-5 w-5 text-white" />
</a>
{/* Reddit */}
<a
href="https://www.reddit.com/r/patchmon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Reddit Community"
>
<FaReddit className="h-5 w-5 text-white" />
</a>
{/* Website */}
<a
href="https://patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Visit patchmon.net"
>
<Globe className="h-5 w-5 text-white" />
</a>
</div>
) : (
<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10">
<div className="animate-pulse space-y-3">
<div className="h-6 bg-white/20 rounded w-3/4" />
<div className="h-4 bg-white/20 rounded w-1/2" />
<div className="h-4 bg-white/20 rounded w-full" />
</div>
</div>
)}
</div>
</div>
{/* Social Links Footer */}
<div className="max-w-xl mx-auto w-full">
<div className="border-t border-white/10 pt-6">
<p className="text-sm text-gray-400 mb-4">Connect with us</p>
<div className="flex flex-wrap items-center gap-2">
{/* GitHub */}
<a
href="https://github.com/PatchMon/PatchMon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="GitHub Repository"
>
<Github className="h-5 w-5 text-white" />
{githubStars !== null && (
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current text-yellow-400" />
<span className="text-sm font-medium text-white">
{githubStars}
</span>
</div>
)}
</a>
{/* Roadmap */}
<a
href="https://github.com/orgs/PatchMon/projects/2/views/1"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Roadmap"
>
<Route className="h-5 w-5 text-white" />
</a>
{/* Docs */}
<a
href="https://docs.patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Documentation"
>
<BookOpen className="h-5 w-5 text-white" />
</a>
{/* Discord */}
<a
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Discord Community"
>
<DiscordIcon className="h-5 w-5 text-white" />
</a>
{/* Email */}
<a
href="mailto:support@patchmon.net"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Email Support"
>
<Mail className="h-5 w-5 text-white" />
</a>
{/* YouTube */}
<a
href="https://youtube.com/@patchmonTV"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="YouTube Channel"
>
<FaYoutube className="h-5 w-5 text-white" />
</a>
{/* Reddit */}
<a
href="https://www.reddit.com/r/patchmon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Reddit Community"
>
<FaReddit className="h-5 w-5 text-white" />
</a>
{/* Website */}
<a
href="https://patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Visit patchmon.net"
>
<Globe className="h-5 w-5 text-white" />
</a>
</div>
</div>
</div>
</div>
</div>
)}
{/* Right side - Login Form */}
<div className="flex-1 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative z-10">
<div
className={`${showGithubVersionOnLogin ? "flex-1" : "w-full"} flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative z-10`}
>
<div className="max-w-md w-full space-y-8 bg-white dark:bg-secondary-900 rounded-2xl shadow-2xl p-8 lg:p-10">
<div>
<div className="mx-auto h-16 w-16 flex items-center justify-center">

View File

@@ -18,12 +18,7 @@ import {
import { useEffect, useId, useState } from "react";
import UpgradeNotificationIcon from "../components/UpgradeNotificationIcon";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
import {
agentFileAPI,
permissionsAPI,
settingsAPI,
versionAPI,
} from "../utils/api";
import { agentFileAPI, settingsAPI, versionAPI } from "../utils/api";
const Settings = () => {
const repoPublicId = useId();
@@ -33,7 +28,6 @@ const Settings = () => {
const hostId = useId();
const portId = useId();
const updateIntervalId = useId();
const defaultRoleId = useId();
const githubRepoUrlId = useId();
const sshKeyPathId = useId();
const _scriptFileId = useId();
@@ -44,8 +38,6 @@ const Settings = () => {
serverPort: 3001,
updateInterval: 60,
autoUpdate: false,
signupEnabled: false,
defaultUserRole: "user",
githubRepoUrl: "git@github.com:9technologygroup/patchmon.net.git",
repositoryType: "public",
sshKeyPath: "",
@@ -175,12 +167,6 @@ const Settings = () => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
};
// Fetch available roles for default user role dropdown
const { data: roles, isLoading: rolesLoading } = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
@@ -190,9 +176,6 @@ const Settings = () => {
serverPort: settings.server_port || 3001,
updateInterval: settings.update_interval || 60,
autoUpdate: settings.auto_update || false,
// biome-ignore lint/complexity/noUselessTernary: Seems to be desired given the comment
signupEnabled: settings.signup_enabled === true ? true : false, // Explicit boolean conversion
defaultUserRole: settings.default_user_role || "user",
githubRepoUrl:
settings.github_repo_url ||
"https://github.com/PatchMon/PatchMon.git",
@@ -997,66 +980,6 @@ const Settings = () => {
</p>
</div>
{/* User Signup Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.signupEnabled}
onChange={(e) =>
handleInputChange("signupEnabled", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
Enable User Self-Registration
</div>
</label>
{/* Default User Role Dropdown */}
{formData.signupEnabled && (
<div className="mt-3 ml-6">
<label
htmlFor={defaultRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Default Role for New Users
</label>
<select
id={defaultRoleId}
value={formData.defaultUserRole}
onChange={(e) =>
handleInputChange("defaultUserRole", e.target.value)
}
className="w-full max-w-xs border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
disabled={rolesLoading}
>
{rolesLoading ? (
<option>Loading roles...</option>
) : roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() +
role.role.slice(1)}
</option>
))
) : (
<option value="user">User</option>
)}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
New users will be assigned this role when they register.
</p>
</div>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, users can create their own accounts through the
signup page. When disabled, only administrators can create
user accounts.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">

1733
package-lock.json generated

File diff suppressed because it is too large Load Diff