import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { SecurityAPI, type APITokenRecord } from '@/api/security'; import { showError, showSuccess } from '@/utils/toast'; import { formatRelativeTime } from '@/utils/format'; import { useWebSocket } from '@/App'; import type { DockerHost, Host } from '@/types/api'; import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal'; import { logger } from '@/utils/logger'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import BadgeCheck from 'lucide-solid/icons/badge-check'; import { API_SCOPE_LABELS, API_SCOPE_OPTIONS, DOCKER_MANAGE_SCOPE, DOCKER_REPORT_SCOPE, HOST_AGENT_SCOPE, SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE, } from '@/constants/apiScopes'; interface APITokenManagerProps { currentTokenHint?: string; onTokensChanged?: () => void; refreshing?: boolean; } const SCOPES_DOC_URL = 'https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes'; const WILDCARD_SCOPE = '*'; export const APITokenManager: Component = (props) => { const { state, markDockerHostsTokenRevoked, markHostsTokenRevoked } = useWebSocket(); const dockerHosts = createMemo(() => state.dockerHosts ?? []); const hosts = createMemo(() => state.hosts ?? []); const dockerTokenUsage = createMemo(() => { type UsageHost = { id: string; label: string }; const usage = new Map(); for (const host of dockerHosts()) { const tokenId = host.tokenId; if (!tokenId) continue; const label = host.displayName?.trim() || host.hostname || host.id; const previous = usage.get(tokenId); if (previous) { usage.set(tokenId, { count: previous.count + 1, hosts: [...previous.hosts, { id: host.id, label }], }); } else { usage.set(tokenId, { count: 1, hosts: [{ id: host.id, label }] }); } } return usage; }); const hostTokenUsage = createMemo(() => { type UsageHost = { id: string; label: string }; const usage = new Map(); for (const host of hosts()) { const tokenId = host.tokenId; if (!tokenId) continue; const label = host.displayName?.trim() || host.hostname || host.id; const previous = usage.get(tokenId); if (previous) { usage.set(tokenId, { count: previous.count + 1, hosts: [...previous.hosts, { id: host.id, label }], }); } else { usage.set(tokenId, { count: 1, hosts: [{ id: host.id, label }] }); } } return usage; }); const [tokens, setTokens] = createSignal([]); const [tokensLoaded, setTokensLoaded] = createSignal(false); const sortedTokens = createMemo(() => [...tokens()].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ), ); const totalTokens = createMemo(() => sortedTokens().length); const wildcardCount = createMemo(() => sortedTokens().filter((token) => { const scopes = token.scopes; return !scopes || scopes.length === 0 || scopes.includes('*'); }).length, ); const scopedTokenCount = createMemo(() => totalTokens() - wildcardCount()); const hasWildcardTokens = createMemo(() => wildcardCount() > 0); const [loading, setLoading] = createSignal(true); const [isGenerating, setIsGenerating] = createSignal(false); const [newTokenValue, setNewTokenValue] = createSignal(null); const [newTokenRecord, setNewTokenRecord] = createSignal(null); const [nameInput, setNameInput] = createSignal(''); const tokenRevealState = useTokenRevealState(); const [selectedScopes, setSelectedScopes] = createSignal([]); type ScopeGroup = (typeof API_SCOPE_OPTIONS)[number]['group']; type ScopeOption = (typeof API_SCOPE_OPTIONS)[number]; const scopeGroupOrder: ScopeGroup[] = ['Monitoring', 'Agents', 'Settings']; const scopeGroups = createMemo<[ScopeGroup, ScopeOption[]][]>(() => { const grouped: Record = { Monitoring: [], Agents: [], Settings: [], }; for (const option of API_SCOPE_OPTIONS) { grouped[option.group].push(option); } return scopeGroupOrder .map((group) => [group, grouped[group]] as [ScopeGroup, ScopeOption[]]) .filter(([, options]) => options.length > 0); }); const isFullAccessSelected = () => selectedScopes().length === 0 || selectedScopes().includes(WILDCARD_SCOPE); const scopePresets: { label: string; scopes: string[]; description: string }[] = [ { label: 'Host agent', scopes: [HOST_AGENT_SCOPE], description: 'Allow pulse-host-agent to submit OS, CPU, and disk metrics.', }, { label: 'Container report', scopes: [DOCKER_REPORT_SCOPE], description: 'Permits container agents (Docker or Podman) to stream host and container telemetry only.', }, { label: 'Container manage', scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE], description: 'Extends container reporting with lifecycle actions (restart, stop, etc.).', }, { label: 'Settings read', scopes: [SETTINGS_READ_SCOPE], description: 'Read configuration snapshots and diagnostics without modifying anything.', }, { label: 'Settings admin', scopes: [SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE], description: 'Full settings read/write – equivalent to automation with admin privileges.', }, ]; const presetMatchesSelection = (presetScopes: string[]) => { const selection = [...selectedScopes()] .filter((scope) => scope !== WILDCARD_SCOPE) .sort(); const target = [...presetScopes].sort(); if (target.length === 0) { return isFullAccessSelected(); } if (selection.length !== target.length) { return false; } return target.every((scope) => selection.includes(scope)); }; const applyScopePreset = (scopes: string[]) => { const unique = Array.from(new Set(scopes)).filter(Boolean); setSelectedScopes(unique); }; const clearScopes = () => setSelectedScopes([]); let createSectionRef: HTMLDivElement | undefined; const [createHighlight, setCreateHighlight] = createSignal(false); let highlightTimer: number | undefined; const focusCreateSection = () => { if (!createSectionRef) return; createSectionRef.scrollIntoView({ behavior: 'smooth', block: 'start' }); setCreateHighlight(true); window.clearTimeout(highlightTimer); highlightTimer = window.setTimeout(() => setCreateHighlight(false), 1600); }; onCleanup(() => { if (highlightTimer) window.clearTimeout(highlightTimer); }); const loadTokens = async () => { setLoading(true); setTokensLoaded(false); try { const list = await SecurityAPI.listTokens(); setTokens(list); setTokensLoaded(true); } catch (err) { logger.error('Failed to load API tokens', err); showError('Failed to load API tokens'); } finally { setLoading(false); } }; onMount(() => { void loadTokens(); }); createEffect(() => { if (!tokensLoaded()) return; const activeTokenIds = new Set(tokens().map((token) => token.id)); const pendingDockerByToken = new Map(); for (const host of dockerHosts()) { const tokenId = host.tokenId; if (!tokenId) continue; if (activeTokenIds.has(tokenId)) continue; if (host.revokedTokenId === tokenId) continue; if (!pendingDockerByToken.has(tokenId)) { pendingDockerByToken.set(tokenId, []); } pendingDockerByToken.get(tokenId)!.push(host.id); } pendingDockerByToken.forEach((hostIds, tokenId) => { if (hostIds.length === 0) return; markDockerHostsTokenRevoked(tokenId, hostIds); }); const pendingHostsByToken = new Map(); for (const host of hosts()) { const tokenId = host.tokenId; if (!tokenId) continue; if (activeTokenIds.has(tokenId)) continue; if (host.revokedTokenId === tokenId && host.tokenRevokedAt) continue; if (!pendingHostsByToken.has(tokenId)) { pendingHostsByToken.set(tokenId, []); } pendingHostsByToken.get(tokenId)!.push(host.id); } pendingHostsByToken.forEach((hostIds, tokenId) => { if (hostIds.length === 0) return; markHostsTokenRevoked(tokenId, hostIds); }); }); const handleGenerate = async () => { setIsGenerating(true); try { const trimmedName = nameInput().trim() || undefined; const scopeSelection = [...selectedScopes()].sort(); const scopePayload = scopeSelection.length > 0 ? scopeSelection : undefined; const { token, record } = await SecurityAPI.createToken(trimmedName, scopePayload); setTokens((prev) => [record, ...prev]); setNewTokenRecord(record); setNewTokenValue(token); setNameInput(''); showTokenReveal({ token, record, source: 'security', note: 'Copy this token now. You can reopen this dialog from Security → API tokens while this page stays open.', }); showSuccess('New API token generated. Copy it below while it is still visible.'); props.onTokensChanged?.(); } catch (err) { logger.error('Failed to generate API token', err); showError('Failed to generate API token'); } finally { setIsGenerating(false); } }; const tokenHint = (record: APITokenRecord | null | undefined) => { if (!record) return '—'; if (record.prefix && record.suffix) { return `${record.prefix}…${record.suffix}`; } if (record.prefix) { return `${record.prefix}…`; } return '—'; }; const tokenNameForDialog = (record: APITokenRecord) => { if (record.name?.trim()) return record.name.trim(); if (record.prefix && record.suffix) return `${record.prefix}…${record.suffix}`; if (record.prefix) return `${record.prefix}…`; return 'untitled token'; }; const handleDelete = async (record: APITokenRecord) => { const dockerUsage = dockerTokenUsage().get(record.id); const hostUsage = hostTokenUsage().get(record.id); const displayName = tokenNameForDialog(record); const affectedDockerHostIds = dockerUsage ? dockerUsage.hosts.map((host) => host.id) : []; const affectedHostAgentIds = hostUsage ? hostUsage.hosts.map((host) => host.id) : []; let revokeMessage: string | undefined; const messageChunks: string[] = []; if (dockerUsage) { const hostListPreview = dockerUsage.hosts .slice(0, 5) .map((host) => host.label) .join(', '); const extraCount = dockerUsage.hosts.length - 5; const hostSummary = extraCount > 0 ? `${hostListPreview}, +${extraCount} more` : hostListPreview; const hostCountLabel = dockerUsage.count === 1 ? 'container host' : `${dockerUsage.count} container hosts`; messageChunks.push(`${hostCountLabel}: ${hostSummary}`); } if (hostUsage) { const agentListPreview = hostUsage.hosts .slice(0, 5) .map((host) => host.label) .join(', '); const agentExtra = hostUsage.hosts.length - 5; const agentSummary = agentExtra > 0 ? `${agentListPreview}, +${agentExtra} more` : agentListPreview; const agentCountLabel = hostUsage.count === 1 ? 'host agent' : `${hostUsage.count} host agents`; messageChunks.push(`${agentCountLabel}: ${agentSummary}`); } if (messageChunks.length > 0) { revokeMessage = `Token "${displayName}" was previously used by ${messageChunks.join(' • ')}. Update those agents with a new token.`; } try { await SecurityAPI.deleteToken(record.id); setTokens((prev) => prev.filter((token) => token.id !== record.id)); showSuccess('Token revoked', revokeMessage); props.onTokensChanged?.(); if (affectedDockerHostIds.length > 0) { markDockerHostsTokenRevoked(record.id, affectedDockerHostIds); } if (affectedHostAgentIds.length > 0) { markHostsTokenRevoked(record.id, affectedHostAgentIds); } const current = newTokenRecord(); if (current && current.id === record.id) { setNewTokenValue(null); setNewTokenRecord(null); } } catch (err) { logger.error('Failed to revoke API token', err); showError('Failed to revoke API token'); } }; const isRevealActiveForCurrentToken = () => { const active = tokenRevealState(); if (!active) return false; return newTokenValue() !== null && active.token === newTokenValue(); }; const reopenTokenDialog = () => { const token = newTokenValue(); const record = newTokenRecord(); if (!token || !record) return; showTokenReveal({ token, record, source: 'security', note: 'Copy this token now. Close the dialog once you have stored it safely.', }); }; return (
Total tokens
{totalTokens()}

Stored credentials across all agents

Scoped tokens
{scopedTokenCount()}

Limited access tokens with defined scopes

Full access tokens
{wildcardCount()}

{hasWildcardTokens() ? 'Legacy wildcard tokens – rotate into scoped presets when possible.' : 'All tokens scoped – no wildcard credentials detected.'}

Refreshing security status… ✓ Token generated: {newTokenRecord()?.name || 'Untitled'} ({tokenHint(newTokenRecord())})
0} fallback={ No tokens yet.{' '} {' '} to authenticate agents and integrations. } >

Token inventory

Active credentials sorted by most recent creation date.

{(token) => { const dockerUsageEntry = dockerTokenUsage().get(token.id); const hostUsageEntry = hostTokenUsage().get(token.id); const usageSegments: string[] = []; const usageTitleSegments: string[] = []; if (dockerUsageEntry) { usageSegments.push( dockerUsageEntry.count === 1 ? dockerUsageEntry.hosts[0]?.label ?? 'Container host' : `${dockerUsageEntry.count} container hosts`, ); usageTitleSegments.push( `Container hosts: ${dockerUsageEntry.hosts.map((host) => host.label).join(', ')}`, ); } if (hostUsageEntry) { usageSegments.push( hostUsageEntry.count === 1 ? `${hostUsageEntry.hosts[0]?.label ?? 'Host agent'} (agent)` : `${hostUsageEntry.count} host agents`, ); usageTitleSegments.push( `Host agents: ${hostUsageEntry.hosts.map((host) => host.label).join(', ')}`, ); } const hostSummary = usageSegments.length > 0 ? usageSegments.join(' • ') : '—'; const rawScopes = token.scopes && token.scopes.length > 0 ? token.scopes : ['*']; const scopeBadges = rawScopes.includes('*') ? [{ value: '*', label: 'Full' }] : rawScopes.map((scope) => ({ value: scope, label: API_SCOPE_LABELS[scope] ?? scope, })); const rowIsWildcard = scopeBadges.some((scope) => scope.value === '*'); return ( ); }}
Name Hint Scopes Usage Created Last used Action
{token.name || 'Untitled'} {tokenHint(token)}
{(scope) => { const isWildcard = scope.value === '*'; return ( {scope.label} ); }}
0 ? usageTitleSegments.join('\n') : undefined} >
{hostSummary} 1}> Host agents sharing this token ({hostUsageEntry!.count})
{formatRelativeTime(new Date(token.createdAt).getTime())} {token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'}
{ createSectionRef = el; }} >
setNameInput(e.currentTarget.value)} placeholder="e.g. Container pipeline" class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-500/40" />
Quick presets
{(preset) => ( )}
Custom scopes
{([group, options]) => (
{group}
{(option) => { const isActive = () => selectedScopes().includes(option.value); return ( ); }}
)}
⚠ {wildcardCount()} full access {wildcardCount() === 1 ? 'token' : 'tokens'} – consider switching to scoped presets for least privilege. 💡 Separate tokens per integration • Rotate regularly •{' '} Scope reference
); }; export default APITokenManager;