Add unified account creation flow
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { createSession, destroySession, recordLogin } from '$lib/server/auth/session';
|
||||
import { verifyPassword } from '$lib/server/auth/password';
|
||||
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
||||
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
|
||||
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => ({
|
||||
@@ -24,17 +25,68 @@ export const actions: Actions = {
|
||||
}
|
||||
if (!emailDeliveryConfigured()) return fail(503, { magicLinkError: 'Email sign-in is not configured yet.', email });
|
||||
const database = await getDatabase();
|
||||
const identity = await (await database.prepare(`SELECT user_emails.user_id,magic_link_tokens.created_at
|
||||
let identity = await (await database.prepare(`SELECT user_emails.user_id,magic_link_tokens.created_at
|
||||
FROM user_emails LEFT JOIN magic_link_tokens ON magic_link_tokens.user_id=user_emails.user_id
|
||||
AND magic_link_tokens.purpose='login' AND magic_link_tokens.consumed_at IS NULL
|
||||
WHERE user_emails.email=? COLLATE NOCASE ORDER BY magic_link_tokens.created_at DESC LIMIT 1`)).get(email);
|
||||
let purpose: 'login' | 'verify_email' = 'login';
|
||||
if (!identity?.user_id) {
|
||||
identity = await (await database.prepare(`SELECT user_id,created_at FROM magic_link_tokens
|
||||
WHERE email=? COLLATE NOCASE AND purpose='verify_email' AND consumed_at IS NULL AND expires_at>?
|
||||
ORDER BY created_at DESC LIMIT 1`)).get(email, new Date().toISOString());
|
||||
purpose = 'verify_email';
|
||||
}
|
||||
if (!identity?.user_id) {
|
||||
const id = randomUUID(), now = new Date().toISOString();
|
||||
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
|
||||
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, 'New Agent', now);
|
||||
identity = { user_id: id, created_at: null };
|
||||
purpose = 'verify_email';
|
||||
}
|
||||
const recent = identity?.created_at && Date.now() - new Date(String(identity.created_at)).getTime() < 60_000;
|
||||
if (identity?.user_id && !recent) {
|
||||
const token = await createMagicLinkToken(database, String(identity.user_id), email, 'login');
|
||||
await sendAuthenticationLink(email, token, 'login');
|
||||
const token = await createMagicLinkToken(database, String(identity.user_id), email, purpose);
|
||||
await sendAuthenticationLink(email, token, purpose);
|
||||
}
|
||||
return { magicLinkSent: true };
|
||||
},
|
||||
register: async ({ request, cookies }) => {
|
||||
const form = await request.formData();
|
||||
const displayName = String(form.get('displayName') ?? '').normalize('NFKC').trim();
|
||||
const username = String(form.get('username') ?? '').normalize('NFKC').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
const confirm = String(form.get('confirmPassword') ?? '');
|
||||
const displayLength = Array.from(displayName).length;
|
||||
const values = { registerDisplayName: displayName, registerUsername: username };
|
||||
if (displayLength < 2 || displayLength > 40 || /[\p{C}\r\n]/u.test(displayName)) {
|
||||
return fail(400, { registerError: 'Use a display name with 2–40 visible characters.', ...values });
|
||||
}
|
||||
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) {
|
||||
return fail(400, { registerError: 'Use a login username with 3–32 ASCII letters, numbers, underscores, or hyphens.', ...values });
|
||||
}
|
||||
if (['admin', 'administrator', 'moderator', 'shd', 'support'].includes(username.toLowerCase())) {
|
||||
return fail(400, { registerError: 'That login username is reserved.', ...values });
|
||||
}
|
||||
if (password.length < 12 || password.length > 128) {
|
||||
return fail(400, { registerError: 'Use a password between 12 and 128 characters.', ...values });
|
||||
}
|
||||
if (password !== confirm) return fail(400, { registerError: 'The passwords do not match.', ...values });
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) {
|
||||
return fail(409, { registerError: 'That login username is already in use.', ...values });
|
||||
}
|
||||
const id = randomUUID(), now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
|
||||
VALUES(?,?,?,?,?,?)`)).run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, username, await hashPassword(password), 'user', now);
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, displayName, now);
|
||||
await ensureBootstrapAdmin(database, id);
|
||||
await createSession(cookies, id);
|
||||
await recordLogin(id, 'password', true);
|
||||
redirect(303, '/account');
|
||||
},
|
||||
login: async ({ request, cookies }) => {
|
||||
const form = await request.formData();
|
||||
const username = String(form.get('username') ?? '').trim();
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
let passkeyPending = $state(false);
|
||||
let passkeySupported = $state(true);
|
||||
let passkeyError = $state('');
|
||||
let passwordMode = $state<'signin' | 'create'>('signin');
|
||||
let passwordPanelOpen = $state(false);
|
||||
$effect(() => {
|
||||
if (form?.registerError) {
|
||||
passwordMode = 'create';
|
||||
passwordPanelOpen = true;
|
||||
} else if (form?.loginError) {
|
||||
passwordMode = 'signin';
|
||||
passwordPanelOpen = true;
|
||||
}
|
||||
});
|
||||
onMount(() => passkeySupported = browserSupportsWebAuthn());
|
||||
async function signInWithPasskey() {
|
||||
if (passkeyPending) return;
|
||||
@@ -39,11 +50,12 @@
|
||||
</script>
|
||||
<svelte:head><title>Sign in — SHD.build</title></svelte:head>
|
||||
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD IDENTITY NETWORK</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div>
|
||||
<p class="auth-mode-note">Sign in to an existing account or create one with email, Discord, or local credentials.</p>
|
||||
<button class="primary full passkey-signin" disabled={!passkeySupported || passkeyPending} aria-busy={passkeyPending} onclick={signInWithPasskey}>{passkeyPending ? 'VERIFYING PASSKEY…' : 'SIGN IN WITH A PASSKEY →'}</button>
|
||||
{#if !passkeySupported}<p class="auth-note">This browser does not support passkeys.</p>{/if}
|
||||
{#if passkeyError}<p class="error">{passkeyError}</p>{/if}
|
||||
<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>
|
||||
<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">Your one-time access 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 ACCESS LINK →</button><small class="identity-note">A verified link signs you in, or creates your SHD.build account if this email is new.</small></PendingForm>
|
||||
<div class="divider"><span>OR CONTINUE WITH</span></div>
|
||||
<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}>
|
||||
@@ -67,6 +79,16 @@
|
||||
</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>
|
||||
<details class="password-fallback" bind:open={passwordPanelOpen}><summary>USE USERNAME AND PASSWORD</summary>
|
||||
<div class="auth-mode-switch" role="group" aria-label="Password access mode">
|
||||
<button type="button" class:active={passwordMode === 'signin'} onclick={() => passwordMode = 'signin'}>SIGN IN</button>
|
||||
<button type="button" class:active={passwordMode === 'create'} onclick={() => passwordMode = 'create'}>CREATE ACCOUNT</button>
|
||||
</div>
|
||||
{#if passwordMode === 'signin'}
|
||||
<PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username webauthn" placeholder="Private login username" 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>
|
||||
{:else}
|
||||
<PendingForm action="?/register"><label class="field">DISPLAY NAME<input name="displayName" value={form?.registerDisplayName ?? ''} autocomplete="nickname" placeholder="Public agent name" required /></label><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={form?.registerUsername ?? ''} autocomplete="username" placeholder="ASCII letters, numbers, _ or -" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="new-password" placeholder="12–128 characters" minlength="12" maxlength="128" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" placeholder="Repeat password" minlength="12" maxlength="128" required /></label>{#if form?.registerError}<p class="error">{form.registerError}</p>{/if}<button class="secondary full">CREATE ACCOUNT →</button></PendingForm>
|
||||
{/if}
|
||||
</details>
|
||||
<small>Passkeys are preferred for returning agents and can be added after account creation. Password sign-in remains optional.</small>
|
||||
</section></div></main>
|
||||
|
||||
Reference in New Issue
Block a user