Add passkey and magic link authentication

This commit is contained in:
JB
2026-07-25 01:54:33 -04:00
parent 3c9f443499
commit c3244b0c79
31 changed files with 1017 additions and 58 deletions
+18 -4
View File
@@ -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==
+15
View File
@@ -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 <no-reply@shd.build>
+18 -4
View File
@@ -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==
+14
View File
@@ -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
+45 -2
View File
@@ -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,
+17
View File
@@ -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
+285 -2
View File
@@ -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",
+4
View File
@@ -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"
}
}
+31
View File
@@ -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) {
+6 -5
View File
@@ -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()
+3
View File
@@ -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}
+7 -3
View File
@@ -9,7 +9,7 @@ export async function softDeleteAccount(
reason: string | null
): Promise<boolean> {
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);
+29
View File
@@ -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<string> {
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' };
}
+43
View File
@@ -0,0 +1,43 @@
import { env } from '$env/dynamic/private';
import nodemailer from 'nodemailer';
let transporter: ReturnType<typeof nodemailer.createTransport> | 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<void> {
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: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD IDENTITY NETWORK</p><h1 style="font-size:24px">${subject}</h1><p style="color:#a5aaab">This one-time link expires in 15 minutes.</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">${action}</a></p><p style="color:#737a7c;font-size:12px">If you did not request this, you can ignore this message.</p></div>`
});
}
+26
View File
@@ -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<AuthenticationMethods> {
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;
}
+31
View File
@@ -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<string> {
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;
}
+4 -2
View File
@@ -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<v
});
}
export async function recordLogin(userId: string | null, provider: 'password' | 'discord', succeeded: boolean): Promise<void> {
export async function recordLogin(userId: string | null, provider: 'password' | 'discord' | 'magic_link' | 'passkey', succeeded: boolean): Promise<void> {
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<AuthUser | null>
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<AuthUser | null>
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',
+104 -13
View File
@@ -5,19 +5,25 @@ import { getDatabase } from '$lib/server/database';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { AVATAR_PRESETS, AvatarValidationError, processAvatarUpload, saveUploadedAvatar, selectPresetAvatar } from '$lib/server/avatars';
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const [builds, guides, favorites, ratings, connection] = await Promise.all([
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id),
(await database.prepare(`SELECT users.discord_id,users.password_hash,discord_profiles.username AS discord_username,
(await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,discord_profiles.username AS discord_username,
discord_profiles.display_name,discord_profiles.avatar_hash
FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id)
FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id),
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(user.id),
(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(user.id),
getAuthenticationMethods(database, user.id)
]);
const oauthErrorCode = url.searchParams.get('oauth_error');
const oauthErrors: Record<string, { title: string; message: string }> = {
@@ -47,17 +53,79 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
username: connection.discord_username ? String(connection.discord_username) : null,
displayName: connection.display_name ? String(connection.display_name) : null,
avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
canUnlink: typeof connection.password_hash === 'string'
canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
} : null,
hasPassword: typeof connection?.password_hash === 'string',
hasLocalCredentials: typeof connection?.password_hash === 'string' && typeof connection?.login_username === 'string',
emailIdentity: emailIdentity ? { email: String(emailIdentity.email), verifiedAt: String(emailIdentity.verified_at) } : null,
authenticationMethods,
emailDeliveryConfigured: emailDeliveryConfigured(),
emailVerified: url.searchParams.has('email_verified'),
passkeys: passkeys.map((passkey) => ({
id: String(passkey.id), name: String(passkey.name), deviceType: String(passkey.device_type),
backedUp: Boolean(passkey.backed_up), lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
createdAt: String(passkey.created_at)
})),
chooseDisplayName: url.searchParams.has('choose_display_name'),
discordLinked: url.searchParams.has('discord_linked'),
oauthError,
avatarPresets: AVATAR_PRESETS
avatarPresets: AVATAR_PRESETS
};
};
export const actions: Actions = {
requestEmailVerification: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const email = String((await request.formData()).get('email') ?? '').normalize('NFKC').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
return fail(400, { emailError: 'Enter a valid email address.' });
}
if (!emailDeliveryConfigured()) return fail(503, { emailError: 'Email delivery is not configured yet.' });
const database = await getDatabase();
const owner = await (await database.prepare('SELECT user_id FROM user_emails WHERE email=? COLLATE NOCASE AND user_id<>?')).get(email, user.id);
if (owner) return fail(409, { emailError: 'That email address is already linked to another agent.' });
const token = await createMagicLinkToken(database, user.id, email, 'verify_email');
await sendAuthenticationLink(email, token, 'verify_email');
return { emailVerificationSent: true };
},
removePasskey: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const id = String((await request.formData()).get('passkeyId') ?? '');
const database = await getDatabase();
const methods = await getAuthenticationMethods(database, user.id);
if (!canRemoveAuthenticationMethod(methods, 'passkey')) {
return fail(409, { passkeyError: 'Add another sign-in method before removing your only passkey.' });
}
await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(id, user.id);
return { passkeyRemoved: true };
},
removeEmailIdentity: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'email')) {
return fail(409, { emailError: 'Add another sign-in method before removing your verified email.' });
}
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(user.id);
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(user.id);
return { emailRemoved: true };
},
removeLocalCredentials: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const password = String((await request.formData()).get('password') ?? '');
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id);
if (typeof credential?.password_hash !== 'string') return fail(409, { localCredentialError: 'Password sign-in is not configured.' });
if (!(await verifyPassword(credential.password_hash, password))) return fail(400, { localCredentialError: 'Your password is incorrect.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'local')) {
return fail(409, { localCredentialError: 'Add another sign-in method before removing username and password sign-in.' });
}
await (await database.prepare('UPDATE users SET login_username=NULL,password_hash=NULL WHERE id=?')).run(user.id);
return { localCredentialsRemoved: true };
},
selectAvatar: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
@@ -88,13 +156,36 @@ export const actions: Actions = {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const username = String((await request.formData()).get('username') ?? '').normalize('NFKC').trim();
const database = await getDatabase();
const credential = await (await database.prepare('SELECT login_username,password_hash FROM users WHERE id=?')).get(user.id);
if (typeof credential?.login_username !== 'string' || typeof credential?.password_hash !== 'string') {
return fail(409, { loginUsernameError: 'Add username and password sign-in before changing its username.' });
}
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) return fail(400, { loginUsernameError: 'Use 3–32 ASCII letters, numbers, underscores, or hyphens.' });
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { loginUsernameError: 'That login username is reserved.' });
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { loginUsernameError: 'That login username is already in use.' });
await (await database.prepare('UPDATE users SET username = ? WHERE id = ?')).run(username, user.id);
if (await (await database.prepare('SELECT id FROM users WHERE login_username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { loginUsernameError: 'That login username is already in use.' });
await (await database.prepare('UPDATE users SET login_username = ? WHERE id = ?')).run(username, user.id);
return { loginUsernameChanged: true };
},
addLocalCredentials: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const username = String(form.get('username') ?? '').normalize('NFKC').trim();
const password = String(form.get('newPassword') ?? '');
const confirm = String(form.get('confirmPassword') ?? '');
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) return fail(400, { localCredentialError: 'Use a username with 3–32 ASCII letters, numbers, underscores, or hyphens.' });
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { localCredentialError: 'That login username is reserved.' });
if (password.length < 12 || password.length > 128) return fail(400, { localCredentialError: 'Use a password between 12 and 128 characters.' });
if (password !== confirm) return fail(400, { localCredentialError: 'The new passwords do not match.' });
const database = await getDatabase();
const current = await (await database.prepare('SELECT login_username,password_hash FROM users WHERE id=?')).get(user.id);
if (current?.login_username || current?.password_hash) return fail(409, { localCredentialError: 'Username and password sign-in is already configured.' });
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) return fail(409, { localCredentialError: 'That login username is already in use.' });
await (await database.prepare('UPDATE users SET login_username=?,password_hash=? WHERE id=?'))
.run(username, await hashPassword(password), user.id);
return { localCredentialsAdded: true };
},
changeDisplayName: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
@@ -118,8 +209,8 @@ export const actions: Actions = {
if (next !== confirm) return fail(400, { passwordError: 'The new passwords do not match.' });
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id = ?')).get(user.id);
if (typeof credential?.password_hash !== 'string') return fail(409, { passwordError: 'Add username and password sign-in first.' });
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, current))) return fail(400, { passwordError: 'Your current password is incorrect.' });
if (typeof credential?.password_hash !== 'string' && !user.discordLinked) return fail(400, { passwordError: 'A verified sign-in method is required before setting a password.' });
await (await database.prepare('UPDATE users SET password_hash = ? WHERE id = ?')).run(await hashPassword(next), user.id);
await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id);
await createSession(cookies, user.id);
@@ -131,7 +222,7 @@ export const actions: Actions = {
const form = await request.formData();
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
const password = String(form.get('password') ?? '');
if (confirmation !== user.username) return fail(400, { deleteError: `Enter ${user.username} exactly to confirm deletion.` });
if (confirmation !== 'DELETE') return fail(400, { deleteError: 'Enter DELETE exactly to confirm deletion.' });
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id);
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, password))) {
@@ -150,10 +241,10 @@ export const actions: Actions = {
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash,discord_id FROM users WHERE id=?')).get(user.id);
if (!credential?.discord_id) return fail(409, { discordUnlinkError: 'No Discord account is linked.' });
if (typeof credential.password_hash !== 'string') {
return fail(400, { discordUnlinkError: 'Add a password sign-in method before unlinking Discord so you do not lose access.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'discord')) {
return fail(400, { discordUnlinkError: 'Add another sign-in method before unlinking Discord so you do not lose access.' });
}
if (!(await verifyPassword(credential.password_hash, password))) {
if (typeof credential.password_hash === 'string' && !(await verifyPassword(credential.password_hash, password))) {
return fail(400, { discordUnlinkError: 'Your password is incorrect.' });
}
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(user.id);
+62 -7
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
let { data, form } = $props();
const user = $derived(data.user!);
let discordLinkPending = $state(false);
@@ -7,7 +9,7 @@
type AvatarPanel = 'presets' | 'upload';
function initialAccountPanel(): AccountPanel {
if (form?.discordUnlinkError || data.oauthError || data.discordLinked || form?.discordUnlinked) return 'connections';
if (form?.passwordError || form?.loginUsernameError) return 'access';
if (form?.passwordError || form?.loginUsernameError || form?.localCredentialError || form?.emailError || form?.passkeyError || data.emailVerified) return 'access';
if (form?.deleteError) return 'account';
return 'identity';
}
@@ -16,6 +18,35 @@
}
let accountPanel = $state<AccountPanel>(initialAccountPanel());
let avatarPanel = $state<AvatarPanel>(initialAvatarPanel());
let passkeySupported = $state(true);
let passkeyPending = $state(false);
let passkeyMessage = $state('');
let passkeyClientError = $state('');
let passkeyName = $state('My passkey');
onMount(() => passkeySupported = browserSupportsWebAuthn());
async function registerPasskey() {
if (passkeyPending) return;
passkeyPending = true;
passkeyMessage = '';
passkeyClientError = '';
try {
const optionsRequest = await fetch('/auth/passkey/register/options', { method: 'POST' });
if (!optionsRequest.ok) throw new Error('Could not start passkey registration.');
const { options, challengeId } = await optionsRequest.json();
const response = await startRegistration({ optionsJSON: options });
const verification = await fetch('/auth/passkey/register/verify', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ challengeId, response, name: passkeyName })
});
const result = await verification.json();
if (!verification.ok || !result.verified) throw new Error(result.message || 'Passkey registration failed.');
passkeyMessage = 'Passkey registered. Reloading account methods…';
location.reload();
} catch (error) {
passkeyClientError = error instanceof Error ? error.message : 'Passkey registration was cancelled.';
passkeyPending = false;
}
}
function beginDiscordLink(event: MouseEvent) {
if (discordLinkPending) { event.preventDefault(); return; }
discordLinkPending = true;
@@ -81,17 +112,41 @@
</div>
{:else if accountPanel === 'access'}
<div class="account-panel" id="access-panel" role="tabpanel" aria-labelledby="access-tab">
<p class="eyebrow">PRIVATE CREDENTIAL</p><h2>LOGIN USERNAME</h2>
<PendingForm action="?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={user.username} autocomplete="username" required /></label><small class="identity-note">Used only to sign in and visible to you and authorized ISAC operators.</small>{#if form?.loginUsernameError}<p class="error">{form.loginUsernameError}</p>{/if}<button class="secondary console-action">UPDATE LOGIN USERNAME →</button></PendingForm>
<hr/><p class="eyebrow">SECURITY</p><h2>{data.hasPassword ? 'CHANGE PASSWORD' : 'SET PASSWORD'}</h2>
<PendingForm action="?/changePassword">{#if data.hasPassword}<label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" autocomplete="current-password" required /></label>{/if}<label class="field">NEW PASSWORD<input name="newPassword" type="password" autocomplete="new-password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">{data.hasPassword ? 'UPDATE PASSWORD' : 'ADD PASSWORD SIGN-IN'} →</button></PendingForm>
<p class="eyebrow">PREFERRED ACCESS</p><h2>PASSKEYS</h2>
<p class="identity-note passkey-intro">Use Face ID, Touch ID, Windows Hello, a security key, or your password manager without entering a SHD.build password.</p>
{#if data.passkeys.length}
<div class="passkey-list">
{#each data.passkeys as passkey}
<div class="passkey-card"><div><b>{passkey.name}</b><small>{passkey.backedUp ? 'SYNCED PASSKEY' : 'DEVICE PASSKEY'} · ADDED {new Date(passkey.createdAt).toLocaleDateString()}</small></div><PendingForm action="?/removePasskey"><input type="hidden" name="passkeyId" value={passkey.id}/><button class="passkey-remove" aria-label={`Remove ${passkey.name}`} disabled={data.authenticationMethods.total <= 1}>REMOVE</button></PendingForm></div>
{/each}
</div>
{:else}<p class="account-method-empty">NO PASSKEYS REGISTERED</p>{/if}
{#if form?.passkeyError}<p class="error">{form.passkeyError}</p>{/if}
{#if form?.passkeyRemoved}<p class="success">Passkey removed.</p>{/if}
{#if passkeyClientError}<p class="error">{passkeyClientError}</p>{/if}
{#if passkeyMessage}<p class="success">{passkeyMessage}</p>{/if}
<label class="field">PASSKEY NAME<input bind:value={passkeyName} maxlength="60" placeholder="e.g. Windows Hello" /></label>
<button class="primary console-action" disabled={!passkeySupported || passkeyPending || !passkeyName.trim()} aria-busy={passkeyPending} onclick={registerPasskey}>{passkeyPending ? 'REGISTERING…' : 'ADD A PASSKEY →'}</button>
{#if !passkeySupported}<p class="connection-warning">This browser does not support passkeys.</p>{/if}
<hr/><p class="eyebrow">PASSWORDLESS RECOVERY</p><h2>MAGIC LINK EMAIL</h2>
{#if data.emailIdentity}<div class="verified-email"><div><b>{data.emailIdentity.email}</b><small>VERIFIED EMAIL IDENTITY</small></div><span>VERIFIED</span></div><PendingForm action="?/removeEmailIdentity"><button class="secondary console-action" disabled={data.authenticationMethods.total <= 1}>REMOVE MAGIC-LINK EMAIL</button></PendingForm>{/if}
<PendingForm action="?/requestEmailVerification"><label class="field">{data.emailIdentity ? 'CHANGE VERIFIED EMAIL' : 'EMAIL ADDRESS'}<input name="email" type="email" value={data.emailIdentity?.email ?? ''} autocomplete="email" required /></label><small class="identity-note">We will email a verification link before this address can be used to sign in.</small>{#if form?.emailError}<p class="error">{form.emailError}</p>{/if}{#if form?.emailVerificationSent}<p class="success">Verification link sent. It expires in 15 minutes.</p>{/if}{#if data.emailVerified}<p class="success">Email verified. Magic-link sign-in is now active.</p>{/if}<button class="secondary console-action" disabled={!data.emailDeliveryConfigured}>SEND VERIFICATION LINK →</button></PendingForm>
<hr/><p class="eyebrow">OPTIONAL FALLBACK</p><h2>USERNAME &amp; PASSWORD</h2>
{#if data.hasLocalCredentials}
<PendingForm action="?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={user.loginUsername ?? ''} autocomplete="username" required /></label><small class="identity-note">Used only to sign in and visible to you and authorized ISAC operators.</small>{#if form?.loginUsernameError}<p class="error">{form.loginUsernameError}</p>{/if}<button class="secondary console-action">UPDATE LOGIN USERNAME →</button></PendingForm>
<hr/><PendingForm action="?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" autocomplete="current-password" required /></label><label class="field">NEW PASSWORD<input name="newPassword" type="password" autocomplete="new-password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">UPDATE PASSWORD →</button></PendingForm>
<details class="method-removal"><summary>REMOVE USERNAME &amp; PASSWORD</summary><p>This removes both local credentials. Your public display name is unaffected.</p><PendingForm action="?/removeLocalCredentials"><label class="field">CURRENT PASSWORD<input name="password" type="password" autocomplete="current-password" required /></label>{#if form?.localCredentialError}<p class="error">{form.localCredentialError}</p>{/if}<button class="danger console-action" disabled={data.authenticationMethods.total <= 1}>REMOVE PASSWORD SIGN-IN</button></PendingForm></details>
{:else}
<p class="account-method-empty">USERNAME AND PASSWORD SIGN-IN IS NOT CONFIGURED</p>
<PendingForm action="?/addLocalCredentials"><label class="field">PRIVATE LOGIN USERNAME<input name="username" autocomplete="username" required /></label><label class="field">PASSWORD<input name="newPassword" type="password" autocomplete="new-password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" minlength="12" required /></label>{#if form?.localCredentialError}<p class="error">{form.localCredentialError}</p>{/if}{#if form?.localCredentialsAdded}<p class="success">Username and password sign-in added.</p>{/if}<button class="secondary console-action">ADD PASSWORD FALLBACK →</button></PendingForm>
{/if}
</div>
{:else if accountPanel === 'connections'}
<div class="account-panel" id="connections-panel" role="tabpanel" aria-labelledby="connections-tab">
<p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2>
{#if data.discordConnection}
<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>{data.discordConnection.displayName ?? data.discordConnection.username ?? 'DISCORD USER'}</b><small>{data.discordConnection.username ? `@${data.discordConnection.username} · ` : ''}ID {data.discordConnection.id}</small></div></div><span class="connection-state">LINKED</span></div>
<PendingForm action="?/unlinkDiscord" class="unlink-console"><label class="field">PASSWORD TO UNLINK<input name="password" type="password" autocomplete="current-password" disabled={!data.discordConnection.canUnlink} required={data.discordConnection.canUnlink}/></label>{#if !data.discordConnection.canUnlink}<p class="connection-warning">Discord is your only sign-in method. Set a password in Access before unlinking.</p>{/if}{#if form?.discordUnlinkError}<p class="error">{form.discordUnlinkError}</p>{/if}<button class="secondary console-action" disabled={!data.discordConnection.canUnlink}>UNLINK DISCORD</button></PendingForm>
<PendingForm action="?/unlinkDiscord" class="unlink-console">{#if data.hasPassword}<label class="field">PASSWORD TO UNLINK<input name="password" type="password" autocomplete="current-password" required/></label>{/if}{#if !data.discordConnection.canUnlink}<p class="connection-warning">Discord is your only sign-in method. Add a passkey, verified email, or password in Access before unlinking.</p>{/if}{#if form?.discordUnlinkError}<p class="error">{form.discordUnlinkError}</p>{/if}<button class="secondary console-action" disabled={!data.discordConnection.canUnlink}>UNLINK DISCORD</button></PendingForm>
{:else}
<div class="connection-card discord-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>DISCORD</b><small>OAuth identity provider</small></div></div><a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={beginDiscordLink}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Link Discord</span></a></div>
{/if}
@@ -100,7 +155,7 @@
<div class="account-panel" id="account-panel" role="tabpanel" aria-labelledby="account-tab">
<p class="eyebrow danger-eyebrow">ACCOUNT LIFECYCLE</p><h2>DELETE ACCOUNT</h2>
<p class="account-retention">Your builds, guides, and comments remain attributed to a deleted agent. Your login username, display name, and Discord identity become available again.</p>
<details class="danger-zone" open={Boolean(form?.deleteError)}><summary>OPEN DELETION CONTROLS</summary><PendingForm action="?/deleteAccount"><label class="field">TYPE {user.username} TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
<details class="danger-zone" open={Boolean(form?.deleteError)}><summary>OPEN DELETION CONTROLS</summary><PendingForm action="?/deleteAccount"><label class="field">TYPE DELETE TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
</div>
{/if}
</aside>
+3 -3
View File
@@ -16,12 +16,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
WHERE users.login_username LIKE ? ESCAPE '\\' COLLATE NOCASE
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
`)).all(pattern, pattern);
const pagination = getPagination(url, summaryRows.length);
const users = await (await database.prepare(`
SELECT users.id,users.username,COALESCE(user_profiles.display_name,users.username) AS display_name,
SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
users.role,users.discord_id,users.created_at,
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
deleted_accounts.deleted_at,
@@ -31,7 +31,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
WHERE users.login_username LIKE ? ESCAPE '\\' COLLATE NOCASE
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
LIMIT ? OFFSET ?
+1 -1
View File
@@ -9,6 +9,6 @@
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username} · {user.discord_id ? 'DISCORD LINKED' : 'LOCAL IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div>
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'} · {user.discord_id ? 'DISCORD LINKED' : 'ACCOUNT IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div>
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
</div>
+8 -5
View File
@@ -7,12 +7,13 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { avatarUrl } from '$lib/avatar';
import { deleteUploadedAvatar } from '$lib/server/avatars';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
export const load: PageServerLoad = async ({ cookies, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
const database = await getDatabase();
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments] = await Promise.all([
(await database.prepare(`SELECT users.id,users.username,COALESCE(user_profiles.display_name,users.username) AS display_name,
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments, authenticationMethods] = await Promise.all([
(await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
users.role,users.discord_id,users.created_at,
CASE WHEN users.password_hash IS NOT NULL THEN 1 ELSE 0 END AS has_password,
discord_profiles.username AS discord_username,discord_profiles.display_name AS discord_display_name,
@@ -33,6 +34,7 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
(await database.prepare('SELECT id,title,slug,visibility,updated_at FROM builds WHERE owner_id=? ORDER BY updated_at DESC LIMIT 50')).all(params.id),
(await database.prepare(`SELECT comments.id,comments.body,comments.created_at,builds.title,builds.slug
FROM comments JOIN builds ON builds.id=comments.build_id WHERE comments.user_id=? ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id)
,getAuthenticationMethods(database, params.id)
]);
if (!profile) error(404, 'User not found');
return {
@@ -45,6 +47,7 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
directPermissionIds: directPermissions.map((row) => String(row.permission_id)),
logins, builds, comments,
authenticationMethods,
operatorPermissions: await getPermissions(operator),
isSelf: operator.id === params.id
};
@@ -144,11 +147,11 @@ export const actions: Actions = {
unlinkDiscord: async ({ cookies, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
const database = await getDatabase();
const target = await (await database.prepare('SELECT discord_id,password_hash FROM users WHERE id=?')).get(params.id);
const target = await (await database.prepare('SELECT discord_id FROM users WHERE id=?')).get(params.id);
if (!target) return fail(404, { identityError: 'User not found.' });
if (!target.discord_id) return fail(409, { identityError: 'This user does not have a linked Discord account.' });
if (typeof target.password_hash !== 'string') {
return fail(400, { identityError: 'Discord is this user’s only sign-in method. They must set a password before it can be unlinked.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'discord')) {
return fail(400, { identityError: 'Discord cannot be unlinked because it is this user’s only sign-in method.' });
}
const now = new Date().toISOString();
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(params.id);
+2 -2
View File
@@ -10,7 +10,7 @@
const accountState = () => data.profile.deleted_at ? 'DELETED' : data.profile.blocked_at ? 'BLOCKED' : data.profile.banned_until && new Date(data.profile.banned_until) > new Date() ? 'BANNED' : 'ACTIVE';
</script>
<svelte:head><title>{data.profile.display_name} — ISAC user operations</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.display_name}</h2><p>Private login: {data.profile.username} · Created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}</p></div><a class="secondary button-link isac-control-link" href="/admin/users">← USER REGISTRY</a></div>
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.display_name}</h2><p>Private login: {data.profile.username ?? 'Not configured'} · Created {date(data.profile.created_at)} · {data.authenticationMethods.total} sign-in method{data.authenticationMethods.total === 1 ? '' : 's'}</p></div><a class="secondary button-link isac-control-link" href="/admin/users">← USER REGISTRY</a></div>
<div class="module-kpis"><div><span>ACCESS STATE</span><strong class:restricted={accountState() !== 'ACTIVE'}>{accountState()}</strong><small>{data.profile.moderation_reason ?? 'No restrictions'}</small></div><div><span>LAST ACTIVE</span><strong class="metric-date">{date(data.profile.last_active_at)}</strong><small>Site activity</small></div><div><span>BUILDS</span><strong>{data.builds.length}</strong><small>Recent records loaded</small></div><div><span>COMMENTS</span><strong>{data.comments.length}</strong><small>Recent records loaded</small></div></div>
<div class="user-console-grid">
@@ -47,7 +47,7 @@
{#if form?.avatarRemoved}<p class="success">Custom avatar removed and reset to the default SHD portrait.</p>{/if}
</div>
<hr/>
<div class="isac-identity"><p class="label">LINKED DISCORD IDENTITY</p>{#if data.profile.discord_id}<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><div><b>{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}</b><small>{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}</small></div></div><span class="connection-state">LINKED</span></div>{#if can('users.identities')}<PendingForm action="?/unlinkDiscord" class="unlink-console">{#if !Number(data.profile.has_password)}<p class="connection-warning">Discord is this agent’s only sign-in method. They must set a password before unlinking.</p>{/if}{#if form?.identityError}<p class="error">{form.identityError}</p>{/if}<button class="secondary console-action moderation-action" disabled={!Number(data.profile.has_password)}>UNLINK DISCORD</button></PendingForm>{/if}{:else}<p class="console-note identity-empty">NO DISCORD IDENTITY LINKED</p>{/if}</div>
<div class="isac-identity"><p class="label">LINKED DISCORD IDENTITY</p>{#if data.profile.discord_id}<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><div><b>{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}</b><small>{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}</small></div></div><span class="connection-state">LINKED</span></div>{#if can('users.identities')}<PendingForm action="?/unlinkDiscord" class="unlink-console">{#if data.authenticationMethods.total <= 1}<p class="connection-warning">Discord is this agent’s only sign-in method and cannot be removed.</p>{/if}{#if form?.identityError}<p class="error">{form.identityError}</p>{/if}<button class="secondary console-action moderation-action" disabled={data.authenticationMethods.total <= 1}>UNLINK DISCORD</button></PendingForm>{/if}{:else}<p class="console-note identity-empty">NO DISCORD IDENTITY LINKED</p>{/if}</div>
<hr/>
{#if can('users.moderate') && !data.profile.deleted_at}
<PendingForm action="?/moderate"><label class="field">ACCESS STATE<select name="state"><option value="active">Active</option><option value="blocked">Blocked indefinitely</option><option value="banned">Temporarily banned</option></select></label><label class="field">BAN EXPIRATION<input name="bannedUntil" type="datetime-local"/></label><label class="field">REASON<textarea name="reason" placeholder="Required for blocks and bans"></textarea></label>{#if data.isSelf}<p class="error">ISAC will not permit self-restriction.</p>{/if}{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}<div class="form-actions"><button class="danger moderation-action" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
+34
View File
@@ -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');
};
@@ -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 });
};
@@ -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<ArrayBuffer> {
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' });
};
@@ -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 });
};
@@ -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 });
};
+31 -2
View File
@@ -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);
+39 -1
View File
@@ -1,7 +1,34 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
let { data, form } = $props();
let discordPending = $state(false);
let passkeyPending = $state(false);
let passkeySupported = $state(true);
let passkeyError = $state('');
onMount(() => passkeySupported = browserSupportsWebAuthn());
async function signInWithPasskey() {
if (passkeyPending) return;
passkeyPending = true;
passkeyError = '';
try {
const optionsRequest = await fetch('/auth/passkey/authenticate/options', { method: 'POST' });
if (!optionsRequest.ok) throw new Error('Could not start passkey sign-in.');
const { options, challengeId } = await optionsRequest.json();
const response = await startAuthentication({ optionsJSON: options });
const verification = await fetch('/auth/passkey/authenticate/verify', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ challengeId, response })
});
const result = await verification.json();
if (!verification.ok || !result.verified) throw new Error(result.message || 'Passkey sign-in failed.');
location.href = result.redirectTo || '/account';
} catch (error) {
passkeyError = error instanceof Error ? error.message : 'Passkey sign-in was cancelled.';
passkeyPending = false;
}
}
function beginDiscordSignIn(event: MouseEvent) {
if (discordPending) {
event.preventDefault();
@@ -11,4 +38,15 @@
}
</script>
<svelte:head><title>Sign in — SHD.build</title></svelte:head>
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD IDENTITY NETWORK</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div><a class="discord" class:interaction-pending={discordPending} href="/auth/discord" aria-busy={discordPending} onclick={beginDiscordSignIn}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Continue with Discord</span></a><div class="divider"><span>OR USE LOCAL CREDENTIALS</span></div><PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username" placeholder="Agent handle" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="current-password" placeholder="••••••••••••" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}{#if data.oauthError}<p class="error">{data.oauthError}</p>{/if}<button class="primary full">AUTHENTICATE →</button></PendingForm><small>Credentials are verified securely on the server. Discord shares only the basic identity information required to link your account.</small></section></div></main>
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD IDENTITY NETWORK</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div>
<button class="primary full passkey-signin" disabled={!passkeySupported || passkeyPending} aria-busy={passkeyPending} onclick={signInWithPasskey}>{passkeyPending ? 'VERIFYING PASSKEY…' : 'SIGN IN WITH A PASSKEY →'}</button>
{#if !passkeySupported}<p class="auth-note">This browser does not support passkeys.</p>{/if}
{#if passkeyError}<p class="error">{passkeyError}</p>{/if}
<div class="divider"><span>OR EMAIL A ONE-TIME LINK</span></div>
<PendingForm action="?/magicLink"><label class="field">VERIFIED EMAIL<input name="email" type="email" value={form?.email ?? ''} autocomplete="email" placeholder="agent@example.com" required /></label>{#if form?.magicLinkError}<p class="error">{form.magicLinkError}</p>{/if}{#if form?.magicLinkSent || data.magicLinkSent}<p class="success">If that email is linked to an agent, a sign-in link is on its way.</p>{/if}{#if data.magicError}<p class="error">{data.magicError}</p>{/if}<button class="secondary full" disabled={!data.emailDeliveryConfigured}>EMAIL MAGIC LINK →</button></PendingForm>
<div class="divider"><span>OR CONTINUE WITH</span></div>
<a class="discord" class:interaction-pending={discordPending} href="/auth/discord" aria-busy={discordPending} onclick={beginDiscordSignIn}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Continue with Discord</span></a>
{#if data.oauthError}<p class="error">{data.oauthError}</p>{/if}
<details class="password-fallback" open={Boolean(form?.loginError)}><summary>USE USERNAME AND PASSWORD</summary><PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username webauthn" placeholder="Agent handle" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="current-password" placeholder="••••••••••••" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}<button class="secondary full">SIGN IN WITH PASSWORD →</button></PendingForm></details>
<small>Passkeys and one-time email links are preferred. Password sign-in remains available as an optional fallback.</small>
</section></div></main>
-2
View File
@@ -1,2 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = ({ url }) => ({ oauthError: url.searchParams.has('oauth_error') });