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 { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types.js';
|
import type { PageServerLoad } from './$types.js';
|
||||||
import { withBypassRLS } from '$lib/server/rls.js';
|
import { withBypassRLS } from '$lib/server/rls.js';
|
||||||
|
import { prisma } from '$lib/server/db.js';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
if (!locals.user) throw redirect(302, '/auth/login');
|
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 } });
|
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 {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
sessionCount
|
sessionCount,
|
||||||
|
apiKeys
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,11 +28,19 @@
|
|||||||
let sessionCount = $state(0);
|
let sessionCount = $state(0);
|
||||||
let revokingSessions = $state(false);
|
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(() => {
|
$effect.pre(() => {
|
||||||
name = data.user.name;
|
name = data.user.name;
|
||||||
email = data.user.email;
|
email = data.user.email;
|
||||||
avatarUrl = data.user.avatarUrl;
|
avatarUrl = data.user.avatarUrl;
|
||||||
sessionCount = data.sessionCount;
|
sessionCount = data.sessionCount;
|
||||||
|
apiKeys = data.apiKeys;
|
||||||
});
|
});
|
||||||
|
|
||||||
function onFileSelect(e: Event) {
|
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() {
|
async function revokeOtherSessions() {
|
||||||
revokingSessions = true;
|
revokingSessions = true;
|
||||||
try {
|
try {
|
||||||
@@ -270,6 +315,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Sessions -->
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Sessions</h2>
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Sessions</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user