Refine account connections and provider sign-in UI

This commit is contained in:
JB
2026-07-25 02:17:45 -04:00
parent c3244b0c79
commit 854a7e7cc3
8 changed files with 111 additions and 21 deletions
+2 -6
View File
@@ -234,19 +234,15 @@ export const actions: Actions = {
cookies.delete('shd_session', { path: '/' });
redirect(303, '/?account_deleted=1');
},
unlinkDiscord: async ({ request, cookies }) => {
unlinkDiscord: async ({ 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,discord_id FROM users WHERE id=?')).get(user.id);
const credential = await (await database.prepare('SELECT discord_id FROM users WHERE id=?')).get(user.id);
if (!credential?.discord_id) return fail(409, { discordUnlinkError: 'No Discord account is linked.' });
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 (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);
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(user.id);
return { discordUnlinked: true };
+40 -13
View File
@@ -5,18 +5,26 @@
let { data, form } = $props();
const user = $derived(data.user!);
let discordLinkPending = $state(false);
type AccountPanel = 'identity' | 'access' | 'connections' | 'account';
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) return 'connections';
if (form?.passwordError || form?.loginUsernameError || form?.localCredentialError || form?.emailError || form?.passkeyError || data.emailVerified) return 'access';
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 ? 'upload' : 'presets';
}
let accountPanel = $state<AccountPanel>(initialAccountPanel());
let accessPanel = $state<AccessPanel>(initialAccessPanel());
let avatarPanel = $state<AvatarPanel>(initialAvatarPanel());
let passkeySupported = $state(true);
let passkeyPending = $state(false);
@@ -61,7 +69,7 @@
</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. You can continue signing in with your username and password.</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">
@@ -75,7 +83,6 @@
<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="connections-tab" class:active={accountPanel === 'connections'} role="tab" aria-selected={accountPanel === 'connections'} aria-controls="connections-panel" onclick={() => accountPanel = 'connections'}>LINKS</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>
@@ -112,6 +119,13 @@
</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}
@@ -128,10 +142,12 @@
<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>
{: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>
<hr/><p class="eyebrow">OPTIONAL FALLBACK</p><h2>USERNAME &amp; PASSWORD</h2>
{: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>
@@ -140,16 +156,27 @@
<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>
{: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"><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">{#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>
<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">
+21 -1
View File
@@ -45,7 +45,27 @@
<div class="divider"><span>OR EMAIL A ONE-TIME LINK</span></div>
<PendingForm action="?/magicLink"><label class="field">VERIFIED EMAIL<input name="email" type="email" value={form?.email ?? ''} autocomplete="email" placeholder="agent@example.com" required /></label>{#if form?.magicLinkError}<p class="error">{form.magicLinkError}</p>{/if}{#if form?.magicLinkSent || data.magicLinkSent}<p class="success">If that email is linked to an agent, a sign-in link is on its way.</p>{/if}{#if data.magicError}<p class="error">{data.magicError}</p>{/if}<button class="secondary full" disabled={!data.emailDeliveryConfigured}>EMAIL MAGIC LINK →</button></PendingForm>
<div class="divider"><span>OR CONTINUE WITH</span></div>
<a class="discord" class:interaction-pending={discordPending} href="/auth/discord" aria-busy={discordPending} onclick={beginDiscordSignIn}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Continue with Discord</span></a>
<div class="signin-provider-rail" aria-label="External sign-in providers">
<a class="signin-provider discord-provider" class:interaction-pending={discordPending} href="/auth/discord" aria-busy={discordPending} onclick={beginDiscordSignIn}>
<span class="signin-provider-icon"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /></span>
<span>Discord</span>
</a>
<button class="signin-provider google-signin-provider" type="button" disabled title="Google sign-in is planned" aria-label="Google sign-in — planned">
<span class="signin-provider-icon"><img src="/google-g.svg" alt="" aria-hidden="true" /></span>
<span>Google</span>
<em>Planned</em>
</button>
<button class="signin-provider apple-signin-provider" type="button" disabled title="Apple sign-in is planned" aria-label="Apple sign-in — planned">
<span class="signin-provider-icon"><img src="/apple-mark.svg" alt="" aria-hidden="true" /></span>
<span>Apple</span>
<em>Planned</em>
</button>
<button class="signin-provider steam-signin-provider" type="button" disabled title="Steam sign-in is planned" aria-label="Steam sign-in — planned">
<span class="signin-provider-icon"><img src="/steam-mark.svg" alt="" aria-hidden="true" /></span>
<span>Steam</span>
<em>Planned</em>
</button>
</div>
{#if data.oauthError}<p class="error">{data.oauthError}</p>{/if}
<details class="password-fallback" open={Boolean(form?.loginError)}><summary>USE USERNAME AND PASSWORD</summary><PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username webauthn" placeholder="Agent handle" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="current-password" placeholder="••••••••••••" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}<button class="secondary full">SIGN IN WITH PASSWORD →</button></PendingForm></details>
<small>Passkeys and one-time email links are preferred. Password sign-in remains available as an optional fallback.</small>