mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 19:23:31 +00:00
feat: add Generate New API Token functionality
- Add backend endpoint to regenerate API tokens without resetting auth - Updates .env file with new token while preserving other settings - Frontend component with clear UX for token generation - Shows new token once with copy functionality - Indicates restart required to activate new token - Works across all deployment types (Docker, LXC, native) Much better UX than telling users to 'reconfigure security' just for a new token
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { Component, createSignal, Show } from 'solid-js';
|
||||
import { showSuccess, showError } from '@/utils/toast';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
|
||||
export const GenerateAPIToken: Component = () => {
|
||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||
const [newToken, setNewToken] = createSignal<string | null>(null);
|
||||
const [showToken, setShowToken] = createSignal(false);
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
const [deploymentType, setDeploymentType] = createSignal<string>('');
|
||||
|
||||
const generateNewToken = async () => {
|
||||
if (!confirm('Generate a new API token? The old token will stop working immediately.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/security/regenerate-token', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || 'Failed to generate token');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setNewToken(data.token);
|
||||
setDeploymentType(data.deploymentType);
|
||||
setShowToken(true);
|
||||
showSuccess('New API token generated! Save it now - it won\'t be shown again.');
|
||||
} catch (error) {
|
||||
showError(`Failed to generate token: ${error}`);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!newToken()) return;
|
||||
|
||||
const success = await copyToClipboard(newToken()!);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
showError('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const getRestartInstructions = () => {
|
||||
switch(deploymentType()) {
|
||||
case 'docker':
|
||||
return 'Restart your Docker container to activate the new token.';
|
||||
case 'proxmoxve':
|
||||
return 'Restart Pulse from the ProxmoxVE host to activate the new token.';
|
||||
case 'systemd':
|
||||
return 'Run: sudo systemctl restart pulse';
|
||||
default:
|
||||
return 'Restart the Pulse service to activate the new token.';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<Show when={!showToken()}>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
API Token Active
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
|
||||
An API token is configured for this instance. Use it with the X-API-Token header for automation.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={generateNewToken}
|
||||
disabled={isGenerating()}
|
||||
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isGenerating() ? 'Generating...' : 'Generate New Token'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p class="font-medium mb-1">Using the API Token:</p>
|
||||
<code class="block bg-gray-900 dark:bg-gray-950 text-gray-100 p-2 rounded text-xs">
|
||||
curl -H "X-API-Token: YOUR_TOKEN" http://pulse:7655/api/...
|
||||
</code>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={showToken() && newToken()}>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-green-800 dark:text-green-200 mb-2">
|
||||
✅ New API Token Generated!
|
||||
</h4>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
Save this token now - it will never be shown again!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Your New API Token</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
|
||||
{newToken()}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{copied() ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<div class="flex items-start space-x-2">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 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 class="text-xs text-amber-700 dark:text-amber-300">
|
||||
<p class="font-semibold">Restart Required</p>
|
||||
<p class="mt-1">{getRestartInstructions()}</p>
|
||||
<p class="mt-1 text-amber-600 dark:text-amber-400">The old token has been invalidated and will no longer work.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowToken(false);
|
||||
setNewToken(null);
|
||||
}}
|
||||
class="px-4 py-2 bg-gray-600 text-white text-sm rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useWebSocket } from '@/App';
|
||||
import { showSuccess, showError } from '@/utils/toast';
|
||||
import { NodeModal } from './NodeModal';
|
||||
import { QuickSecuritySetup } from './QuickSecuritySetup';
|
||||
import { GenerateAPIToken } from './GenerateAPIToken';
|
||||
import { ChangePasswordModal } from './ChangePasswordModal';
|
||||
import { RemovePasswordModal } from './RemovePasswordModal';
|
||||
import { SettingsAPI } from '@/api/settings';
|
||||
@@ -1559,35 +1560,7 @@ const Settings: Component = () => {
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
API Token Active
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
An API token is configured for this instance. Use it with the X-API-Token header for automation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<div class="flex items-start space-x-2">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<p class="font-semibold">Security Notice</p>
|
||||
<p class="mt-1">API tokens are only visible during initial setup. If you've lost your token, you'll need to reconfigure security to generate a new one.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p class="font-medium mb-1">Using the API Token:</p>
|
||||
<code class="block bg-gray-900 dark:bg-gray-950 text-gray-100 p-2 rounded text-xs">
|
||||
curl -H "X-API-Token: YOUR_TOKEN" http://pulse:7655/api/...
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<GenerateAPIToken />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -260,6 +260,9 @@ func (r *Router) setupRoutes() {
|
||||
// Quick security setup route - using fixed version
|
||||
r.mux.HandleFunc("/api/security/quick-setup", handleQuickSecuritySetupFixed(r))
|
||||
|
||||
// API token regeneration endpoint
|
||||
r.mux.HandleFunc("/api/security/regenerate-token", r.HandleRegenerateAPIToken)
|
||||
|
||||
// Apply security restart endpoint
|
||||
r.mux.HandleFunc("/api/security/apply-restart", func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodPost {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -284,4 +286,85 @@ ENABLE_AUDIT_LOG=true
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRegenerateAPIToken generates a new API token and updates the .env file
|
||||
func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Request) {
|
||||
// Require authentication
|
||||
if !CheckAuth(r.config, w, rq) {
|
||||
return
|
||||
}
|
||||
|
||||
if rq.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new 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)
|
||||
|
||||
// Determine env file path
|
||||
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
||||
if r.config.ConfigPath == "" {
|
||||
envPath = "/etc/pulse/.env"
|
||||
}
|
||||
|
||||
// Docker uses /data/.env
|
||||
if _, err := os.Stat("/data/.env"); err == nil {
|
||||
envPath = "/data/.env"
|
||||
}
|
||||
|
||||
// Read existing .env file
|
||||
content, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("path", envPath).Msg("Failed to read .env file")
|
||||
http.Error(w, "Security configuration not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the API_TOKEN line
|
||||
lines := strings.Split(string(content), "\n")
|
||||
var updated bool
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "API_TOKEN=") {
|
||||
lines[i] = fmt.Sprintf("API_TOKEN='%s'", newToken)
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !updated {
|
||||
// API_TOKEN line not found, add it
|
||||
lines = append(lines, fmt.Sprintf("API_TOKEN='%s'", newToken))
|
||||
}
|
||||
|
||||
// Write updated content back
|
||||
newContent := strings.Join(lines, "\n")
|
||||
if err := os.WriteFile(envPath, []byte(newContent), 0600); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to update .env file")
|
||||
http.Error(w, "Failed to save new token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Msg("API token regenerated successfully")
|
||||
|
||||
// Get deployment type for restart instructions
|
||||
deploymentType := updates.GetDeploymentType()
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"token": newToken,
|
||||
"deploymentType": deploymentType,
|
||||
"requiresRestart": true,
|
||||
"message": "New API token generated. Restart required to activate.",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
Reference in New Issue
Block a user