Unify API token reveal workflow

This commit is contained in:
rcourtman 2025-10-15 22:58:31 +00:00
parent 7c8ab4fbea
commit 605512aa6b
4 changed files with 212 additions and 143 deletions

View file

@ -3,6 +3,8 @@ import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { STORAGE_KEYS } from '@/constants';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { showTokenReveal } from '@/stores/tokenReveal';
import type { APITokenRecord } from '@/api/security';
export const FirstRunSetup: Component = () => {
const [username, setUsername] = createSignal('admin');
@ -131,6 +133,20 @@ export const FirstRunSetup: Component = () => {
setSavedPassword(useCustomPassword() ? password() : generatedPassword());
setSavedToken(token);
const bootstrapRecord: APITokenRecord = {
id: 'bootstrap-token',
name: 'Bootstrap token',
prefix: token.slice(0, 6),
suffix: token.slice(-4),
createdAt: new Date().toISOString(),
};
showTokenReveal({
token,
record: bootstrapRecord,
source: 'first-run',
note: 'Copy this bootstrap token now. It unlocks API access for agents and automations.',
});
// Show credentials
setShowCredentials(true);
showSuccess('Security configured successfully!');

View file

@ -3,11 +3,10 @@ import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { SecurityAPI, type APITokenRecord } from '@/api/security';
import { showError, showSuccess } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { formatRelativeTime } from '@/utils/format';
import { useWebSocket } from '@/App';
import type { DockerHost } from '@/types/api';
import { showTokenReveal } from '@/stores/tokenReveal';
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
interface APITokenManagerProps {
currentTokenHint?: string;
@ -42,8 +41,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const [isGenerating, setIsGenerating] = createSignal(false);
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
const [copied, setCopied] = createSignal(false);
const [nameInput, setNameInput] = createSignal('');
const tokenRevealState = useTokenRevealState();
const loadTokens = async () => {
setLoading(true);
@ -64,7 +63,6 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const handleGenerate = async () => {
setIsGenerating(true);
setCopied(false);
try {
const trimmedName = nameInput().trim() || undefined;
const { token, record } = await SecurityAPI.createToken(trimmedName);
@ -78,7 +76,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
token,
record,
source: 'security',
note: 'Copy this token now. You can always rotate it from Security → API tokens.',
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?.();
@ -117,19 +115,6 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
return 'unnamed token';
};
const handleCopy = async () => {
const value = newTokenValue();
if (!value) return;
const success = await copyToClipboard(value);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
showError('Failed to copy to clipboard');
}
};
const handleDelete = async (record: APITokenRecord) => {
const usage = dockerTokenUsage().get(record.id);
const displayName = tokenNameForDialog(record);
@ -166,6 +151,24 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
}
};
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 (
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@ -200,56 +203,58 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</div>
</Show>
{/* CRITICAL: Show generated token FIRST and PROMINENTLY */}
<Show when={newTokenValue()}>
<div class="space-y-4 border-4 border-green-500 dark:border-green-600 rounded-lg p-5 bg-green-50 dark:bg-green-900/30">
{/* Generated token reminder - only show when dialog is not already visible */}
<Show when={newTokenValue() && !isRevealActiveForCurrentToken()}>
<div class="space-y-3 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/30 p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="flex-shrink-0 rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 p-2">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="flex-1 space-y-3">
<div>
<h3 class="text-lg font-bold text-green-900 dark:text-green-100">Token Generated!</h3>
<p class="text-sm font-semibold text-green-800 dark:text-green-200 mt-1">
This is shown ONCE. Copy it now or lose it forever!
<div class="space-y-2">
<h3 class="text-base font-semibold text-green-900 dark:text-green-100">
Token ready to copy
</h3>
<p class="text-sm text-green-800 dark:text-green-200 leading-snug">
We keep the full token inside a secure dialog. Reopen it if you still need to copy the value before navigating away.
</p>
<Show when={newTokenRecord()}>
<p class="text-xs text-green-900/80 dark:text-green-200/80">
Label{' '}
<span class="font-semibold">{newTokenRecord()?.name || 'Untitled token'}</span>
<Show when={newTokenRecord()?.prefix || newTokenRecord()?.suffix}>
{' '}· Hint{' '}
<code class="rounded bg-green-100 dark:bg-green-900/50 px-1.5 py-0.5 font-mono text-[11px] text-green-800 dark:text-green-200">
{tokenHint(newTokenRecord()!)}
</code>
</Show>
</p>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-green-900 dark:text-green-100 uppercase tracking-wide">
Your new token:
</label>
<div class="flex items-center gap-2">
<code class="flex-1 font-mono text-base bg-white dark:bg-gray-800 px-4 py-3 rounded-lg border-2 border-green-300 dark:border-green-700 break-all text-gray-900 dark:text-gray-100 font-bold">
{newTokenValue()}
</code>
<button
type="button"
onClick={handleCopy}
class="px-5 py-3 text-sm font-bold bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors shadow-lg"
>
{copied() ? '✓ Copied!' : 'Copy Token'}
</button>
</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg p-3">
<p class="text-sm text-yellow-900 dark:text-yellow-100 font-medium">
💡 Keep this token safe and use it anywhere Pulse requires API authenticationDocker agents, automations, or custom integrations.
</p>
</div>
<button
type="button"
onClick={() => setNewTokenValue(null)}
class="text-sm text-green-700 dark:text-green-300 hover:underline"
>
I've saved it, dismiss this
</button>
</Show>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
onClick={reopenTokenDialog}
class="inline-flex items-center gap-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-sm font-semibold px-4 py-2 transition-colors shadow-sm"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7h4m0 0v4m0-4l-6 6-4-4-6 6" />
</svg>
Show token dialog
</button>
<button
type="button"
onClick={() => {
setNewTokenValue(null);
setNewTokenRecord(null);
}}
class="inline-flex items-center rounded-md border border-green-400 dark:border-green-700 px-4 py-2 text-sm font-medium text-green-800 dark:text-green-200 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors"
>
Dismiss reminder
</button>
</div>
</div>
</Show>

View file

@ -1,6 +1,7 @@
import { Component, createSignal, Show, createMemo, createEffect } from 'solid-js';
import { apiFetch } from '@/utils/apiClient';
import { SecurityAPI, type APITokenRecord } from '@/api/security';
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
interface CommandBuilderProps {
command: string;
@ -24,9 +25,10 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
// Token generation/revocation state
const [showGenerateModal, setShowGenerateModal] = createSignal(false);
const [isGenerating, setIsGenerating] = createSignal(false);
const [newlyGeneratedToken, setNewlyGeneratedToken] = createSignal<string | null>(null);
const [showNewTokenModal, setShowNewTokenModal] = createSignal(false);
const [tokenLabel, setTokenLabel] = createSignal('Docker agent token');
const [latestGeneratedToken, setLatestGeneratedToken] = createSignal<string | null>(null);
const [latestGeneratedRecord, setLatestGeneratedRecord] = createSignal<APITokenRecord | null>(null);
const tokenRevealState = useTokenRevealState();
const defaultTokenLabel = () => `Docker agent token ${new Date().toISOString().slice(0, 10)}`;
const canManageTokens = () => props.canManageTokens !== false;
@ -36,6 +38,25 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
setShowGenerateModal(true);
};
const isRevealActiveForLatestToken = () => {
const token = latestGeneratedToken();
const active = tokenRevealState();
if (!token || !active) return false;
return active.token === token;
};
const reopenLatestTokenDialog = () => {
const token = latestGeneratedToken();
const record = latestGeneratedRecord();
if (!token || !record) return;
showTokenReveal({
token,
record,
source: 'docker-command',
note: 'Copy this token now. Close the dialog once you have stored it securely.',
});
};
// Initialize with stored token if available
createEffect(() => {
if (props.storedToken && tokenInput() === '') {
@ -177,9 +198,10 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
const { token: newToken, record } = await SecurityAPI.createToken(desiredName);
setShowGenerateModal(false);
setNewlyGeneratedToken(newToken);
setShowNewTokenModal(true);
setLatestGeneratedToken(newToken);
setLatestGeneratedRecord(record);
reopenLatestTokenDialog();
// Auto-populate the command builder
setTokenInput(newToken);
if (props.onTokenGenerated) {
@ -195,7 +217,7 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
}
}
window.showToast('success', 'New API token generated. Save it now it will not be shown again.');
window.showToast('success', 'New API token generated. Copy it from the dialog while it is visible.');
} catch (error) {
console.error('Token generation failed:', error);
window.showToast('error', error instanceof Error ? error.message : 'Failed to generate token');
@ -543,86 +565,61 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
</Show>
{/* New Token Display Modal */}
<Show when={showNewTokenModal()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">New API Token Generated!</h3>
<div class="space-y-4 mb-6">
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded p-3">
<p class="text-sm text-green-800 dark:text-green-300">
Your new token has been generated and is active immediately. The command builder has been auto-filled with your new token.
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Your new token (save this now):</label>
<div class="flex gap-2">
<code class="flex-1 text-sm font-mono bg-gray-100 dark:bg-gray-900 px-3 py-2 rounded border border-gray-300 dark:border-gray-600 break-all">
{newlyGeneratedToken()}
</code>
<button
type="button"
onClick={() => {
const value = newlyGeneratedToken();
if (!value) return;
const fallbackCopy = () => {
try {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
return copied;
} catch (err) {
console.error('Fallback copy failed', err);
return false;
}
};
const copyPromise =
typeof navigator !== 'undefined' && navigator.clipboard?.writeText
? navigator.clipboard.writeText(value).then(() => true).catch((err) => {
console.warn('Clipboard API copy failed, falling back', err);
return fallbackCopy();
})
: Promise.resolve(fallbackCopy());
copyPromise.then((success) => {
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Token copied!' : 'Failed to copy token');
}
}
);
}}
class="px-3 py-2 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors whitespace-nowrap"
>
Copy
</button>
<Show
when={latestGeneratedToken() && !isRevealActiveForLatestToken()}
>
<div class="mt-4 space-y-3 rounded-lg border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 p-4">
<div class="flex items-start gap-2">
<div class="flex-shrink-0 text-blue-600 dark:text-blue-300">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div class="space-y-1">
<p class="text-sm font-semibold text-blue-800 dark:text-blue-200">
New token ready
</p>
<p class="text-xs text-blue-700 dark:text-blue-300 leading-snug">
Reopen the secure dialog if you still need to copy the value before you leave this page. The command above already includes it.
</p>
<Show when={latestGeneratedRecord()}>
<div class="text-xs text-blue-700/80 dark:text-blue-300/90">
Label{' '}
<span class="font-semibold">
{latestGeneratedRecord()?.name || 'Untitled token'}
</span>
<Show when={latestGeneratedRecord()?.prefix || latestGeneratedRecord()?.suffix}>
{' '}· Hint{' '}
<code class="rounded bg-blue-100 dark:bg-blue-900/40 px-1.5 py-0.5 font-mono text-[11px] text-blue-700 dark:text-blue-200">
{latestGeneratedRecord()?.prefix}{latestGeneratedRecord()?.suffix}
</code>
</Show>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-3">
<p class="text-xs text-blue-800 dark:text-blue-300">
💡 This token won't be shown again. Make sure to save it securely or use it right away in the command below.
</p>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
onClick={() => {
setShowNewTokenModal(false);
setNewlyGeneratedToken(null);
}}
class="px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
Done
</button>
</Show>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
onClick={reopenLatestTokenDialog}
class="inline-flex items-center gap-1 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold px-3 py-2 transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
Show token dialog
</button>
<button
type="button"
onClick={() => {
setLatestGeneratedToken(null);
setLatestGeneratedRecord(null);
}}
class="inline-flex items-center rounded-md border border-blue-300 dark:border-blue-700 px-3 py-2 text-xs font-medium text-blue-800 dark:text-blue-200 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors"
>
Dismiss reminder
</button>
</div>
</div>
</Show>
</div>

View file

@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen, fireEvent } from '@solidjs/testing-library';
import { TokenRevealDialog } from '@/components/TokenRevealDialog';
import { showTokenReveal, dismissTokenReveal } from '@/stores/tokenReveal';
const mockRecord = {
id: 'test-token',
name: 'Integration token',
prefix: 'abc123',
suffix: '7890',
createdAt: new Date().toISOString(),
};
describe('TokenRevealDialog', () => {
afterEach(() => {
dismissTokenReveal();
cleanup();
});
it('renders and dismisses the token dialog', async () => {
render(() => <TokenRevealDialog />);
expect(screen.queryByText(/API token ready/i)).toBeNull();
showTokenReveal({
token: 'abc123token7890',
record: mockRecord,
source: 'test',
});
expect(await screen.findByText(/API token ready/i)).toBeInTheDocument();
expect(screen.getByText(/abc123token7890/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /close token dialog/i }));
expect(screen.queryByText(/API token ready/i)).toBeNull();
});
it('supports dismissal via escape key', async () => {
render(() => <TokenRevealDialog />);
showTokenReveal({
token: 'escape-test-token',
record: mockRecord,
source: 'test',
});
expect(await screen.findByText(/API token ready/i)).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByText(/API token ready/i)).toBeNull();
});
});