From 7c8ab4fbea693ae3d806d2d5e37d842e09241cce Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 15 Oct 2025 22:45:14 +0000 Subject: [PATCH] Add reusable API token reveal dialog --- frontend-modern/src/App.tsx | 2 + .../components/Settings/APITokenManager.tsx | 7 + .../src/components/Settings/DockerAgents.tsx | 7 + .../src/components/TokenRevealDialog.tsx | 164 ++++++++++++++++++ frontend-modern/src/stores/tokenReveal.ts | 29 ++++ 5 files changed, 209 insertions(+) create mode 100644 frontend-modern/src/components/TokenRevealDialog.tsx create mode 100644 frontend-modern/src/stores/tokenReveal.ts diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 65d5675..59eb6f9 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -43,6 +43,7 @@ import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; import { DockerIcon } from '@/components/icons/DockerIcon'; import { AlertsIcon } from '@/components/icons/AlertsIcon'; import { SettingsGearIcon } from '@/components/icons/SettingsGearIcon'; +import { TokenRevealDialog } from './components/TokenRevealDialog'; // Enhanced store type with proper typing type EnhancedStore = ReturnType; @@ -568,6 +569,7 @@ function App() { + diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index 06186be..a38df9c 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -7,6 +7,7 @@ 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'; interface APITokenManagerProps { currentTokenHint?: string; @@ -73,6 +74,12 @@ export const APITokenManager: Component = (props) => { setNewTokenRecord(record); setNameInput(''); + showTokenReveal({ + token, + record, + source: 'security', + note: 'Copy this token now. You can always rotate it from Security → API tokens.', + }); showSuccess('New API token generated. Copy it below while it is still visible.'); props.onTokensChanged?.(); diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index 7ab318b..b038e3b 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -8,6 +8,7 @@ import { notificationStore } from '@/stores/notifications'; import type { SecurityStatus } from '@/types/config'; import { SecurityAPI, type APITokenRecord } from '@/api/security'; import type { DockerHost } from '@/types/api'; +import { showTokenReveal } from '@/stores/tokenReveal'; export const DockerAgents: Component = () => { const { state } = useWebSocket(); @@ -343,6 +344,12 @@ const modalCommandProgress = createMemo(() => { previousTokenStrategy = 'generate'; setShowGenerateTokenModal(false); setNewTokenName(''); + showTokenReveal({ + token, + record, + source: 'docker', + note: 'Copy this token into the install command for your Docker agent or other automation.', + }); if (typeof window !== 'undefined') { try { window.localStorage.setItem('apiToken', token); diff --git a/frontend-modern/src/components/TokenRevealDialog.tsx b/frontend-modern/src/components/TokenRevealDialog.tsx new file mode 100644 index 0000000..a60c138 --- /dev/null +++ b/frontend-modern/src/components/TokenRevealDialog.tsx @@ -0,0 +1,164 @@ +import { Show, createSignal, createEffect, onCleanup } from 'solid-js'; +import type { Component } from 'solid-js'; +import { Card } from '@/components/shared/Card'; +import { copyToClipboard } from '@/utils/clipboard'; +import { showError, showSuccess } from '@/utils/toast'; +import { useTokenRevealState, dismissTokenReveal } from '@/stores/tokenReveal'; + +const formatSourceLabel = (value?: string) => { + if (!value) return ''; + return value + .split(/[-_\s]+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(' '); +}; + +export const TokenRevealDialog: Component = () => { + const state = useTokenRevealState(); + const [copied, setCopied] = createSignal(false); + + const handleDismiss = () => { + dismissTokenReveal(); + setCopied(false); + }; + + const handleCopy = async (token: string) => { + const success = await copyToClipboard(token); + if (success) { + setCopied(true); + showSuccess('Token copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } else { + showError('Failed to copy token'); + } + }; + + createEffect(() => { + const current = state(); + setCopied(false); + if (!current) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleDismiss(); + } + }; + + window.addEventListener('keydown', onKeyDown); + onCleanup(() => window.removeEventListener('keydown', onKeyDown)); + }); + + return ( + + {(tokenInfo) => { + const info = tokenInfo(); + const sourceLabel = formatSourceLabel(info.source); + const recordName = info.record?.name?.trim() || 'Untitled token'; + const hint = + info.note || + 'Copy this token now; Pulse will not display it again after you close this dialog.'; + const tokenHint = + info.record?.prefix && info.record?.suffix + ? `${info.record.prefix}…${info.record.suffix}` + : info.record?.prefix + ? `${info.record.prefix}…` + : info.record?.suffix + ? `…${info.record.suffix}` + : null; + + return ( +
+ + ); + }} + + ); +}; + +export default TokenRevealDialog; diff --git a/frontend-modern/src/stores/tokenReveal.ts b/frontend-modern/src/stores/tokenReveal.ts new file mode 100644 index 0000000..504e45c --- /dev/null +++ b/frontend-modern/src/stores/tokenReveal.ts @@ -0,0 +1,29 @@ +import { createSignal } from 'solid-js'; +import type { APITokenRecord } from '@/api/security'; + +export interface TokenRevealPayload { + token: string; + record: APITokenRecord; + source?: string; + note?: string; +} + +interface TokenRevealState extends TokenRevealPayload { + issuedAt: number; +} + +const [state, setState] = createSignal(null); + +export const tokenRevealStore = { + state, + show(payload: TokenRevealPayload) { + setState({ ...payload, issuedAt: Date.now() }); + }, + dismiss() { + setState(null); + }, +}; + +export const useTokenRevealState = () => state; +export const showTokenReveal = (payload: TokenRevealPayload) => tokenRevealStore.show(payload); +export const dismissTokenReveal = () => tokenRevealStore.dismiss();