ALL 0.1.0 Code

This commit is contained in:
2026-02-28 04:21:27 +00:00
commit 7958510989
76 changed files with 17135 additions and 0 deletions

View 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>