From eb5e31a13f7df279874a0d4173016a4415a4d2ad Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 10:58:45 +0000 Subject: [PATCH] Auto-create first-host install token from setup handoff --- .../v6/internal/subsystems/agent-lifecycle.md | 6 +- .../v6/internal/subsystems/api-contracts.md | 7 ++ .../InfrastructureInstallerSection.tsx | 48 +++++++++++-- ...nfrastructureOperationsController.test.tsx | 44 ++++++++++++ .../useInfrastructureInstallState.tsx | 70 +++++++++++++++---- 5 files changed, 158 insertions(+), 17 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 9d8cf25939..a1f77fcb71 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -166,7 +166,11 @@ management, and fleet control surfaces. infrastructure and install commands are available, the same lifecycle path must auto-watch the canonical `/api/state` projection for the first reporting host rather than requiring a brand-new operator to know and type - a hostname or agent ID just to see the first success handoff. + a hostname or agent ID just to see the first success handoff. When that + workspace is entered through first-run setup handoff, the same lifecycle + path must also auto-create the scoped first-host install token so the + operator lands on ready-to-copy commands instead of being asked to perform a + second manual token-generation step immediately after securing the server. 9. Keep `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx` ordered around the actual first-run operator sequence: credentials that must be saved now should be visible before the operator leaves the screen, and diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2e81599203..9c5c4497cd 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -216,6 +216,13 @@ Own canonical runtime payload shapes between backend and frontend. detecting the first active reporting system during install so brand-new operators can receive the first success handoff without typing a hostname or agent ID. +20. Keep the shared first-run install-token transport explicit on + `/api/security/tokens` as used by + `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`: + once quick setup has produced the setup handoff credentials, the canonical + token-creation contract must remain usable immediately from the install + workspace so the first-host flow can auto-create the scoped install token + without forcing the operator through a second manual token-generation step. ## Current State diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx index 5e12a84313..c90be5f33a 100644 --- a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx @@ -43,7 +43,32 @@ export const InfrastructureInstallerSection: Component = () => {

Security configured. Save these first-run credentials now.

This is the canonical handoff from first-run setup into Infrastructure Install. - Generate a scoped install token below before copying agent commands. + + {' '} + Pulse could not prepare the first scoped install token automatically, so + use the token step below and continue from there. + + } + > + {' '} + Pulse already prepared the first scoped install token for this handoff, so + you can move straight to copying the install command for the first host. + + } + > + {' '} + Pulse is preparing the first scoped install token now so the install commands + unlock without another setup step. +

@@ -153,7 +178,13 @@ export const InfrastructureInstallerSection: Component = () => {

{state.requiresToken() - ? 'Create a fresh token for the generated install commands and host reporting.' + ? state.setupHandoffAutoTokenPending() + ? 'Pulse is generating the first scoped install token for this setup handoff now.' + : state.latestTokenSource() === 'setup_handoff' + ? 'Pulse generated the first scoped install token automatically. The install commands below are ready for the first host.' + : state.setupHandoffAutoTokenFailed() + ? 'Pulse could not generate the first scoped install token automatically. Generate one here to unlock the install commands.' + : 'Create a fresh token for the generated install commands and host reporting.' : 'Tokens are optional on this Pulse instance. Generate one if you want copied commands to preserve explicit credentialed transport.'}

@@ -191,8 +222,17 @@ export const InfrastructureInstallerSection: Component = () => { - Install token {state.latestRecord()?.name} created. Commands below - now include this credential. + {state.latestTokenSource() === 'setup_handoff' ? ( + <> + First-host install token {state.latestRecord()?.name} prepared + automatically. Commands below already include this credential. + + ) : ( + <> + Install token {state.latestRecord()?.name} created. Commands + below now include this credential. + + )}
diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx index 265efc8f35..6f21eaa82d 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx @@ -24,6 +24,7 @@ import type { KubernetesCluster, State, } from '@/types/api'; +import { STORAGE_KEYS } from '@/utils/localStorage'; const { navigateMock } = vi.hoisted(() => ({ navigateMock: vi.fn(), @@ -624,6 +625,7 @@ const getTargetProfileSelect = (): HTMLElement => { beforeEach(() => { securityStatusResponse = { requiresAuth: true, apiTokenConfigured: false }; + sessionStorage.clear(); navigateMock.mockReset(); lookupMock.mockReset(); getStateMock.mockReset(); @@ -663,6 +665,7 @@ beforeEach(() => { afterEach(() => { cleanup(); + sessionStorage.clear(); vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -677,6 +680,47 @@ describe('InfrastructureOperationsController token generation', () => { expect(screen.getByRole('button', { name: /Generate token/i })).toBeInTheDocument(); }); + it('auto-generates the first-host install token from setup handoff', async () => { + createTokenMock.mockResolvedValue({ + token: 'handoff-install-token', + record: { + id: 'token-record-handoff', + name: 'Agent 2026-03-27 10-56', + prefix: 'agent', + suffix: '1234', + createdAt: new Date().toISOString(), + }, + }); + + sessionStorage.setItem( + STORAGE_KEYS.SETUP_HANDOFF, + JSON.stringify({ + username: 'admin', + password: 'generated-password', + apiToken: 'admin-token', + createdAt: new Date().toISOString(), + }), + ); + + setupComponent(); + + await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); + await waitFor(() => expect(screen.getByText(/First-host install token/i)).toBeInTheDocument(), { + interval: 0, + }); + + expect(trackAgentInstallTokenGeneratedMock).toHaveBeenCalledWith( + 'settings_unified_agents', + 'setup_handoff', + ); + expect(notificationSuccessMock).not.toHaveBeenCalled(); + expect(screen.getByText(/Security configured\. Save these first-run credentials now\./i)).toBeInTheDocument(); + expect( + screen.getByText(/Pulse already prepared the first scoped install token for this handoff/i), + ).toBeInTheDocument(); + expect(screen.getByText('Installation commands')).toBeInTheDocument(); + }); + it('generates a token and shows confirmation', async () => { createTokenMock.mockResolvedValue({ token: 'test-token-123', diff --git a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx index 773c5bfd03..add77c20a1 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx +++ b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx @@ -97,6 +97,12 @@ export const useInfrastructureInstallState = ( const [customAgentUrl, setCustomAgentUrl] = createSignal(''); const [customCaPath, setCustomCaPath] = createSignal(''); const [setupHandoff, setSetupHandoff] = createSignal(null); + const [latestTokenSource, setLatestTokenSource] = createSignal<'manual' | 'setup_handoff' | null>( + null, + ); + const [setupHandoffAutoTokenPending, setSetupHandoffAutoTokenPending] = createSignal(false); + const [setupHandoffAutoTokenFailed, setSetupHandoffAutoTokenFailed] = createSignal(false); + const [setupHandoffAutoTokenAttempted, setSetupHandoffAutoTokenAttempted] = createSignal(false); createEffect(() => { if (requiresToken()) { @@ -225,9 +231,10 @@ Generate a scoped install token below before copying Unified Agent install comma notificationStore.success('Confirmed install commands without an API token.', 3500); }; - const handleGenerateToken = async () => { - if (isGeneratingToken()) return; - + const generateInstallToken = async ( + source: 'manual' | 'setup_handoff', + options: { notifySuccess?: boolean } = {}, + ) => { setIsGeneratingToken(true); try { const desiredName = tokenName().trim() || buildDefaultTokenName(); @@ -242,24 +249,41 @@ Generate a scoped install token below before copying Unified Agent install comma setCurrentToken(token); setLatestRecord(record); + setLatestTokenSource(source); setTokenName(''); setConfirmedNoToken(false); - trackAgentInstallTokenGenerated(UNIFIED_AGENT_TELEMETRY_SURFACE, 'manual'); - notificationStore.success( - 'Token generated with Agent config + reporting, Docker, and Kubernetes permissions.', - 4000, - ); + setSetupHandoffAutoTokenFailed(false); + trackAgentInstallTokenGenerated(UNIFIED_AGENT_TELEMETRY_SURFACE, source); + if (options.notifySuccess) { + notificationStore.success( + 'Token generated with Agent config + reporting, Docker, and Kubernetes permissions.', + 4000, + ); + } } catch (err) { logger.error('Failed to generate agent token', err); - notificationStore.error( - 'Failed to generate agent token. Confirm you are signed in as an administrator.', - 6000, - ); + if (source === 'setup_handoff') { + setSetupHandoffAutoTokenFailed(true); + } else { + notificationStore.error( + 'Failed to generate agent token. Confirm you are signed in as an administrator.', + 6000, + ); + } } finally { setIsGeneratingToken(false); + if (source === 'setup_handoff') { + setSetupHandoffAutoTokenPending(false); + } } }; + const handleGenerateToken = async () => { + if (isGeneratingToken()) return; + + await generateInstallToken('manual', { notifySuccess: true }); + }; + const resolvedCommandToken = () => { if (requiresToken()) { return currentToken() || TOKEN_PLACEHOLDER; @@ -267,6 +291,25 @@ Generate a scoped install token below before copying Unified Agent install comma return currentToken(); }; + createEffect(() => { + const shouldAutoCreateInstallToken = + Boolean(setupHandoff()) && + securityStatus() !== null && + requiresToken() && + !currentToken() && + !latestRecord() && + !isGeneratingToken() && + !setupHandoffAutoTokenAttempted(); + + if (!shouldAutoCreateInstallToken) { + return; + } + + setSetupHandoffAutoTokenAttempted(true); + setSetupHandoffAutoTokenPending(true); + void generateInstallToken('setup_handoff'); + }); + createEffect(() => { const shouldAutoDetectFirstHost = commandsUnlocked() && !lookupValue().trim() && !lookupResult()?.agent?.connected; @@ -467,6 +510,7 @@ Generate a scoped install token below before copying Unified Agent install comma isEmbedded, isGeneratingToken, latestRecord, + latestTokenSource, lookupError, lookupLoading, lookupResult, @@ -484,6 +528,8 @@ Generate a scoped install token below before copying Unified Agent install comma setLookupValue, setTokenName, setupHandoff, + setupHandoffAutoTokenFailed, + setupHandoffAutoTokenPending, tokenName, }; };