diff --git a/frontend-modern/src/api/system.ts b/frontend-modern/src/api/system.ts new file mode 100644 index 000000000..05e372e72 --- /dev/null +++ b/frontend-modern/src/api/system.ts @@ -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 { + 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 { + const headers: Record = { + '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 { + const headers: Record = { + '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 { + 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): Promise { + const headers: Record = { + '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'); + } + } +} \ No newline at end of file diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx new file mode 100644 index 000000000..8d1dd82ce --- /dev/null +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -0,0 +1,193 @@ +import { createSignal, Show, onMount } from 'solid-js'; +import { SystemAPI, APITokenStatus } from '@/api/system'; + +export function APITokenManager() { + const [tokenStatus, setTokenStatus] = createSignal(null); + const [loading, setLoading] = createSignal(false); + const [showToken, setShowToken] = createSignal(false); + const [generatedToken, setGeneratedToken] = createSignal(null); + const [error, setError] = createSignal(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 ( +
+

API Token Management

+ + +
+

{error()}

+
+
+ + +

+ No API token is currently configured. Generate one to secure your Pulse instance. +

+ + + +
+

+ Note: Once generated, the API token will be required for all configuration changes, + exports, and imports. Make sure to save it securely! +

+
+
+ } + > +
+
+

+ API token is configured and active +

+
+ + + Token stored locally + + +
+
+ + +
+

+ New API Token Generated - Save This Now! +

+
+ e.currentTarget.select()} + /> + +
+

+ This token will not be shown again. Copy it now and store it securely. +

+
+
+ +
+ + + +
+ +
+ + + {/* Delete Confirmation Modal */} + +
+
+

+ Remove API Token? +

+

+ This will remove API authentication from your Pulse instance. All configuration endpoints + will be accessible without credentials. +

+
+ + +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 4ed15aff2..49d1472ac 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -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 = () => {

API Security

- - - -
- - - -
-

API Protection Enabled

-

- Your Pulse instance is protected with API token authentication. -

- - - -
-
-
- } - > -
-
- - - -
-

API Protection Not Configured

-

- Your Pulse instance is currently running without API authentication. - All configuration endpoints are accessible without credentials. -

-
-
-
- - - -
-

Configure API Token

-

- Setting an API token will require authentication for all configuration changes and exports. -

- -
-
-

For systemd service:

-
-sudo systemctl edit pulse
-# Add these lines:
-[Service]
-Environment="API_TOKEN=your-secure-token-here"
-
-# Then restart:
-sudo systemctl restart pulse
-
- -
-

For Docker:

-
-docker run -d \
-  -e API_TOKEN=your-secure-token \
-  -p 7655:7655 \
-  -v pulse-data:/data \
-  rcourtman/pulse:latest
-
-
- -
-

- Security Note: Choose a strong, random token. You can generate one with: openssl rand -hex 32 -

-
-
+
diff --git a/internal/api/frontend-modern/index.html b/internal/api/frontend-modern/index.html index 212497788..78bd7f095 100644 --- a/internal/api/frontend-modern/index.html +++ b/internal/api/frontend-modern/index.html @@ -6,12 +6,11 @@ Pulse - -
+ \ No newline at end of file diff --git a/internal/api/frontend-modern/src/App.tsx b/internal/api/frontend-modern/src/App.tsx index 0c757e651..efb7002bd 100644 --- a/internal/api/frontend-modern/src/App.tsx +++ b/internal/api/frontend-modern/src/App.tsx @@ -113,6 +113,9 @@ function App() { Pulse + + RC +