Add reusable API token reveal dialog

This commit is contained in:
rcourtman 2025-10-15 22:45:14 +00:00
parent e2a0fbe816
commit 7c8ab4fbea
5 changed files with 209 additions and 0 deletions

View file

@ -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<typeof getGlobalWebSocketStore>;
@ -568,6 +569,7 @@ function App() {
</div>
<ToastContainer />
<NotificationContainer />
<TokenRevealDialog />
<TooltipRoot />
</DarkModeContext.Provider>
</WebSocketContext.Provider>

View file

@ -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<APITokenManagerProps> = (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?.();

View file

@ -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);

View file

@ -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 (
<Show when={state()}>
{(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 (
<div class="fixed inset-0 z-[60] flex items-center justify-center px-4 py-6">
<div
class="absolute inset-0 bg-gray-900/70 backdrop-blur-sm"
role="presentation"
onClick={handleDismiss}
/>
<div class="relative z-[61] w-full max-w-xl">
<Card padding="lg" class="shadow-2xl relative">
<button
type="button"
class="absolute top-3 right-3 rounded-md px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
onClick={handleDismiss}
aria-label="Close token dialog"
>
Close
</button>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 p-2">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" 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="space-y-2 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
API token ready
</h2>
<Show when={sourceLabel}>
<span class="inline-flex items-center rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide">
{sourceLabel}
</span>
</Show>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 leading-snug">{hint}</p>
</div>
</div>
<div class="mt-5 space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Token value
</div>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<code class="flex-1 rounded-lg border border-green-300 dark:border-green-700 bg-white dark:bg-gray-900 px-4 py-3 font-mono text-base font-semibold text-gray-900 dark:text-gray-100 break-all">
{info.token}
</code>
<button
type="button"
onClick={() => handleCopy(info.token)}
class="inline-flex items-center justify-center rounded-lg bg-green-600 hover:bg-green-700 text-white text-sm font-semibold px-4 py-2 transition-colors shadow-sm"
>
{copied() ? 'Copied!' : 'Copy token'}
</button>
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
Label: <span class="font-semibold text-gray-800 dark:text-gray-200">{recordName}</span>
<Show when={tokenHint}>
<span>
{' '}
· Hint:{' '}
<code class="rounded bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 font-mono text-[11px] text-gray-600 dark:text-gray-400">
{tokenHint as string}
</code>
</span>
</Show>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
onClick={handleDismiss}
class="rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Dismiss
</button>
</div>
</Card>
</div>
</div>
);
}}
</Show>
);
};
export default TokenRevealDialog;

View file

@ -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<TokenRevealState | null>(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();