mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Auto-create first-host install token from setup handoff
This commit is contained in:
@@ -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>
|
||||
|
||||
+44
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user