189 lines
15 KiB
Svelte
189 lines
15 KiB
Svelte
<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 & 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 & 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>
|