Unify API token reveal workflow
This commit is contained in:
parent
7c8ab4fbea
commit
605512aa6b
4 changed files with 212 additions and 143 deletions
|
|
@ -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!');
|
||||
|
|
|
|||
|
|
@ -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 authentication—Docker 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue