feat: implement API token management UI (addresses #302)

- Add interactive API token management in Settings > Security tab
- Users can now generate, view, regenerate, and delete API tokens from the UI
- Tokens are persisted in system.json and survive restarts
- Environment variable API_TOKEN still takes precedence for backward compatibility
- Proper authentication enforcement when tokens are configured
- Secure token generation using crypto/rand (32 bytes, hex encoded)
- Clean UI with copy-to-clipboard functionality for newly generated tokens
This commit is contained in:
Pulse Monitor 2025-08-12 15:32:23 +00:00
parent ef3789e9e0
commit 75f4b74b83
9 changed files with 576 additions and 86 deletions

View file

@ -0,0 +1,111 @@
// System API for managing system settings including API tokens
export interface APITokenStatus {
hasToken: boolean;
token?: string;
}
export interface SystemSettings {
pollingInterval: number;
updateChannel?: string;
autoUpdateEnabled: boolean;
autoUpdateCheckInterval?: number;
autoUpdateTime?: string;
apiToken?: string;
}
export class SystemAPI {
// API Token Management
static async getAPITokenStatus(): Promise<APITokenStatus> {
const response = await fetch('/api/system/api-token');
if (!response.ok) {
throw new Error('Failed to get API token status');
}
return response.json();
}
static async generateAPIToken(): Promise<APITokenStatus> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Include existing token if we have one
const existingToken = localStorage.getItem('apiToken');
if (existingToken) {
headers['X-API-Token'] = existingToken;
}
const response = await fetch('/api/system/api-token/generate', {
method: 'POST',
headers,
});
if (!response.ok) {
throw new Error('Failed to generate API token');
}
const result = await response.json();
// Store the new token locally
if (result.token) {
localStorage.setItem('apiToken', result.token);
}
return result;
}
static async deleteAPIToken(): Promise<void> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Include existing token for auth
const existingToken = localStorage.getItem('apiToken');
if (existingToken) {
headers['X-API-Token'] = existingToken;
}
const response = await fetch('/api/system/api-token/delete', {
method: 'DELETE',
headers,
});
if (!response.ok) {
throw new Error('Failed to delete API token');
}
// Clear local storage
localStorage.removeItem('apiToken');
}
// System Settings
static async getSystemSettings(): Promise<SystemSettings> {
const response = await fetch('/api/system/settings');
if (!response.ok) {
throw new Error('Failed to get system settings');
}
return response.json();
}
static async updateSystemSettings(settings: Partial<SystemSettings>): Promise<void> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Include API token if configured
const apiToken = localStorage.getItem('apiToken');
if (apiToken) {
headers['X-API-Token'] = apiToken;
}
const response = await fetch('/api/system/settings/update', {
method: 'POST',
headers,
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error('Failed to update system settings');
}
}
}

View file

@ -0,0 +1,193 @@
import { createSignal, Show, onMount } from 'solid-js';
import { SystemAPI, APITokenStatus } from '@/api/system';
export function APITokenManager() {
const [tokenStatus, setTokenStatus] = createSignal<APITokenStatus | null>(null);
const [loading, setLoading] = createSignal(false);
const [showToken, setShowToken] = createSignal(false);
const [generatedToken, setGeneratedToken] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null);
const [copied, setCopied] = createSignal(false);
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
// Load initial status
onMount(async () => {
try {
const status = await SystemAPI.getAPITokenStatus();
setTokenStatus(status);
} catch (err) {
console.error('Failed to load API token status:', err);
}
});
const generateToken = async () => {
setLoading(true);
setError(null);
try {
const result = await SystemAPI.generateAPIToken();
setTokenStatus(result);
setGeneratedToken(result.token || null);
setShowToken(true);
} catch (err: any) {
setError(err.message || 'Failed to generate token');
} finally {
setLoading(false);
}
};
const deleteToken = async () => {
setLoading(true);
setError(null);
try {
await SystemAPI.deleteAPIToken();
setTokenStatus({ hasToken: false });
setGeneratedToken(null);
setShowToken(false);
setShowDeleteConfirm(false);
} catch (err: any) {
setError(err.message || 'Failed to delete token');
} finally {
setLoading(false);
}
};
const copyToClipboard = async () => {
if (generatedToken()) {
try {
await navigator.clipboard.writeText(generatedToken()!);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
};
return (
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">API Token Management</h4>
<Show when={error()}>
<div class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-700 dark:text-red-300">{error()}</p>
</div>
</Show>
<Show
when={tokenStatus()?.hasToken}
fallback={
<div class="space-y-4">
<p class="text-xs text-gray-600 dark:text-gray-400">
No API token is currently configured. Generate one to secure your Pulse instance.
</p>
<button
onClick={generateToken}
disabled={loading()}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading() ? 'Generating...' : 'Generate API Token'}
</button>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<p class="text-xs text-blue-700 dark:text-blue-300">
<strong>Note:</strong> Once generated, the API token will be required for all configuration changes,
exports, and imports. Make sure to save it securely!
</p>
</div>
</div>
}
>
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-600 dark:text-gray-400">
API token is configured and active
</p>
<div class="flex items-center gap-2">
<Show when={localStorage.getItem('apiToken')}>
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded">
Token stored locally
</span>
</Show>
</div>
</div>
<Show when={generatedToken() && showToken()}>
<div class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
New API Token Generated - Save This Now!
</p>
<div class="relative">
<input
type="text"
value={generatedToken()!}
readonly
class="w-full px-3 py-2 pr-20 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md"
onClick={(e) => e.currentTarget.select()}
/>
<button
onClick={copyToClipboard}
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-2">
This token will not be shown again. Copy it now and store it securely.
</p>
</div>
</Show>
<div class="flex gap-2">
<button
onClick={generateToken}
disabled={loading()}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading() ? 'Generating...' : 'Regenerate Token'}
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={loading()}
class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Remove Token
</button>
</div>
</div>
</Show>
{/* Delete Confirmation Modal */}
<Show when={showDeleteConfirm()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">
Remove API Token?
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
This will remove API authentication from your Pulse instance. All configuration endpoints
will be accessible without credentials.
</p>
<div class="flex justify-end gap-2">
<button
onClick={() => setShowDeleteConfirm(false)}
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={deleteToken}
disabled={loading()}
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading() ? 'Removing...' : 'Remove Token'}
</button>
</div>
</div>
</div>
</Show>
</div>
);
}

View file

@ -3,6 +3,7 @@ import { useWebSocket } from '@/App';
import { showSuccess, showError } from '@/utils/toast';
import { NodeModal } from './NodeModal';
import RegistrationTokens from './RegistrationTokens';
import { APITokenManager } from './APITokenManager';
import { SettingsAPI } from '@/api/settings';
import { NodesAPI } from '@/api/nodes';
import { UpdatesAPI } from '@/api/updates';
@ -1472,90 +1473,7 @@ const Settings: Component = () => {
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">API Security</h3>
<Show when={securityStatus()}>
<Show
when={!securityStatus()?.apiTokenConfigured}
fallback={
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
<div class="flex items-start">
<svg class="h-5 w-5 text-green-600 dark:text-green-400 mt-0.5 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div>
<p class="text-sm font-medium text-green-800 dark:text-green-200">API Protection Enabled</p>
<p class="text-xs text-green-700 dark:text-green-300 mt-1">
Your Pulse instance is protected with API token authentication.
</p>
<Show when={localStorage.getItem('apiToken')}>
<button
onClick={() => {
localStorage.removeItem('apiToken');
showSuccess('API token cleared from browser storage');
}}
class="mt-2 text-xs text-green-700 dark:text-green-300 underline hover:no-underline"
>
Clear stored token
</button>
</Show>
</div>
</div>
</div>
}
>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
<div class="flex items-start">
<svg class="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">API Protection Not Configured</p>
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
Your Pulse instance is currently running without API authentication.
All configuration endpoints are accessible without credentials.
</p>
</div>
</div>
</div>
</Show>
</Show>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Configure API Token</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
Setting an API token will require authentication for all configuration changes and exports.
</p>
<div class="space-y-3">
<div class="bg-gray-50 dark:bg-gray-900 rounded p-3">
<p class="text-xs font-mono text-gray-700 dark:text-gray-300 mb-2">For systemd service:</p>
<pre class="text-xs bg-black text-green-400 p-2 rounded overflow-x-auto">
sudo systemctl edit pulse
# Add these lines:
[Service]
Environment="API_TOKEN=your-secure-token-here"
# Then restart:
sudo systemctl restart pulse</pre>
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded p-3">
<p class="text-xs font-mono text-gray-700 dark:text-gray-300 mb-2">For Docker:</p>
<pre class="text-xs bg-black text-green-400 p-2 rounded overflow-x-auto">
docker run -d \
-e API_TOKEN=your-secure-token \
-p 7655:7655 \
-v pulse-data:/data \
rcourtman/pulse:latest</pre>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-3 mt-4">
<p class="text-xs text-blue-700 dark:text-blue-300">
<strong>Security Note:</strong> Choose a strong, random token. You can generate one with: <code class="font-mono">openssl rand -hex 32</code>
</p>
</div>
</div>
<APITokenManager />
</div>
<div>

View file

@ -6,12 +6,11 @@
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>Pulse</title>
<script type="module" crossorigin src="/assets/index-Dw5R8pC-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CqoxKn9z.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

View file

@ -113,6 +113,9 @@ function App() {
<circle class="pulse-center fill-white dark:fill-[#dbeafe]" cx="128" cy="128" r="26"/>
</svg>
<span class="text-lg font-medium text-gray-800 dark:text-gray-200">Pulse</span>
<Show when={versionInfo()?.channel === 'rc'}>
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">RC</span>
</Show>
</div>
<div class="header-controls flex justify-end items-center gap-4 md:flex-1">
<button

View file

@ -28,6 +28,7 @@ type Router struct {
updateManager *updates.Manager
exportLimiter *RateLimiter
tokenManager *tokens.TokenManager
persistence *config.ConfigPersistence
}
@ -42,6 +43,7 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket
updateManager: updates.NewManager(cfg),
exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute
tokenManager: tokens.NewTokenManager(cfg.DataPath),
persistence: config.NewConfigPersistence(cfg.DataPath),
}
r.setupRoutes()
@ -302,6 +304,14 @@ func (r *Router) setupRoutes() {
// Settings routes
r.mux.HandleFunc("/api/settings", getSettings)
r.mux.HandleFunc("/api/settings/update", updateSettings)
// System settings and API token management
systemSettingsHandler := NewSystemSettingsHandler(r.config, r.persistence)
r.mux.HandleFunc("/api/system/settings", systemSettingsHandler.HandleGetSystemSettings)
r.mux.HandleFunc("/api/system/settings/update", systemSettingsHandler.HandleUpdateSystemSettings)
r.mux.HandleFunc("/api/system/api-token", systemSettingsHandler.HandleGetAPIToken)
r.mux.HandleFunc("/api/system/api-token/generate", systemSettingsHandler.HandleGenerateAPIToken)
r.mux.HandleFunc("/api/system/api-token/delete", systemSettingsHandler.HandleDeleteAPIToken)
// WebSocket endpoint
r.mux.HandleFunc("/ws", r.handleWebSocket)

View file

@ -0,0 +1,250 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log"
)
// SystemSettingsHandler handles system settings including API token management
type SystemSettingsHandler struct {
config *config.Config
persistence *config.ConfigPersistence
}
// NewSystemSettingsHandler creates a new system settings handler
func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPersistence) *SystemSettingsHandler {
return &SystemSettingsHandler{
config: cfg,
persistence: persistence,
}
}
// APITokenResponse represents the API token response
type APITokenResponse struct {
HasToken bool `json:"hasToken"`
Token string `json:"token,omitempty"`
}
// HandleGetAPIToken returns the current API token status
func (h *SystemSettingsHandler) HandleGetAPIToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check if API token is set
hasToken := h.config.APIToken != ""
response := APITokenResponse{
HasToken: hasToken,
}
// Only return the token if it's requested with proper auth
if hasToken && r.URL.Query().Get("reveal") == "true" {
// Verify the request is authenticated
providedToken := r.Header.Get("X-API-Token")
if providedToken == h.config.APIToken {
response.Token = h.config.APIToken
} else {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleGenerateAPIToken generates a new API token
func (h *SystemSettingsHandler) HandleGenerateAPIToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// If a token already exists, require authentication
if h.config.APIToken != "" {
providedToken := r.Header.Get("X-API-Token")
if providedToken != h.config.APIToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
// Generate a new secure token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
log.Error().Err(err).Msg("Failed to generate random token")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
newToken := hex.EncodeToString(tokenBytes)
// Save to system settings
settings, err := h.persistence.LoadSystemSettings()
if err != nil {
log.Error().Err(err).Msg("Failed to load system settings")
settings = &config.SystemSettings{}
}
settings.APIToken = newToken
if err := h.persistence.SaveSystemSettings(*settings); err != nil {
log.Error().Err(err).Msg("Failed to save system settings")
http.Error(w, "Failed to save token", http.StatusInternalServerError)
return
}
// Update the running config
h.config.APIToken = newToken
// Don't override if env var is set
if os.Getenv("API_TOKEN") != "" {
log.Warn().Msg("API_TOKEN environment variable is set and will override UI-configured token on restart")
}
log.Info().Msg("API token generated via UI")
response := APITokenResponse{
HasToken: true,
Token: newToken,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleDeleteAPIToken removes the API token
func (h *SystemSettingsHandler) HandleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication if token exists
if h.config.APIToken != "" {
providedToken := r.Header.Get("X-API-Token")
if providedToken != h.config.APIToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
// Save to system settings
settings, err := h.persistence.LoadSystemSettings()
if err != nil {
log.Error().Err(err).Msg("Failed to load system settings")
settings = &config.SystemSettings{}
}
settings.APIToken = ""
if err := h.persistence.SaveSystemSettings(*settings); err != nil {
log.Error().Err(err).Msg("Failed to save system settings")
http.Error(w, "Failed to remove token", http.StatusInternalServerError)
return
}
// Update the running config
h.config.APIToken = ""
// Warn if env var is set
if os.Getenv("API_TOKEN") != "" {
log.Warn().Msg("API_TOKEN environment variable is set and will override this change on restart")
}
log.Info().Msg("API token removed via UI")
response := APITokenResponse{
HasToken: false,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleGetSystemSettings returns all system settings
func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
settings, err := h.persistence.LoadSystemSettings()
if err != nil {
log.Error().Err(err).Msg("Failed to load system settings")
http.Error(w, "Failed to load settings", http.StatusInternalServerError)
return
}
// Don't expose the actual token in this endpoint
if settings.APIToken != "" {
settings.APIToken = "***HIDDEN***"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(settings)
}
// HandleUpdateSystemSettings updates system settings
func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication if token exists
if h.config.APIToken != "" {
providedToken := r.Header.Get("X-API-Token")
if providedToken != h.config.APIToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
var settings config.SystemSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Don't allow updating API token through this endpoint
existingSettings, _ := h.persistence.LoadSystemSettings()
if existingSettings != nil {
settings.APIToken = existingSettings.APIToken
}
if err := h.persistence.SaveSystemSettings(settings); err != nil {
log.Error().Err(err).Msg("Failed to save system settings")
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
return
}
// Update relevant config fields
if settings.PollingInterval > 0 {
h.config.PollingInterval = time.Duration(settings.PollingInterval) * time.Second
}
if settings.UpdateChannel != "" {
h.config.UpdateChannel = settings.UpdateChannel
}
h.config.AutoUpdateEnabled = settings.AutoUpdateEnabled
if settings.AutoUpdateCheckInterval > 0 {
h.config.AutoUpdateCheckInterval = time.Duration(settings.AutoUpdateCheckInterval) * time.Hour
}
if settings.AutoUpdateTime != "" {
h.config.AutoUpdateTime = settings.AutoUpdateTime
}
log.Info().Msg("System settings updated via UI")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}

View file

@ -200,9 +200,13 @@ func Load() (*Config, error) {
if systemSettings.ConnectionTimeout > 0 {
cfg.ConnectionTimeout = time.Duration(systemSettings.ConnectionTimeout) * time.Second
}
if systemSettings.APIToken != "" {
cfg.APIToken = systemSettings.APIToken
}
log.Info().
Dur("interval", cfg.PollingInterval).
Str("updateChannel", cfg.UpdateChannel).
Bool("hasAPIToken", cfg.APIToken != "").
Msg("Loaded system configuration")
}
}
@ -316,6 +320,7 @@ func SaveConfig(cfg *Config) error {
AutoUpdateTime: cfg.AutoUpdateTime,
AllowedOrigins: cfg.AllowedOrigins,
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
APIToken: cfg.APIToken,
}
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
return fmt.Errorf("failed to save system config: %w", err)

View file

@ -295,6 +295,7 @@ type SystemSettings struct {
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
APIToken string `json:"apiToken,omitempty"`
}
// SaveNodesConfig saves nodes configuration to file (encrypted)