From 22b9e4578cb0af30939ad7b95039eb5c156e95cb Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 02:26:19 -0400 Subject: [PATCH] Add unified account creation flow --- docs/DESIGN_SYSTEM.md | 9 +++++ src/app.css | 4 +- src/routes/sign-in/+page.server.ts | 60 ++++++++++++++++++++++++++++-- src/routes/sign-in/+page.svelte | 28 ++++++++++++-- 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 6f96be0..b2ff812 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -145,6 +145,15 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps - Provider buttons still require SHD.build's focus-visible, pressed, pending, and duplicate-interaction protection where those states do not conflict with the provider's branding requirements. +- Authentication uses one unified access screen rather than separate sign-in and + registration destinations. Verified email and first-time OAuth identities may + create an account; returning identities sign into the matching account. +- Password access uses an explicit `SIGN IN` / `CREATE ACCOUNT` mode switch + inside the fallback panel. Account creation separates the public display name + from the private ASCII login username. +- Passkeys are a returning-user method on this screen. New agents first establish + an account through verified email, OAuth, or local credentials, then enroll a + passkey from Account Access. ### Admin dashboard diff --git a/src/app.css b/src/app.css index 6ae17b2..099326e 100644 --- a/src/app.css +++ b/src/app.css @@ -37,7 +37,7 @@ .featured{background:#101314;padding:70px max(24px,5vw) 90px}.section-heading{max-width:1240px;margin:0 auto 28px;display:flex;align-items:end;justify-content:space-between}.section-heading h2{font-size:34px;margin:8px 0}.section-heading button{color:#aaa;background:none;border:0;font-size:10px;letter-spacing:1px}.build-grid{max-width:1240px;margin:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.build-grid article{background:#16191a;border:1px solid #2d3132;border-top:2px solid var(--build-color)}.card-top{display:flex;justify-content:space-between;padding:13px 16px;font-size:9px;letter-spacing:1px}.card-top span:first-child{color:var(--build-color)}.weapon-art{height:135px;background:linear-gradient(135deg,#1e2324,#111);display:grid;place-items:center;position:relative;overflow:hidden}.weapon-art div{width:65%;height:14px;background:var(--build-color);clip-path:polygon(0 20%,65% 20%,75% 0,100% 25%,94% 65%,66% 60%,58% 100%,47% 100%,49% 62%,0 72%);opacity:.65}.weapon-art span{position:absolute;font-size:9px;color:#ffffff33;letter-spacing:4px}.card-body{padding:20px}.card-body>p{font-size:8px;color:var(--build-color);letter-spacing:1.5px}.card-body h3{text-transform:uppercase;font-size:19px}.tags{display:flex;gap:7px}.tags span{font-size:8px;border:1px solid #3b4041;padding:5px 7px;color:#aeb1b2}.author{display:flex;align-items:center;gap:8px;border-top:1px solid #2d3132;margin-top:18px;padding-top:15px;font-size:8px;color:#777}.author>span:last-child{margin-left:auto}.avatar{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#2f3435;color:#f6a821} .app-page{max-width:1320px;margin:auto;padding:60px 5vw;min-height:calc(100vh - 76px)}.page-title{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-title h1{font-size:50px;margin:6px 0}.page-title input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.actions{display:flex;align-items:center;gap:14px}.actions>span{font-size:9px;color:#6a6;opacity:0}.actions>span.visible{opacity:1}.builder-layout{display:grid;grid-template-columns:1fr 340px;gap:24px}.slot-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot-card{text-align:left;min-height:160px;padding:20px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer;display:grid;grid-template-columns:1fr auto}.slot-card:hover{border-color:#f6a821}.slot-card .slot-label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:#777}.slot-icon{font-size:30px;color:#f6a821}.slot-card strong,.slot-card small,.slot-card b{grid-column:1/3}.slot-card strong{font-size:20px}.slot-card small{color:#888}.slot-card b{color:#f6a821}.summary,.data-panel{background:#141718;border:1px solid #303536;padding:25px}.summary h2{font-size:25px}.stat{display:grid;grid-template-columns:1fr auto;gap:8px;margin:20px 0}.stat span{color:#999;font-size:11px}.stat i{height:3px;background:#f6a821;grid-column:1/3}.summary hr{border:0;border-top:1px solid #333;margin:28px 0}.mini{display:flex;justify-content:space-between;font-size:11px;margin:14px 0}.mini span{color:#888}.notice{font-size:11px;line-height:1.5;color:#747a7c;border-left:2px solid #f6a821;padding-left:12px;margin-top:30px} .admin-grid{display:grid;grid-template-columns:260px 1fr;gap:20px}.admin-nav{display:flex;flex-direction:column}.admin-nav button{display:flex;justify-content:space-between;background:#111415;color:#9ca0a1;border:0;border-bottom:1px solid #292d2f;padding:18px;text-align:left}.admin-nav button:first-child{color:#f6a821;border-left:2px solid #f6a821}.table-head{display:flex;justify-content:space-between}.table-head p{font-size:11px;color:#777}.table-head input{height:38px;background:#0e1011;color:white;border:1px solid #333;padding:0 12px}.data-row{display:grid;grid-template-columns:1.4fr 1.2fr .8fr .6fr .3fr;gap:15px;align-items:center;border-top:1px solid #2b3031;padding:18px 4px;font-size:11px}.data-row.labels{font-size:8px;color:#6f7577}.data-row button{background:none;border:0;color:#f6a821;font-size:9px}.status{color:#73bd8a;font-size:8px} - .auth-page,.empty-page{min-height:calc(100vh - 76px);display:grid;place-items:center;padding:40px}.auth-page section{width:min(430px,100%);background:#141718;border:1px solid #343839;border-top:2px solid #f6a821;padding:38px}.auth-page h1,.empty-page h1{font-size:46px;margin-bottom:12px}.auth-page>section>p:nth-of-type(2){color:#929798;line-height:1.5}.discord{width:100%;padding:15px;background:#5865f2;color:#fff;border:0;font-size:11px;font-weight:bold;letter-spacing:1px}.divider{text-align:center;border-top:1px solid #333;margin:30px 0 20px}.divider span{position:relative;top:-7px;background:#141718;padding:0 12px;font-size:8px;color:#666}.auth-page label{display:block;font-size:9px;letter-spacing:1px;color:#999;margin:15px 0}.auth-page input{width:100%;display:block;margin-top:7px;background:#0e1011;border:1px solid #35393a;color:#fff;padding:13px}.full{width:100%;margin:8px 0 20px}.auth-page small{display:block;color:#676d6e;line-height:1.5}.empty-page{text-align:center}.empty-page p:not(.eyebrow){color:#888}.toast{position:fixed;z-index:20;right:25px;bottom:25px;background:#f6a821;color:#111;padding:14px 20px;font-size:11px;font-weight:bold} + .auth-page,.empty-page{min-height:calc(100vh - 76px);display:grid;place-items:center;padding:40px}.auth-page section{width:min(430px,100%);background:#141718;border:1px solid #343839;border-top:2px solid #f6a821;padding:38px}.auth-page h1,.empty-page h1{font-size:46px;margin-bottom:12px}.auth-page>section>p:nth-of-type(2){color:#929798;line-height:1.5}.discord{width:100%;padding:15px;background:#5865f2;color:#fff;border:0;font-size:11px;font-weight:bold;letter-spacing:1px}.divider{display:flex;align-items:center;gap:12px;margin:30px 0 20px;text-align:center}.divider:before,.divider:after{content:"";height:1px;flex:1;background:#333}.divider span{flex:0 0 auto;font-size:8px;color:#666}.auth-page label{display:block;font-size:9px;letter-spacing:1px;color:#999;margin:15px 0}.auth-page input{width:100%;display:block;margin-top:7px;background:#0e1011;border:1px solid #35393a;color:#fff;padding:13px}.full{width:100%;margin:8px 0 20px}.auth-page small{display:block;color:#676d6e;line-height:1.5}.empty-page{text-align:center}.empty-page p:not(.eyebrow){color:#888}.toast{position:fixed;z-index:20;right:25px;bottom:25px;background:#f6a821;color:#111;padding:14px 20px;font-size:11px;font-weight:bold} @media(max-width:900px){header nav{display:none}.sign-in{margin-left:auto}.hero{grid-template-columns:1fr;padding-top:35px}.hero h1{font-size:62px}.loadout-preview{min-height:500px}.build-grid{grid-template-columns:1fr}.builder-layout,.admin-grid{grid-template-columns:1fr}.slot-grid{grid-template-columns:1fr}.summary{order:-1}.page-title{align-items:start;gap:25px}.page-title input{font-size:30px;width:100%}}@media(max-width:560px){.hero{padding-left:20px;padding-right:20px}.hero h1{font-size:48px;letter-spacing:-3px}.metrics{gap:25px}.hero-actions{flex-direction:column}.loadout-preview{display:none}.hero{min-height:auto;padding-bottom:60px}.page-title{flex-direction:column}.admin-grid{display:block}.data-panel{overflow:auto}.data-row{min-width:650px}.brand{font-size:13px}} /* Routed application compatibility: preserve the original SHD visual system. */ @@ -62,6 +62,8 @@ .danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:8px;font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:8px;line-height:1.5}.danger-zone form{padding-bottom:10px} .auth-page{display:block;padding:55px clamp(20px,5vw,80px)}.auth-frame{width:min(980px,100%);min-height:520px;margin:auto;display:grid;grid-template-columns:1.15fr .85fr;border:1px solid #34393a;background:#101314;clip-path:polygon(0 0,96% 0,100% 7%,100% 100%,4% 100%,0 93%)}.auth-intel{width:auto!important;padding:65px!important;border:0!important;border-right:1px solid #303536!important;background:radial-gradient(circle at 15% 80%,#392b15 0,transparent 33%),#0e1112!important}.auth-intel h1{font-size:66px!important;line-height:.85;letter-spacing:-3px;margin:24px 0}.auth-intel h1 em{font-style:normal;color:#f6a821}.auth-intel>p:not(.eyebrow){max-width:470px;color:#909698;font:15px/1.6 Georgia,serif}.auth-status{display:flex;justify-content:space-between;margin-top:55px;padding-top:14px;border-top:1px solid #303536;color:#626a6c;font-size:7px;letter-spacing:1.2px}.auth-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.auth-console{width:auto!important;padding:34px!important;border:0!important;background:#141718!important}.auth-console .console-head b{color:#f6a821}.auth-console .discord{min-height:48px;margin-top:18px;padding:10px 18px;display:flex;align-items:center;justify-content:center;gap:14px;background:#5865f2;color:#fff;border:0;border-radius:4px;font-size:15px;font-weight:600;letter-spacing:0;text-transform:none}.auth-console .discord img{display:block;width:27px;height:21px;object-fit:contain;flex:0 0 auto}.auth-console .discord:hover{background:#4752c4;filter:none}.auth-console .discord:active{background:#3c45a5;filter:none}.auth-console .discord:focus-visible{outline-color:#e0e3ff}.auth-console small{margin-top:18px} .signin-provider-rail{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.signin-provider{position:relative;min-width:0;min-height:76px;margin:0;padding:8px 4px 7px;display:grid;grid-template-rows:34px auto;place-items:center;gap:5px;border:1px solid #424849;border-radius:0;background:#101314;color:#dfe1e2;font-family:Arial,Helvetica,sans-serif;font-size:8px;font-style:normal;font-weight:800;letter-spacing:.3px;text-transform:none;cursor:pointer;transition:var(--shd-transition)}.signin-provider-icon{width:34px;height:34px;display:grid;place-items:center}.signin-provider-icon img{display:block;width:21px;height:21px;object-fit:contain}.signin-provider em{position:absolute;right:3px;top:3px;color:#737a7c;font-size:5px;font-style:normal;font-weight:900;letter-spacing:.5px;text-transform:uppercase}.signin-provider:hover:not(:disabled){transform:translateY(-1px);filter:brightness(1.12)}.signin-provider:active:not(:disabled){transform:translateY(2px) scale(.97)}.signin-provider:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.signin-provider:disabled{cursor:not-allowed;opacity:.46;filter:saturate(.55)}.discord-provider{border-color:#5865f2;background:#5865f2;color:#fff}.discord-provider:hover{background:#4752c4;filter:none!important}.discord-provider .signin-provider-icon img{width:25px;height:19px}.google-signin-provider{border-color:#8e918f;background:#131314;color:#e3e3e3}.google-signin-provider .signin-provider-icon{background:#fff}.apple-signin-provider{border-color:#73777a;background:#000;color:#fff}.apple-signin-provider .signin-provider-icon img{width:22px;height:22px}.steam-signin-provider{border-color:#66c0f4;background:#171d25;color:#fff}.steam-signin-provider .signin-provider-icon{color:#fff}.steam-signin-provider .signin-provider-icon img{width:25px;height:25px} +.auth-mode-note{margin:-3px 0 14px;color:#7b8385;font-size:9px;line-height:1.5}.auth-mode-switch{display:grid;grid-template-columns:1fr 1fr;margin:12px 0 4px;border:1px solid #383d3e}.auth-mode-switch button{height:34px;border:0;border-left:1px solid #383d3e;background:#0d1011;color:#747c7e;font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.auth-mode-switch button:first-child{border-left:0}.auth-mode-switch button.active{background:#2b2111;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.auth-mode-switch button:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px} +.auth-page .auth-console form .full + .identity-note{margin-top:-14px} .passkey-signin{position:relative;min-height:48px;margin-top:18px}.passkey-signin[aria-busy=true]{padding-right:42px}.passkey-signin[aria-busy=true]:after{content:"";position:absolute;right:16px;width:12px;height:12px;border:2px solid #111;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite}.auth-note{margin:0;color:var(--shd-text-muted);font-size:8px}.password-fallback{margin:18px 0;border:1px solid var(--shd-border);background:#101314}.password-fallback summary{padding:12px;color:#8e9698;font-size:8px;font-weight:900;letter-spacing:1px;cursor:pointer}.password-fallback summary:hover{color:var(--shd-orange)}.password-fallback form{padding:0 12px 12px}.password-fallback .full{margin-bottom:0} .builder-page{padding-top:28px}.builder-command{margin-bottom:14px;border-bottom:1px solid #303536}.builder-command .page-title{margin-bottom:12px}.builder-status{display:flex;gap:24px;padding:9px 2px;color:#61696b;font-size:7px;letter-spacing:1.2px}.builder-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.builder-page .slot-card{position:relative;overflow:hidden}.builder-page .slot-card:after{content:"";position:absolute;right:-24px;bottom:-24px;width:52px;height:52px;border:1px solid #f6a82122;transform:rotate(45deg)}.builder-page .summary{border-top:2px solid #f6a821} @media(max-width:900px){.filter-bar{grid-template-columns:1fr 1fr}.catalogue-hero{align-items:flex-start}.guide-layout,.profile-dashboard,.auth-frame{grid-template-columns:1fr}.profile-command{align-items:flex-start}.profile-metrics{display:grid;grid-template-columns:1fr 1fr}.account-console{order:-1}.auth-intel{border-right:0!important;border-bottom:1px solid #303536!important;padding:42px!important}.auth-intel h1{font-size:52px!important}}@media(max-width:560px){.catalogue-hero,.profile-command{flex-direction:column;align-items:flex-start}.catalogue-count{text-align:left;margin-top:20px}.catalogue-count .primary{justify-self:start}.filter-bar{grid-template-columns:1fr}.guide-meta{position:static;margin-top:25px;flex-wrap:wrap}.profile-metrics{width:100%;margin-top:20px}.profile-columns{grid-template-columns:1fr}.auth-page{padding:25px 14px}.auth-intel,.auth-console{padding:28px!important}} diff --git a/src/routes/sign-in/+page.server.ts b/src/routes/sign-in/+page.server.ts index e14a49a..79ec9ec 100644 --- a/src/routes/sign-in/+page.server.ts +++ b/src/routes/sign-in/+page.server.ts @@ -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(); diff --git a/src/routes/sign-in/+page.svelte b/src/routes/sign-in/+page.svelte index defb3d6..70f42d6 100644 --- a/src/routes/sign-in/+page.svelte +++ b/src/routes/sign-in/+page.svelte @@ -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 @@ Sign in — SHD.build

SHD IDENTITY NETWORK

AGENT
ACCESS.

Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.

NETWORK ONLINENODE NYC-01
SECURE SIGN-INSHD // AUTH
+

Sign in to an existing account or create one with email, Discord, or local credentials.

{#if !passkeySupported}

This browser does not support passkeys.

{/if} {#if passkeyError}

{passkeyError}

{/if}
OR EMAIL A ONE-TIME LINK
- {#if form?.magicLinkError}

{form.magicLinkError}

{/if}{#if form?.magicLinkSent || data.magicLinkSent}

If that email is linked to an agent, a sign-in link is on its way.

{/if}{#if data.magicError}

{data.magicError}

{/if}
+ {#if form?.magicLinkError}

{form.magicLinkError}

{/if}{#if form?.magicLinkSent || data.magicLinkSent}

Your one-time access link is on its way.

{/if}{#if data.magicError}

{data.magicError}

{/if}A verified link signs you in, or creates your SHD.build account if this email is new.
OR CONTINUE WITH