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 { copyToClipboard } from '@/utils/clipboard';
|
||||||
import { STORAGE_KEYS } from '@/constants';
|
import { STORAGE_KEYS } from '@/constants';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
|
import { showTokenReveal } from '@/stores/tokenReveal';
|
||||||
|
import type { APITokenRecord } from '@/api/security';
|
||||||
|
|
||||||
export const FirstRunSetup: Component = () => {
|
export const FirstRunSetup: Component = () => {
|
||||||
const [username, setUsername] = createSignal('admin');
|
const [username, setUsername] = createSignal('admin');
|
||||||
|
|
@ -131,6 +133,20 @@ export const FirstRunSetup: Component = () => {
|
||||||
setSavedPassword(useCustomPassword() ? password() : generatedPassword());
|
setSavedPassword(useCustomPassword() ? password() : generatedPassword());
|
||||||
setSavedToken(token);
|
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
|
// Show credentials
|
||||||
setShowCredentials(true);
|
setShowCredentials(true);
|
||||||
showSuccess('Security configured successfully!');
|
showSuccess('Security configured successfully!');
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,10 @@ import { Card } from '@/components/shared/Card';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
import { SecurityAPI, type APITokenRecord } from '@/api/security';
|
import { SecurityAPI, type APITokenRecord } from '@/api/security';
|
||||||
import { showError, showSuccess } from '@/utils/toast';
|
import { showError, showSuccess } from '@/utils/toast';
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
|
||||||
import { formatRelativeTime } from '@/utils/format';
|
import { formatRelativeTime } from '@/utils/format';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import type { DockerHost } from '@/types/api';
|
import type { DockerHost } from '@/types/api';
|
||||||
import { showTokenReveal } from '@/stores/tokenReveal';
|
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
|
||||||
|
|
||||||
interface APITokenManagerProps {
|
interface APITokenManagerProps {
|
||||||
currentTokenHint?: string;
|
currentTokenHint?: string;
|
||||||
|
|
@ -42,8 +41,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||||
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
|
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
|
||||||
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
|
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
|
||||||
const [copied, setCopied] = createSignal(false);
|
|
||||||
const [nameInput, setNameInput] = createSignal('');
|
const [nameInput, setNameInput] = createSignal('');
|
||||||
|
const tokenRevealState = useTokenRevealState();
|
||||||
|
|
||||||
const loadTokens = async () => {
|
const loadTokens = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -64,7 +63,6 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setCopied(false);
|
|
||||||
try {
|
try {
|
||||||
const trimmedName = nameInput().trim() || undefined;
|
const trimmedName = nameInput().trim() || undefined;
|
||||||
const { token, record } = await SecurityAPI.createToken(trimmedName);
|
const { token, record } = await SecurityAPI.createToken(trimmedName);
|
||||||
|
|
@ -78,7 +76,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
token,
|
token,
|
||||||
record,
|
record,
|
||||||
source: 'security',
|
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.');
|
showSuccess('New API token generated. Copy it below while it is still visible.');
|
||||||
props.onTokensChanged?.();
|
props.onTokensChanged?.();
|
||||||
|
|
@ -117,19 +115,6 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
return 'unnamed token';
|
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 handleDelete = async (record: APITokenRecord) => {
|
||||||
const usage = dockerTokenUsage().get(record.id);
|
const usage = dockerTokenUsage().get(record.id);
|
||||||
const displayName = tokenNameForDialog(record);
|
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 (
|
return (
|
||||||
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
|
<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">
|
<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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* CRITICAL: Show generated token FIRST and PROMINENTLY */}
|
{/* Generated token reminder - only show when dialog is not already visible */}
|
||||||
<Show when={newTokenValue()}>
|
<Show when={newTokenValue() && !isRevealActiveForCurrentToken()}>
|
||||||
<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">
|
<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 items-start gap-3">
|
||||||
<div class="flex-shrink-0">
|
<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-8 h-8 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 space-y-3">
|
<div class="space-y-2">
|
||||||
<div>
|
<h3 class="text-base font-semibold text-green-900 dark:text-green-100">
|
||||||
<h3 class="text-lg font-bold text-green-900 dark:text-green-100">Token Generated!</h3>
|
Token ready to copy
|
||||||
<p class="text-sm font-semibold text-green-800 dark:text-green-200 mt-1">
|
</h3>
|
||||||
⚠️ This is shown ONCE. Copy it now or lose it forever!
|
<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>
|
</p>
|
||||||
</div>
|
</Show>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Component, createSignal, Show, createMemo, createEffect } from 'solid-js';
|
import { Component, createSignal, Show, createMemo, createEffect } from 'solid-js';
|
||||||
import { apiFetch } from '@/utils/apiClient';
|
import { apiFetch } from '@/utils/apiClient';
|
||||||
import { SecurityAPI, type APITokenRecord } from '@/api/security';
|
import { SecurityAPI, type APITokenRecord } from '@/api/security';
|
||||||
|
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
|
||||||
|
|
||||||
interface CommandBuilderProps {
|
interface CommandBuilderProps {
|
||||||
command: string;
|
command: string;
|
||||||
|
|
@ -24,9 +25,10 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
// Token generation/revocation state
|
// Token generation/revocation state
|
||||||
const [showGenerateModal, setShowGenerateModal] = createSignal(false);
|
const [showGenerateModal, setShowGenerateModal] = createSignal(false);
|
||||||
const [isGenerating, setIsGenerating] = 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 [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 defaultTokenLabel = () => `Docker agent token ${new Date().toISOString().slice(0, 10)}`;
|
||||||
const canManageTokens = () => props.canManageTokens !== false;
|
const canManageTokens = () => props.canManageTokens !== false;
|
||||||
|
|
@ -36,6 +38,25 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
setShowGenerateModal(true);
|
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
|
// Initialize with stored token if available
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.storedToken && tokenInput() === '') {
|
if (props.storedToken && tokenInput() === '') {
|
||||||
|
|
@ -177,9 +198,10 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
const { token: newToken, record } = await SecurityAPI.createToken(desiredName);
|
const { token: newToken, record } = await SecurityAPI.createToken(desiredName);
|
||||||
|
|
||||||
setShowGenerateModal(false);
|
setShowGenerateModal(false);
|
||||||
setNewlyGeneratedToken(newToken);
|
setLatestGeneratedToken(newToken);
|
||||||
setShowNewTokenModal(true);
|
setLatestGeneratedRecord(record);
|
||||||
|
|
||||||
|
reopenLatestTokenDialog();
|
||||||
// Auto-populate the command builder
|
// Auto-populate the command builder
|
||||||
setTokenInput(newToken);
|
setTokenInput(newToken);
|
||||||
if (props.onTokenGenerated) {
|
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) {
|
} catch (error) {
|
||||||
console.error('Token generation failed:', error);
|
console.error('Token generation failed:', error);
|
||||||
window.showToast('error', error instanceof Error ? error.message : 'Failed to generate token');
|
window.showToast('error', error instanceof Error ? error.message : 'Failed to generate token');
|
||||||
|
|
@ -543,86 +565,61 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* New Token Display Modal */}
|
{/* New Token Display Modal */}
|
||||||
<Show when={showNewTokenModal()}>
|
<Show
|
||||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
when={latestGeneratedToken() && !isRevealActiveForLatestToken()}
|
||||||
<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="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="space-y-4 mb-6">
|
<div class="flex items-start gap-2">
|
||||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded p-3">
|
<div class="flex-shrink-0 text-blue-600 dark:text-blue-300">
|
||||||
<p class="text-sm text-green-800 dark:text-green-300">
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
✓ Your new token has been generated and is active immediately. The command builder has been auto-filled with your new token.
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
</p>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Your new token (save this now):</label>
|
<p class="text-sm font-semibold text-blue-800 dark:text-blue-200">
|
||||||
<div class="flex gap-2">
|
New token ready
|
||||||
<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">
|
</p>
|
||||||
{newlyGeneratedToken()}
|
<p class="text-xs text-blue-700 dark:text-blue-300 leading-snug">
|
||||||
</code>
|
Reopen the secure dialog if you still need to copy the value before you leave this page. The command above already includes it.
|
||||||
<button
|
</p>
|
||||||
type="button"
|
<Show when={latestGeneratedRecord()}>
|
||||||
onClick={() => {
|
<div class="text-xs text-blue-700/80 dark:text-blue-300/90">
|
||||||
const value = newlyGeneratedToken();
|
Label{' '}
|
||||||
if (!value) return;
|
<span class="font-semibold">
|
||||||
|
{latestGeneratedRecord()?.name || 'Untitled token'}
|
||||||
const fallbackCopy = () => {
|
</span>
|
||||||
try {
|
<Show when={latestGeneratedRecord()?.prefix || latestGeneratedRecord()?.suffix}>
|
||||||
const textarea = document.createElement('textarea');
|
{' '}· Hint{' '}
|
||||||
textarea.value = value;
|
<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">
|
||||||
textarea.setAttribute('readonly', '');
|
{latestGeneratedRecord()?.prefix}…{latestGeneratedRecord()?.suffix}
|
||||||
textarea.style.position = 'fixed';
|
</code>
|
||||||
textarea.style.left = '-9999px';
|
</Show>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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