Files
shd.build/src/lib/components/AccountControl.svelte
T
JB a434cbe60c
Validate and deploy production / validate (push) Successful in 26s
Validate and deploy staging / validate (push) Successful in 25s
Validate and deploy production / deploy (push) Failing after 1m10s
Validate and deploy staging / deploy (push) Failing after 8s
feat: expand profiles and weapon configuration
2026-07-28 00:11:40 -04:00

189 lines
15 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 ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import InfoPopover from '$lib/components/InfoPopover.svelte';
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import { toast } from '$lib/toast';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
let {
account,
displayName,
avatarUrl
}: {
account: any;
displayName: string;
avatarUrl: string;
} = $props();
type AccountPanel = 'identity' | 'access' | 'account';
type AccessPanel = 'passkeys' | 'email' | 'local' | 'connections';
type AvatarPanel = 'presets' | 'upload';
function initialPanel(): AccountPanel {
return (
['identity', 'access', 'account'].includes(account.initialPanel)
? account.initialPanel
: 'identity'
) as AccountPanel;
}
let accountPanel = $state<AccountPanel>(initialPanel());
let accessPanel = $state<AccessPanel>('passkeys');
let avatarPanel = $state<AvatarPanel>('presets');
let avatarEditorOpen = $state(false);
let passkeySupported = $state(true);
let passkeyPending = $state(false);
let passkeyName = $state('My passkey');
let discordLinkPending = $state(false);
let discordUnlinkConfirm = $state(false);
onMount(() => passkeySupported = browserSupportsWebAuthn());
async function registerPasskey() {
if (passkeyPending) return;
passkeyPending = true;
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.');
}
toast.success('Passkey registered', 'Your new sign-in method is ready.');
location.reload();
} catch (error) {
toast.error(
'Passkey not registered',
error instanceof Error ? error.message : 'Passkey registration was cancelled.'
);
passkeyPending = false;
}
}
</script>
<section class="account-console owner-account-control">
<div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div>
<div class="account-tabs" aria-label="Account controls" role="tablist">
<button class:active={accountPanel === 'identity'} role="tab" aria-selected={accountPanel === 'identity'} onclick={() => accountPanel = 'identity'}>PROFILE</button>
<button class:active={accountPanel === 'access'} role="tab" aria-selected={accountPanel === 'access'} onclick={() => accountPanel = 'access'}>ACCESS</button>
<button class:active={accountPanel === 'account'} role="tab" aria-selected={accountPanel === 'account'} onclick={() => accountPanel = 'account'}>ACCOUNT</button>
</div>
{#if accountPanel === 'identity'}
<div class="account-panel" role="tabpanel">
<p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
<div class="current-avatar">
<img src={avatarUrl} alt={`${displayName}'s current avatar`}/>
<div><small>CURRENT AVATAR</small><strong>{displayName}</strong></div>
<button type="button" class="secondary avatar-edit-trigger" aria-expanded={avatarEditorOpen} aria-controls="avatar-editor" onclick={() => avatarEditorOpen = !avatarEditorOpen}>{avatarEditorOpen ? 'CLOSE' : 'EDIT AVATAR'}</button>
</div>
{#if avatarEditorOpen}
<div id="avatar-editor" class="avatar-editor">
<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 account.avatarPresets as preset}
<PendingForm action="/account?/selectAvatar" class="avatar-preset-form">
<button class="avatar-preset" class:selected={avatarUrl === preset.url} name="preset" value={preset.id} aria-label={`Use ${preset.label} avatar`} aria-pressed={avatarUrl === preset.url}>
<img src={preset.url} alt=""/><span>{preset.label}</span>
</button>
</PendingForm>
{/each}
</div>
{:else}
<PendingForm action="/account?/uploadAvatar" enctype="multipart/form-data" class="avatar-upload">
<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>
<ModerationDisclosure compact/>
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
</PendingForm>
{/if}
</div>
{/if}
<hr/>
<div class="account-section-heading">
<div><p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2></div>
<InfoPopover label="Display-name requirements" title="DISPLAY-NAME REQUIREMENTS" text="Use a unique name with 2–40 visible characters on one line. Unicode characters and spaces are supported."/>
</div>
<PendingForm action="/account?/changeDisplayName">
<label class="field"><span class="sr-only">PUBLIC DISPLAY NAME</span><input name="displayName" value={displayName} maxlength="40" required/></label>
<button class="primary console-action">UPDATE DISPLAY NAME →</button>
</PendingForm>
</div>
{:else if accountPanel === 'access'}
<div class="account-panel" role="tabpanel">
<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>{account.passkeys.length}</b></button>
<button class:active={accessPanel === 'email'} role="tab" aria-selected={accessPanel === 'email'} onclick={() => accessPanel = 'email'}><span>EMAIL</span><b>{account.emailIdentity ? 1 : 0}</b></button>
<button class:active={accessPanel === 'local'} role="tab" aria-selected={accessPanel === 'local'} onclick={() => accessPanel = 'local'}><span>PASSWORD</span><b>{account.hasLocalCredentials ? 1 : 0}</b></button>
<button class:active={accessPanel === 'connections'} role="tab" aria-selected={accessPanel === 'connections'} onclick={() => accessPanel = 'connections'}><span>LINKS</span><b>{account.discordConnection ? 1 : 0}</b></button>
</div>
{#if accessPanel === 'passkeys'}
<p class="eyebrow">PREFERRED ACCESS</p><h2>PASSKEYS</h2>
{#if account.passkeys.length}
<div class="passkey-list">
{#each account.passkeys as passkey}
<div class="passkey-card"><div><b>{passkey.name}</b><small>{passkey.backedUp ? 'SYNCED PASSKEY' : 'DEVICE PASSKEY'}</small></div><PendingForm action="/account?/removePasskey"><input type="hidden" name="passkeyId" value={passkey.id}/><button class="passkey-remove" aria-label={`Remove ${passkey.name}`} disabled={account.authenticationMethods.total <= 1}>REMOVE</button></PendingForm></div>
{/each}
</div>
{:else}<p class="account-method-empty">NO PASSKEYS REGISTERED</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>
{:else if accessPanel === 'email'}
<p class="eyebrow">PASSWORDLESS RECOVERY</p><h2>MAGIC LINK EMAIL</h2>
{#if account.emailIdentity}
<div class="verified-email"><div><b>{account.emailIdentity.email}</b><small>VERIFIED EMAIL IDENTITY</small></div><span>VERIFIED</span></div>
<PendingForm action="/account?/removeEmailIdentity"><button class="secondary console-action" disabled={account.authenticationMethods.total <= 1}>REMOVE MAGIC-LINK EMAIL</button></PendingForm>
{/if}
<PendingForm action="/account?/requestEmailVerification">
<label class="field">{account.emailIdentity ? 'CHANGE VERIFIED EMAIL' : 'EMAIL ADDRESS'}<input name="email" type="email" value={account.emailIdentity?.email ?? ''} autocomplete="email" required/></label>
<button class="secondary console-action" disabled={!account.emailDeliveryConfigured}>SEND VERIFICATION LINK →</button>
</PendingForm>
{:else if accessPanel === 'local'}
<p class="eyebrow">OPTIONAL FALLBACK</p><h2>USERNAME &amp; PASSWORD</h2>
{#if account.hasLocalCredentials}
<PendingForm action="/account?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={account.loginUsername ?? ''} autocomplete="username" required/></label><button class="secondary console-action">UPDATE LOGIN USERNAME →</button></PendingForm>
<hr/>
<PendingForm action="/account?/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><button class="primary console-action">UPDATE PASSWORD →</button></PendingForm>
<details class="method-removal"><summary>REMOVE USERNAME &amp; PASSWORD</summary><PendingForm action="/account?/removeLocalCredentials"><label class="field">CURRENT PASSWORD<input name="password" type="password" autocomplete="current-password" required/></label><button class="danger console-action" disabled={account.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="/account?/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><button class="secondary console-action">ADD PASSWORD FALLBACK →</button></PendingForm>
{/if}
{:else}
<p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN PROVIDERS</h2>
{#if account.discordConnection}
<div class="connection-card discord-connection linked-connection provider-connection-card">
<div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><div><b>{account.discordConnection.displayName ?? account.discordConnection.username ?? 'DISCORD USER'}</b><small>{account.discordConnection.username ? `@${account.discordConnection.username}` : `ID ${account.discordConnection.id}`}</small></div></div>
<button class="connection-state provider-unlink-trigger" disabled={!account.discordConnection.canUnlink} onclick={() => discordUnlinkConfirm = true}>UNLINK</button>
{#if discordUnlinkConfirm}<div class="provider-unlink-confirm"><b>UNLINK DISCORD?</b><div><button class="secondary" onclick={() => discordUnlinkConfirm = false}>CANCEL</button><PendingForm action="/account?/unlinkDiscord"><button class="primary">CONFIRM</button></PendingForm></div></div>{/if}
</div>
{:else}
<a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={() => discordLinkPending = true}><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><span>Link Discord</span></a>
{/if}
{/if}
</div>
{:else}
<div class="account-panel" role="tabpanel">
<p class="eyebrow danger-eyebrow">ACCOUNT LIFECYCLE</p><h2>DELETE ACCOUNT</h2>
<p class="account-retention">Your public contributions remain attributed to a deleted Agent.</p>
<details class="danger-zone"><summary>OPEN DELETION CONTROLS</summary><PendingForm action="/account?/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>Delete retained login events and device metadata instead of waiting for normal retention cleanup.</small></span></label><button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
</div>
{/if}
</section>
<style>
.owner-account-control{width:100%;min-width:0;padding:12px}.current-avatar{display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.current-avatar>img{width:58px;height:58px;border:1px solid var(--shd-orange);border-radius:50%;object-fit:cover}.current-avatar>div{min-width:0;display:grid;gap:3px}.current-avatar small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.current-avatar strong{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.avatar-edit-trigger{min-height:40px;padding:0 10px}.avatar-editor{display:grid;gap:9px;margin-top:9px;padding-top:9px;border-top:1px solid var(--shd-border)}.account-section-heading{display:flex;align-items:start;justify-content:space-between;gap:8px}.account-section-heading h2{margin-bottom:0}.owner-account-control :global(.image-drop-input){grid-template-columns:58px minmax(0,1fr)}.owner-account-control :global(.image-upload-actions){grid-column:1/-1;grid-template-columns:1fr}.discord-link-button{width:100%;min-height:44px;display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid #6571dc;background:#171a32;color:#fff}.discord-link-button img{width:22px}.provider-unlink-confirm{grid-column:1/-1;display:grid;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--shd-border)}.provider-unlink-confirm>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.provider-unlink-confirm button{width:100%;min-height:40px}
@media(max-width:650px){.current-avatar{grid-template-columns:52px minmax(0,1fr)}.current-avatar>img{width:52px;height:52px}.avatar-edit-trigger{grid-column:1/-1;width:100%;min-height:44px}}
</style>