From 605512aa6b0867464189b1a68193bdc52ec55ea3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 15 Oct 2025 22:58:31 +0000 Subject: [PATCH] Unify API token reveal workflow --- .../src/components/FirstRunSetup.tsx | 16 ++ .../components/Settings/APITokenManager.tsx | 127 +++++++------- .../components/Settings/CommandBuilder.tsx | 161 +++++++++--------- .../__tests__/TokenRevealDialog.test.tsx | 51 ++++++ 4 files changed, 212 insertions(+), 143 deletions(-) create mode 100644 frontend-modern/src/components/__tests__/TokenRevealDialog.test.tsx diff --git a/frontend-modern/src/components/FirstRunSetup.tsx b/frontend-modern/src/components/FirstRunSetup.tsx index 38b29c4..58b1eb0 100644 --- a/frontend-modern/src/components/FirstRunSetup.tsx +++ b/frontend-modern/src/components/FirstRunSetup.tsx @@ -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!'); diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index a38df9c..6ced9ee 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -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 = (props) => { const [isGenerating, setIsGenerating] = createSignal(false); const [newTokenValue, setNewTokenValue] = createSignal(null); const [newTokenRecord, setNewTokenRecord] = createSignal(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 = (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 = (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 = (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 = (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 (
@@ -200,56 +203,58 @@ export const APITokenManager: Component = (props) => {
- {/* CRITICAL: Show generated token FIRST and PROMINENTLY */} - -
+ {/* Generated token reminder - only show when dialog is not already visible */} + +
-
- +
+
-
-
-

Token Generated!

-

- ⚠️ This is shown ONCE. Copy it now or lose it forever! +

+

+ Token ready to copy +

+

+ We keep the full token inside a secure dialog. Reopen it if you still need to copy the value before navigating away. +

+ +

+ Label{' '} + {newTokenRecord()?.name || 'Untitled token'} + + {' '}· Hint{' '} + + {tokenHint(newTokenRecord()!)} + +

-
- -
- -
- - {newTokenValue()} - - -
-
- -
-

- 💡 Keep this token safe and use it anywhere Pulse requires API authentication—Docker agents, automations, or custom integrations. -

-
- - +
+
+ + +
diff --git a/frontend-modern/src/components/Settings/CommandBuilder.tsx b/frontend-modern/src/components/Settings/CommandBuilder.tsx index 442b65c..ea40151 100644 --- a/frontend-modern/src/components/Settings/CommandBuilder.tsx +++ b/frontend-modern/src/components/Settings/CommandBuilder.tsx @@ -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 = (props) => { // Token generation/revocation state const [showGenerateModal, setShowGenerateModal] = createSignal(false); const [isGenerating, setIsGenerating] = createSignal(false); - const [newlyGeneratedToken, setNewlyGeneratedToken] = createSignal(null); - const [showNewTokenModal, setShowNewTokenModal] = createSignal(false); const [tokenLabel, setTokenLabel] = createSignal('Docker agent token'); + const [latestGeneratedToken, setLatestGeneratedToken] = createSignal(null); + const [latestGeneratedRecord, setLatestGeneratedRecord] = createSignal(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 = (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 = (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 = (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 = (props) => { {/* New Token Display Modal */} - -
-
-

New API Token Generated!

-
-
-

- ✓ Your new token has been generated and is active immediately. The command builder has been auto-filled with your new token. -

-
-
- -
- - {newlyGeneratedToken()} - - + +
+
+
+ + + +
+
+

+ New token ready +

+

+ Reopen the secure dialog if you still need to copy the value before you leave this page. The command above already includes it. +

+ +
+ Label{' '} + + {latestGeneratedRecord()?.name || 'Untitled token'} + + + {' '}· Hint{' '} + + {latestGeneratedRecord()?.prefix}…{latestGeneratedRecord()?.suffix} + +
-
-
-

- 💡 This token won't be shown again. Make sure to save it securely or use it right away in the command below. -

-
-
-
- +
+
+ + +
diff --git a/frontend-modern/src/components/__tests__/TokenRevealDialog.test.tsx b/frontend-modern/src/components/__tests__/TokenRevealDialog.test.tsx new file mode 100644 index 0000000..4cec65c --- /dev/null +++ b/frontend-modern/src/components/__tests__/TokenRevealDialog.test.tsx @@ -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(() => ); + 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(() => ); + + 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(); + }); +});