Add passkey and magic link authentication

This commit is contained in:
JB
2026-07-25 01:54:33 -04:00
parent 3c9f443499
commit c3244b0c79
31 changed files with 1017 additions and 58 deletions
+104 -13
View File
@@ -5,19 +5,25 @@ import { getDatabase } from '$lib/server/database';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { AVATAR_PRESETS, AvatarValidationError, processAvatarUpload, saveUploadedAvatar, selectPresetAvatar } from '$lib/server/avatars';
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const [builds, guides, favorites, ratings, connection] = await Promise.all([
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id),
(await database.prepare(`SELECT users.discord_id,users.password_hash,discord_profiles.username AS discord_username,
(await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,discord_profiles.username AS discord_username,
discord_profiles.display_name,discord_profiles.avatar_hash
FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id)
FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id),
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(user.id),
(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(user.id),
getAuthenticationMethods(database, user.id)
]);
const oauthErrorCode = url.searchParams.get('oauth_error');
const oauthErrors: Record<string, { title: string; message: string }> = {
@@ -47,17 +53,79 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
username: connection.discord_username ? String(connection.discord_username) : null,
displayName: connection.display_name ? String(connection.display_name) : null,
avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
canUnlink: typeof connection.password_hash === 'string'
canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
} : null,
hasPassword: typeof connection?.password_hash === 'string',
hasLocalCredentials: typeof connection?.password_hash === 'string' && typeof connection?.login_username === 'string',
emailIdentity: emailIdentity ? { email: String(emailIdentity.email), verifiedAt: String(emailIdentity.verified_at) } : null,
authenticationMethods,
emailDeliveryConfigured: emailDeliveryConfigured(),
emailVerified: url.searchParams.has('email_verified'),
passkeys: passkeys.map((passkey) => ({
id: String(passkey.id), name: String(passkey.name), deviceType: String(passkey.device_type),
backedUp: Boolean(passkey.backed_up), lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
createdAt: String(passkey.created_at)
})),
chooseDisplayName: url.searchParams.has('choose_display_name'),
discordLinked: url.searchParams.has('discord_linked'),
oauthError,
avatarPresets: AVATAR_PRESETS
avatarPresets: AVATAR_PRESETS
};
};
export const actions: Actions = {
requestEmailVerification: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const email = String((await request.formData()).get('email') ?? '').normalize('NFKC').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
return fail(400, { emailError: 'Enter a valid email address.' });
}
if (!emailDeliveryConfigured()) return fail(503, { emailError: 'Email delivery is not configured yet.' });
const database = await getDatabase();
const owner = await (await database.prepare('SELECT user_id FROM user_emails WHERE email=? COLLATE NOCASE AND user_id<>?')).get(email, user.id);
if (owner) return fail(409, { emailError: 'That email address is already linked to another agent.' });
const token = await createMagicLinkToken(database, user.id, email, 'verify_email');
await sendAuthenticationLink(email, token, 'verify_email');
return { emailVerificationSent: true };
},
removePasskey: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const id = String((await request.formData()).get('passkeyId') ?? '');
const database = await getDatabase();
const methods = await getAuthenticationMethods(database, user.id);
if (!canRemoveAuthenticationMethod(methods, 'passkey')) {
return fail(409, { passkeyError: 'Add another sign-in method before removing your only passkey.' });
}
await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(id, user.id);
return { passkeyRemoved: true };
},
removeEmailIdentity: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'email')) {
return fail(409, { emailError: 'Add another sign-in method before removing your verified email.' });
}
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(user.id);
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(user.id);
return { emailRemoved: true };
},
removeLocalCredentials: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const password = String((await request.formData()).get('password') ?? '');
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id);
if (typeof credential?.password_hash !== 'string') return fail(409, { localCredentialError: 'Password sign-in is not configured.' });
if (!(await verifyPassword(credential.password_hash, password))) return fail(400, { localCredentialError: 'Your password is incorrect.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'local')) {
return fail(409, { localCredentialError: 'Add another sign-in method before removing username and password sign-in.' });
}
await (await database.prepare('UPDATE users SET login_username=NULL,password_hash=NULL WHERE id=?')).run(user.id);
return { localCredentialsRemoved: true };
},
selectAvatar: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
@@ -88,13 +156,36 @@ export const actions: Actions = {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const username = String((await request.formData()).get('username') ?? '').normalize('NFKC').trim();
const database = await getDatabase();
const credential = await (await database.prepare('SELECT login_username,password_hash FROM users WHERE id=?')).get(user.id);
if (typeof credential?.login_username !== 'string' || typeof credential?.password_hash !== 'string') {
return fail(409, { loginUsernameError: 'Add username and password sign-in before changing its username.' });
}
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) return fail(400, { loginUsernameError: 'Use 3–32 ASCII letters, numbers, underscores, or hyphens.' });
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { loginUsernameError: 'That login username is reserved.' });
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { loginUsernameError: 'That login username is already in use.' });
await (await database.prepare('UPDATE users SET username = ? WHERE id = ?')).run(username, user.id);
if (await (await database.prepare('SELECT id FROM users WHERE login_username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { loginUsernameError: 'That login username is already in use.' });
await (await database.prepare('UPDATE users SET login_username = ? WHERE id = ?')).run(username, user.id);
return { loginUsernameChanged: true };
},
addLocalCredentials: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const username = String(form.get('username') ?? '').normalize('NFKC').trim();
const password = String(form.get('newPassword') ?? '');
const confirm = String(form.get('confirmPassword') ?? '');
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) return fail(400, { localCredentialError: 'Use a username with 3–32 ASCII letters, numbers, underscores, or hyphens.' });
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { localCredentialError: 'That login username is reserved.' });
if (password.length < 12 || password.length > 128) return fail(400, { localCredentialError: 'Use a password between 12 and 128 characters.' });
if (password !== confirm) return fail(400, { localCredentialError: 'The new passwords do not match.' });
const database = await getDatabase();
const current = await (await database.prepare('SELECT login_username,password_hash FROM users WHERE id=?')).get(user.id);
if (current?.login_username || current?.password_hash) return fail(409, { localCredentialError: 'Username and password sign-in is already configured.' });
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) return fail(409, { localCredentialError: 'That login username is already in use.' });
await (await database.prepare('UPDATE users SET login_username=?,password_hash=? WHERE id=?'))
.run(username, await hashPassword(password), user.id);
return { localCredentialsAdded: true };
},
changeDisplayName: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
@@ -118,8 +209,8 @@ export const actions: Actions = {
if (next !== confirm) return fail(400, { passwordError: 'The new passwords do not match.' });
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id = ?')).get(user.id);
if (typeof credential?.password_hash !== 'string') return fail(409, { passwordError: 'Add username and password sign-in first.' });
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, current))) return fail(400, { passwordError: 'Your current password is incorrect.' });
if (typeof credential?.password_hash !== 'string' && !user.discordLinked) return fail(400, { passwordError: 'A verified sign-in method is required before setting a password.' });
await (await database.prepare('UPDATE users SET password_hash = ? WHERE id = ?')).run(await hashPassword(next), user.id);
await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id);
await createSession(cookies, user.id);
@@ -131,7 +222,7 @@ export const actions: Actions = {
const form = await request.formData();
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
const password = String(form.get('password') ?? '');
if (confirmation !== user.username) return fail(400, { deleteError: `Enter ${user.username} exactly to confirm deletion.` });
if (confirmation !== 'DELETE') return fail(400, { deleteError: 'Enter DELETE exactly to confirm deletion.' });
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id);
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, password))) {
@@ -150,10 +241,10 @@ export const actions: Actions = {
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash,discord_id FROM users WHERE id=?')).get(user.id);
if (!credential?.discord_id) return fail(409, { discordUnlinkError: 'No Discord account is linked.' });
if (typeof credential.password_hash !== 'string') {
return fail(400, { discordUnlinkError: 'Add a password sign-in method before unlinking Discord so you do not lose access.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'discord')) {
return fail(400, { discordUnlinkError: 'Add another sign-in method before unlinking Discord so you do not lose access.' });
}
if (!(await verifyPassword(credential.password_hash, password))) {
if (typeof credential.password_hash === 'string' && !(await verifyPassword(credential.password_hash, password))) {
return fail(400, { discordUnlinkError: 'Your password is incorrect.' });
}
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(user.id);
+62 -7
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
let { data, form } = $props();
const user = $derived(data.user!);
let discordLinkPending = $state(false);
@@ -7,7 +9,7 @@
type AvatarPanel = 'presets' | 'upload';
function initialAccountPanel(): AccountPanel {
if (form?.discordUnlinkError || data.oauthError || data.discordLinked || form?.discordUnlinked) return 'connections';
if (form?.passwordError || form?.loginUsernameError) return 'access';
if (form?.passwordError || form?.loginUsernameError || form?.localCredentialError || form?.emailError || form?.passkeyError || data.emailVerified) return 'access';
if (form?.deleteError) return 'account';
return 'identity';
}
@@ -16,6 +18,35 @@
}
let accountPanel = $state<AccountPanel>(initialAccountPanel());
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;
@@ -81,17 +112,41 @@
</div>
{:else if accountPanel === 'access'}
<div class="account-panel" id="access-panel" role="tabpanel" aria-labelledby="access-tab">
<p class="eyebrow">PRIVATE CREDENTIAL</p><h2>LOGIN USERNAME</h2>
<PendingForm action="?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={user.username} 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/><p class="eyebrow">SECURITY</p><h2>{data.hasPassword ? 'CHANGE PASSWORD' : 'SET PASSWORD'}</h2>
<PendingForm action="?/changePassword">{#if data.hasPassword}<label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" autocomplete="current-password" required /></label>{/if}<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">{data.hasPassword ? 'UPDATE PASSWORD' : 'ADD PASSWORD SIGN-IN'} →</button></PendingForm>
<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}
<hr/><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>
<hr/><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}
</div>
{:else if accountPanel === 'connections'}
<div class="account-panel" id="connections-panel" role="tabpanel" aria-labelledby="connections-tab">
<p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2>
{#if data.discordConnection}
<div class="connection-card discord-connection linked-connection"><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><span class="connection-state">LINKED</span></div>
<PendingForm action="?/unlinkDiscord" class="unlink-console"><label class="field">PASSWORD TO UNLINK<input name="password" type="password" autocomplete="current-password" disabled={!data.discordConnection.canUnlink} required={data.discordConnection.canUnlink}/></label>{#if !data.discordConnection.canUnlink}<p class="connection-warning">Discord is your only sign-in method. Set a password in Access before unlinking.</p>{/if}{#if form?.discordUnlinkError}<p class="error">{form.discordUnlinkError}</p>{/if}<button class="secondary console-action" disabled={!data.discordConnection.canUnlink}>UNLINK DISCORD</button></PendingForm>
<PendingForm action="?/unlinkDiscord" class="unlink-console">{#if data.hasPassword}<label class="field">PASSWORD TO UNLINK<input name="password" type="password" autocomplete="current-password" required/></label>{/if}{#if !data.discordConnection.canUnlink}<p class="connection-warning">Discord is your only sign-in method. Add a passkey, verified email, or password in Access before unlinking.</p>{/if}{#if form?.discordUnlinkError}<p class="error">{form.discordUnlinkError}</p>{/if}<button class="secondary console-action" disabled={!data.discordConnection.canUnlink}>UNLINK DISCORD</button></PendingForm>
{: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}
@@ -100,7 +155,7 @@
<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 {user.username} TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
<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>{#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>