ALL 0.1.0 Code
This commit is contained in:
711
src/routes/(app)/settings/user/+page.svelte
Normal file
711
src/routes/(app)/settings/user/+page.svelte
Normal file
@@ -0,0 +1,711 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { users as usersApi, apiKeys as apiKeysApi } from "$lib/api";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import type { ApiKey, ApiKeyCreated } from "$lib/types/api";
|
||||
|
||||
// --- Profile state ---
|
||||
let name = $state("");
|
||||
let email = $state("");
|
||||
let currentPassword = $state("");
|
||||
let newPassword = $state("");
|
||||
let confirmPassword = $state("");
|
||||
let profileLoading = $state(false);
|
||||
let passwordLoading = $state(false);
|
||||
let avatarLoading = $state(false);
|
||||
let profileError = $state("");
|
||||
let profileSuccess = $state("");
|
||||
let passwordError = $state("");
|
||||
let passwordSuccess = $state("");
|
||||
let avatarError = $state("");
|
||||
let avatarSuccess = $state("");
|
||||
|
||||
// --- API Keys state ---
|
||||
const ALL_SCOPES = [
|
||||
{ id: "read:projects", label: "Read Projects", group: "Projects" },
|
||||
{ id: "write:projects", label: "Write Projects", group: "Projects" },
|
||||
{ id: "read:boards", label: "Read Boards", group: "Boards" },
|
||||
{ id: "write:boards", label: "Write Boards", group: "Boards" },
|
||||
{ id: "read:teams", label: "Read Teams", group: "Teams" },
|
||||
{ id: "write:teams", label: "Write Teams", group: "Teams" },
|
||||
{ id: "read:files", label: "Read Files", group: "Files" },
|
||||
{ id: "write:files", label: "Write Files", group: "Files" },
|
||||
{
|
||||
id: "read:notifications",
|
||||
label: "Read Notifications",
|
||||
group: "Notifications",
|
||||
},
|
||||
];
|
||||
|
||||
let apiKeyList = $state<ApiKey[]>([]);
|
||||
let apiKeysLoading = $state(true);
|
||||
let newKeyName = $state("");
|
||||
let newKeyScopes = $state<Record<string, boolean>>({});
|
||||
let creatingKey = $state(false);
|
||||
let newKeyError = $state("");
|
||||
let createdKey = $state<ApiKeyCreated | null>(null);
|
||||
let copiedKey = $state(false);
|
||||
let showCreateForm = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.user) {
|
||||
name = authStore.user.name;
|
||||
email = authStore.user.email;
|
||||
}
|
||||
try {
|
||||
apiKeyList = await apiKeysApi.list();
|
||||
} catch {
|
||||
apiKeyList = [];
|
||||
} finally {
|
||||
apiKeysLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
let userInitial = $derived(name?.charAt(0).toUpperCase() ?? "U");
|
||||
|
||||
// --- Profile handlers ---
|
||||
async function uploadAvatar(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
avatarLoading = true;
|
||||
avatarError = "";
|
||||
avatarSuccess = "";
|
||||
try {
|
||||
const updated = await usersApi.uploadAvatar(file);
|
||||
authStore.setUser(updated);
|
||||
avatarSuccess = "Avatar updated successfully.";
|
||||
} catch (err: unknown) {
|
||||
avatarError =
|
||||
err instanceof Error ? err.message : "Failed to upload avatar";
|
||||
} finally {
|
||||
avatarLoading = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile(e: Event) {
|
||||
e.preventDefault();
|
||||
profileLoading = true;
|
||||
profileError = "";
|
||||
profileSuccess = "";
|
||||
try {
|
||||
const updated = await usersApi.updateMe({ name, email });
|
||||
authStore.setUser(updated);
|
||||
profileSuccess = "Profile updated successfully.";
|
||||
} catch (err: unknown) {
|
||||
profileError = err instanceof Error ? err.message : "Failed to save";
|
||||
} finally {
|
||||
profileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword(e: Event) {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
passwordError = "Passwords do not match";
|
||||
return;
|
||||
}
|
||||
passwordLoading = true;
|
||||
passwordError = "";
|
||||
passwordSuccess = "";
|
||||
try {
|
||||
await usersApi.changePassword(currentPassword, newPassword);
|
||||
currentPassword = "";
|
||||
newPassword = "";
|
||||
confirmPassword = "";
|
||||
passwordSuccess = "Password updated successfully.";
|
||||
} catch (err: unknown) {
|
||||
passwordError =
|
||||
err instanceof Error ? err.message : "Failed to update password";
|
||||
} finally {
|
||||
passwordLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- API Key handlers ---
|
||||
function openCreateForm() {
|
||||
newKeyName = "";
|
||||
newKeyScopes = {};
|
||||
newKeyError = "";
|
||||
createdKey = null;
|
||||
showCreateForm = true;
|
||||
}
|
||||
|
||||
function closeCreateForm() {
|
||||
showCreateForm = false;
|
||||
}
|
||||
|
||||
let selectedScopeCount = $derived(
|
||||
Object.values(newKeyScopes).filter(Boolean).length,
|
||||
);
|
||||
|
||||
async function createKey(e: Event) {
|
||||
e.preventDefault();
|
||||
const scopes = ALL_SCOPES.filter((s) => newKeyScopes[s.id]).map(
|
||||
(s) => s.id,
|
||||
);
|
||||
if (!newKeyName.trim()) {
|
||||
newKeyError = "Name is required.";
|
||||
return;
|
||||
}
|
||||
if (scopes.length === 0) {
|
||||
newKeyError = "Select at least one scope.";
|
||||
return;
|
||||
}
|
||||
creatingKey = true;
|
||||
newKeyError = "";
|
||||
try {
|
||||
const result = await apiKeysApi.create(newKeyName.trim(), scopes);
|
||||
createdKey = result;
|
||||
apiKeyList = await apiKeysApi.list();
|
||||
newKeyName = "";
|
||||
newKeyScopes = {};
|
||||
showCreateForm = false;
|
||||
} catch (err: unknown) {
|
||||
newKeyError =
|
||||
err instanceof Error ? err.message : "Failed to create key.";
|
||||
} finally {
|
||||
creatingKey = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeKey(keyId: string) {
|
||||
if (!confirm("Revoke this API key? Any apps using it will lose access."))
|
||||
return;
|
||||
try {
|
||||
await apiKeysApi.revoke(keyId);
|
||||
apiKeyList = apiKeyList.filter((k) => k.id !== keyId);
|
||||
if (createdKey?.id === keyId) createdKey = null;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
if (!createdKey) return;
|
||||
await navigator.clipboard.writeText(createdKey.key);
|
||||
copiedKey = true;
|
||||
setTimeout(() => (copiedKey = false), 2500);
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
const scopeGroups = ALL_SCOPES.reduce<Record<string, typeof ALL_SCOPES>>(
|
||||
(acc, s) => {
|
||||
(acc[s.group] ??= []).push(s);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>User Settings — FPMB</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Manage your FPMB profile, avatar, account password, and API keys."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-10">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight mb-2">
|
||||
User Settings
|
||||
</h1>
|
||||
<p class="text-neutral-400">Manage your profile and account preferences.</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Section -->
|
||||
<section
|
||||
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-6 border-b border-neutral-700">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Profile Information</h2>
|
||||
<p class="text-sm text-neutral-400">
|
||||
Update your account's profile information and email address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={saveProfile} class="p-6 space-y-6">
|
||||
{#if profileError}
|
||||
<div
|
||||
class="rounded-md bg-red-900/50 border border-red-700 p-3 text-sm text-red-300"
|
||||
>
|
||||
{profileError}
|
||||
</div>
|
||||
{/if}
|
||||
{#if profileSuccess}
|
||||
<div
|
||||
class="rounded-md bg-green-900/50 border border-green-700 p-3 text-sm text-green-300"
|
||||
>
|
||||
{profileSuccess}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center space-x-6">
|
||||
<div class="shrink-0">
|
||||
{#if authStore.user?.avatar_url}
|
||||
<img
|
||||
src={authStore.user.avatar_url}
|
||||
alt="Avatar"
|
||||
class="h-16 w-16 rounded-full object-cover shadow-inner"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="h-16 w-16 rounded-full bg-blue-600 flex items-center justify-center text-xl font-medium text-white shadow-inner"
|
||||
>
|
||||
{userInitial}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#if avatarError}<p class="text-sm text-red-400">
|
||||
{avatarError}
|
||||
</p>{/if}
|
||||
{#if avatarSuccess}<p class="text-sm text-green-400">
|
||||
{avatarSuccess}
|
||||
</p>{/if}
|
||||
<label
|
||||
class="cursor-pointer inline-flex items-center gap-2 bg-neutral-700 hover:bg-neutral-600 text-white text-sm font-medium py-1.5 px-4 rounded-md border border-neutral-600 transition-colors {avatarLoading
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: ''}"
|
||||
>
|
||||
{avatarLoading ? "Uploading..." : "Upload Avatar"}
|
||||
<input
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||||
class="hidden"
|
||||
onchange={uploadAvatar}
|
||||
disabled={avatarLoading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-neutral-300"
|
||||
>Full Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-neutral-300"
|
||||
>Email Address</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 border-t border-neutral-700 mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={profileLoading}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-md shadow-sm border border-transparent transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
{profileLoading ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Security Section -->
|
||||
<section
|
||||
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-6 border-b border-neutral-700">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Update Password</h2>
|
||||
<p class="text-sm text-neutral-400">
|
||||
Ensure your account is using a long, random password to stay secure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={savePassword} class="p-6 space-y-6">
|
||||
{#if passwordError}
|
||||
<div
|
||||
class="rounded-md bg-red-900/50 border border-red-700 p-3 text-sm text-red-300"
|
||||
>
|
||||
{passwordError}
|
||||
</div>
|
||||
{/if}
|
||||
{#if passwordSuccess}
|
||||
<div
|
||||
class="rounded-md bg-green-900/50 border border-green-700 p-3 text-sm text-green-300"
|
||||
>
|
||||
{passwordSuccess}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="max-w-md space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="current_password"
|
||||
class="block text-sm font-medium text-neutral-300"
|
||||
>Current Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="current_password"
|
||||
bind:value={currentPassword}
|
||||
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="new_password"
|
||||
class="block text-sm font-medium text-neutral-300"
|
||||
>New Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="new_password"
|
||||
bind:value={newPassword}
|
||||
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="confirm_password"
|
||||
class="block text-sm font-medium text-neutral-300"
|
||||
>Confirm Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm_password"
|
||||
bind:value={confirmPassword}
|
||||
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 border-t border-neutral-700 mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={passwordLoading}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-md shadow-sm border border-transparent transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
{passwordLoading ? "Updating..." : "Update Password"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- API Keys Section -->
|
||||
<section
|
||||
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="p-6 border-b border-neutral-700 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">API Keys</h2>
|
||||
<p class="text-sm text-neutral-400">
|
||||
Generate personal API keys with granular scopes for programmatic
|
||||
access.
|
||||
</p>
|
||||
</div>
|
||||
{#if !showCreateForm}
|
||||
<button
|
||||
onclick={openCreateForm}
|
||||
class="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md text-sm transition-colors border border-transparent shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/></svg
|
||||
>
|
||||
New API Key
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Newly-created key banner (shown once) -->
|
||||
{#if createdKey}
|
||||
<div
|
||||
class="mx-6 mt-6 rounded-lg border border-green-600/40 bg-green-900/20 p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
class="w-5 h-5 text-green-400 mt-0.5 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-green-300 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 font-mono bg-neutral-900 border border-neutral-600 rounded px-3 py-2 text-green-300 truncate select-all"
|
||||
>{createdKey.key}</code
|
||||
>
|
||||
<button
|
||||
onclick={copyKey}
|
||||
class="shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-2 rounded-md border transition-colors {copiedKey
|
||||
? 'bg-green-700 border-green-600 text-white'
|
||||
: 'bg-neutral-700 border-neutral-600 text-neutral-200 hover:bg-neutral-600'}"
|
||||
>
|
||||
{#if copiedKey}
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/></svg
|
||||
>
|
||||
Copied!
|
||||
{:else}
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/></svg
|
||||
>
|
||||
Copy
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (createdKey = null)}
|
||||
class="text-neutral-500 hover:text-white transition-colors p-0.5 rounded"
|
||||
aria-label="Dismiss banner"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Create form -->
|
||||
{#if showCreateForm}
|
||||
<form
|
||||
onsubmit={createKey}
|
||||
class="p-6 space-y-6 border-b border-neutral-700"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="api-key-name"
|
||||
class="block text-sm font-medium text-neutral-300 mb-1.5"
|
||||
>Key Name</label
|
||||
>
|
||||
<input
|
||||
id="api-key-name"
|
||||
type="text"
|
||||
bind:value={newKeyName}
|
||||
placeholder="e.g. CI / CD Pipeline"
|
||||
class="w-full max-w-sm px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-300 mb-3">
|
||||
Scopes <span class="text-neutral-500 font-normal"
|
||||
>({selectedScopeCount} selected)</span
|
||||
>
|
||||
</p>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-5"
|
||||
>
|
||||
{#each Object.entries(scopeGroups) as [group, scopes]}
|
||||
<div>
|
||||
<p
|
||||
class="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-2"
|
||||
>
|
||||
{group}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{#each scopes as scope}
|
||||
<label
|
||||
class="flex items-center gap-2.5 cursor-pointer group"
|
||||
for="scope-{scope.id}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="scope-{scope.id}"
|
||||
bind:checked={newKeyScopes[scope.id]}
|
||||
class="w-4 h-4 rounded border-neutral-600 bg-neutral-700 text-blue-500 focus:ring-blue-500 focus:ring-offset-neutral-800 cursor-pointer"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-neutral-300 group-hover:text-white transition-colors"
|
||||
>{scope.label}</span
|
||||
>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if newKeyError}
|
||||
<p class="text-sm text-red-400">{newKeyError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creatingKey}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-5 rounded-md text-sm transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{#if creatingKey}
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v8z"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
Generate Key
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeCreateForm}
|
||||
class="text-sm font-medium text-neutral-400 hover:text-white transition-colors px-3 py-2 rounded hover:bg-neutral-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Key list -->
|
||||
<div class="divide-y divide-neutral-700">
|
||||
{#if apiKeysLoading}
|
||||
<div class="p-8 text-center text-neutral-500 text-sm">
|
||||
Loading keys...
|
||||
</div>
|
||||
{:else if apiKeyList.length === 0 && !createdKey}
|
||||
<div
|
||||
class="p-10 flex flex-col items-center justify-center text-neutral-500"
|
||||
>
|
||||
<svg
|
||||
class="w-10 h-10 mb-3 opacity-40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">No API keys yet. Create one above.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each apiKeyList as key (key.id)}
|
||||
<div
|
||||
class="px-6 py-4 flex items-start justify-between gap-4 group hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1.5">
|
||||
<span class="text-sm font-semibold text-white">{key.name}</span>
|
||||
<code
|
||||
class="text-xs font-mono bg-neutral-900 border border-neutral-700 text-neutral-400 px-2 py-0.5 rounded"
|
||||
>{key.prefix}…</code
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 mb-2">
|
||||
{#each key.scopes as scope}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-900/40 text-blue-300 border border-blue-700/40"
|
||||
>{scope}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500">
|
||||
Created {formatDate(key.created_at)}{#if key.last_used}
|
||||
· Last used {formatDate(key.last_used)}{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => revokeKey(key.id)}
|
||||
class="shrink-0 flex items-center gap-1.5 text-xs font-medium text-neutral-500 hover:text-red-400 transition-colors px-2 py-1.5 rounded hover:bg-red-900/20 opacity-0 group-hover:opacity-100"
|
||||
title="Revoke this key"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
Reference in New Issue
Block a user