Add passkey and magic link authentication
This commit is contained in:
+18
-4
@@ -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==
|
||||
|
||||
@@ -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
@@ -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==
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+285
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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>`
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import type { PageLoad } from './$types';
|
||||
export const load: PageLoad = ({ url }) => ({ oauthError: url.searchParams.has('oauth_error') });
|
||||
Reference in New Issue
Block a user