Files
shd.build/src/routes/account/+page.svelte
T
2026-07-25 22:28:40 -04:00

227 lines
23 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
let { data, form } = $props();
const user = $derived(data.user!);
let discordLinkPending = $state(false);
let discordUnlinkConfirm = $state(false);
type AccountPanel = 'identity' | 'access' | 'account';
type AccessPanel = 'passkeys' | 'email' | 'local' | 'connections';
type AvatarPanel = 'presets' | 'upload';
function initialAccountPanel(): AccountPanel {
if (form?.discordUnlinkError || data.oauthError || data.discordLinked || form?.discordUnlinked || form?.passwordError || form?.loginUsernameError || form?.localCredentialError || form?.emailError || form?.passkeyError || data.emailVerified) return 'access';
if (form?.deleteError) return 'account';
return 'identity';
}
function initialAccessPanel(): AccessPanel {
if (form?.discordUnlinkError || data.oauthError || data.discordLinked || form?.discordUnlinked) return 'connections';
if (form?.emailError || form?.emailVerificationSent || form?.emailRemoved || data.emailVerified) return 'email';
if (form?.passwordError || form?.loginUsernameError || form?.localCredentialError || form?.localCredentialsAdded || form?.localCredentialsRemoved) return 'local';
return 'passkeys';
}
function initialAvatarPanel(): AvatarPanel {
return form?.avatarError || form?.avatarReviewQueued ? 'upload' : 'presets';
}
const notificationIsIsac = (notification: any) => notification.category === 'isac' || String(notification.source).startsWith('ISAC');
const notificationTone = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(approved|accepted|deployed|restored|successful)/.test(text)) return 'positive';
if (/(removed|rejected|not approved|failed|could not|needs revision|violation)/.test(text)) return 'negative';
return 'neutral';
};
const notificationSeverity = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(error|failed|could not)/.test(text)) return 'ERROR';
if (/(warning|violation|needs revision)/.test(text)) return 'WARNING';
return null;
};
let accountPanel = $state<AccountPanel>(initialAccountPanel());
let accessPanel = $state<AccessPanel>(initialAccessPanel());
let avatarPanel = $state<AvatarPanel>(initialAvatarPanel());
let passkeySupported = $state(true);
let passkeyPending = $state(false);
let passkeyMessage = $state('');
let passkeyClientError = $state('');
let passkeyName = $state('My passkey');
onMount(() => passkeySupported = browserSupportsWebAuthn());
async function registerPasskey() {
if (passkeyPending) return;
passkeyPending = true;
passkeyMessage = '';
passkeyClientError = '';
try {
const optionsRequest = await fetch('/auth/passkey/register/options', { method: 'POST' });
if (!optionsRequest.ok) throw new Error('Could not start passkey registration.');
const { options, challengeId } = await optionsRequest.json();
const response = await startRegistration({ optionsJSON: options });
const verification = await fetch('/auth/passkey/register/verify', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ challengeId, response, name: passkeyName })
});
const result = await verification.json();
if (!verification.ok || !result.verified) throw new Error(result.message || 'Passkey registration failed.');
passkeyMessage = 'Passkey registered. Reloading account methods…';
location.reload();
} catch (error) {
passkeyClientError = error instanceof Error ? error.message : 'Passkey registration was cancelled.';
passkeyPending = false;
}
}
function beginDiscordLink(event: MouseEvent) {
if (discordLinkPending) { event.preventDefault(); return; }
discordLinkPending = true;
}
</script>
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
<main class="page profile-page">
<section class="profile-command">
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div>
</section>
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}
{#if form?.discordUnlinked}<p class="success">Discord account unlinked. Your other configured sign-in methods remain active.</p>{/if}
{#if data.oauthError}<div class="account-alert error-alert" role="alert"><b>{data.oauthError.title}</b><span>{data.oauthError.message}</span></div>{/if}
<div class="profile-dashboard">
<section class="profile-feed">
<div class="profile-module profile-notifications">
<div class="module-head"><div><p class="eyebrow">SHD COMMUNICATION NETWORK</p><h2>NOTIFICATIONS</h2></div><a class="secondary" href="/account/notifications">OPEN INBOX{data.account.notificationUnread ? ` · ${data.account.notificationUnread} UNREAD` : ''} →</a></div>
<div class="profile-notification-list">
{#each data.account.notifications as notification}
<a class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={notificationIsIsac(notification)} class:positive={notificationTone(notification) === 'positive'} class:negative={notificationTone(notification) === 'negative'} href="/account/notifications"><div><img src={notificationIsIsac(notification) ? '/favicon.svg' : '/avatars/default/comms-specialist.webp'} alt=""/><span><b>{notification.source}</b><small>{String(notification.category).toUpperCase()} · {new Date(String(notification.created_at)).toLocaleDateString()}</small></span></div><span><strong>{notification.title}</strong><small>{notification.body}</small></span><em>{notificationSeverity(notification) ?? 'OPEN'} ›</em></a>
{:else}<div class="profile-empty compact"><span>◇</span><b>NO NOTIFICATIONS</b><p>ISAC and community messages will appear here.</p></div>{/each}
</div>
</div>
<div class="profile-module"><div class="module-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>MY BUILDS</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>
{#if data.account.builds.length}<div class="profile-table"><div class="profile-row heading"><span>LOADOUT</span><span>VISIBILITY</span><span>UPDATED</span><span></span></div>{#each data.account.builds as build}<div class="profile-row"><div><strong>{build.title}</strong><small>/{build.slug}</small></div><span>{build.visibility}</span><span>{new Date(String(build.updated_at)).toLocaleDateString()}</span><a href={`/builder?build=${build.slug}`}>EDIT →</a></div>{/each}</div>{:else}<div class="profile-empty"><span>◇</span><b>NO BUILDS RECORDED</b><p>Your saved and published loadouts will appear here.</p><a href="/builder">CREATE YOUR FIRST BUILD →</a></div>{/if}
</div>
<div class="profile-columns"><div class="profile-module"><p class="eyebrow">SAVED INTELLIGENCE</p><h2>FAVORITES</h2><strong class="large-count">{data.account.favorites.length}</strong><span class="count-label">SAVED LOADOUTS</span></div><div class="profile-module"><p class="eyebrow">FIELD FEEDBACK</p><h2>RATINGS</h2><strong class="large-count">{data.account.ratings.length}</strong><span class="count-label">RATED LOADOUTS</span></div></div>
</section>
<aside class="account-console">
<div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div>
<div class="account-tabs" aria-label="Account controls" role="tablist">
<button id="identity-tab" class:active={accountPanel === 'identity'} role="tab" aria-selected={accountPanel === 'identity'} aria-controls="identity-panel" onclick={() => accountPanel = 'identity'}>PROFILE</button>
<button id="access-tab" class:active={accountPanel === 'access'} role="tab" aria-selected={accountPanel === 'access'} aria-controls="access-panel" onclick={() => accountPanel = 'access'}>ACCESS</button>
<button id="account-tab" class:active={accountPanel === 'account'} role="tab" aria-selected={accountPanel === 'account'} aria-controls="account-panel" onclick={() => accountPanel = 'account'}>ACCOUNT</button>
</div>
{#if accountPanel === 'identity'}
<div class="account-panel" id="identity-panel" role="tabpanel" aria-labelledby="identity-tab">
<div id="avatar" class="profile-anchor" aria-hidden="true"></div><p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
<div class="account-subtabs" role="tablist" aria-label="Avatar source">
<button class:active={avatarPanel === 'presets'} role="tab" aria-selected={avatarPanel === 'presets'} onclick={() => avatarPanel = 'presets'}>SHD PRESETS</button>
<button class:active={avatarPanel === 'upload'} role="tab" aria-selected={avatarPanel === 'upload'} onclick={() => avatarPanel = 'upload'}>CUSTOM UPLOAD</button>
</div>
{#if avatarPanel === 'presets'}
<div class="avatar-picker" aria-label="SHD avatar presets">
{#each data.avatarPresets as preset}
<PendingForm action="?/selectAvatar" class="avatar-preset-form">
<button class="avatar-preset" class:selected={user.avatarUrl === preset.url} name="preset" value={preset.id} aria-label={`Use ${preset.label} avatar`} aria-pressed={user.avatarUrl === preset.url}>
<img src={preset.url} alt="" /><span>{preset.label}</span>
</button>
</PendingForm>
{/each}
</div>
{#if form?.avatarChanged}<p class="success">Avatar updated.</p>{/if}
{:else}
<PendingForm action="?/uploadAvatar" enctype="multipart/form-data" class="avatar-upload">
<p class="eyebrow">CUSTOM AVATAR</p>
<ImageUploadInput name="avatar" accept="image/jpeg,image/png,image/webp,image/avif" required emptyLabel="NO AVATAR SELECTED" browseLabel="CHOOSE AVATAR"/>
<small class="identity-note">JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.</small>
{#if form?.avatarError}<p class="error">{form.avatarError}</p>{/if}
{#if form?.avatarChanged}<p class="success">{form?.avatarReviewQueued ? 'Avatar active now · queued for Oversight review.' : 'Avatar updated.'}</p>{/if}
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
</PendingForm>
{/if}
<hr/>
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
</div>
{:else if accountPanel === 'access'}
<div class="account-panel" id="access-panel" role="tabpanel" aria-labelledby="access-tab">
<div class="access-subtabs" role="tablist" aria-label="Sign-in method controls">
<button class:active={accessPanel === 'passkeys'} role="tab" aria-selected={accessPanel === 'passkeys'} onclick={() => accessPanel = 'passkeys'}><span>PASSKEYS</span><b>{data.passkeys.length}</b></button>
<button class:active={accessPanel === 'email'} role="tab" aria-selected={accessPanel === 'email'} onclick={() => accessPanel = 'email'}><span>EMAIL</span><b>{data.emailIdentity ? 1 : 0}</b></button>
<button class:active={accessPanel === 'local'} role="tab" aria-selected={accessPanel === 'local'} onclick={() => accessPanel = 'local'}><span>PASSWORD</span><b>{data.hasLocalCredentials ? 1 : 0}</b></button>
<button class:active={accessPanel === 'connections'} role="tab" aria-selected={accessPanel === 'connections'} onclick={() => accessPanel = 'connections'}><span>LINKS</span><b>{data.discordConnection ? 1 : 0}</b></button>
</div>
{#if accessPanel === 'passkeys'}
<p class="eyebrow">PREFERRED ACCESS</p><h2>PASSKEYS</h2>
<p class="identity-note passkey-intro">Use Face ID, Touch ID, Windows Hello, a security key, or your password manager without entering a SHD.build password.</p>
{#if data.passkeys.length}
<div class="passkey-list">
{#each data.passkeys as passkey}
<div class="passkey-card"><div><b>{passkey.name}</b><small>{passkey.backedUp ? 'SYNCED PASSKEY' : 'DEVICE PASSKEY'} · ADDED {new Date(passkey.createdAt).toLocaleDateString()}</small></div><PendingForm action="?/removePasskey"><input type="hidden" name="passkeyId" value={passkey.id}/><button class="passkey-remove" aria-label={`Remove ${passkey.name}`} disabled={data.authenticationMethods.total <= 1}>REMOVE</button></PendingForm></div>
{/each}
</div>
{:else}<p class="account-method-empty">NO PASSKEYS REGISTERED</p>{/if}
{#if form?.passkeyError}<p class="error">{form.passkeyError}</p>{/if}
{#if form?.passkeyRemoved}<p class="success">Passkey removed.</p>{/if}
{#if passkeyClientError}<p class="error">{passkeyClientError}</p>{/if}
{#if passkeyMessage}<p class="success">{passkeyMessage}</p>{/if}
<label class="field">PASSKEY NAME<input bind:value={passkeyName} maxlength="60" placeholder="e.g. Windows Hello" /></label>
<button class="primary console-action" disabled={!passkeySupported || passkeyPending || !passkeyName.trim()} aria-busy={passkeyPending} onclick={registerPasskey}>{passkeyPending ? 'REGISTERING…' : 'ADD A PASSKEY →'}</button>
{#if !passkeySupported}<p class="connection-warning">This browser does not support passkeys.</p>{/if}
{:else if accessPanel === 'email'}
<p class="eyebrow">PASSWORDLESS RECOVERY</p><h2>MAGIC LINK EMAIL</h2>
{#if data.emailIdentity}<div class="verified-email"><div><b>{data.emailIdentity.email}</b><small>VERIFIED EMAIL IDENTITY</small></div><span>VERIFIED</span></div><PendingForm action="?/removeEmailIdentity"><button class="secondary console-action" disabled={data.authenticationMethods.total <= 1}>REMOVE MAGIC-LINK EMAIL</button></PendingForm>{/if}
<PendingForm action="?/requestEmailVerification"><label class="field">{data.emailIdentity ? 'CHANGE VERIFIED EMAIL' : 'EMAIL ADDRESS'}<input name="email" type="email" value={data.emailIdentity?.email ?? ''} autocomplete="email" required /></label><small class="identity-note">We will email a verification link before this address can be used to sign in.</small>{#if form?.emailError}<p class="error">{form.emailError}</p>{/if}{#if form?.emailVerificationSent}<p class="success">Verification link sent. It expires in 15 minutes.</p>{/if}{#if data.emailVerified}<p class="success">Email verified. Magic-link sign-in is now active.</p>{/if}<button class="secondary console-action" disabled={!data.emailDeliveryConfigured}>SEND VERIFICATION LINK →</button></PendingForm>
{:else if accessPanel === 'local'}
<p class="eyebrow">OPTIONAL FALLBACK</p><h2>USERNAME &amp; PASSWORD</h2>
{#if data.hasLocalCredentials}
<PendingForm action="?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={user.loginUsername ?? ''} autocomplete="username" required /></label><small class="identity-note">Used only to sign in and visible to you and authorized ISAC operators.</small>{#if form?.loginUsernameError}<p class="error">{form.loginUsernameError}</p>{/if}<button class="secondary console-action">UPDATE LOGIN USERNAME →</button></PendingForm>
<hr/><PendingForm action="?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" autocomplete="current-password" required /></label><label class="field">NEW PASSWORD<input name="newPassword" type="password" autocomplete="new-password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">UPDATE PASSWORD →</button></PendingForm>
<details class="method-removal"><summary>REMOVE USERNAME &amp; PASSWORD</summary><p>This removes both local credentials. Your public display name is unaffected.</p><PendingForm action="?/removeLocalCredentials"><label class="field">CURRENT PASSWORD<input name="password" type="password" autocomplete="current-password" required /></label>{#if form?.localCredentialError}<p class="error">{form.localCredentialError}</p>{/if}<button class="danger console-action" disabled={data.authenticationMethods.total <= 1}>REMOVE PASSWORD SIGN-IN</button></PendingForm></details>
{:else}
<p class="account-method-empty">USERNAME AND PASSWORD SIGN-IN IS NOT CONFIGURED</p>
<PendingForm action="?/addLocalCredentials"><label class="field">PRIVATE LOGIN USERNAME<input name="username" autocomplete="username" required /></label><label class="field">PASSWORD<input name="newPassword" type="password" autocomplete="new-password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" minlength="12" required /></label>{#if form?.localCredentialError}<p class="error">{form.localCredentialError}</p>{/if}{#if form?.localCredentialsAdded}<p class="success">Username and password sign-in added.</p>{/if}<button class="secondary console-action">ADD PASSWORD FALLBACK →</button></PendingForm>
{/if}
{:else}
<p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN PROVIDERS</h2>
<div class="provider-stack">
{#if data.discordConnection}
<div class="connection-card discord-connection linked-connection provider-connection-card" class:confirming={discordUnlinkConfirm}>
<div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>{data.discordConnection.displayName ?? data.discordConnection.username ?? 'DISCORD USER'}</b><small>{data.discordConnection.username ? `@${data.discordConnection.username} · ` : ''}ID {data.discordConnection.id}</small></div></div>
<button class="connection-state provider-unlink-trigger" disabled={!data.discordConnection.canUnlink} aria-label="Unlink Discord" onclick={() => discordUnlinkConfirm = true}><span>LINKED</span><span>UNLINK</span></button>
{#if discordUnlinkConfirm}
<div class="provider-unlink-confirm"><div><b>UNLINK DISCORD?</b><p>Disconnect this identity from your SHD.build account?</p></div><div><button class="secondary" onclick={() => discordUnlinkConfirm = false}>CANCEL</button><PendingForm action="?/unlinkDiscord"><button class="primary">CONFIRM UNLINK</button></PendingForm></div></div>
{/if}
{#if !data.discordConnection.canUnlink}<p class="connection-warning provider-card-message">Discord is your only sign-in method. Add a passkey, verified email, or password before unlinking.</p>{/if}
{#if form?.discordUnlinkError}<p class="error provider-card-message">{form.discordUnlinkError}</p>{/if}
</div>
{:else}
<div class="connection-card discord-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>DISCORD</b><small>OAuth identity provider</small></div></div><a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={beginDiscordLink}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Link Discord</span></a></div>
{/if}
<div class="provider-placeholder"><span class="provider-monogram google-provider">G</span><div><b>GOOGLE</b><small>ACCOUNT LINKING PLACEHOLDER</small></div><em>PLANNED</em></div>
<div class="provider-placeholder"><span class="provider-monogram apple-provider">A</span><div><b>APPLE</b><small>ACCOUNT LINKING PLACEHOLDER</small></div><em>PLANNED</em></div>
<div class="provider-placeholder"><span class="provider-monogram steam-provider">S</span><div><b>STEAM</b><small>ACCOUNT LINKING PLACEHOLDER</small></div><em>PLANNED</em></div>
</div>
{/if}
</div>
{:else}
<div class="account-panel" id="account-panel" role="tabpanel" aria-labelledby="account-tab">
<p class="eyebrow danger-eyebrow">ACCOUNT LIFECYCLE</p><h2>DELETE ACCOUNT</h2>
<p class="account-retention">Your builds, guides, and comments remain attributed to a deleted agent. Your login username, display name, and Discord identity become available again.</p>
<details class="danger-zone" open={Boolean(form?.deleteError)}><summary>OPEN DELETION CONTROLS</summary><PendingForm action="?/deleteAccount"><label class="field">TYPE DELETE TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label><label class="deletion-purge-option"><input name="purgeSecurityData" type="checkbox"/><span><b>PURGE SECURITY HISTORY NOW</b><small>Immediately delete retained login events and their IP, device, session, and failure metadata instead of waiting for normal retention cleanup.</small></span></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
</div>
{/if}
</aside>
</div>
</main>
<style>
.profile-notification-list a > div { grid-template-columns: 30px minmax(0, 1fr); align-items: center; }
.profile-notification-list a > div > img { width: 30px; height: 30px; object-fit: cover; border: 1px solid #3b4446; border-radius: 50%; background: #111; }
.profile-notification-list a > div > span { min-width: 0; display: grid; gap: 3px; }
.profile-notification-list a.isac-message b { color: #8ecce5; }
.profile-notification-list a.positive { border-left: 2px solid var(--shd-success); background: #101713; }
.profile-notification-list a.negative { border-left: 2px solid var(--shd-danger); background: #191111; }
.profile-notification-list a.isac-message:not(.positive):not(.negative) { border-left: 2px solid #68a9c4; background: #10171a; }
.profile-notification-list a.positive em { color: var(--shd-success); }
.profile-notification-list a.negative em { color: var(--shd-danger); }
</style>