Add API key management UI to account settings
Users can create, view, and delete API keys from their profile. Keys are shown once on creation with a copy button. List shows key prefix, creation date, and last used date. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,31 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { withBypassRLS } from '$lib/server/rls.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) throw redirect(302, '/auth/login');
|
||||
|
||||
const sessionCount = await withBypassRLS(async (tx) => {
|
||||
const [sessionCount, apiKeys] = await Promise.all([
|
||||
withBypassRLS(async (tx) => {
|
||||
return tx.session.count({ where: { userId: locals.user!.id } });
|
||||
});
|
||||
}),
|
||||
prisma.apiKey.findMany({
|
||||
where: { userId: locals.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
lastUsedAt: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
sessionCount
|
||||
sessionCount,
|
||||
apiKeys
|
||||
};
|
||||
};
|
||||
|
||||
@@ -28,11 +28,19 @@
|
||||
let sessionCount = $state(0);
|
||||
let revokingSessions = $state(false);
|
||||
|
||||
// API Keys
|
||||
let apiKeys = $state<{ id: string; name: string; keyPrefix: string; lastUsedAt: Date | string | null; createdAt: Date | string }[]>([]);
|
||||
let newKeyName = $state('');
|
||||
let creatingKey = $state(false);
|
||||
let newlyCreatedKey = $state<string | null>(null);
|
||||
let copiedKey = $state(false);
|
||||
|
||||
$effect.pre(() => {
|
||||
name = data.user.name;
|
||||
email = data.user.email;
|
||||
avatarUrl = data.user.avatarUrl;
|
||||
sessionCount = data.sessionCount;
|
||||
apiKeys = data.apiKeys;
|
||||
});
|
||||
|
||||
function onFileSelect(e: Event) {
|
||||
@@ -115,6 +123,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
if (!newKeyName.trim()) return;
|
||||
creatingKey = true;
|
||||
newlyCreatedKey = null;
|
||||
try {
|
||||
const result = await api('/api/account/api-keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: newKeyName.trim() })
|
||||
});
|
||||
if (result.ok) {
|
||||
newlyCreatedKey = result.data.key;
|
||||
apiKeys = [result.data.apiKey, ...apiKeys];
|
||||
newKeyName = '';
|
||||
toast.success('API key created');
|
||||
}
|
||||
} finally {
|
||||
creatingKey = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiKey(id: string) {
|
||||
const result = await api('/api/account/api-keys', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
if (result.ok) {
|
||||
apiKeys = apiKeys.filter((k) => k.id !== id);
|
||||
toast.success('API key deleted');
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey(key: string) {
|
||||
await navigator.clipboard.writeText(key);
|
||||
copiedKey = true;
|
||||
setTimeout(() => (copiedKey = false), 2000);
|
||||
}
|
||||
|
||||
async function revokeOtherSessions() {
|
||||
revokingSessions = true;
|
||||
try {
|
||||
@@ -270,6 +315,83 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- API Keys -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">API Keys</h2>
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
API keys allow programmatic access to your account. Use them with <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">Authorization: Bearer pnc_...</code>
|
||||
</p>
|
||||
|
||||
{#if newlyCreatedKey}
|
||||
<div class="mb-4 p-3 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20">
|
||||
<p class="text-sm font-medium text-green-800 dark:text-green-200 mb-2">Key created — copy it now, it won't be shown again:</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 text-xs bg-white dark:bg-gray-800 border border-green-200 dark:border-green-800 rounded px-3 py-2 font-mono break-all select-all">{newlyCreatedKey}</code>
|
||||
<button
|
||||
onclick={() => copyKey(newlyCreatedKey!)}
|
||||
class="flex-shrink-0 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
type="button"
|
||||
>
|
||||
{copiedKey ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Create new key -->
|
||||
<form onsubmit={async (e) => { e.preventDefault(); await createApiKey(); }} class="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newKeyName}
|
||||
placeholder="Key name (e.g. Claude Code)"
|
||||
maxlength={100}
|
||||
class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creatingKey || !newKeyName.trim()}
|
||||
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50 transition whitespace-nowrap"
|
||||
>
|
||||
{creatingKey ? 'Creating...' : 'Create Key'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Key list -->
|
||||
{#if apiKeys.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each apiKeys as key (key.id)}
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">{key.name}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">{key.keyPrefix}...
|
||||
<span class="mx-1">·</span>
|
||||
Created {new Date(key.createdAt).toLocaleDateString()}
|
||||
{#if key.lastUsedAt}
|
||||
<span class="mx-1">·</span>
|
||||
Last used {new Date(key.lastUsedAt).toLocaleDateString()}
|
||||
{:else}
|
||||
<span class="mx-1">·</span>
|
||||
Never used
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => deleteApiKey(key.id)}
|
||||
class="flex-shrink-0 rounded-lg border border-red-300 dark:border-red-700 px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-2">No API keys yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sessions -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Sessions</h2>
|
||||
|
||||
Reference in New Issue
Block a user