Skip to content

Commit 41348e9

Browse files
lane711claude
andauthored
fix(api-keys): show new key until user copies, no auto-redirect (#984)
Remove 1.5s auto-reload that fired before user could copy the secret. New key row now injected into the DOM immediately. Banner stays visible until user clicks "Copy & dismiss" or the × close button. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e48e019 commit 41348e9

5 files changed

Lines changed: 137 additions & 12 deletions

File tree

my-sonicjs-app/wrangler.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ workers_dev = true
1313
# Note: database_name and database_id are automatically updated by GitHub Actions
1414
[[d1_databases]]
1515
binding = "DB"
16-
database_name = "sonicjs-worktree-lane711-hello-cruel-world-plugin"
17-
database_id = "1aa3d74a-be00-4e7f-b6dd-6a3d73701fed"
16+
database_name = "sonicjs-worktree-lane711-api-key-copy-before-redirect"
17+
database_id = "a211809e-e558-4b82-b2f3-9b8de0d5e4d8"
1818
migrations_dir = "./migrations"
1919

2020
# R2 Bucket for media storage (using CI bucket)

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/src/db/migrations-bundle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* AUTO-GENERATED FILE - DO NOT EDIT
33
* Generated by: scripts/generate-migrations.ts
4-
* Generated at: 2026-06-30T02:34:57.710Z
4+
* Generated at: 2026-06-30T23:25:00.969Z
55
*
66
* This file contains all migration SQL bundled for use in Cloudflare Workers
77
* where filesystem access is not available at runtime.

packages/core/src/plugins/core-plugins/api-keys-plugin/components/keys-page.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,18 @@ export function renderApiKeysPage(data: ApiKeysPageData): string {
7272
7373
<!-- Newly-created secret banner (populated by JS, shown once) -->
7474
<div id="new-key-banner" class="hidden mb-6 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 p-4 ring-1 ring-emerald-200 dark:ring-emerald-800">
75-
<p class="text-sm font-medium text-emerald-800 dark:text-emerald-300 mb-2">
76-
Copy your new key now — it will not be shown again.
77-
</p>
75+
<div class="flex items-start justify-between mb-2">
76+
<p class="text-sm font-medium text-emerald-800 dark:text-emerald-300">
77+
Copy your new key now — it will not be shown again.
78+
</p>
79+
<button type="button" onclick="document.getElementById('new-key-banner').classList.add('hidden')"
80+
class="ml-4 text-emerald-600 dark:text-emerald-400 hover:text-emerald-800 dark:hover:text-emerald-200 text-lg leading-none">&times;</button>
81+
</div>
7882
<div class="flex items-center gap-2">
7983
<code id="new-key-value" class="flex-1 rounded-lg bg-white dark:bg-zinc-900 px-3 py-2 text-sm font-mono text-zinc-950 dark:text-white ring-1 ring-zinc-950/10 dark:ring-white/10 break-all"></code>
8084
<button type="button" onclick="copyNewKey()"
8185
class="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-500 transition-colors">
82-
Copy
86+
Copy &amp; dismiss
8387
</button>
8488
</div>
8589
</div>
@@ -151,11 +155,50 @@ export function renderApiKeysPage(data: ApiKeysPageData): string {
151155
document.getElementById('create-key-modal').classList.add('hidden')
152156
document.getElementById('new-key-value').textContent = apiKey.key
153157
document.getElementById('new-key-banner').classList.remove('hidden')
154-
setTimeout(() => location.reload(), 1500)
158+
// Add new key row to table immediately so user can see it without reloading
159+
const tbody = document.getElementById('keys-tbody')
160+
const emptyRow = tbody.querySelector('td[colspan]')
161+
if (emptyRow) emptyRow.closest('tr').remove()
162+
const tr = document.createElement('tr')
163+
tr.className = 'border-t border-zinc-950/5 dark:border-white/10'
164+
tr.setAttribute('data-key-id', apiKey.id)
165+
const prefix = apiKey.prefix
166+
const today = new Date().toISOString().slice(0, 10)
167+
const expires = apiKey.expiresAt ? new Date(apiKey.expiresAt).toISOString().slice(0, 10) : 'Never'
168+
// Build row using DOM methods to avoid nested template-literal escaping issues
169+
const nameTd = document.createElement('td')
170+
nameTd.className = 'py-3 pr-4 text-sm font-medium text-zinc-950 dark:text-white'
171+
nameTd.textContent = apiKey.name
172+
const prefixTd = document.createElement('td')
173+
prefixTd.className = 'py-3 pr-4 text-sm font-mono text-zinc-500 dark:text-zinc-400'
174+
prefixTd.textContent = prefix + '…'
175+
const createdTd = document.createElement('td')
176+
createdTd.className = 'py-3 pr-4 text-sm text-zinc-500 dark:text-zinc-400'
177+
createdTd.textContent = today
178+
const lastUsedTd = document.createElement('td')
179+
lastUsedTd.className = 'py-3 pr-4 text-sm text-zinc-500 dark:text-zinc-400'
180+
lastUsedTd.textContent = 'Never'
181+
const expiresTd = document.createElement('td')
182+
expiresTd.className = 'py-3 pr-4 text-sm text-zinc-500 dark:text-zinc-400'
183+
expiresTd.textContent = expires
184+
const actionsTd = document.createElement('td')
185+
actionsTd.className = 'py-3 text-right'
186+
const revokeBtn = document.createElement('button')
187+
revokeBtn.type = 'button'
188+
revokeBtn.className = 'rounded-lg bg-red-50 dark:bg-red-900/20 px-3 py-1.5 text-sm font-medium text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40 ring-1 ring-red-200 dark:ring-red-800 transition-colors'
189+
revokeBtn.textContent = 'Revoke'
190+
revokeBtn.addEventListener('click', () => revokeApiKey(apiKey.id, apiKey.name))
191+
actionsTd.appendChild(revokeBtn)
192+
tr.appendChild(nameTd); tr.appendChild(prefixTd); tr.appendChild(createdTd)
193+
tr.appendChild(lastUsedTd); tr.appendChild(expiresTd); tr.appendChild(actionsTd)
194+
tbody.appendChild(tr)
155195
}
156196
function copyNewKey() {
157197
const v = document.getElementById('new-key-value').textContent
158-
navigator.clipboard.writeText(v).then(() => window.showNotification && window.showNotification('Copied', 'success'))
198+
navigator.clipboard.writeText(v).then(() => {
199+
window.showNotification && window.showNotification('Copied to clipboard', 'success')
200+
document.getElementById('new-key-banner').classList.add('hidden')
201+
})
159202
}
160203
async function revokeApiKey(id, name) {
161204
if (!confirm('Revoke "' + name + '"? This cannot be undone and will break any client using it.')) return
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { test, expect } from '@playwright/test'
2+
import { loginAsAdmin } from './utils/test-helpers'
3+
4+
test.describe('API Keys - copy key before redirect', () => {
5+
test.beforeEach(async ({ page }) => {
6+
await loginAsAdmin(page)
7+
})
8+
9+
test('shows key banner after create without page reload', async ({ page }) => {
10+
await page.goto('/admin/plugins/api-keys')
11+
12+
// Open create modal
13+
await page.getByRole('button', { name: 'Create API key' }).click()
14+
await expect(page.locator('#create-key-modal')).not.toHaveClass(/hidden/)
15+
16+
// Fill form
17+
await page.locator('#new-key-name').fill('e2e-test-key')
18+
await page.getByRole('button', { name: 'Create' }).click()
19+
20+
// Banner should appear with key value
21+
const banner = page.locator('#new-key-banner')
22+
await expect(banner).not.toHaveClass(/hidden/)
23+
24+
const keyValue = page.locator('#new-key-value')
25+
const key = await keyValue.textContent()
26+
expect(key).toBeTruthy()
27+
expect(key!.length).toBeGreaterThan(10)
28+
29+
// Page should NOT have reloaded yet (banner still visible)
30+
await expect(banner).not.toHaveClass(/hidden/)
31+
32+
// Modal should be closed
33+
await expect(page.locator('#create-key-modal')).toHaveClass(/hidden/)
34+
})
35+
36+
test('new key row appears in table without reload', async ({ page }) => {
37+
await page.goto('/admin/plugins/api-keys')
38+
39+
const initialRowCount = await page.locator('#keys-tbody tr').count()
40+
41+
await page.getByRole('button', { name: 'Create API key' }).click()
42+
await page.locator('#new-key-name').fill('e2e-table-row-test')
43+
await page.getByRole('button', { name: 'Create' }).click()
44+
45+
// Banner visible
46+
await expect(page.locator('#new-key-banner')).not.toHaveClass(/hidden/)
47+
48+
// Table row added without reload
49+
await expect(page.locator('#keys-tbody tr')).toHaveCount(initialRowCount + 1)
50+
})
51+
52+
test('copy & dismiss button hides the banner', async ({ page, context }) => {
53+
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
54+
await page.goto('/admin/plugins/api-keys')
55+
56+
await page.getByRole('button', { name: 'Create API key' }).click()
57+
await page.locator('#new-key-name').fill('e2e-copy-dismiss-test')
58+
await page.getByRole('button', { name: 'Create' }).click()
59+
60+
await expect(page.locator('#new-key-banner')).not.toHaveClass(/hidden/)
61+
62+
await page.getByRole('button', { name: 'Copy & dismiss' }).click()
63+
64+
// Banner should hide after copy
65+
await expect(page.locator('#new-key-banner')).toHaveClass(/hidden/)
66+
})
67+
68+
test('dismiss X button hides banner without copy', async ({ page }) => {
69+
await page.goto('/admin/plugins/api-keys')
70+
71+
await page.getByRole('button', { name: 'Create API key' }).click()
72+
await page.locator('#new-key-name').fill('e2e-dismiss-x-test')
73+
await page.getByRole('button', { name: 'Create' }).click()
74+
75+
await expect(page.locator('#new-key-banner')).not.toHaveClass(/hidden/)
76+
77+
// Click × button
78+
await page.locator('#new-key-banner button').first().click()
79+
80+
await expect(page.locator('#new-key-banner')).toHaveClass(/hidden/)
81+
})
82+
})

0 commit comments

Comments
 (0)