From e565b292bfc6c5e236795cc1bbcf773125b0d55e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 23 Oct 2025 17:14:34 +0000 Subject: [PATCH] feat: unify agent deployments and scoped token flows --- .../src/components/Dashboard/Dashboard.tsx | 67 +-- .../src/components/Dashboard/GuestRow.tsx | 199 +++++++- .../src/components/Settings/DockerAgents.tsx | 357 +++----------- .../src/components/Settings/GuestURLs.tsx | 453 ------------------ .../src/components/Settings/HostAgents.tsx | 451 ++++++++++++----- .../src/components/Settings/Settings.tsx | 311 ++++++++---- .../src/hooks/useScopedTokenManager.ts | 174 +++++++ 7 files changed, 1017 insertions(+), 995 deletions(-) delete mode 100644 frontend-modern/src/components/Settings/GuestURLs.tsx create mode 100644 frontend-modern/src/hooks/useScopedTokenManager.ts diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 32400b0..8b2e822 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -85,6 +85,17 @@ export function Dashboard(props: DashboardProps) { } }); + // Callback to update a guest's custom URL in metadata + const handleCustomUrlUpdate = (guestId: string, url: string) => { + setGuestMetadata((prev) => ({ + ...prev, + [guestId]: { + ...(prev[guestId] || { id: guestId }), + customUrl: url || undefined, + }, + })); + }; + // Create a mapping from node ID to node object const nodeByInstance = createMemo(() => { const map: Record = {}; @@ -230,8 +241,7 @@ export function Dashboard(props: DashboardProps) { const allGuests = createMemo(() => { const vms = props.vms || []; const containers = props.containers || []; - const guests: (VM | Container)[] = [...vms, ...containers]; - return guests; + return [...vms, ...containers]; }); // Filter guests based on current settings @@ -782,33 +792,32 @@ export function Dashboard(props: DashboardProps) { }> - {(guest) => ( - - {(() => { - // Match backend ID generation logic: standalone nodes use "node-vmid", clusters use "instance-node-vmid" - const guestId = - guest.id || - (guest.instance === guest.node - ? `${guest.node}-${guest.vmid}` - : `${guest.instance}-${guest.node}-${guest.vmid}`); - const metadata = - guestMetadata()[guestId] || - guestMetadata()[`${guest.node}-${guest.vmid}`]; - const parentNode = node ?? resolveParentNode(guest); - const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true; - return ( - - ); - })()} - - )} + {(guest) => { + // Match backend ID generation logic: standalone nodes use "node-vmid", clusters use "instance-node-vmid" + const guestId = + guest.id || + (guest.instance === guest.node + ? `${guest.node}-${guest.vmid}` + : `${guest.instance}-${guest.node}-${guest.vmid}`); + const metadata = + guestMetadata()[guestId] || + guestMetadata()[`${guest.node}-${guest.vmid}`]; + const parentNode = node ?? resolveParentNode(guest); + const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true; + return ( + + + + ); + }} ); diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index a572c44..8186ab1 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -6,10 +6,18 @@ import { IOMetric } from './IOMetric'; import { TagBadges } from './TagBadges'; import { DiskList } from './DiskList'; import { isGuestRunning, shouldDisplayGuestMetrics } from '@/utils/status'; +import { GuestMetadataAPI } from '@/api/guestMetadata'; +import { showSuccess, showError } from '@/utils/toast'; type Guest = VM | Container; const drawerState = new Map(); +// Global editing state - use a signal so all components react +const [currentlyEditingGuestId, setCurrentlyEditingGuestId] = createSignal(null); +// Store the editing value globally so it survives re-renders +const editingValues = new Map(); +// Signal to trigger reactivity when editing values change +const [editingValuesVersion, setEditingValuesVersion] = createSignal(0); const buildGuestId = (guest: Guest) => { if (guest.id) return guest.id; @@ -44,13 +52,21 @@ interface GuestRowProps { onTagClick?: (tag: string) => void; activeSearch?: string; parentNodeOnline?: boolean; + onCustomUrlUpdate?: (guestId: string, url: string) => void; } export function GuestRow(props: GuestRowProps) { - const [customUrl, setCustomUrl] = createSignal(props.customUrl); const initialGuestId = buildGuestId(props.guest); - const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(initialGuestId) ?? false); const guestId = createMemo(() => buildGuestId(props.guest)); + const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId()); + + const [customUrl, setCustomUrl] = createSignal(props.customUrl); + const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(initialGuestId) ?? false); + const editingUrlValue = createMemo(() => { + editingValuesVersion(); // Subscribe to changes + return editingValues.get(guestId()) || ''; + }); + let urlInputRef: HTMLInputElement | undefined; const hasFilesystemDetails = createMemo(() => (props.guest.disks?.length ?? 0) > 0); const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []); @@ -62,12 +78,16 @@ export function GuestRow(props: GuestRowProps) { const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0); const hasAgentInfo = createMemo(() => agentVersion().length > 0); - // Update custom URL when prop changes + // Update custom URL when prop changes, but only if we're not currently editing createEffect(() => { - setCustomUrl(props.customUrl); + // Don't update customUrl from props if this guest is currently being edited + if (currentlyEditingGuestId() !== guestId()) { + setCustomUrl(props.customUrl); + } }); + const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100); const memPercent = createMemo(() => { if (!props.guest.memory) return 0; @@ -135,11 +155,80 @@ export function GuestRow(props: GuestRowProps) { const toggleDrawer = (event: MouseEvent) => { if (!canShowDrawer()) return; const target = event.target as HTMLElement; - if (target.closest('a, button, [data-prevent-toggle]')) { + if (target.closest('a, button, input, [data-prevent-toggle]')) { return; } setDrawerOpen((prev) => !prev); }; + + const startEditingUrl = (event: MouseEvent) => { + event.stopPropagation(); + + // If another guest is being edited, save it first + const currentEditing = currentlyEditingGuestId(); + if (currentEditing !== null && currentEditing !== guestId()) { + // Find the input for the currently editing guest and blur it + const currentInput = document.querySelector(`input[data-guest-id="${currentEditing}"]`) as HTMLInputElement; + if (currentInput) { + currentInput.blur(); + } + } + + editingValues.set(guestId(), customUrl() || ''); + setEditingValuesVersion(v => v + 1); + setCurrentlyEditingGuestId(guestId()); + + // Focus the input after it renders + queueMicrotask(() => { + if (urlInputRef) { + urlInputRef.focus(); + urlInputRef.select(); + } + }); + }; + + const saveUrl = async () => { + // Only save if this guest is the one being edited + if (currentlyEditingGuestId() !== guestId()) return; + + const newUrl = (editingValues.get(guestId()) || '').trim(); + + // Clear global editing state + editingValues.delete(guestId()); + setEditingValuesVersion(v => v + 1); + setCurrentlyEditingGuestId(null); + + // If URL hasn't changed, don't save + if (newUrl === (customUrl() || '')) return; + + try { + await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl }); + setCustomUrl(newUrl || undefined); + + // Notify parent to update metadata + if (props.onCustomUrlUpdate) { + props.onCustomUrlUpdate(guestId(), newUrl); + } + + if (newUrl) { + showSuccess('Guest URL saved'); + } else { + showSuccess('Guest URL cleared'); + } + } catch (err: any) { + console.error('Failed to save guest URL:', err); + showError(err.message || 'Failed to save guest URL'); + } + }; + + const cancelEditingUrl = () => { + // Only cancel if this guest is the one being edited + if (currentlyEditingGuestId() !== guestId()) return; + + editingValues.delete(guestId()); + setEditingValuesVersion(v => v + 1); + setCurrentlyEditingGuestId(null); + }; const diskPercent = createMemo(() => { if (!props.guest.disk || props.guest.disk.total === 0) return 0; // Check if usage is -1 (unknown/no guest agent) @@ -246,28 +335,92 @@ export function GuestRow(props: GuestRowProps) { {/* Name - Sticky column */}
- {/* Name - clickable if custom URL is set */} + {/* Name - show input when editing, otherwise show name with optional link */} - {props.guest.name} - +
+ + {props.guest.name} + + + event.stopPropagation()} + > + + + + + +
} > - event.stopPropagation()} - > - {props.guest.name} - +
+ { + editingValues.set(guestId(), e.currentTarget.value); + setEditingValuesVersion(v => v + 1); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveUrl(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEditingUrl(); + } + }} + onClick={(e) => e.stopPropagation()} + placeholder="https://192.168.1.100:8006" + class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
{/* Tag badges */} diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index d0955ba..96b81be 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -1,4 +1,4 @@ -import { Component, createSignal, Show, For, onCleanup, onMount, createEffect, on, createMemo } from 'solid-js'; +import { Component, createSignal, Show, For, onMount, createEffect, createMemo } from 'solid-js'; import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; @@ -6,19 +6,17 @@ import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; import { MonitoringAPI } from '@/api/monitoring'; import { notificationStore } from '@/stores/notifications'; import type { SecurityStatus } from '@/types/config'; -import { SecurityAPI, type APITokenRecord } from '@/api/security'; import type { DockerHost } from '@/types/api'; +import type { APITokenRecord } from '@/api/security'; import { showTokenReveal } from '@/stores/tokenReveal'; +import { DOCKER_MANAGE_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes'; +import { useScopedTokenManager } from '@/hooks/useScopedTokenManager'; export const DockerAgents: Component = () => { const { state } = useWebSocket(); const [showInstructions, setShowInstructions] = createSignal(true); let hasLoggedSecurityStatusError = false; - let hasLoggedTokenAuthWarning = false; - let hasNotifiedTokenLoadError = false; - let hasNotifiedTokenAuthFailure = false; - let previousTokenStrategy: 'existing' | 'generate' | null = null; const [showHidden, setShowHidden] = createSignal(false); @@ -42,17 +40,24 @@ export const DockerAgents: Component = () => { const [uninstallCommandCopied, setUninstallCommandCopied] = createSignal(false); const [removeActionLoading, setRemoveActionLoading] = createSignal<'queue' | 'force' | 'hide' | null>(null); const [showAdvancedOptions, setShowAdvancedOptions] = createSignal(false); - const [apiToken, setApiToken] = createSignal(null); const [securityStatus, setSecurityStatus] = createSignal(null); - const [availableTokens, setAvailableTokens] = createSignal([]); - const [loadingTokens, setLoadingTokens] = createSignal(false); - const [tokensLoaded, setTokensLoaded] = createSignal(false); - const [tokensError, setTokensError] = createSignal(false); - const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); - const [tokenAccessDenied, setTokenAccessDenied] = createSignal(false); - const [selectedTokenStrategy, setSelectedTokenStrategy] = createSignal<'existing' | 'generate' | null>(null); const [showGenerateTokenModal, setShowGenerateTokenModal] = createSignal(false); const [newTokenName, setNewTokenName] = createSignal(''); + const [generateError, setGenerateError] = createSignal(null); + const [latestRecord, setLatestRecord] = createSignal(null); + + const tokenStepLabel = 'Step 1 · Generate API token'; + const commandStepLabel = 'Step 2 · Install command'; + + const { + token: apiToken, + isGeneratingToken, + generateToken, + } = useScopedTokenManager({ + scope: DOCKER_REPORT_SCOPE, + storageKey: 'dockerAgentToken', + legacyKeys: ['apiToken'], + }); const pulseUrl = () => { if (typeof window !== 'undefined') { @@ -145,21 +150,6 @@ const modalCommandProgress = createMemo(() => { return; } - const readToken = () => window.localStorage.getItem('apiToken'); - const currentToken = readToken(); - if (currentToken) { - setApiToken(currentToken); - } - - const handleStorage = (event: StorageEvent) => { - if (event.key === 'apiToken') { - setApiToken(event.newValue); - } - }; - - window.addEventListener('storage', handleStorage); - onCleanup(() => window.removeEventListener('storage', handleStorage)); - const fetchSecurityStatus = async () => { try { const response = await fetch('/api/security/status', { credentials: 'include' }); @@ -177,89 +167,9 @@ const modalCommandProgress = createMemo(() => { fetchSecurityStatus(); }); - const loadTokens = async () => { - if (loadingTokens()) return; - setLoadingTokens(true); - setTokensError(false); - try { - const tokens = await SecurityAPI.listTokens(); - const sorted = [...tokens].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - setAvailableTokens(sorted); - setTokenAccessDenied(false); - hasNotifiedTokenAuthFailure = false; - hasNotifiedTokenLoadError = false; - setTokensLoaded(true); - } catch (err) { - setTokensError(true); - if (err instanceof Error && /authentication required/i.test(err.message)) { - setTokenAccessDenied(true); - if (!hasLoggedTokenAuthWarning) { - hasLoggedTokenAuthWarning = true; - console.debug('API token listing requires authentication.'); - } - if (!hasNotifiedTokenAuthFailure) { - hasNotifiedTokenAuthFailure = true; - notificationStore.error('Authentication required to list API tokens', 6000); - } - } else { - if (!hasNotifiedTokenLoadError) { - hasNotifiedTokenLoadError = true; - console.error('Failed to load API tokens', err); - notificationStore.error('Failed to load API tokens', 6000); - } - } - } finally { - setLoadingTokens(false); - } - }; - - const retryTokenLoad = () => { - if (loadingTokens()) return; - notificationStore.info('Retrying API token load…', 4000); - setTokensLoaded(false); - void loadTokens(); - }; - - createEffect(on( - () => showInstructions(), - (isShowing) => { - if (isShowing && !tokensLoaded() && !loadingTokens()) { - void loadTokens(); - } - } - )); - - const hasStoredToken = () => Boolean(apiToken()); - const canGenerateToken = () => !tokenAccessDenied(); - const commandReady = () => !requiresToken() || (!!apiToken() && selectedTokenStrategy() !== null); + const commandReady = () => !requiresToken() || Boolean(apiToken()); // Find the token record that matches the currently stored token - const storedTokenRecord = () => { - const hint = securityStatus()?.apiTokenHint; - if (!hint) return null; - - return availableTokens().find(token => { - const tokenHint = `${token.prefix}...${token.suffix}`; - return tokenHint === hint; - }); - }; - - createEffect(on( - () => [requiresToken(), apiToken(), selectedTokenStrategy()], - ([requiresToken, hasToken, strategy]) => { - if (!requiresToken) { - setSelectedTokenStrategy('existing'); - return; - } - - if (!hasToken && strategy === 'existing') { - setSelectedTokenStrategy(null); - } - } - )); - const copyToClipboard = async (text: string): Promise => { try { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { @@ -291,79 +201,34 @@ const modalCommandProgress = createMemo(() => { } }; - const handleSelectExistingToken = () => { - if (!hasStoredToken()) { - if (typeof window !== 'undefined' && window.showToast) { - window.showToast('warning', 'No saved token found in this browser. Generate a new token instead.'); - } - return; - } - - setSelectedTokenStrategy('existing'); - if (typeof window !== 'undefined' && window.showToast) { - window.showToast('success', 'Install command updated with your saved token.'); - } - }; - - const openGenerateTokenFlow = () => { - if (!canGenerateToken()) { - if (typeof window !== 'undefined' && window.showToast) { - window.showToast('error', 'Sign in with an administrator account to generate tokens here.'); - } - return; - } - - const currentTokens = availableTokens(); - const defaultName = `Docker host ${currentTokens.length + 1}`; - previousTokenStrategy = selectedTokenStrategy(); - setSelectedTokenStrategy('generate'); + const openGenerateTokenModal = () => { + setGenerateError(null); + const defaultName = `Docker host ${new Date().toISOString().slice(0, 10)}`; setNewTokenName(defaultName); setShowGenerateTokenModal(true); }; const handleCreateToken = async () => { if (isGeneratingToken()) return; - if (!canGenerateToken()) { - notificationStore.error('You need administrator access to create API tokens from here.', 6000); - return; - } - - setIsGeneratingToken(true); + setGenerateError(null); try { - const currentTokens = availableTokens(); - const defaultName = `Docker host ${currentTokens.length + 1}`; - const desiredName = newTokenName().trim() || defaultName; - const { token, record } = await SecurityAPI.createToken(desiredName); + const desiredName = newTokenName().trim() || `Docker host ${new Date().toISOString().slice(0, 10)}`; + const { token, record } = await generateToken(desiredName); - // Update the tokens list with the new token - const filtered = currentTokens.filter((t) => t.id !== record.id); - setAvailableTokens([record, ...filtered]); - - setApiToken(token); - setSelectedTokenStrategy('generate'); - previousTokenStrategy = 'generate'; setShowGenerateTokenModal(false); setNewTokenName(''); + setLatestRecord(record); showTokenReveal({ token, record, source: 'docker', - note: 'Copy this token into the install command for your Docker agent or other automation.', + note: `Copy this token into the install command for your Docker agent. Scope: ${DOCKER_REPORT_SCOPE}.`, }); - if (typeof window !== 'undefined') { - try { - window.localStorage.setItem('apiToken', token); - window.dispatchEvent(new StorageEvent('storage', { key: 'apiToken', newValue: token })); - } catch (err) { - console.warn('Unable to persist API token in localStorage', err); - } - } - notificationStore.success('New API token generated and added to the install command.', 6000); + notificationStore.success('New Docker reporting token generated and added to the install command.', 6000); } catch (err) { console.error('Failed to generate API token', err); + setGenerateError('Failed to generate API token. Confirm you are signed in as an administrator.'); notificationStore.error('Failed to generate API token', 6000); - } finally { - setIsGeneratingToken(false); } }; @@ -573,156 +438,47 @@ WantedBy=multi-user.target`;
-

Step 1 · Choose an API token

+

{tokenStepLabel}

- Use the token saved in this browser or create a new credential just for this Docker host. The choice will populate the install command automatically. + Generate a fresh credential for this Docker host. Each token created here is limited to the {DOCKER_REPORT_SCOPE} scope. +

+

+ Need lifecycle control or bespoke scopes? Visit Security → API tokens to craft a custom token and add {DOCKER_MANAGE_SCOPE} if you plan to issue lifecycle commands.

- -
- Loading API tokens… + +
+ {generateError()}
- -
-

- {tokenAccessDenied() - ? 'Authentication required to list API tokens. Sign in with an administrator account, then try again.' - : 'Failed to load API tokens. Please try again.'} -

-
- - - - Still failing? Check network connectivity and server logs. - - -
-
-
- -
- - - -
- -
-
- - + +
+ + -
- Best practice: Give each Docker host its own API token. You can audit or revoke tokens anytime from{' '} - - Security Settings - - . -
+ + Token {latestRecord()?.name} created ({latestRecord()?.prefix}…{latestRecord()?.suffix}). Copy the full value from the pop-up and store it securely—this is the only time it is shown. +
-
+ - -
- Pick an option above to unlock the install command. -
-
- -
- Need a reusable token? Generate one here or visit Security Settings to manage tokens across users. -
-
+
- + -
- - - {/* Guest URLs Table */} -
- -
Loading guest URLs...
-
- } - > -
- - - - - - - - - - - - - { - // Sort by node hostname for display, not instance ID - const nodeA = guestsA[0]?.node || ''; - const nodeB = guestsB[0]?.node || ''; - return nodeA.localeCompare(nodeB); - })} - > - {([instanceId, guests]) => ( - <> - {/* Node header row - show hostname not instance ID */} - - - - {/* Guest rows for this node */} - - {(guest) => { - const guestId = - guest.id || `${guest.instance}-${guest.node}-${guest.vmid}`; - const fallbackId = `${guest.node}-${guest.vmid}`; - - const metadataKey = createMemo(() => - resolveMetadataKey(guestId, fallbackId), - ); - const meta = createMemo(() => guestMetadata()[metadataKey()]); - const url = createMemo(() => meta()?.customUrl || ''); - const hasUrl = createMemo(() => url().trim().length > 0); - const urlError = createMemo(() => urlErrors()[metadataKey()]); - - return ( - - - - - - - - ); - }} - - - )} - - } - > - - - - - -
- Name - - Type - - VMID - - Custom URL - - Actions -
- {guests[0]?.node || instanceId} -
-
- {guest.name} -
-
- - {guest.type === 'qemu' ? 'VM' : 'LXC'} - - - {guest.vmid} - -
- - updateGuestURL(metadataKey(), e.currentTarget.value) - } - class={`w-full min-w-[300px] px-2 py-1 text-sm border rounded - bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 - focus:ring-2 focus:border-transparent ${ - urlError() - ? 'border-red-500 dark:border-red-400 focus:ring-red-500' - : 'border-gray-300 dark:border-gray-600 focus:ring-blue-500' - }`} - style="min-width: 300px;" - /> - -
- {urlError()} -
-
-
-
-
- - -
-
- No guests found -
-
- -
-
- ); -} diff --git a/frontend-modern/src/components/Settings/HostAgents.tsx b/frontend-modern/src/components/Settings/HostAgents.tsx index a984560..78566c8 100644 --- a/frontend-modern/src/components/Settings/HostAgents.tsx +++ b/frontend-modern/src/components/Settings/HostAgents.tsx @@ -1,4 +1,4 @@ -import { Component, For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'; +import { Component, For, Show, createMemo, createSignal, onMount } from 'solid-js'; import type { JSX } from 'solid-js'; import { useWebSocket } from '@/App'; import type { Host } from '@/types/api'; @@ -6,10 +6,12 @@ import { SectionHeader } from '@/components/shared/SectionHeader'; import { Card } from '@/components/shared/Card'; import CopyButton from '@/components/shared/CopyButton'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; -import { SecurityAPI } from '@/api/security'; import { notificationStore } from '@/stores/notifications'; import { showTokenReveal } from '@/stores/tokenReveal'; import { HOST_AGENT_SCOPE } from '@/constants/apiScopes'; +import type { SecurityStatus } from '@/types/config'; +import type { APITokenRecord } from '@/api/security'; +import { useScopedTokenManager } from '@/hooks/useScopedTokenManager'; type HostAgentVariant = 'all' | 'linux' | 'macos' | 'windows'; @@ -17,10 +19,29 @@ interface HostAgentsProps { variant?: HostAgentVariant; } +type HostPlatform = 'linux' | 'macos' | 'windows'; + +const hostPlatformOptions: { id: HostPlatform; label: string; description: string }[] = [ + { + id: 'linux', + label: 'Linux', + description: 'Download the static binary and enable the systemd service on Debian, Ubuntu, RHEL, Arch, and more.', + }, + { + id: 'macos', + label: 'macOS', + description: 'Use the universal binary with launchd to keep desktops and servers reporting in the background.', + }, + { + id: 'windows', + label: 'Windows', + description: 'Compile the agent for Windows or run it under WSL until native builds ship. Service template included.', + }, +]; + const RELEASE_BASE = 'https://github.com/rcourtman/Pulse/releases/latest/download'; const TOKEN_PLACEHOLDER = ''; - const pulseUrl = () => { if (typeof window === 'undefined') return 'http://localhost:7655'; const { protocol, hostname, port } = window.location; @@ -138,9 +159,26 @@ const platformFilters: Record = { export const HostAgents: Component = (props) => { const variant: HostAgentVariant = props.variant ?? 'all'; const { state } = useWebSocket(); - const [apiToken, setApiToken] = createSignal(''); - const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); - const [tokenAccessDenied, setTokenAccessDenied] = createSignal(false); + + let hasLoggedSecurityStatusError = false; + + const [showInstructions, setShowInstructions] = createSignal(true); + const [securityStatus, setSecurityStatus] = createSignal(null); + const [showGenerateTokenModal, setShowGenerateTokenModal] = createSignal(false); + const [newTokenName, setNewTokenName] = createSignal(''); + const [generateError, setGenerateError] = createSignal(null); + const [latestRecord, setLatestRecord] = createSignal(null); + + const { + token: apiToken, + setToken: setApiToken, + isGeneratingToken, + generateToken, + } = useScopedTokenManager({ + scope: HOST_AGENT_SCOPE, + storageKey: 'hostAgentToken', + legacyKeys: ['apiToken'], + }); const hosts = createMemo(() => { const list = state.hosts ?? []; @@ -155,68 +193,121 @@ export const HostAgents: Component = (props) => { return tags.join(', '); }; - const installMeta = commandsByVariant[variant]; + const [selectedPlatform, setSelectedPlatform] = createSignal('linux'); + + const effectiveVariant = createMemo(() => + variant === 'all' ? selectedPlatform() : variant, + ); + + const installMeta = createMemo(() => commandsByVariant[effectiveVariant()]); + const tokenStepLabel = () => `${variant === 'all' ? 'Step 2' : 'Step 1'} · Choose an API token`; + const commandStepLabel = () => `${variant === 'all' ? 'Step 3' : 'Step 2'} · Installation commands`; onMount(() => { - if (typeof window === 'undefined') return; - try { - const stored = window.localStorage.getItem('apiToken'); - if (stored) { - setApiToken(stored); - } - } catch (err) { - console.warn('Unable to read API token from localStorage', err); - } - }); - - createEffect(() => { - if (typeof window === 'undefined') return; - const token = apiToken(); - try { - if (token) { - window.localStorage.setItem('apiToken', token); - } else { - window.localStorage.removeItem('apiToken'); - } - } catch (err) { - console.warn('Unable to persist API token in localStorage', err); - } - }); - - const generateToken = async () => { - if (isGeneratingToken()) return; - if (tokenAccessDenied()) { - notificationStore.error('Administrator access required to generate host agent tokens.', 6000); + if (typeof window === 'undefined') { return; } - setIsGeneratingToken(true); + const fetchSecurityStatus = async () => { + try { + const response = await fetch('/api/security/status', { credentials: 'include' }); + if (response.ok) { + const data = (await response.json()) as SecurityStatus; + setSecurityStatus(data); + } + } catch (err) { + if (!hasLoggedSecurityStatusError) { + hasLoggedSecurityStatusError = true; + console.error('Failed to load security status', err); + } + } + }; + fetchSecurityStatus(); + }); + + + const requiresToken = () => { + const status = securityStatus(); + if (status) { + return status.requiresAuth || status.apiTokenConfigured; + } + return true; + }; + + const commandReady = () => !requiresToken() || Boolean(apiToken()); + + const openGenerateTokenModal = () => { + setGenerateError(null); + const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`; + setNewTokenName(defaultName); + setShowGenerateTokenModal(true); + }; + + const handleCreateToken = async () => { + if (isGeneratingToken()) return; + + setGenerateError(null); try { - const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`; - const { token, record } = await SecurityAPI.createToken(defaultName, [HOST_AGENT_SCOPE]); - setApiToken(token); + const desiredName = newTokenName().trim() || `Host agent ${new Date().toISOString().slice(0, 10)}`; + const { token, record } = await generateToken(desiredName); + + setShowGenerateTokenModal(false); + setNewTokenName(''); + setLatestRecord(record); showTokenReveal({ token, record, source: 'host-agent', - note: 'Copy this token into the host agent install command or store it securely for automation.', + note: `Copy this token into the host agent install command. Scope: ${HOST_AGENT_SCOPE}.`, }); notificationStore.success('Created host agent API token with reporting scope.', 6000); } catch (err) { - console.error('Failed to create host agent token', err); - if (err instanceof Error && /authentication required|forbidden/i.test(err.message)) { - setTokenAccessDenied(true); - notificationStore.error('Sign in with an administrator account to generate tokens here.', 6000); - } else { - notificationStore.error('Failed to generate API token', 6000); - } - } finally { - setIsGeneratingToken(false); + console.error('Failed to generate host agent token', err); + setGenerateError('Failed to generate host agent token. Confirm you are signed in as an administrator.'); + notificationStore.error('Failed to generate API token', 6000); } }; + const copyToClipboard = async (text: string): Promise => { + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + if (typeof document === 'undefined') { + return false; + } + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-999999px'; + textarea.style.top = '-999999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + return document.execCommand('copy'); + } finally { + document.body.removeChild(textarea); + } + } catch (err) { + console.error('Failed to copy to clipboard', err); + return false; + } + }; + + const resolvedToken = () => { + if (!requiresToken()) { + return 'disabled'; + } + return apiToken() || TOKEN_PLACEHOLDER; + }; + const cardTitle = () => { - switch (variant) { + if (variant === 'all') { + return 'Pulse host agent'; + } + switch (effectiveVariant()) { case 'linux': return 'Linux servers'; case 'macos': @@ -229,13 +320,17 @@ export const HostAgents: Component = (props) => { }; const cardDescription = () => { - switch (variant) { + if (variant === 'all') { + return 'Install the Pulse host agent on Linux, macOS, or Windows servers to surface uptime, OS metadata, and capacity metrics.'; + } + const platform = effectiveVariant(); + switch (platform) { case 'linux': return 'Install the Pulse host agent on Debian, Ubuntu, RHEL, Arch, or other Linux hosts to surface uptime and capacity metrics.'; case 'macos': return 'Deploy the lightweight host agent via launchd to keep macOS hardware in view alongside your Proxmox estate.'; case 'windows': - return 'Track Windows Server hosts through a native Pulse agent. A first-party build is on the roadmap—compile from source today or watch this space.'; + return 'Track Windows Server hosts with the Pulse agent. Native builds are on the roadmap—compile today or run it under WSL.'; default: return 'Install the Pulse host agent on Linux, macOS, or Windows servers to surface uptime, OS metadata, and capacity metrics.'; } @@ -245,74 +340,212 @@ export const HostAgents: Component = (props) => {
- +
-

API token

-
- Manage tokens via Settings → Security +
+

{installMeta().title}

+

{installMeta().description}

-
-
- setApiToken(e.currentTarget.value.trim())} - placeholder="Paste API token (leave blank to keep placeholder)" - class="flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - - - Token will be embedded in the commands below. - -
-

- Tokens generated here automatically include the host agent reporting scope (host-agent:report). -

-

{installMeta.title}

-

{installMeta.description}

- -
- - {(snippet) => ( -
-
-

{snippet.label}

- - Copy command - + +
+ +
+
+

Step 1 · Choose the operating system

+

+ Pick the platform you are onboarding. The install commands adapt automatically. +

+
+
+ + {(option) => { + const isActive = () => selectedPlatform() === option.id; + return ( + + ); + }} +
-
-                  
-                    {snippet.command.replace(
-                      TOKEN_PLACEHOLDER,
-                      apiToken() || TOKEN_PLACEHOLDER,
-                    )}
-                  
-                
- -

{snippet.note}

-
- )} - -
+
+ + +
+
+

{tokenStepLabel()}

+

+ Generate a scoped token for this host. Tokens minted here grant the {HOST_AGENT_SCOPE} permission only. +

+

+ Need additional scopes? Visit Security → API tokens to create a bespoke credential. +

+
+ + +
+ {generateError()} +
+
+ + +
+ + + + + Token {latestRecord()?.name} created ({latestRecord()?.prefix}…{latestRecord()?.suffix}). Copy the full value from the pop-up and store it securely—this is the only time it is shown. + +
+
+ + +
+
+ + +
+
+

{commandStepLabel()}

+ +
+
+ + {(snippet) => ( +
+
+
{snippet.label}
+ + Copy command + +
+
+                          
+                            {snippet.command.replace(
+                              TOKEN_PLACEHOLDER,
+                              resolvedToken(),
+                            )}
+                          
+                        
+ +

{snippet.note}

+
+
+ )} +
+
+
+
+ + +

+ Select or generate an API token to embed it in the install commands. +

+
+
+ + +
+
+
+

Generate a new host agent token

+

+ Pulse will create a scoped token for this host and automatically insert it into the install commands. You can manage or revoke tokens anytime from Security settings. +

+
+
+ + setNewTokenName(event.currentTarget.value)} + class="w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60" + placeholder="Host agent token" + /> +

+ Friendly names make it easier to audit tokens later (e.g. host-lab-01). +

+
+
+ + +
+
+
+
+

Reporting hosts

diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 8cbdab2..8ee6554 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -6,7 +6,6 @@ import { showSuccess, showError } from '@/utils/toast'; import { copyToClipboard } from '@/utils/clipboard'; import { NodeModal } from './NodeModal'; import { ChangePasswordModal } from './ChangePasswordModal'; -import { GuestURLs } from './GuestURLs'; import { DockerAgents } from './DockerAgents'; import { HostAgents } from './HostAgents'; import APITokenManager from './APITokenManager'; @@ -32,9 +31,7 @@ import Activity from 'lucide-solid/icons/activity'; import Loader from 'lucide-solid/icons/loader'; import Boxes from 'lucide-solid/icons/boxes'; import Network from 'lucide-solid/icons/network'; -import Terminal from 'lucide-solid/icons/terminal'; import Monitor from 'lucide-solid/icons/monitor'; -import Laptop from 'lucide-solid/icons/laptop'; import Sliders from 'lucide-solid/icons/sliders-horizontal'; import RefreshCw from 'lucide-solid/icons/refresh-cw'; import Clock from 'lucide-solid/icons/clock'; @@ -228,15 +225,9 @@ interface DiscoveryScanStatus { } type SettingsTab = - | 'pve' - | 'pbs' - | 'pmg' - | 'docker' + | 'agent-hub' | 'podman' | 'kubernetes' - | 'linuxServers' - | 'windowsServers' - | 'macServers' | 'system-general' | 'system-network' | 'system-updates' @@ -248,22 +239,12 @@ type SettingsTab = | 'diagnostics' | 'updates'; +type AgentKey = 'pve' | 'pbs' | 'pmg' | 'docker' | 'host' | 'podman' | 'kubernetes'; + const SETTINGS_HEADER_META: Record = { - pve: { - title: 'Proxmox VE', - description: 'Connect clusters, manage nodes, and control discovery for Proxmox VE.', - }, - pbs: { - title: 'Proxmox Backup Server', - description: 'Add and maintain Proxmox Backup Server endpoints used for snapshot and archive monitoring.', - }, - pmg: { - title: 'Proxmox Mail Gateway', - description: 'Monitor mail flow, spam trends, and quarantine health from your PMG nodes.', - }, - docker: { - title: 'Docker Agents', - description: 'Configure Docker hosts, agent tokens, and polling behaviour for container insights.', + 'agent-hub': { + title: 'Agent Deployments', + description: 'Select a platform to generate tokens, deploy agents, or add Proxmox endpoints.', }, podman: { title: 'Podman (container runtime)', @@ -273,18 +254,6 @@ const SETTINGS_HEADER_META: Record = (props) => { const location = useLocation(); const deriveTabFromPath = (path: string): SettingsTab => { - if (path.includes('/settings/pbs')) return 'pbs'; - if (path.includes('/settings/pmg')) return 'pmg'; - if (path.includes('/settings/docker')) return 'docker'; + if (path.includes('/settings/agent-hub')) return 'agent-hub'; if (path.includes('/settings/podman')) return 'podman'; if (path.includes('/settings/kubernetes')) return 'kubernetes'; - if (path.includes('/settings/linuxServers')) return 'linuxServers'; - if (path.includes('/settings/windowsServers')) return 'windowsServers'; - if (path.includes('/settings/macServers')) return 'macServers'; if (path.includes('/settings/system-general')) return 'system-general'; if (path.includes('/settings/system-network')) return 'system-network'; if (path.includes('/settings/system-updates')) return 'system-updates'; if (path.includes('/settings/system-backups')) return 'system-backups'; - // Legacy redirect: old /settings/system goes to general if (path.includes('/settings/system')) return 'system-general'; if (path.includes('/settings/api')) return 'api'; if (path.includes('/settings/security-overview')) return 'security-overview'; if (path.includes('/settings/security-auth')) return 'security-auth'; if (path.includes('/settings/security-sso')) return 'security-sso'; - // Legacy redirect: old /settings/security goes to overview if (path.includes('/settings/security')) return 'security-overview'; if (path.includes('/settings/diagnostics')) return 'diagnostics'; if (path.includes('/settings/updates')) return 'updates'; - return 'pve'; + // Legacy platform paths map to the agent hub + if ( + path.includes('/settings/pve') || + path.includes('/settings/pbs') || + path.includes('/settings/pmg') || + path.includes('/settings/docker') || + path.includes('/settings/linuxServers') || + path.includes('/settings/windowsServers') || + path.includes('/settings/macServers') + ) { + return 'agent-hub'; + } + return 'agent-hub'; + }; + + const deriveAgentFromPath = (path: string): AgentKey | null => { + if (path.includes('/settings/pve')) return 'pve'; + if (path.includes('/settings/pbs')) return 'pbs'; + if (path.includes('/settings/pmg')) return 'pmg'; + if (path.includes('/settings/docker')) return 'docker'; + if ( + path.includes('/settings/host') || + path.includes('/settings/host-agents') || + path.includes('/settings/linuxServers') || + path.includes('/settings/windowsServers') || + path.includes('/settings/macServers') + ) { + return 'host'; + } + if (path.includes('/settings/podman')) return 'podman'; + if (path.includes('/settings/kubernetes')) return 'kubernetes'; + return null; }; const [currentTab, setCurrentTab] = createSignal(deriveTabFromPath(location.pathname)); const activeTab = () => currentTab(); + const [selectedAgent, setSelectedAgent] = createSignal('pve'); + + const agentGroups: { + title: string; + description?: string; + agents: Array<{ id: AgentKey; label: string; description: string; icon: JSX.Element; disabled?: boolean }>; +}[] = [ + { + title: 'Proxmox API integrations', + description: 'Connect Pulse directly to your Proxmox estate using scoped API tokens.', + agents: [ + { + id: 'pve', + label: 'Proxmox VE', + description: 'Connect VE clusters and manage discovery.', + icon: , + }, + { + id: 'pbs', + label: 'Proxmox Backup Server', + description: 'Monitor backup jobs and datastore health.', + icon: , + }, + { + id: 'pmg', + label: 'Proxmox Mail Gateway', + description: 'Capture mail flow, spam trends, and queues.', + icon: , + }, + ], + }, + { + title: 'Agent deployments', + description: 'Install lightweight Pulse agents to report telemetry back to this hub.', + agents: [ + { + id: 'docker', + label: 'Docker hosts', + description: 'Deploy the pulse-docker-agent for container telemetry.', + icon: , + }, + { + id: 'host', + label: 'Pulse host agent', + description: 'Install on Linux, macOS, or Windows servers.', + icon: , + }, + ], + }, + { + title: 'Coming soon', + description: 'Roadmap integrations that are currently in development.', + agents: [ + { + id: 'podman', + label: 'Podman hosts (coming soon)', + description: 'Podman agent support is under active development.', + icon: , + disabled: true, + }, + { + id: 'kubernetes', + label: 'Kubernetes (coming soon)', + description: 'Native Kubernetes monitoring is on the roadmap.', + icon: , + disabled: true, + }, + ], + }, +]; + const agentCards = agentGroups.flatMap((group) => group.agents); + + const agentPaths: Record = { + pve: '/settings/pve', + pbs: '/settings/pbs', + pmg: '/settings/pmg', + docker: '/settings/docker', + host: '/settings/host-agents', + podman: '/settings/podman', + kubernetes: '/settings/kubernetes', + } as Record; + + const handleSelectAgent = (agent: AgentKey) => { + setSelectedAgent(agent); + if (currentTab() !== 'agent-hub') { + setCurrentTab('agent-hub'); + } + const target = agentPaths[agent]; + if (target && location.pathname !== target) { + navigate(target, { scroll: false }); + } + }; + const setActiveTab = (tab: SettingsTab) => { + if (tab === 'agent-hub' && selectedAgent() === 'podman') { + setSelectedAgent('pve'); + } else if (tab === 'agent-hub' && selectedAgent() === 'kubernetes') { + setSelectedAgent('pve'); + } + + if (tab === 'agent-hub' && !['pve', 'pbs', 'pmg', 'docker', 'host'].includes(selectedAgent())) { + setSelectedAgent('pve'); + } + + if (tab === 'podman') { + setSelectedAgent('podman'); + } else if (tab === 'kubernetes') { + setSelectedAgent('kubernetes'); + } + const targetPath = `/settings/${tab}`; if (location.pathname !== targetPath) { navigate(targetPath, { scroll: false }); @@ -411,9 +513,10 @@ const Settings: Component = (props) => { () => location.pathname, (path) => { if (path === '/settings' || path === '/settings/') { - if (currentTab() !== 'pve') { - setCurrentTab('pve'); + if (currentTab() !== 'agent-hub') { + setCurrentTab('agent-hub'); } + setSelectedAgent('pve'); return; } @@ -421,6 +524,19 @@ const Settings: Component = (props) => { if (resolved !== currentTab()) { setCurrentTab(resolved); } + + if (resolved === 'agent-hub') { + const agentFromPath = deriveAgentFromPath(path); + if (agentFromPath) { + setSelectedAgent(agentFromPath); + } else if (!['pve', 'pbs', 'pmg', 'docker', 'host'].includes(selectedAgent())) { + setSelectedAgent('pve'); + } + } else if (resolved === 'podman') { + setSelectedAgent('podman'); + } else if (resolved === 'kubernetes') { + setSelectedAgent('kubernetes'); + } }, ), ); @@ -776,15 +892,9 @@ const Settings: Component = (props) => { id: 'platforms', label: 'Platforms', items: [ - { id: 'pve', label: 'Proxmox VE nodes', icon: }, - { id: 'pbs', label: 'Proxmox Backup Server', icon: }, - { id: 'pmg', label: 'Proxmox Mail Gateway', icon: }, - { id: 'docker', label: 'Docker hosts', icon: }, + { id: 'agent-hub', label: 'Agent deployments', icon: }, { id: 'podman', label: 'Podman hosts', icon: , disabled: true }, { id: 'kubernetes', label: 'Kubernetes', icon: , disabled: true }, - { id: 'linuxServers', label: 'Linux servers', icon: }, - { id: 'windowsServers', label: 'Windows servers', icon: }, - { id: 'macServers', label: 'macOS devices', icon: }, ], }, { @@ -1854,7 +1964,7 @@ const Settings: Component = (props) => { = (props) => {
+ + +
+
+

+ Step 1 · Choose a platform +

+

+ Select the integration you want to deploy or manage. +

+
+
+
+ + {(card) => { + const isActive = () => selectedAgent() === card.id; + return ( + + ); + }} + +
+
+
{/* PVE Nodes Tab */} - +
@@ -2511,20 +2679,13 @@ const Settings: Component = (props) => {
- -
- -
{/* PBS Nodes Tab */} - +
@@ -2934,7 +3095,7 @@ const Settings: Component = (props) => { {/* PMG Nodes Tab */} - +
@@ -3334,7 +3495,7 @@ const Settings: Component = (props) => { {/* Docker Tab */} - + @@ -3354,19 +3515,9 @@ const Settings: Component = (props) => { /> - {/* Linux Host Agents */} - - - - - {/* Windows Host Agents */} - - - - - {/* macOS Host Agents */} - - + {/* Host Agents */} + + {/* System General Tab */} diff --git a/frontend-modern/src/hooks/useScopedTokenManager.ts b/frontend-modern/src/hooks/useScopedTokenManager.ts new file mode 100644 index 0000000..51c4135 --- /dev/null +++ b/frontend-modern/src/hooks/useScopedTokenManager.ts @@ -0,0 +1,174 @@ +import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; +import type { APITokenRecord, CreateAPITokenResponse } from '@/api/security'; +import { SecurityAPI } from '@/api/security'; + +export interface UseScopedTokenManagerOptions { + scope: string | readonly string[]; + storageKey: string; + legacyKeys?: readonly string[]; +} + +export interface ScopedTokenManager { + token: () => string | null; + setToken: (value: string | null) => void; + hasStoredToken: () => boolean; + availableTokens: () => APITokenRecord[]; + loadingTokens: () => boolean; + tokensLoaded: () => boolean; + tokensError: () => boolean; + tokenAccessDenied: () => boolean; + loadTokens: () => Promise; + retryLoadTokens: () => Promise; + isGeneratingToken: () => boolean; + generateToken: (name: string) => Promise; +} + +const tokenMatchesScopes = (token: APITokenRecord, requiredScopes: string[]): boolean => { + const scopes = token.scopes ?? []; + if (scopes.length === 0) { + // Legacy tokens without scopes behave as wildcard. + return true; + } + if (scopes.includes('*')) { + return true; + } + return requiredScopes.every((scope) => scopes.includes(scope)); +}; + +const readStoredToken = (primaryKey: string, legacyKeys: readonly string[] = []) => { + if (typeof window === 'undefined') { + return null; + } + const primary = window.localStorage.getItem(primaryKey); + if (primary) { + return primary; + } + for (const key of legacyKeys) { + const value = window.localStorage.getItem(key); + if (value) { + return value; + } + } + return null; +}; + +export const useScopedTokenManager = (options: UseScopedTokenManagerOptions): ScopedTokenManager => { + const requiredScopes = Array.isArray(options.scope) ? [...options.scope] : [options.scope]; + const { storageKey, legacyKeys = [] } = options; + + const [token, setTokenState] = createSignal(readStoredToken(storageKey, legacyKeys)); + const [availableTokens, setAvailableTokens] = createSignal([]); + const [loadingTokens, setLoadingTokens] = createSignal(false); + const [tokensLoaded, setTokensLoaded] = createSignal(false); + const [tokensError, setTokensError] = createSignal(false); + const [tokenAccessDenied, setTokenAccessDenied] = createSignal(false); + const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); + + createEffect(() => { + const current = token(); + if (typeof window === 'undefined') { + return; + } + try { + if (current) { + window.localStorage.setItem(storageKey, current); + try { + window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: current })); + } catch (eventErr) { + console.debug('Unable to dispatch storage event for token update', eventErr); + } + } else { + window.localStorage.removeItem(storageKey); + try { + window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: null })); + } catch (eventErr) { + console.debug('Unable to dispatch storage event for token removal', eventErr); + } + } + } catch (err) { + console.warn('Unable to persist API token in localStorage', err); + } + }); + + if (typeof window !== 'undefined') { + const handleStorage = (event: StorageEvent) => { + if (!event.key) return; + if (event.key === storageKey || legacyKeys.includes(event.key)) { + const updated = readStoredToken(storageKey, legacyKeys); + setTokenState(updated); + } + }; + window.addEventListener('storage', handleStorage); + onCleanup(() => window.removeEventListener('storage', handleStorage)); + } + + const loadTokens = async () => { + if (loadingTokens()) return; + setLoadingTokens(true); + setTokensError(false); + setTokensLoaded(false); + try { + const tokens = await SecurityAPI.listTokens(); + const filtered = tokens.filter((candidate) => tokenMatchesScopes(candidate, requiredScopes)); + filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + setAvailableTokens(filtered); + setTokenAccessDenied(false); + setTokensLoaded(true); + } catch (err) { + setTokensError(true); + if (err instanceof Error && /authentication required/i.test(err.message)) { + setTokenAccessDenied(true); + } + throw err; + } finally { + setLoadingTokens(false); + } + }; + + const retryLoadTokens = async () => { + setTokensLoaded(false); + await loadTokens(); + }; + + const generateToken = async (name: string) => { + if (isGeneratingToken()) { + throw new Error('Token generation already in progress'); + } + setIsGeneratingToken(true); + try { + const response = await SecurityAPI.createToken(name, requiredScopes); + const { token: newToken, record } = response; + setTokenState(newToken); + setAvailableTokens((current) => { + const filtered = current.filter((item) => item.id !== record.id); + return [record, ...filtered]; + }); + setTokenAccessDenied(false); + return response; + } catch (err) { + if (err instanceof Error && /authentication required|forbidden/i.test(err.message)) { + setTokenAccessDenied(true); + } + throw err; + } finally { + setIsGeneratingToken(false); + } + }; + + const hasStoredToken = createMemo(() => Boolean(token())); + + return { + token, + setToken: setTokenState, + hasStoredToken, + availableTokens, + loadingTokens, + tokensLoaded, + tokensError, + tokenAccessDenied, + loadTokens, + retryLoadTokens, + isGeneratingToken, + generateToken, + }; +};