fix: store key_plain on sub-keys, display always in settings with copy button

This commit is contained in:
M1 2026-03-17 06:40:33 +04:00
parent c684d96d90
commit 54c89a5a11
4 changed files with 18 additions and 35 deletions

View File

@ -59,6 +59,7 @@ export async function migrate() {
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_lookup TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL,
key_plain TEXT NOT NULL,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
label TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),

View File

@ -147,7 +147,7 @@ export const account = new Elysia({ prefix: "/account" })
// Get account settings
.get("/settings", async ({ accountId }) => {
const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`;
const keys = await sql`SELECT id, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
const keys = await sql`SELECT id, key_plain, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
return {
account_id: acc.id,
has_email: !!acc.email_hash,
@ -192,7 +192,7 @@ export const account = new Elysia({ prefix: "/account" })
const keyLookup = sha256(norm);
const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 });
const [created] = await sql`INSERT INTO api_keys (key_lookup, key_hash, account_id, label) VALUES (${keyLookup}, ${keyHash}, ${accountId}, ${body.label}) RETURNING id`;
const [created] = await sql`INSERT INTO api_keys (key_lookup, key_hash, key_plain, account_id, label) VALUES (${keyLookup}, ${keyHash}, ${rawKey}, ${accountId}, ${body.label}) RETURNING id`;
return { key: rawKey, id: created.id, label: body.label };
}, {
body: t.Object({

View File

@ -141,7 +141,7 @@ export const dashboard = new Elysia()
if (!accountId) return redirect("/dashboard");
const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`;
const apiKeys = await sql`SELECT id, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
const apiKeys = await sql`SELECT id, key_plain, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
const loginKey = cookie?.pingql_key?.value ?? null;
return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey });

View File

@ -66,27 +66,22 @@
</div>
</div>
<!-- New sub-key reveal (shown after creation) -->
<div id="new-key-reveal" class="hidden mb-4 p-4 bg-green-950/30 rounded-lg border border-green-900/50">
<p class="text-xs text-green-400 mb-2">Sub-key created — copy it now, it won't be shown again.</p>
<div class="flex gap-2">
<code id="new-key-value" class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-sm font-mono select-all"></code>
<button onclick="copyNewKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
</div>
</div>
<!-- Sub-keys list -->
<div id="keys-list" class="space-y-2">
<% if (it.apiKeys.length === 0) { %>
<p class="text-xs text-gray-600 italic">No sub-keys yet.</p>
<% } else { %>
<% it.apiKeys.forEach(function(k) { %>
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
<div>
<div class="p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
<div class="flex items-center justify-between mb-2">
<p class="text-sm text-gray-200"><%= k.label %></p>
<p class="text-xs text-gray-600 mt-0.5">created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %></p>
<button onclick="deleteKey('<%= k.id %>', this)" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
</div>
<button onclick="deleteKey('<%= k.id %>')" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
<div class="flex gap-2">
<code class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-xs font-mono select-all"><%= k.key_plain %></code>
<button onclick="copyKey(this, '<%= k.key_plain %>')" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
</div>
<p class="text-xs text-gray-600 mt-1.5">created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %></p>
</div>
<% }) %>
<% } %>
@ -143,33 +138,20 @@
async function createKey() {
const label = document.getElementById('key-label').value.trim();
if (!label) return;
const data = await api('/account/keys', { method: 'POST', body: { label } });
hideCreateKey();
document.getElementById('new-key-value').textContent = data.key;
document.getElementById('new-key-reveal').classList.remove('hidden');
// Add to list without reload
const list = document.getElementById('keys-list');
const empty = list.querySelector('p.italic');
if (empty) empty.remove();
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50';
item.innerHTML = `<div><p class="text-sm text-gray-200">${label}</p><p class="text-xs text-gray-600 mt-0.5">just now · never used</p></div><button onclick="deleteKey('${data.id}')" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>`;
list.appendChild(item);
await api('/account/keys', { method: 'POST', body: { label } });
location.reload();
}
function copyNewKey() {
const val = document.getElementById('new-key-value').textContent;
function copyKey(btn, val) {
navigator.clipboard.writeText(val);
const btn = event.target;
btn.textContent = 'Copied!';
btn.classList.add('text-green-400');
btn.textContent = 'Copied!'; btn.classList.add('text-green-400');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
}
async function deleteKey(id) {
async function deleteKey(id, btn) {
if (!confirm('Revoke this key? Any apps using it will stop working.')) return;
await api(`/account/keys/${id}`, { method: 'DELETE' });
location.reload();
btn.closest('.p-3').remove();
}
function showStatus(id, msg, color) {