refactor: remove legacy system.json API token management

- Remove old /api/system/api-token endpoints
- Remove APIToken field from SystemSettings struct
- Remove token handling from system_settings.go
- Clean up config.Load() to not read token from system.json
- Remove unused frontend API token functions
- Remove unused APITokenManager and CurrentAPIToken components

API tokens are now managed exclusively via .env file with the new
/api/security/regenerate-token endpoint. This eliminates confusion
between the two systems and ensures consistency.
This commit is contained in:
Pulse Monitor 2025-08-15 10:04:39 +00:00
parent 4323339c5e
commit f524166f9d
6 changed files with 37 additions and 422 deletions

View file

@ -1,53 +1,16 @@
// System API for managing system settings including API tokens
// System API for managing system settings
import { apiFetchJSON } from '@/utils/apiClient';
export interface APITokenStatus {
hasToken: boolean;
token?: string;
}
export interface SystemSettings {
pollingInterval: number;
updateChannel?: string;
autoUpdateEnabled: boolean;
autoUpdateCheckInterval?: number;
autoUpdateTime?: string;
apiToken?: string;
// apiToken removed - now handled via security API
}
export class SystemAPI {
// API Token Management
static async getAPITokenStatus(): Promise<APITokenStatus> {
return apiFetchJSON('/api/system/api-token');
}
static async getAPIToken(reveal: boolean = false): Promise<APITokenStatus> {
const url = reveal ? '/api/system/api-token?reveal=true' : '/api/system/api-token';
return apiFetchJSON(url);
}
static async generateAPIToken(): Promise<APITokenStatus> {
const result = await apiFetchJSON('/api/system/api-token/generate', {
method: 'POST',
});
// Store the new token locally
if (result.token) {
localStorage.setItem('apiToken', result.token);
}
return result;
}
static async deleteAPIToken(): Promise<void> {
await apiFetchJSON('/api/system/api-token/delete', {
method: 'DELETE',
});
// Clear local storage
localStorage.removeItem('apiToken');
}
// System Settings
static async getSystemSettings(): Promise<SystemSettings> {
return apiFetchJSON('/api/system/settings');

View file

@ -1,201 +0,0 @@
import { createSignal, Show, onMount } from 'solid-js';
import { SystemAPI, APITokenStatus } from '@/api/system';
import { copyToClipboard } from '@/utils/clipboard';
export function APITokenManager() {
const [tokenStatus, setTokenStatus] = createSignal<APITokenStatus | null>(null);
const [loading, setLoading] = createSignal(false);
const [showToken, setShowToken] = createSignal(false);
const [currentToken, setCurrentToken] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null);
const [copied, setCopied] = createSignal(false);
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
// Load initial status and fetch the actual token if it exists
onMount(async () => {
try {
const status = await SystemAPI.getAPITokenStatus();
setTokenStatus(status);
// If there's a token, fetch it immediately
if (status.hasToken) {
try {
const tokenData = await SystemAPI.getAPIToken(true);
if (tokenData.token) {
setCurrentToken(tokenData.token);
setShowToken(true);
}
} catch (err) {
console.error('Failed to fetch existing API token:', err);
}
}
} 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);
setCurrentToken(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 });
setCurrentToken(null);
setShowToken(false);
setShowDeleteConfirm(false);
} catch (err: any) {
setError(err.message || 'Failed to delete token');
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
if (!currentToken()) return;
const success = await copyToClipboard(currentToken()!);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
setError('Failed to copy - please select and copy manually');
}
};
return (
<div>
<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-sm text-gray-600 dark:text-gray-400">
No token configured
</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 Token'}
</button>
</div>
}
>
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-600 dark:text-gray-400">
Token active
</p>
<button
onClick={() => setShowDeleteConfirm(true)}
class="text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
Delete
</button>
</div>
<Show when={currentToken() && showToken()}>
<div class="p-4 bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Token
</p>
<div class="relative">
<input
type="text"
value={currentToken()!}
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={handleCopy}
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>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-2 space-y-2">
<p>Use this token for API authentication:</p>
<code class="block bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
curl -H "X-API-Token: {currentToken()}" {window.location.origin}/api/health
</code>
</div>
</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

@ -601,9 +601,7 @@ func (r *Router) setupRoutes() {
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)
// Old API token endpoints removed - now using /api/security/regenerate-token
// WebSocket endpoint
r.mux.HandleFunc("/ws", r.handleWebSocket)

View file

@ -1,18 +1,15 @@
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
// SystemSettingsHandler handles system settings
type SystemSettingsHandler struct {
config *config.Config
persistence *config.ConfigPersistence
@ -26,147 +23,7 @@ func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPers
}
}
// 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 (session, password, or API token)
if CheckAuth(h.config, w, r) {
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
}
// Use standard auth check if any authentication is configured
// This allows generation via web UI when logged in with password
if !CheckAuth(h.config, w, r) {
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
}
// Use standard auth check (allows session, password, or API token)
// This allows deletion via web UI when logged in with password
if !CheckAuth(h.config, w, r) {
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
// HandleGetSystemSettings returns the current 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)
@ -176,29 +33,24 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r
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
settings = &config.SystemSettings{
PollingInterval: 5,
}
}
// 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
// HandleUpdateSystemSettings updates the 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
}
// Use standard auth check (allows session, password, or API token)
// Require authentication
if !CheckAuth(h.config, w, r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
@ -207,26 +59,25 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
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
// Load existing settings to preserve fields not in the request
// (removed - not needed without API token preservation)
// Update the config
if settings.PollingInterval > 0 {
h.config.PollingInterval = time.Duration(settings.PollingInterval) * time.Second
}
if settings.AllowedOrigins != "" {
h.config.AllowedOrigins = settings.AllowedOrigins
}
if settings.ConnectionTimeout > 0 {
h.config.ConnectionTimeout = time.Duration(settings.ConnectionTimeout) * time.Second
}
if settings.UpdateChannel != "" {
h.config.UpdateChannel = settings.UpdateChannel
}
// Update auto-update settings
h.config.AutoUpdateEnabled = settings.AutoUpdateEnabled
if settings.AutoUpdateCheckInterval > 0 {
h.config.AutoUpdateCheckInterval = time.Duration(settings.AutoUpdateCheckInterval) * time.Hour
@ -234,9 +85,16 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
if settings.AutoUpdateTime != "" {
h.config.AutoUpdateTime = settings.AutoUpdateTime
}
log.Info().Msg("System settings updated via UI")
// Save to persistence
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
}
log.Info().Msg("System settings updated")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}

View file

@ -229,13 +229,10 @@ func Load() (*Config, error) {
if systemSettings.ConnectionTimeout > 0 {
cfg.ConnectionTimeout = time.Duration(systemSettings.ConnectionTimeout) * time.Second
}
if systemSettings.APIToken != "" {
cfg.APIToken = systemSettings.APIToken
}
// APIToken no longer loaded from system.json - only from .env
log.Info().
Dur("interval", cfg.PollingInterval).
Str("updateChannel", cfg.UpdateChannel).
Bool("hasAPIToken", cfg.APIToken != "").
Msg("Loaded system configuration")
}
}

View file

@ -295,7 +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"`
// APIToken removed - now handled via .env file only
}
// SaveNodesConfig saves nodes configuration to file (encrypted)