fix: gate agent commands until token step confirmed

This commit is contained in:
rcourtman 2025-10-23 18:23:38 +00:00
parent f800d7d59d
commit 45e6ff48eb
2 changed files with 160 additions and 10 deletions

View file

@ -45,6 +45,7 @@ export const DockerAgents: Component = () => {
const [newTokenName, setNewTokenName] = createSignal(''); const [newTokenName, setNewTokenName] = createSignal('');
const [generateError, setGenerateError] = createSignal<string | null>(null); const [generateError, setGenerateError] = createSignal<string | null>(null);
const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null); const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null);
const [stepTwoComplete, setStepTwoComplete] = createSignal(false);
const tokenStepLabel = 'Step 2 · Generate API token'; const tokenStepLabel = 'Step 2 · Generate API token';
const commandStepLabel = 'Step 3 · Install command'; const commandStepLabel = 'Step 3 · Install command';
@ -59,6 +60,12 @@ export const DockerAgents: Component = () => {
legacyKeys: ['apiToken'], legacyKeys: ['apiToken'],
}); });
createEffect(() => {
if (!apiToken()) {
setStepTwoComplete(false);
}
});
const pulseUrl = () => { const pulseUrl = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const protocol = window.location.protocol; const protocol = window.location.protocol;
@ -167,7 +174,21 @@ const modalCommandProgress = createMemo(() => {
fetchSecurityStatus(); fetchSecurityStatus();
}); });
const commandReady = () => !requiresToken() || Boolean(apiToken()); const tokenReady = () => !requiresToken() || Boolean(apiToken());
const commandsUnlocked = () => tokenReady() && stepTwoComplete();
const acknowledgeTokenUse = () => {
if (!requiresToken()) {
setStepTwoComplete(true);
return;
}
if (apiToken()) {
setStepTwoComplete(true);
notificationStore.success('Token ready to use in the install command.', 3500);
} else {
notificationStore.info('Generate or select a token before continuing.', 4000);
}
};
// Find the token record that matches the currently stored token // Find the token record that matches the currently stored token
const copyToClipboard = async (text: string): Promise<boolean> => { const copyToClipboard = async (text: string): Promise<boolean> => {
@ -206,6 +227,7 @@ const modalCommandProgress = createMemo(() => {
const defaultName = `Docker host ${new Date().toISOString().slice(0, 10)}`; const defaultName = `Docker host ${new Date().toISOString().slice(0, 10)}`;
setNewTokenName(defaultName); setNewTokenName(defaultName);
setShowGenerateTokenModal(true); setShowGenerateTokenModal(true);
setStepTwoComplete(false);
}; };
const handleCreateToken = async () => { const handleCreateToken = async () => {
@ -218,6 +240,7 @@ const modalCommandProgress = createMemo(() => {
setShowGenerateTokenModal(false); setShowGenerateTokenModal(false);
setNewTokenName(''); setNewTokenName('');
setLatestRecord(record); setLatestRecord(record);
setStepTwoComplete(true);
showTokenReveal({ showTokenReveal({
token, token,
record, record,
@ -472,10 +495,56 @@ WantedBy=multi-user.target`;
> >
{isGeneratingToken() ? 'Generating…' : 'Generate token'} {isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button> </button>
<Show when={apiToken()}>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<div class={`flex-1 rounded-lg border px-4 py-2 text-xs ${
stepTwoComplete()
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-200'
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200'
}`}>
{stepTwoComplete()
? 'Token ready. You can continue to the install command.'
: 'Stored token detected. Confirm to insert it into the install command.'}
</div>
<button
type="button"
onClick={acknowledgeTokenUse}
disabled={stepTwoComplete()}
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
stepTwoComplete()
? 'bg-green-600 text-white cursor-default'
: 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
}`}
>
{stepTwoComplete() ? 'Token ready' : 'Use token in commands'}
</button>
</div>
</Show>
</div> </div>
</Show> </Show>
<Show when={commandReady()}> <Show when={!requiresToken()}>
<div class="space-y-3">
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-200">
Tokens are optional on this Pulse instance. Confirm to continue to the install command.
</div>
<button
type="button"
onClick={acknowledgeTokenUse}
disabled={stepTwoComplete()}
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
stepTwoComplete()
? 'bg-green-600 text-white cursor-default'
: 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
}`}
>
{stepTwoComplete() ? 'Confirmed' : 'Confirm and continue'}
</button>
</div>
</Show>
<Show when={commandsUnlocked()}>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{commandStepLabel}</label> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">{commandStepLabel}</label>
@ -488,10 +557,9 @@ WantedBy=multi-user.target`;
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy'); window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
} }
}} }}
disabled={requiresToken() && !apiToken()} class="px-3 py-1.5 text-xs font-medium rounded transition-colors bg-blue-600 text-white hover:bg-blue-700"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 text-white hover:bg-blue-700"
> >
Copy Copy first command
</button> </button>
</div> </div>
<div class="relative rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 p-3 overflow-x-auto"> <div class="relative rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 p-3 overflow-x-auto">
@ -505,6 +573,15 @@ WantedBy=multi-user.target`;
</div> </div>
</Show> </Show>
<Show when={requiresToken() && (!apiToken() || !stepTwoComplete())}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Generate a new token or confirm the stored one to unlock the install command.
</p>
</Show>
<Show when={!requiresToken() && !stepTwoComplete()}>
<p class="text-xs text-gray-500 dark:text-gray-400">Confirm the no-token setup to continue.</p>
</Show>
<details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300"> <details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100"> <summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
Advanced options (uninstall & manual install) Advanced options (uninstall & manual install)

View file

@ -1,4 +1,4 @@
import { Component, For, Show, createMemo, createSignal, onMount } from 'solid-js'; import { Component, For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import type { JSX } from 'solid-js'; import type { JSX } from 'solid-js';
import { useWebSocket } from '@/App'; import { useWebSocket } from '@/App';
import type { Host } from '@/types/api'; import type { Host } from '@/types/api';
@ -168,6 +168,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
const [newTokenName, setNewTokenName] = createSignal(''); const [newTokenName, setNewTokenName] = createSignal('');
const [generateError, setGenerateError] = createSignal<string | null>(null); const [generateError, setGenerateError] = createSignal<string | null>(null);
const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null); const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null);
const [stepTwoComplete, setStepTwoComplete] = createSignal(false);
const { const {
token: apiToken, token: apiToken,
@ -180,6 +181,12 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
legacyKeys: ['apiToken'], legacyKeys: ['apiToken'],
}); });
createEffect(() => {
if (!apiToken()) {
setStepTwoComplete(false);
}
});
const hosts = createMemo(() => { const hosts = createMemo(() => {
const list = state.hosts ?? []; const list = state.hosts ?? [];
const filters = platformFilters[variant]; const filters = platformFilters[variant];
@ -234,13 +241,28 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
return true; return true;
}; };
const commandReady = () => !requiresToken() || Boolean(apiToken()); const tokenReady = () => !requiresToken() || Boolean(apiToken());
const commandsUnlocked = () => tokenReady() && stepTwoComplete();
const acknowledgeTokenUse = () => {
if (!requiresToken()) {
setStepTwoComplete(true);
return;
}
if (apiToken()) {
setStepTwoComplete(true);
notificationStore.success('Token ready to embed in the install commands.', 3500);
} else {
notificationStore.info('Generate or select a token before continuing.', 4000);
}
};
const openGenerateTokenModal = () => { const openGenerateTokenModal = () => {
setGenerateError(null); setGenerateError(null);
const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`; const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`;
setNewTokenName(defaultName); setNewTokenName(defaultName);
setShowGenerateTokenModal(true); setShowGenerateTokenModal(true);
setStepTwoComplete(false);
}; };
const handleCreateToken = async () => { const handleCreateToken = async () => {
@ -254,6 +276,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
setShowGenerateTokenModal(false); setShowGenerateTokenModal(false);
setNewTokenName(''); setNewTokenName('');
setLatestRecord(record); setLatestRecord(record);
setStepTwoComplete(true);
showTokenReveal({ showTokenReveal({
token, token,
record, record,
@ -382,6 +405,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
setGenerateError(null); setGenerateError(null);
setLatestRecord(null); setLatestRecord(null);
setApiToken(null); setApiToken(null);
setStepTwoComplete(false);
}} }}
> >
<p class="font-semibold text-gray-900 dark:text-gray-100">{option.label}</p> <p class="font-semibold text-gray-900 dark:text-gray-100">{option.label}</p>
@ -431,10 +455,56 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
> >
{isGeneratingToken() ? 'Generating…' : 'Generate token'} {isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button> </button>
<Show when={apiToken()}>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<div class={`flex-1 rounded-lg border px-4 py-2 text-xs ${
stepTwoComplete()
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-200'
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200'
}`}>
{stepTwoComplete()
? 'Token ready. You can continue to the commands.'
: 'Stored token detected. Confirm to insert it into the commands below.'}
</div>
<button
type="button"
onClick={acknowledgeTokenUse}
disabled={stepTwoComplete()}
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
stepTwoComplete()
? 'bg-green-600 text-white cursor-default'
: 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
}`}
>
{stepTwoComplete() ? 'Token ready' : 'Use token in commands'}
</button>
</div>
</Show>
</div> </div>
</Show> </Show>
<Show when={commandReady()}> <Show when={!requiresToken()}>
<div class="space-y-3">
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-200">
Tokens are optional on this Pulse instance. Confirm to continue to the install commands.
</div>
<button
type="button"
onClick={acknowledgeTokenUse}
disabled={stepTwoComplete()}
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
stepTwoComplete()
? 'bg-green-600 text-white cursor-default'
: 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
}`}
>
{stepTwoComplete() ? 'Confirmed' : 'Confirm and continue'}
</button>
</div>
</Show>
<Show when={commandsUnlocked()}>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{commandStepLabel()}</h4> <h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{commandStepLabel()}</h4>
@ -487,11 +557,14 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
</div> </div>
</Show> </Show>
<Show when={requiresToken() && !commandReady()}> <Show when={requiresToken() && (!apiToken() || !stepTwoComplete())}>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
Select or generate an API token to embed it in the install commands. Generate a new token or confirm the stored one to unlock the install commands.
</p> </p>
</Show> </Show>
<Show when={!requiresToken() && !stepTwoComplete()}>
<p class="text-xs text-gray-500 dark:text-gray-400">Confirm the no-token setup to continue.</p>
</Show>
</div> </div>
</Show> </Show>
</Card> </Card>