Add reusable API token reveal dialog
This commit is contained in:
parent
e2a0fbe816
commit
7c8ab4fbea
5 changed files with 209 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
164
frontend-modern/src/components/TokenRevealDialog.tsx
Normal file
164
frontend-modern/src/components/TokenRevealDialog.tsx
Normal 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;
|
||||
29
frontend-modern/src/stores/tokenReveal.ts
Normal file
29
frontend-modern/src/stores/tokenReveal.ts
Normal 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();
|
||||
Loading…
Reference in a new issue