Auto-create first-host install token from setup handoff

This commit is contained in:
rcourtman
2026-03-27 10:58:45 +00:00
parent 38e7bfd4c7
commit eb5e31a13f
5 changed files with 158 additions and 17 deletions
@@ -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
@@ -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
@@ -43,7 +43,32 @@ export const InfrastructureInstallerSection: Component = () => {
<p class="font-semibold">Security configured. Save these first-run credentials now.</p>
<p class="text-xs text-emerald-800 dark:text-emerald-200">
This is the canonical handoff from first-run setup into Infrastructure Install.
Generate a scoped install token below before copying agent commands.
<Show
when={state.setupHandoffAutoTokenPending()}
fallback={
<Show
when={state.latestTokenSource() === 'setup_handoff'}
fallback={
<Show
when={state.setupHandoffAutoTokenFailed()}
fallback=" Pulse will prepare the first scoped install token here before you copy agent commands."
>
{' '}
Pulse could not prepare the first scoped install token automatically, so
use the token step below and continue from there.
</Show>
}
>
{' '}
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.
</Show>
}
>
{' '}
Pulse is preparing the first scoped install token now so the install commands
unlock without another setup step.
</Show>
</p>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-md border border-emerald-200 bg-white px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950">
@@ -153,7 +178,13 @@ export const InfrastructureInstallerSection: Component = () => {
</p>
<p class="ml-6 text-sm text-muted">
{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.'}
</p>
</div>
@@ -191,8 +222,17 @@ export const InfrastructureInstallerSection: Component = () => {
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
Install token <strong>{state.latestRecord()?.name}</strong> created. Commands below
now include this credential.
{state.latestTokenSource() === 'setup_handoff' ? (
<>
First-host install token <strong>{state.latestRecord()?.name}</strong> prepared
automatically. Commands below already include this credential.
</>
) : (
<>
Install token <strong>{state.latestRecord()?.name}</strong> created. Commands
below now include this credential.
</>
)}
</span>
</div>
</Show>
@@ -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',
@@ -97,6 +97,12 @@ export const useInfrastructureInstallState = (
const [customAgentUrl, setCustomAgentUrl] = createSignal('');
const [customCaPath, setCustomCaPath] = createSignal('');
const [setupHandoff, setSetupHandoff] = createSignal<SetupHandoffState | null>(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,
};
};