diff --git a/.env.development b/.env.development index 4bc396e..8ab7a65 100644 --- a/.env.development +++ b/.env.development @@ -5,7 +5,21 @@ DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167" # .env.development -TDR_DATABASE_PATH=encrypted:BHnVQl0N/qUPZBf0dXKVitJ/gKbQMMxMUqgnVEdFb/fHQ3S7zV2oU2piNv1RrcvaFvTsEsrroxokGbeKPWiGDzFly/HCdVy91MvZ00OU73dilDqYgGYtVSyFwUgU8k8VrpSTdEIrEEzOjoSj7AV5t2KSeKMoE+AlzuM/A1qfskY= -DISCORD_CLIENT_ID=encrypted:BDTGVwdnufaUNB8sW8lO3UO4LYXEuzqaBiFzCPiuImWfKZYIAXQaefJMRLuB6SgtJ12ajb96wqNX0ai1XU/7/XRWMW1AL7hc8lcdRr1ur5WlDSx3duTi1W8fzCApVSrl2FcpkQHl7HnDJHr/hIctoVVjals= -DISCORD_CLIENT_SECRET=encrypted:BN6NoKAqY/C2mn67YxitBKM18L5mQXp/5HFTXdLAc9dPOfmG9Np3+osDIYCe6It4DUS4uOJKyDIDT2AldjP6J8pWO09tPCyEQEhsP8kjG0pr3FYrSPTmNzA/9couRXV0BvpNcr0NOs8evwaggvYqLoXSdF3HECHf33hFNc1RD2Yu -DISCORD_REDIRECT_URI=encrypted:BMV9gZ2xp1toCtpdOZMrI+211v4DPWllGDLskfo4XZGp51wL1TFNnyAhGqMkq52qS32IirsS5YnpYzlFoyzFDe8uOQ7ojVMWyXKTvkCO5yuZdjmNdgtjoDyFxzo9jbBHIz2Xc8l6gf+nmrVRo9W5OXYwSb+EzkicU0y2/GrW/uBR8wwcedH7lG+YPHY= +TDR_DATABASE_PATH=encrypted:BNTRek+Cz9NI9Qasv0a+BWUNs5u5qTESE+1U7fybMS3NJLQkY8WR/eQ0v8/ZoiiHLSM/EGum5NRNC/iRSYIjkjFTvfC4xFnUXALvy3zM2Iw4AezGWdmOZ6RI5csNtGQKQ0idOicuA1YN3FpcDdOSSZXTOyVniwFduWcAZTuBPTo= +DISCORD_CLIENT_ID=encrypted:BDuk9O2rhgVwi6/NqnG1+n2Jeb/A6ciQwNAdY3Ih7R08DhTF4g2d9ZKpg+OzmIL7BeZF3ikkedlfljxn8xbW8f9YGrrfaLLdTDVClyT+scyfcKeaK6uunQVvipG5UwhotEd3VS8T9IcQyNDVq9y9SEgIjyo= +DISCORD_CLIENT_SECRET=encrypted:BE3+3NfbuQpjbpc8Pyl0e5YEW478dOKdi5cWuwHjJ5L2z/X+1hJ+B6ag4oXwPBt20t12qWtv5IYPJGMb+Uzk6hZLazqkHxl+DUozeNMAOOrNHESPiTwH7fpI0vf4oVTWH+j4iYMRUlmLkjzOICTmAL+ljOJgi5dTqHx05unXGqxZ +DISCORD_REDIRECT_URI=encrypted:BEWUNH2YlygQ23TBiXj9lgJ7hijU13YJhiBYVM1VEHHM3s8ryqWJ+hz2bJ0EyGHg3XU9UBPDhmgLkafULYzY/KgOLmSMq6TkrYq1e7Qec+K70ZyqoNp7L1s9C0EK//dpkE1iMvd92bhLPOeS8sOhFN4dyYbK2vPGRdi7AKxFwiU/jq7pbwkHeU5DQXU= + +# Passwordless authentication +APP_ORIGIN=encrypted:BE7igk/cyE/Wv72Rc8A0O815n/uDyyaKdLwdonSjrc7rHWiwBifF8S6EQ5tUkH8i0eQsxLGxcAJTW0H9gfGw0Nftxl+1Mr2slGLZJxxiJ4Upp1CU3pLwIgcTg82ew8ewOvfYnT6mVLPMHO/02vomjOxYobLKdQ== +PASSKEY_RP_ID=encrypted:BNMQdCdZR9e47vjPfme83ujNlJqsmJgGeri8r+uuJ1MwHeWXuv8FKi+XHKKX8GaXvEdqD0mLSFyYvUMgrzJb/ZwEo/B77opQ67AXFtERz4qUKDc6XdOvFNRUvlPjPaO/eoyqr64WIEjG3w== + +# Shared SMTP account for magic-link and email-verification delivery. +# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set +# SMTP_SECURE=false. +SMTP_HOST=encrypted:BGk17DA/BpNINXIl4d7LKTLi3IfAjvyk1OhP5yPnk6ZjE3FHSJl3ed6tQh5oPoUt8zovm+hyV2rD/Pae+axnWdxKXtUohp5HkqNqC1T4SEeVWpUsx5m/xXt8hxcwEQ/1HfzTcdx41wo/hR2ereZMFaX7sA== +SMTP_PORT=encrypted:BBzjlvppArJeJNKCeNyuf4+bX4ohICRycdb/BbZTryN114iZ1d6wyQr6Gre2Eg9fhTQqdIiDjjqpb9UWbjavutlHFZ0UarMUWaOBRfVd9+sqmXI8/vmA6Scp8z4MxteqB5+6jQ== +SMTP_SECURE=encrypted:BBMBvqEuiHmFAy+C56yr/g1kPQsVHu+xO5eVtHis4vdAHHsGVO/blfVMHDw0PAiHPFITHDrp7GjNnvQSq0AKgLK+Mn2/Iv7XC9oQedoplQFOY4kNuM77qWJur/H93DgtMckkfNB+ +SMTP_USER=encrypted:BHmiGy4eBiIOP5PLYBsqomWDCc7DSFvmfXPyPtD6/Cj0KLQAWaVVQowQbsc0XuvuOhesEAcrMBtAD0+rsAmAhoHvdudqc7jPXk0IAFeeasAp8CWvPzd2Afb6tI/Z2xv5BxVsR6l4p1uh+SlK0lfskBGSdQ== +SMTP_PASSWORD=encrypted:BJ8mNgq9j9mmsptaEP8L47MbuB/l9jmkMGXco5ybw91a/vZFyZ5xLWTUxdVYYlHiBIAhjs1eEsx7dQgc4+5wmGFyNfDILod5Turp/OqDD8qV2c7wsKGzOrV6TCGELscoxiHg28YCZCriZnIs9Uw9yRE= +SMTP_FROM=encrypted:BGOoIMcTZ8Wknq81asUOHBtHsqKmUeVq8SDdvp+F5lWKwWxSos8cE/inhJk8gfVEp8cf6m8xVZQt+qUh0lscKA5LPpyixnFe1AyHRAlfqc+r2200492HxHrwKZfEzZ9RFMlN7omgC8ADAZ6eo5mvT/udqHWJpIQLJNSV5GFgjw== diff --git a/.env.example b/.env.example index f22a083..5acac90 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,18 @@ DISCORD_CLIENT_SECRET= # Proxied development: https://dev.shd.build/auth/discord/callback # Production: https://shd.build/auth/discord/callback DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback + +# Canonical application origin used in emailed authentication links. +APP_ORIGIN=https://dev.shd.build +# Use shd.build for both production and dev.shd.build. Use localhost only when +# accessing development directly at http://localhost:5173. +PASSKEY_RP_ID=shd.build + +# SMTP delivery for email verification and magic-link authentication. +# Port 465 normally uses SMTP_SECURE=true. Port 587 normally uses false and STARTTLS. +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=SHD.build diff --git a/.env.production b/.env.production index 9061750..842dd94 100644 --- a/.env.production +++ b/.env.production @@ -5,7 +5,21 @@ DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d" # .env.production -TDR_DATABASE_PATH=encrypted:BHL3JoS/+pSKq2PFYS/Aa15Xs1vpME0TImVOwk0KG073YlcqUb/i5G93og4hwDoufEmgWpcNE0ORugnLoP4zHMD6rYBtMaXtQG4Whm2CFyyL37h1Fb4boPIzyYQElklc9P6qaikq0Z/K4KWyGGvqMWpqMczjkwXTXCoDvFwrqw== -DISCORD_CLIENT_ID=encrypted:BEkEOyNIAuYmVcXOSruVANVdyBf3fUeyx4va4misGo7iQHWlGFfcjkV/PGfJ41awy46LwcawZuMqCxqPHH52I+qE7FRGu9kpRNqWwB7sEDTNjFfFvaibF6GfTgcjHg7ddIx6VSq4tDGq8JKCbyj2Ib0FixE= -DISCORD_CLIENT_SECRET=encrypted:BHlBoY4z2wCRPZXbD4hMQ5b9vOIeG5hzW2g62CBe8muqeSE9WvMPshhg1UTS8uWsMRw/Ez2eRAKnaGGuBBK+RlkWXiF5g+QeVY0JC2NWcCO3Dr5ZlpYRH5Ql2wENWCGzv6HePhGg40mgvmRv568M/Tm7DlEMhomDRBKUy2BEFELF -DISCORD_REDIRECT_URI=encrypted:BAjTP4WFTGf+aFONbsSHaWY+UR6NyfLfKM2br3UQ7ek/yYxblpIpv+DBld/6dhSVYzR+2YG7hNgl87jhNABRg5HLXaFU5gwBojZ6lf275bfMLlsr53l03DDAPYyRl9e9fr11fZOoZSE3+gf9mtvm/bPnfBNzvQf3bNMW3MUmzHREWENbCWsucg== +TDR_DATABASE_PATH=encrypted:BE4lCm2/hLx3wEPCPvmIWHEPHS2oVLZHcWS6e4FmRwwkSR7GxaifHgccy32IInzWXiUp8P8OnZS6MYJn5X04wKtWV0y4FfbiCGs0e8YpM1Fo513hd1LmB6uDZo5pkacJV07KpG8NV01RveoHhe/ANN/UVwStFc7eZj85G4cu6A== +DISCORD_CLIENT_ID=encrypted:BK2ZUIggBGYBCU3zEaSOmo2mxqiWnOKtdKxK7FWTw56bG3xFoXtmSZ/vyz+OF/pLd6ciS/WO1nAxC+FND4mLqdZfm99MrWacQj79///zB+vsouDdwg3zqS76dxQ1zES4lPTzk+fhRqHe/EsqQK4sweax4fc= +DISCORD_CLIENT_SECRET=encrypted:BAwObVwDUb5HMWun2WUYpm3zrqFQHegE1KQnfq5I9tglZQXl6/XxkwHtNQFmoU0WgpsJERT1N+yO+0CtBzhdpufIHyXqjCj8uYD70wJvXY2BqEkth0JgKcMi7lO7gSgtybnzUGTDS0xLqjpBgw9lkl0rCun7axH/gkrd/rUEXLdF +DISCORD_REDIRECT_URI=encrypted:BGgy7YS2iU62+mSDlUDNwvZhE39Pr2o2kpnFJCg/16MaQK/VoZVyICSuLBuFE0snAUb8KIvcnklusqao8TU1izHeTS+rvBMLvRVzcyHDTZUynrmn4UCzG5w4phiTVi2OhaZvw8hQSkcvoV57mVtBo7LNEAuVEhuv5WAeOHOX9oaV4qYfKVviRQ== + +# Passwordless authentication +APP_ORIGIN=encrypted:BDJyyjEXFeEjdJiHOGRlxpiWkbzFJBZnM6w1VAb8m4YRW+c0Jvg4kl1XzaRtf63TLjfNWVxxwhCBwJ9s5qeP2+O7FvbgKKxoEXeFyP+gBUDsXss8volhd7toPaF9sjdAtpXY5L0M7SVYOct+FQCHAoyX +PASSKEY_RP_ID=encrypted:BKvPDWDsTEwhAtFAsKENg85D4Klo+Ipp6KnZ0EZ1Nlyi6ikuswRqDFrqYtlleBhzwAHhl7/uD3P6e2cyaNvffokcwQQQb8CXhCVRyGFgy1htvpFXhMmdesMfuqoVZ87wb5ngDlHKKV3E2A== + +# Shared SMTP account for magic-link and email-verification delivery. +# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set +# SMTP_SECURE=false. +SMTP_HOST=encrypted:BNKsoY8sz2Kf1ZaRtE1pU7JQM36Qj+KADK23R+pwVYV3RmeO6OP6AtGVGjDoHoPAqCsoSLPLM0INyX9XFdEJP+DtN0EVVoHgXt4+fAd1kXDBZTwZ+NEgFP6E6h8V5gZdMhar6hrnWecGfrFzs2c/1+e5Kg== +SMTP_PORT=encrypted:BDd5baDM0FWRaEDV/5s94/J8a2Ll9ZxLhBy5NVytdKXPbSq40E9UPKHPNS9vqet69Qceq7rzXgLmcdiqkqp0HdlocJUWzA5YIYcY5Er31o7nau7SKAk0KfnzHA6VVsvRDWxo/w== +SMTP_SECURE=encrypted:BPH6vl2VFiVF0m1sE5Ybt8rrM2TjkgEO5pKnu+5P++fiLkTnEr3bp5tAPbhqWAXpmgmXt2SB2KK/j9hH4Ih0/1cGNvSrqee5FNpwHKncrxrVRzdwSuIgYViziwCrReKXxR+uhzVD +SMTP_USER=encrypted:BKWljaiPijrWfVy5QHxWC83SCzs/MUlzFnI0i+CG19ZSfJ3dUqIdnACqjkpuYI5T/tO1AXB2kOptDc6M7foGdUZ1uDHzTWVOUl5yt0kx6rlAPExuRD9PmOdWvH/CzDQvn6MblJumUtZ5XgNTXG6RgZBqGg== +SMTP_PASSWORD=encrypted:BO10G2D7osxIP5jZP9SQbMFMvcmKJ6Pd4jFzWa2oHnz7RjY5pbRX7r+jFddm0lZqgj6jVJnJiFPMxKSpuSKJUoHKgXL36z5LcmwUuILysyKJAWj5HoeDOGzsgASz0pezESsDoWeH1+0iWPbw6A0AmUA= +SMTP_FROM=encrypted:BJmIwi0QgCDhR6cxkHP5EHsWZznB8ixNaHYxFfWMoVGTgHeTwmrXRDPsXBPXBXUMJkpr6yRAOpeADQjJ4TyqhQVw7R1ghk8nzuESH3/oVxPtpBFYaNkahDHMna+x4ZjsRQFwaisFcLm3RPhPQ8WRfR4VtxUjAnSRyfiXNVflsg== diff --git a/README.md b/README.md index b7e6e0c..a401e4d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,20 @@ Never commit or lose `.env.keys`. Production should receive `DOTENV_PRIVATE_KEY_PRODUCTION` through its secret manager rather than copying the keys file onto the server. +## Passwordless authentication + +Passkeys are the preferred sign-in method. `APP_ORIGIN` must exactly match the +public origin used for registration and authentication, while `PASSKEY_RP_ID` +must be that hostname or a registrable parent domain. The hosted environments +use `shd.build`, allowing credentials on both `shd.build` and +`dev.shd.build`. Direct localhost development should use `localhost`. + +Magic links require `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, +`SMTP_PASSWORD`, and `SMTP_FROM`. Port 465 normally uses +`SMTP_SECURE=true`; port 587 normally uses `false` and upgrades with STARTTLS. +Email identities become usable only after the account owner follows a +15-minute, single-use verification link. + ## Validate ```sh diff --git a/database/schema.sql b/database/schema.sql index f91c5c8..85a3461 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1,5 +1,5 @@ -- Initial embedded Turso schema. Every statement is safe to run repeatedly. -CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, discord_id TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, login_username TEXT, password_hash TEXT, discord_id TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS user_profiles ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, display_name TEXT NOT NULL, @@ -40,10 +40,52 @@ CREATE TABLE IF NOT EXISTS user_status ( CREATE TABLE IF NOT EXISTS login_events ( id TEXT PRIMARY KEY, user_id TEXT REFERENCES users(id) ON DELETE SET NULL, - provider TEXT NOT NULL CHECK(provider IN ('password', 'discord')), + provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')), succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)), created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS user_emails ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL COLLATE NOCASE UNIQUE, + verified_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS magic_link_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL COLLATE NOCASE, + purpose TEXT NOT NULL CHECK(purpose IN ('login', 'verify_email')), + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + consumed_at TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS magic_link_tokens_expiry_idx ON magic_link_tokens(expires_at); +CREATE TABLE IF NOT EXISTS passkeys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + webauthn_user_id TEXT NOT NULL, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + device_type TEXT NOT NULL, + backed_up INTEGER NOT NULL DEFAULT 0 CHECK(backed_up IN (0, 1)), + transports TEXT, + name TEXT NOT NULL, + last_used_at TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS passkeys_user_idx ON passkeys(user_id); +CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + flow TEXT NOT NULL CHECK(flow IN ('registration', 'authentication')), + challenge TEXT NOT NULL, + rp_id TEXT NOT NULL, + origin TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS webauthn_challenges_expiry_idx ON webauthn_challenges(expires_at); CREATE TABLE IF NOT EXISTS deleted_accounts ( user_id TEXT PRIMARY KEY REFERENCES users(id), original_username TEXT NOT NULL, @@ -85,6 +127,7 @@ CREATE TABLE IF NOT EXISTS discord_profiles ( updated_at TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS users_username_nocase_idx ON users(username COLLATE NOCASE); +CREATE UNIQUE INDEX IF NOT EXISTS users_login_username_nocase_idx ON users(login_username COLLATE NOCASE) WHERE login_username IS NOT NULL; CREATE TABLE IF NOT EXISTS stat_attributes ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 5586917..df912dd 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -69,6 +69,23 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps the left and the actionable secure-access console on the right. - OAuth providers appear as connection nodes with a recognizable provider color; SHD orange remains reserved for the primary local action and navigation. +- Authentication prioritizes passkeys, followed by one-time email magic links. + Discord remains a prominent provider option, while username/password is an + explicitly labeled optional fallback in a collapsed control. Passkey controls + name the platform interaction rather than exposing WebAuthn terminology. +- Verified email identities and registered passkeys appear as compact method + rows in Account Access. Every method displays its verification or device state, + registration date where useful, and protected removal behavior that prevents + an agent from deleting their last usable sign-in method. +- Local username and password are one optional authentication method, separate + from the public display name and the immutable internal account identifier. + Agents may remove both local credentials together after adding another method, + and may restore them later with a new unique login username and password. +- The “at least one sign-in method” rule is a server-side invariant shared by + self-service and ISAC actions. Passkeys, verified magic-link email, Discord, + and a complete local username/password pair each count as usable methods. + Controls may disable unsafe removal for clarity, but server validation remains + authoritative. Intentional soft account deletion is the sole exception. - Linked identity nodes show provider display name, account handle or stable ID, and an explicit textual `LINKED` state. Unlink controls sit beneath the node, require credential confirmation, and remain disabled with an explanation when diff --git a/package-lock.json b/package-lock.json index aaf9744..62e0ae2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,11 @@ "name": "shd-build", "version": "0.0.1", "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "@tursodatabase/database": "^0.7.1", "argon2": "^0.45.1", + "nodemailer": "^9.0.3", "sharp": "^0.35.3" }, "devDependencies": { @@ -18,6 +21,7 @@ "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/node": "^26.1.1", + "@types/nodemailer": "^8.0.1", "svelte": "^5.56.1", "svelte-check": "^4.6.0", "typescript": "^6.0.3", @@ -95,6 +99,12 @@ "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", "license": "MIT" }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -693,6 +703,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -722,6 +738,174 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", + "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -1020,6 +1204,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1233,6 +1442,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1279,6 +1498,20 @@ "node": ">= 0.4" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1815,6 +2048,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -1887,6 +2129,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1901,6 +2161,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -2146,8 +2412,25 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" }, "node_modules/typescript": { "version": "6.0.3", diff --git a/package.json b/package.json index f4316fd..b0338f7 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,18 @@ "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/node": "^26.1.1", + "@types/nodemailer": "^8.0.1", "svelte": "^5.56.1", "svelte-check": "^4.6.0", "typescript": "^6.0.3", "vite": "^8.0.16" }, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "@tursodatabase/database": "^0.7.1", "argon2": "^0.45.1", + "nodemailer": "^9.0.3", "sharp": "^0.35.3" } } diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 3595b35..2ec76fc 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -18,6 +18,18 @@ await mkdir(dirname(databasePath), { recursive: true }); const database = await connect(databasePath); const schema = await readFile(schemaPath, 'utf8'); +const usersTable = await (await database.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='users'" +)).get(); +if (usersTable) { + const userColumns = await (await database.prepare("PRAGMA table_info('users')")).all(); + if (!userColumns.some((column) => column.name === 'login_username')) { + console.log('Separating optional local login usernames from internal account identifiers.'); + await database.exec('ALTER TABLE users ADD COLUMN login_username TEXT'); + await database.exec('UPDATE users SET login_username=username WHERE password_hash IS NOT NULL'); + } +} + // The project schema deliberately avoids semicolons inside string literals. const statements = schema .split(';') @@ -28,6 +40,25 @@ for (const statement of statements) { await database.exec(statement); } +const loginEventsDefinition = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='login_events'" +)).get(); +if (typeof loginEventsDefinition?.sql === 'string' && !loginEventsDefinition.sql.includes('magic_link')) { + console.log('Expanding login event providers for passwordless authentication.'); + await database.exec('ALTER TABLE login_events RENAME TO login_events_legacy'); + await database.exec(`CREATE TABLE login_events ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')), + succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)), + created_at TEXT NOT NULL + )`); + await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at) + SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`); + await database.exec('DROP TABLE login_events_legacy'); + await database.exec('CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)'); +} + console.log(`Applied ${statements.length} schema statements to ${databasePath}`); if (!databaseAlreadyExisted) { diff --git a/scripts/default-users.mjs b/scripts/default-users.mjs index af91fad..a4afb5d 100644 --- a/scripts/default-users.mjs +++ b/scripts/default-users.mjs @@ -35,18 +35,18 @@ async function secureHash(password) { export async function initializeDefaultUsers(database, options = {}) { const resetPasswords = options.resetPasswords === true; const findUser = await database.prepare( - 'SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE' + 'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE' ); const insertUser = await database.prepare( - 'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)' + 'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)' ); const updateCredential = await database.prepare( - 'UPDATE users SET password_hash = ?, role = ? WHERE id = ?' + 'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?' ); const credentials = []; for (const definition of defaultUsers) { - const existing = await findUser.get(definition.username); + const existing = await findUser.get(definition.username, definition.username); if (existing && !resetPasswords) { console.log(`${definition.username}: already exists (${existing.role}); left unchanged`); continue; @@ -60,12 +60,13 @@ export async function initializeDefaultUsers(database, options = {}) { const passwordHash = await secureHash(password); if (existing) { - await updateCredential.run(passwordHash, String(existing.role), existing.id); + await updateCredential.run(definition.username, passwordHash, String(existing.role), existing.id); console.log(`${definition.username}: credentials reset; role and related data preserved`); } else { await insertUser.run( randomUUID(), definition.username, + definition.username, passwordHash, definition.role, new Date().toISOString() diff --git a/src/app.css b/src/app.css index 593bdb0..03b6f9b 100644 --- a/src/app.css +++ b/src/app.css @@ -61,6 +61,7 @@ .account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1} .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} +.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}} @@ -71,6 +72,8 @@ .moderation-action,.isac-control-link{position:relative;min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:9px;border-radius:0;font-size:8px;font-weight:900;letter-spacing:1.15px;text-transform:uppercase;cursor:pointer;transition:border-color .14s ease,background-color .14s ease,color .14s ease,transform .08s ease,filter .14s ease}.moderation-action.secondary,.isac-control-link{border:1px solid #424849;background:#101314;color:#d7d9d5}.moderation-action.secondary:hover,.isac-control-link:hover{border-color:#8a672b;background:#181711;color:#f6a821}.moderation-action.danger{border:1px solid #70423b;background:#211414;color:#ee8b78}.moderation-action.danger:hover{border-color:#d06d58;background:#321a17;color:#ffad9d}.moderation-action:focus-visible,.isac-control-link:focus-visible{outline:2px solid #f6a821;outline-offset:3px}.moderation-action:not(:disabled):active,.isac-control-link:active{transform:translateY(2px) scale(.985);filter:brightness(.86)}.moderation-action:disabled{border-color:#343839;background:#121415;color:#5f6668;cursor:not-allowed;opacity:.65}.moderation-panel fieldset[aria-busy=true] .moderation-action{cursor:wait;padding-right:34px}.moderation-panel fieldset[aria-busy=true] .moderation-action:after{content:"";position:absolute;right:12px;width:10px;height:10px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite} @keyframes interaction-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.moderation-action,.isac-control-link{transition:none}.moderation-action:not(:disabled):active,.isac-control-link:active{transform:none}.moderation-panel fieldset[aria-busy=true] .moderation-action:after,.discord-link-button[aria-busy="true"]:after{animation-duration:1.4s}} .identity-note{display:block;margin-top:5px;color:#687073;font-size:7px;line-height:1.4} +.passkey-intro{margin:-6px 0 10px}.passkey-list{display:grid;border:1px solid var(--shd-border)}.passkey-card{min-height:48px;display:grid;grid-template-columns:1fr auto;align-items:center;gap:8px;padding:7px 9px;border-top:1px solid var(--shd-border-soft);background:#0d1011}.passkey-card:first-child{border-top:0}.passkey-card>div{display:grid;gap:4px}.passkey-card b,.verified-email b{font-size:9px}.passkey-card small,.verified-email small{color:var(--shd-text-dim);font-size:6px;letter-spacing:.7px}.passkey-remove{height:30px;padding:0 8px;border:1px solid #563734;background:#181112;color:var(--shd-danger);font-size:7px;font-weight:900;letter-spacing:.7px;cursor:pointer}.passkey-remove:hover{border-color:var(--shd-danger);background:#281716}.account-method-empty{margin:0;padding:20px;text-align:center;border:1px dashed var(--shd-border);color:var(--shd-text-dim);font-size:7px;letter-spacing:1px}.verified-email{display:grid;grid-template-columns:1fr auto;align-items:center;padding:9px;border:1px solid #3f6a4c;background:#101713}.verified-email>div{display:grid;gap:4px}.verified-email>span{color:var(--shd-success);font-size:7px;font-weight:900;letter-spacing:1px} +.method-removal{margin-top:14px;border:1px solid #563734;background:#181112}.method-removal summary{padding:10px;color:var(--shd-danger);font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.method-removal>p,.method-removal>form{margin:0;padding:0 10px 10px}.method-removal>p{color:#9f8580;font-size:8px;line-height:1.5} .brand-name{display:block;max-width:420px;margin:10px 0}.effect-editor{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.effect-band{display:grid;grid-template-columns:58px minmax(0,1fr) 100px;align-items:end;gap:8px;padding:9px;background:#101314;border:1px solid #343a3b}.effect-band>strong{align-self:stretch;display:grid;place-content:center;text-align:center;color:#f6a821;font-size:23px;border-right:1px solid #343a3b}.effect-band>strong small{display:block;color:#707779;font-size:6px;letter-spacing:1px}.effect-band .field{margin:0}.split-actions{justify-content:space-between}.split-actions>div{display:flex;gap:8px}.brand-set-row{grid-template-columns:1.1fr 1.2fr 1.2fr 1.2fr .6fr .3fr}.empty-dashboard{min-height:120px;display:grid;place-content:center;text-align:center;gap:6px;color:#626a6c}.empty-dashboard strong{font-size:10px;letter-spacing:1.2px}.empty-dashboard span{font-size:8px} .gear-identity{display:grid;grid-template-columns:2fr 1fr .65fr .65fr;gap:8px}.gear-ranges{display:grid;grid-template-columns:1fr 1fr;gap:8px}.range-band{grid-template-columns:58px 1fr 1fr}.effect-band.unavailable{opacity:.4}.selection-head{display:flex;justify-content:space-between;align-items:end;margin:14px 0 6px}.selection-head>div{display:grid}.selection-head span{font-size:8px;font-weight:bold;letter-spacing:1.2px}.selection-head small{font-size:7px;color:#697174;margin-top:3px}.selection-head b{color:#f6a821;font-size:8px;letter-spacing:1px}.selection-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid #303536}.selection-grid label{min-height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:5px;padding:6px 8px;border:1px solid transparent;border-right-color:#282d2e;border-bottom-color:#282d2e;cursor:pointer;font-size:9px}.selection-grid label:hover{background:#171b1c;border-color:#4b4f50}.selection-grid input{width:auto;margin:0;accent-color:#f6a821}.selection-grid small{font-size:6px;color:#646c6e;letter-spacing:.8px}.selection-grid input:checked~small{color:#75c08c}.gear-row{min-width:1050px;grid-template-columns:1.2fr .8fr .8fr 1.35fr .8fr .6fr .3fr}.gear-row>span small{font-size:8px;color:#858b8d} diff --git a/src/lib/server/auth/account-deletion.ts b/src/lib/server/auth/account-deletion.ts index edb8daa..af3a4c2 100644 --- a/src/lib/server/auth/account-deletion.ts +++ b/src/lib/server/auth/account-deletion.ts @@ -9,7 +9,7 @@ export async function softDeleteAccount( reason: string | null ): Promise { const user = await (await database.prepare(` - SELECT users.username,user_profiles.display_name,deleted_accounts.deleted_at + SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=? @@ -20,15 +20,19 @@ export async function softDeleteAccount( await (await database.prepare( 'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)' - )).run(userId, String(user.username), now, deletedBy, reason); + )).run(userId, String(user.login_username ?? user.username), now, deletedBy, reason); await (await database.prepare( - `UPDATE users SET username=?,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?` + `UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?` )).run(deletedUsername, userId); await (await database.prepare( `INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?) ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at` )).run(userId, 'Deleted Agent', now); await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId); + await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId); + await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId); + await (await database.prepare('DELETE FROM passkeys WHERE user_id=?')).run(userId); + await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(userId); await (await database.prepare('DELETE FROM oauth_states WHERE user_id=?')).run(userId); await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(userId); await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(userId); diff --git a/src/lib/server/auth/magic-link.ts b/src/lib/server/auth/magic-link.ts new file mode 100644 index 0000000..199fb78 --- /dev/null +++ b/src/lib/server/auth/magic-link.ts @@ -0,0 +1,29 @@ +import type { Database } from '@tursodatabase/database'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +const MAGIC_LINK_MINUTES = 15; + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export async function createMagicLinkToken(database: Database, userId: string, email: string, purpose: 'login' | 'verify_email'): Promise { + const token = randomBytes(32).toString('base64url'); + const now = new Date(); + await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=? AND purpose=?')).run(userId, purpose); + await (await database.prepare(`INSERT INTO magic_link_tokens(id,user_id,email,purpose,token_hash,expires_at,created_at) + VALUES(?,?,?,?,?,?,?)`)).run( + randomUUID(), userId, email, purpose, hashToken(token), + new Date(now.getTime() + MAGIC_LINK_MINUTES * 60_000).toISOString(), now.toISOString() + ); + return token; +} + +export async function consumeMagicLinkToken(database: Database, token: string) { + const now = new Date().toISOString(); + const row = await (await database.prepare(`UPDATE magic_link_tokens SET consumed_at=? + WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? + RETURNING id,user_id,email,purpose`)).get(now, hashToken(token), now); + if (!row) return null; + return { id: String(row.id), userId: String(row.user_id), email: String(row.email), purpose: String(row.purpose) as 'login' | 'verify_email' }; +} diff --git a/src/lib/server/auth/mail.ts b/src/lib/server/auth/mail.ts new file mode 100644 index 0000000..084fe80 --- /dev/null +++ b/src/lib/server/auth/mail.ts @@ -0,0 +1,43 @@ +import { env } from '$env/dynamic/private'; +import nodemailer from 'nodemailer'; + +let transporter: ReturnType | undefined; + +function mailer() { + if (!env.SMTP_HOST || !env.SMTP_FROM) { + throw new Error('Email delivery is not configured.'); + } + if (!transporter) { + transporter = nodemailer.createTransport({ + host: env.SMTP_HOST, + port: Number(env.SMTP_PORT || 587), + secure: env.SMTP_SECURE === 'true', + auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined, + requireTLS: env.SMTP_SECURE !== 'true', + disableFileAccess: true, + disableUrlAccess: true + }); + } + return transporter; +} + +export function applicationOrigin(): string { + return (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, ''); +} + +export function emailDeliveryConfigured(): boolean { + return Boolean(env.SMTP_HOST && env.SMTP_FROM); +} + +export async function sendAuthenticationLink(email: string, token: string, purpose: 'login' | 'verify_email'): Promise { + const url = `${applicationOrigin()}/auth/magic-link?token=${encodeURIComponent(token)}`; + const subject = purpose === 'login' ? 'Sign in to SHD.build' : 'Verify your SHD.build email'; + const action = purpose === 'login' ? 'SIGN IN TO SHD.BUILD' : 'VERIFY EMAIL ADDRESS'; + await mailer().sendMail({ + from: env.SMTP_FROM, + to: email, + subject, + text: `${subject}\n\nOpen this one-time link within 15 minutes:\n${url}\n\nIf you did not request this, you can ignore this message.`, + html: `

SHD IDENTITY NETWORK

${subject}

This one-time link expires in 15 minutes.

${action}

If you did not request this, you can ignore this message.

` + }); +} diff --git a/src/lib/server/auth/methods.ts b/src/lib/server/auth/methods.ts new file mode 100644 index 0000000..bc01d9e --- /dev/null +++ b/src/lib/server/auth/methods.ts @@ -0,0 +1,26 @@ +import type { Database } from '@tursodatabase/database'; + +export type AuthenticationMethods = { + local: boolean; + discord: boolean; + email: boolean; + passkeys: number; + total: number; +}; + +export async function getAuthenticationMethods(database: Database, userId: string): Promise { + const row = await (await database.prepare(`SELECT users.login_username,users.password_hash,users.discord_id, + (SELECT COUNT(*) FROM user_emails WHERE user_id=users.id) AS email_count, + (SELECT COUNT(*) FROM passkeys WHERE user_id=users.id) AS passkey_count + FROM users WHERE id=?`)).get(userId); + const local = typeof row?.login_username === 'string' && typeof row?.password_hash === 'string'; + const discord = typeof row?.discord_id === 'string'; + const email = Number(row?.email_count) > 0; + const passkeys = Number(row?.passkey_count ?? 0); + return { local, discord, email, passkeys, total: Number(local) + Number(discord) + Number(email) + passkeys }; +} + +export function canRemoveAuthenticationMethod(methods: AuthenticationMethods, method: 'local' | 'discord' | 'email' | 'passkey'): boolean { + const removed = method === 'passkey' ? 1 : Number(methods[method]); + return removed > 0 && methods.total - removed >= 1; +} diff --git a/src/lib/server/auth/passkeys.ts b/src/lib/server/auth/passkeys.ts new file mode 100644 index 0000000..2fa53a5 --- /dev/null +++ b/src/lib/server/auth/passkeys.ts @@ -0,0 +1,31 @@ +import { env } from '$env/dynamic/private'; +import type { Database } from '@tursodatabase/database'; +import { randomUUID } from 'node:crypto'; + +export const PASSKEY_RP_NAME = 'SHD.build'; + +export function passkeyContext(url: URL): { rpID: string; origin: string } { + const configuredOrigin = env.APP_ORIGIN?.replace(/\/+$/, ''); + const origin = configuredOrigin || url.origin; + const hostname = new URL(origin).hostname; + return { + rpID: env.PASSKEY_RP_ID || (hostname === 'localhost' || hostname === '127.0.0.1' ? hostname : 'shd.build'), + origin + }; +} + +export async function storeChallenge(database: Database, flow: 'registration' | 'authentication', challenge: string, context: { rpID: string; origin: string }, userId?: string): Promise { + const id = randomUUID(), now = new Date(); + await (await database.prepare('DELETE FROM webauthn_challenges WHERE expires_at<=?')).run(now.toISOString()); + await (await database.prepare(`INSERT INTO webauthn_challenges(id,user_id,flow,challenge,rp_id,origin,expires_at,created_at) + VALUES(?,?,?,?,?,?,?,?)`)).run(id, userId ?? null, flow, challenge, context.rpID, context.origin, new Date(now.getTime() + 5 * 60_000).toISOString(), now.toISOString()); + return id; +} + +export async function consumeChallenge(database: Database, id: string, flow: 'registration' | 'authentication', userId?: string) { + const row = await (await database.prepare(`SELECT * FROM webauthn_challenges WHERE id=? AND flow=? AND expires_at>? + AND (? IS NULL OR user_id=?)`)).get(id, flow, new Date().toISOString(), userId ?? null, userId ?? null); + if (!row) return null; + await (await database.prepare('DELETE FROM webauthn_challenges WHERE id=?')).run(id); + return row; +} diff --git a/src/lib/server/auth/session.ts b/src/lib/server/auth/session.ts index aea0f41..46f8085 100644 --- a/src/lib/server/auth/session.ts +++ b/src/lib/server/auth/session.ts @@ -10,6 +10,7 @@ const SESSION_SECONDS = 60 * 60 * 24 * 30; export type AuthUser = { id: string; username: string; + loginUsername: string | null; displayName: string; role: string; discordLinked: boolean; @@ -46,7 +47,7 @@ export async function createSession(cookies: Cookies, userId: string): Promise { +export async function recordLogin(userId: string | null, provider: 'password' | 'discord' | 'magic_link' | 'passkey', succeeded: boolean): Promise { const database = await getDatabase(); await (await database.prepare('INSERT INTO login_events(id,user_id,provider,succeeded,created_at) VALUES(?,?,?,?,?)')) .run(randomUUID(), userId, provider, succeeded ? 1 : 0, new Date().toISOString()); @@ -58,7 +59,7 @@ export async function getSessionUser(cookies: Cookies): Promise const database = await getDatabase(); const statement = await database.prepare(` - SELECT users.id, users.username, COALESCE(user_profiles.display_name,users.username) AS display_name, + SELECT users.id, users.username, users.login_username, COALESCE(user_profiles.display_name,users.username) AS display_name, users.role, users.discord_id, user_avatars.source AS avatar_source, user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at, user_status.last_active_at,user_status.blocked_at,user_status.banned_until, @@ -93,6 +94,7 @@ export async function getSessionUser(cookies: Cookies): Promise return { id: String(user.id), username: String(user.username), + loginUsername: typeof user.login_username === 'string' ? user.login_username : null, displayName: String(user.display_name), role: String(user.role), discordLinked: typeof user.discord_id === 'string', diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index 922bc66..bbe3a5f 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -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 = { @@ -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); diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 2f18527..b6ef1e7 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -1,5 +1,7 @@ {data.profile.display_name} — ISAC user operations -

AGENT DOSSIER

{data.profile.display_name}

Private login: {data.profile.username} · Created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}

← USER REGISTRY
+

AGENT DOSSIER

{data.profile.display_name}

Private login: {data.profile.username ?? 'Not configured'} · Created {date(data.profile.created_at)} · {data.authenticationMethods.total} sign-in method{data.authenticationMethods.total === 1 ? '' : 's'}

← USER REGISTRY
ACCESS STATE{accountState()}{data.profile.moderation_reason ?? 'No restrictions'}
LAST ACTIVE{date(data.profile.last_active_at)}Site activity
BUILDS{data.builds.length}Recent records loaded
COMMENTS{data.comments.length}Recent records loaded
@@ -47,7 +47,7 @@ {#if form?.avatarRemoved}

Custom avatar removed and reset to the default SHD portrait.

{/if}

-

LINKED DISCORD IDENTITY

{#if data.profile.discord_id}
{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}
LINKED
{#if can('users.identities')}{#if !Number(data.profile.has_password)}

Discord is this agent’s only sign-in method. They must set a password before unlinking.

{/if}{#if form?.identityError}

{form.identityError}

{/if}
{/if}{:else}

NO DISCORD IDENTITY LINKED

{/if}
+

LINKED DISCORD IDENTITY

{#if data.profile.discord_id}
{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}
LINKED
{#if can('users.identities')}{#if data.authenticationMethods.total <= 1}

Discord is this agent’s only sign-in method and cannot be removed.

{/if}{#if form?.identityError}

{form.identityError}

{/if}
{/if}{:else}

NO DISCORD IDENTITY LINKED

{/if}

{#if can('users.moderate') && !data.profile.deleted_at} {#if data.isSelf}

ISAC will not permit self-restriction.

{/if}{#if form?.moderationError}

{form.moderationError}

{/if}
diff --git a/src/routes/auth/magic-link/+server.ts b/src/routes/auth/magic-link/+server.ts new file mode 100644 index 0000000..c866dd8 --- /dev/null +++ b/src/routes/auth/magic-link/+server.ts @@ -0,0 +1,34 @@ +import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap'; +import { consumeMagicLinkToken } from '$lib/server/auth/magic-link'; +import { createSession, recordLogin } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const token = url.searchParams.get('token') ?? ''; + const database = await getDatabase(); + const link = token ? await consumeMagicLinkToken(database, token) : null; + if (!link) redirect(303, '/sign-in?magic_error=invalid'); + + const user = await (await database.prepare(`SELECT users.id,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at + FROM users LEFT JOIN user_status ON user_status.user_id=users.id + LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)).get(link.userId); + const unavailable = !user || user.deleted_at || user.blocked_at || + (typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date()); + if (unavailable) { + await recordLogin(link.userId, 'magic_link', false); + redirect(303, '/sign-in?magic_error=unavailable'); + } + + if (link.purpose === 'verify_email') { + const now = new Date().toISOString(); + await (await database.prepare(`INSERT INTO user_emails(user_id,email,verified_at,updated_at) VALUES(?,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET email=excluded.email,verified_at=excluded.verified_at,updated_at=excluded.updated_at`)) + .run(link.userId, link.email, now, now); + } + await ensureBootstrapAdmin(database, link.userId); + await createSession(cookies, link.userId); + await recordLogin(link.userId, 'magic_link', true); + redirect(303, link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account'); +}; diff --git a/src/routes/auth/passkey/authenticate/options/+server.ts b/src/routes/auth/passkey/authenticate/options/+server.ts new file mode 100644 index 0000000..e195bdf --- /dev/null +++ b/src/routes/auth/passkey/authenticate/options/+server.ts @@ -0,0 +1,17 @@ +import { passkeyContext, storeChallenge } from '$lib/server/auth/passkeys'; +import { getDatabase } from '$lib/server/database'; +import { generateAuthenticationOptions } from '@simplewebauthn/server'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ url }) => { + const database = await getDatabase(); + const context = passkeyContext(url); + const options = await generateAuthenticationOptions({ + rpID: context.rpID, + userVerification: 'required', + allowCredentials: [] + }); + const challengeId = await storeChallenge(database, 'authentication', options.challenge, context); + return json({ options, challengeId }); +}; diff --git a/src/routes/auth/passkey/authenticate/verify/+server.ts b/src/routes/auth/passkey/authenticate/verify/+server.ts new file mode 100644 index 0000000..db3f579 --- /dev/null +++ b/src/routes/auth/passkey/authenticate/verify/+server.ts @@ -0,0 +1,57 @@ +import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap'; +import { consumeChallenge } from '$lib/server/auth/passkeys'; +import { createSession, recordLogin } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { verifyAuthenticationResponse, type AuthenticationResponseJSON, type AuthenticatorTransportFuture } from '@simplewebauthn/server'; +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +function bytes(value: unknown): Uint8Array { + if (value instanceof Uint8Array) { + const copy = new Uint8Array(value.byteLength); + copy.set(value); + return copy; + } + if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0)); + if (Array.isArray(value)) return new Uint8Array(value); + throw new Error('Invalid passkey public key.'); +} + +export const POST: RequestHandler = async ({ cookies, request }) => { + const body = await request.json() as { challengeId?: string; response?: AuthenticationResponseJSON }; + if (!body.challengeId || !body.response) error(400, 'Incomplete passkey authentication.'); + const database = await getDatabase(); + const challenge = await consumeChallenge(database, body.challengeId, 'authentication'); + if (!challenge) error(400, 'The passkey request expired. Try again.'); + const passkey = await (await database.prepare(`SELECT passkeys.*,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at + FROM passkeys JOIN users ON users.id=passkeys.user_id + LEFT JOIN user_status ON user_status.user_id=users.id + LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE passkeys.id=?`)).get(body.response.id); + if (!passkey) error(400, 'This passkey is not registered with SHD.build.'); + const unavailable = passkey.deleted_at || passkey.blocked_at || + (typeof passkey.banned_until === 'string' && new Date(passkey.banned_until) > new Date()); + if (unavailable) { + await recordLogin(String(passkey.user_id), 'passkey', false); + error(403, 'This account is not permitted to sign in.'); + } + const transports = passkey.transports ? JSON.parse(String(passkey.transports)) as AuthenticatorTransportFuture[] : undefined; + const verification = await verifyAuthenticationResponse({ + response: body.response, + expectedChallenge: String(challenge.challenge), + expectedOrigin: String(challenge.origin), + expectedRPID: String(challenge.rp_id), + credential: { id: String(passkey.id), publicKey: bytes(passkey.public_key), counter: Number(passkey.counter), transports }, + requireUserVerification: true + }); + if (!verification.verified) { + await recordLogin(String(passkey.user_id), 'passkey', false); + error(400, 'Passkey authentication could not be verified.'); + } + const now = new Date().toISOString(); + await (await database.prepare('UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?')) + .run(verification.authenticationInfo.newCounter, now, passkey.id); + await ensureBootstrapAdmin(database, String(passkey.user_id)); + await createSession(cookies, String(passkey.user_id)); + await recordLogin(String(passkey.user_id), 'passkey', true); + return json({ verified: true, redirectTo: '/account' }); +}; diff --git a/src/routes/auth/passkey/register/options/+server.ts b/src/routes/auth/passkey/register/options/+server.ts new file mode 100644 index 0000000..1b61dfc --- /dev/null +++ b/src/routes/auth/passkey/register/options/+server.ts @@ -0,0 +1,29 @@ +import { getSessionUser } from '$lib/server/auth/session'; +import { PASSKEY_RP_NAME, passkeyContext, storeChallenge } from '$lib/server/auth/passkeys'; +import { getDatabase } from '$lib/server/database'; +import { generateRegistrationOptions } from '@simplewebauthn/server'; +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ cookies, url }) => { + const user = await getSessionUser(cookies); + if (!user) error(401, 'Sign in before registering a passkey.'); + const database = await getDatabase(); + const passkeys = await (await database.prepare('SELECT id,transports FROM passkeys WHERE user_id=?')).all(user.id); + const context = passkeyContext(url); + const options = await generateRegistrationOptions({ + rpName: PASSKEY_RP_NAME, + rpID: context.rpID, + userID: new TextEncoder().encode(user.id), + userName: user.loginUsername ?? user.displayName, + userDisplayName: user.displayName, + attestationType: 'none', + excludeCredentials: passkeys.map((passkey) => ({ + id: String(passkey.id), + transports: passkey.transports ? JSON.parse(String(passkey.transports)) : undefined + })), + authenticatorSelection: { residentKey: 'required', userVerification: 'required' } + }); + const challengeId = await storeChallenge(database, 'registration', options.challenge, context, user.id); + return json({ options, challengeId }); +}; diff --git a/src/routes/auth/passkey/register/verify/+server.ts b/src/routes/auth/passkey/register/verify/+server.ts new file mode 100644 index 0000000..199b664 --- /dev/null +++ b/src/routes/auth/passkey/register/verify/+server.ts @@ -0,0 +1,34 @@ +import { getSessionUser } from '$lib/server/auth/session'; +import { consumeChallenge } from '$lib/server/auth/passkeys'; +import { getDatabase } from '$lib/server/database'; +import { verifyRegistrationResponse, type RegistrationResponseJSON } from '@simplewebauthn/server'; +import { error, json } from '@sveltejs/kit'; +import { Buffer } from 'node:buffer'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const user = await getSessionUser(cookies); + if (!user) error(401, 'Sign in before registering a passkey.'); + const body = await request.json() as { challengeId?: string; name?: string; response?: RegistrationResponseJSON }; + if (!body.challengeId || !body.response) error(400, 'Incomplete passkey registration.'); + const database = await getDatabase(); + const challenge = await consumeChallenge(database, body.challengeId, 'registration', user.id); + if (!challenge) error(400, 'The passkey registration expired. Try again.'); + const verification = await verifyRegistrationResponse({ + response: body.response, + expectedChallenge: String(challenge.challenge), + expectedOrigin: String(challenge.origin), + expectedRPID: String(challenge.rp_id), + requireUserVerification: true + }); + if (!verification.verified || !verification.registrationInfo) error(400, 'Passkey registration could not be verified.'); + const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + const name = String(body.name || 'My passkey').trim().slice(0, 60) || 'My passkey'; + await (await database.prepare(`INSERT INTO passkeys(id,user_id,webauthn_user_id,public_key,counter,device_type,backed_up,transports,name,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`)).run( + credential.id, user.id, Buffer.from(user.id).toString('base64url'), + credential.publicKey, credential.counter, credentialDeviceType, credentialBackedUp ? 1 : 0, + credential.transports ? JSON.stringify(credential.transports) : null, name, new Date().toISOString() + ); + return json({ verified: true }); +}; diff --git a/src/routes/sign-in/+page.server.ts b/src/routes/sign-in/+page.server.ts index 1566fa4..e14a49a 100644 --- a/src/routes/sign-in/+page.server.ts +++ b/src/routes/sign-in/+page.server.ts @@ -2,10 +2,39 @@ import { createSession, destroySession, recordLogin } from '$lib/server/auth/ses import { 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 type { Actions } from './$types'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => ({ + emailDeliveryConfigured: emailDeliveryConfigured(), + magicLinkSent: url.searchParams.has('magic_sent'), + magicError: url.searchParams.get('magic_error') === 'unavailable' + ? 'This account is not permitted to sign in.' + : url.searchParams.has('magic_error') ? 'That sign-in link is invalid or expired.' : null, + oauthError: url.searchParams.has('oauth_error') ? 'Discord sign-in could not be completed.' : null +}); export const actions: Actions = { + magicLink: async ({ request }) => { + const email = String((await request.formData()).get('email') ?? '').normalize('NFKC').trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) { + return fail(400, { magicLinkError: 'Enter a valid email address.', email }); + } + 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 + 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); + 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'); + } + return { magicLinkSent: true }; + }, login: async ({ request, cookies }) => { const form = await request.formData(); const username = String(form.get('username') ?? '').trim(); @@ -13,7 +42,7 @@ export const actions: Actions = { if (!username || !password) return fail(400, { loginError: 'Enter your username and password.', username }); const database = await getDatabase(); const user = await (await database.prepare(`SELECT users.id,users.password_hash,user_status.blocked_at,user_status.banned_until - FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE username = ? COLLATE NOCASE`)).get(username); + FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE login_username = ? COLLATE NOCASE`)).get(username); const valid = typeof user?.password_hash === 'string' && await verifyPassword(user.password_hash, password); if (!valid) { if (user?.id) await recordLogin(String(user.id), 'password', false); diff --git a/src/routes/sign-in/+page.svelte b/src/routes/sign-in/+page.svelte index ed760fe..8cc92ec 100644 --- a/src/routes/sign-in/+page.svelte +++ b/src/routes/sign-in/+page.svelte @@ -1,7 +1,34 @@ 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
Continue with Discord
OR USE LOCAL CREDENTIALS
{#if form?.loginError}

{form.loginError}

{/if}{#if data.oauthError}

{data.oauthError}

{/if}
Credentials are verified securely on the server. Discord shares only the basic identity information required to link your account.
+

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
+ + {#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}
+
OR CONTINUE WITH
+ Continue with Discord + {#if data.oauthError}

{data.oauthError}

{/if} +
USE USERNAME AND PASSWORD{#if form?.loginError}

{form.loginError}

{/if}
+ Passkeys and one-time email links are preferred. Password sign-in remains available as an optional fallback. +
diff --git a/src/routes/sign-in/+page.ts b/src/routes/sign-in/+page.ts deleted file mode 100644 index 0552e7d..0000000 --- a/src/routes/sign-in/+page.ts +++ /dev/null @@ -1,2 +0,0 @@ -import type { PageLoad } from './$types'; -export const load: PageLoad = ({ url }) => ({ oauthError: url.searchParams.has('oauth_error') });