Initial commit: RP site
This commit is contained in:
commit
24a6bc6246
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.vite/
|
||||||
|
dist/
|
||||||
|
.tmp/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
2266
package-lock.json
generated
Normal file
2266
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "rp",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --port 3008 --host 127.0.0.1",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "node build/index.js",
|
||||||
|
"start": "node build/index.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.2.12",
|
||||||
|
"@sveltejs/kit": "^2.20.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
run.sh
Executable file
18
run.sh
Executable file
@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Wrapper for the RP SvelteKit process.
|
||||||
|
# Loads /var/www/rp/.env into the environment, then execs node.
|
||||||
|
set -e
|
||||||
|
DIR="/var/www/rp"
|
||||||
|
PORT="${PORT:-2026}"
|
||||||
|
HOST="${HOST:-127.0.0.1}"
|
||||||
|
ORIGIN="${ORIGIN:-http://rp.thehowlingwhispers.com}"
|
||||||
|
export PORT HOST ORIGIN
|
||||||
|
|
||||||
|
if [ -f "$DIR/.env" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090,SC1091
|
||||||
|
source "$DIR/.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec node "$DIR/build/index.js"
|
||||||
268
scripts/netcup-dns.sh
Executable file
268
scripts/netcup-dns.sh
Executable file
@ -0,0 +1,268 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# /var/www/rp/scripts/netcup-dns.sh
|
||||||
|
# Reusable Netcup DNS tool for thehowlingwhispers.com (and any Netcup-managed zone).
|
||||||
|
#
|
||||||
|
# Credentials: /root/.netcup-creds (chmod 600, NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD)
|
||||||
|
# Audit log: /var/log/netcup-dns.log
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# netcup-dns.sh list <domain> # list all records
|
||||||
|
# netcup-dns.sh add <domain> <host> <type> <value> # add a record (idempotent)
|
||||||
|
# netcup-dns.sh delete <domain> <recordId> # delete a record
|
||||||
|
# netcup-dns.sh --dry-run add <domain> ... # preview without API calls
|
||||||
|
# netcup-dns.sh --yes add <domain> ... # skip confirmation prompt
|
||||||
|
# netcup-dns.sh --json list ... # raw JSON output
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# netcup-dns.sh list thehowlingwhispers.com
|
||||||
|
# netcup-dns.sh add thehowlingwhispers.com rp A 159.195.194.180
|
||||||
|
# netcup-dns.sh add thehowlingwhispers.com sandbox CNAME rp.thehowlingwhispers.com
|
||||||
|
# netcup-dns.sh add thehowlingwhispers.com @ TXT "v=spf1 -all"
|
||||||
|
# netcup-dns.sh --dry-run add thehowlingwhispers.com blog A 159.195.194.180
|
||||||
|
# netcup-dns.sh --json list thehowlingwhispers.com
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─── config ────────────────────────────────────────────────────
|
||||||
|
CREDS_FILE="${NETCUP_CREDS_FILE:-/root/.netcup-creds}"
|
||||||
|
LOG_FILE="${NETCUP_DNS_LOG:-/var/log/netcup-dns.log}"
|
||||||
|
API_BASE="https://ccp.netcup.net/api/v1"
|
||||||
|
JQ_FORMAT='@json' # not used; kept for clarity
|
||||||
|
|
||||||
|
# ─── colors ───────────────────────────────────────────────────
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YEL=$'\e[33m'; C_DIM=$'\e[2m'; C_RST=$'\e[0m'
|
||||||
|
else
|
||||||
|
C_RED=''; C_GRN=''; C_YEL=''; C_DIM=''; C_RST=''
|
||||||
|
fi
|
||||||
|
ok() { printf '%s✓%s %s\n' "$C_GRN" "$C_RST" "$*"; }
|
||||||
|
warn() { printf '%s!%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; }
|
||||||
|
err() { printf '%s✗%s %s\n' "$C_RED" "$C_RST" "$*" >&2; }
|
||||||
|
|
||||||
|
# ─── flag parsing ─────────────────────────────────────────────
|
||||||
|
DRY_RUN=false
|
||||||
|
ASSUME_YES=false
|
||||||
|
JSON_OUT=false
|
||||||
|
SUBCMD=""
|
||||||
|
ARGS=()
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
|
--yes|-y) ASSUME_YES=true; shift ;;
|
||||||
|
--json) JSON_OUT=true; shift ;;
|
||||||
|
--help|-h)
|
||||||
|
sed -n '2,28p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
list|add|delete)
|
||||||
|
SUBCMD="$1"; shift; ARGS=("$@"); break ;;
|
||||||
|
*)
|
||||||
|
err "Unknown flag or command: $1"; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ─── log helper ───────────────────────────────────────────────
|
||||||
|
log() {
|
||||||
|
printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── load creds (chmod 600 enforced) ────────────────────────
|
||||||
|
if [ ! -f "$CREDS_FILE" ]; then
|
||||||
|
err "Credentials file not found: $CREDS_FILE"
|
||||||
|
err "Create it with NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD (chmod 600)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PERMS=$(stat -c '%a' "$CREDS_FILE" 2>/dev/null || echo "")
|
||||||
|
if [ "$PERMS" != "600" ] && [ "$PERMS" != "400" ]; then
|
||||||
|
err "Credentials file must be chmod 600 or 400 (currently: $PERMS). Refusing to run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CREDS_FILE"
|
||||||
|
|
||||||
|
if [ -z "${NETCUP_CUSTOMER:-}" ] || [ -z "${NETCUP_API_KEY:-}" ] || [ -z "${NETCUP_API_PASSWORD:-}" ]; then
|
||||||
|
err "Missing one of NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD in $CREDS_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── API helpers ─────────────────────────────────────────────
|
||||||
|
# Netcup requires X-Api-Key + X-Customer-Number headers, plus
|
||||||
|
# X-Session-Id for authenticated calls. Login returns sessionId.
|
||||||
|
SESSION_ID=""
|
||||||
|
SESSION_LOGGED_IN_AT=0
|
||||||
|
SESSION_TTL=240 # refresh session every 4 minutes
|
||||||
|
|
||||||
|
login() {
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
if [ -n "$SESSION_ID" ] && [ $((now - SESSION_LOGGED_IN_AT)) -lt $SESSION_TTL ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local response http_code body
|
||||||
|
response=$(curl -sS -w '\n%{http_code}' -X POST \
|
||||||
|
-H "X-Api-Key: ${NETCUP_API_KEY}" \
|
||||||
|
-H "X-Customer-Number: ${NETCUP_CUSTOMER}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API_BASE}/login")
|
||||||
|
http_code=$(printf '%s' "$response" | tail -n1)
|
||||||
|
body=$(printf '%s' "$response" | sed '$d')
|
||||||
|
if [ "$http_code" != "200" ]; then
|
||||||
|
err "Login failed (HTTP $http_code): $body"
|
||||||
|
log "ERROR" "login failed: HTTP $http_code"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SESSION_ID=$(printf '%s' "$body" | jq -r '.sessionId')
|
||||||
|
SESSION_LOGGED_IN_AT=$now
|
||||||
|
log "INFO" "login OK (session refreshed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
api() {
|
||||||
|
local method=$1; shift
|
||||||
|
local path=$1; shift
|
||||||
|
local body=${1:-}
|
||||||
|
login
|
||||||
|
local args=(-sS -w '\n%{http_code}' -X "$method" -H "X-Session-Id: $SESSION_ID" -H "Content-Type: application/json")
|
||||||
|
if [ -n "$body" ]; then
|
||||||
|
args+=(-d "$body")
|
||||||
|
fi
|
||||||
|
curl "${args[@]}" "${API_BASE}${path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── subdomain ──────────────────────────────────────────────
|
||||||
|
require_domain() {
|
||||||
|
if [ -z "${1:-}" ]; then err "Domain required"; exit 1; fi
|
||||||
|
echo "$1"
|
||||||
|
}
|
||||||
|
require_arg() {
|
||||||
|
if [ -z "${1:-}" ]; then err "$2 required"; exit 1; fi
|
||||||
|
echo "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── subcommands ─────────────────────────────────────────────
|
||||||
|
cmd_list() {
|
||||||
|
local domain
|
||||||
|
domain=$(require_domain "${ARGS[0]:-}")
|
||||||
|
local response http_code body
|
||||||
|
response=$(api GET "/dns/${domain}")
|
||||||
|
http_code=$(printf '%s' "$response" | tail -n1)
|
||||||
|
body=$(printf '%s' "$response" | sed '$d')
|
||||||
|
if [ "$http_code" != "200" ]; then
|
||||||
|
err "List failed (HTTP $http_code): $body"
|
||||||
|
log "ERROR" "list $domain failed: HTTP $http_code"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if $JSON_OUT; then
|
||||||
|
printf '%s\n' "$body"
|
||||||
|
else
|
||||||
|
printf '%s%-8s %-30s %-25s %s%s\n' "$C_DIM" "TYPE" "HOST" "VALUE" "TTL" "$C_RST"
|
||||||
|
printf '%s\n' "$body" | jq -r '.[] | [.type, .hostname, (.value|tostring|.[0:25]), .ttl] | @tsv' | \
|
||||||
|
while IFS=$'\t' read -r t h v ttl; do
|
||||||
|
printf '%-8s %-30s %-25s %s\n' "$t" "$h" "$v" "$ttl"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
log "INFO" "list $domain (HTTP $http_code, $(printf '%s' "$body" | jq 'length') records)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_add() {
|
||||||
|
local domain host type value
|
||||||
|
domain=$(require_domain "${ARGS[0]:-}")
|
||||||
|
host=$(require_arg "${ARGS[1]:-}" "host")
|
||||||
|
type=$(require_arg "${ARGS[2]:-}" "type")
|
||||||
|
value=$(require_arg "${ARGS[3]:-}" "value")
|
||||||
|
|
||||||
|
# Idempotency check: list first
|
||||||
|
local list_body
|
||||||
|
list_body=$(api GET "/dns/${domain}" | sed '$d')
|
||||||
|
local existing
|
||||||
|
existing=$(printf '%s' "$list_body" | jq -r --arg h "$host" --arg t "$type" --arg v "$value" \
|
||||||
|
'.[] | select(.hostname == $h and .type == $t and (.value|tostring) == $v) | .id')
|
||||||
|
if [ -n "$existing" ] && [ "$existing" != "null" ]; then
|
||||||
|
ok "Already exists: $domain $host $type $value (recordId=$existing) — no-op"
|
||||||
|
log "INFO" "add $domain $host $type $value — no-op (already exists, id=$existing)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "About to add: $domain $host $type $value"
|
||||||
|
if ! $ASSUME_YES && [ -t 0 ]; then
|
||||||
|
read -r -p "Continue? [y/N] " reply
|
||||||
|
case "$reply" in
|
||||||
|
y|Y|yes|YES) ;;
|
||||||
|
*) err "Aborted."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
ok "[dry-run] would POST $host $type $value to $domain"
|
||||||
|
log "INFO" "add $domain $host $type $value — DRY RUN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Netcup payload format
|
||||||
|
local payload
|
||||||
|
payload=$(jq -n --arg h "$host" --arg t "$type" --arg v "$value" \
|
||||||
|
'{hostname:$h, type:$t, value:$v, ttl:3600}')
|
||||||
|
|
||||||
|
local response http_code body
|
||||||
|
response=$(api POST "/dns/${domain}" "$payload")
|
||||||
|
http_code=$(printf '%s' "$response" | tail -n1)
|
||||||
|
body=$(printf '%s' "$response" | sed '$d')
|
||||||
|
if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then
|
||||||
|
err "Add failed (HTTP $http_code): $body"
|
||||||
|
log "ERROR" "add $domain $host $type $value failed: HTTP $http_code"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
local new_id
|
||||||
|
new_id=$(printf '%s' "$body" | jq -r '.id // .recordId // empty')
|
||||||
|
ok "Added: $domain $host $type $value (recordId=$new_id)"
|
||||||
|
log "INFO" "add $domain $host $type $value OK (id=$new_id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_delete() {
|
||||||
|
local domain record_id
|
||||||
|
domain=$(require_domain "${ARGS[0]:-}")
|
||||||
|
record_id=$(require_arg "${ARGS[1]:-}" "recordId")
|
||||||
|
|
||||||
|
warn "About to delete: $domain recordId=$record_id"
|
||||||
|
if ! $ASSUME_YES && [ -t 0 ]; then
|
||||||
|
read -r -p "Continue? [y/N] " reply
|
||||||
|
case "$reply" in
|
||||||
|
y|Y|yes|YES) ;;
|
||||||
|
*) err "Aborted."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
ok "[dry-run] would DELETE recordId=$record_id from $domain"
|
||||||
|
log "INFO" "delete $domain $record_id — DRY RUN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local response http_code body
|
||||||
|
response=$(api DELETE "/dns/${domain}/${record_id}")
|
||||||
|
http_code=$(printf '%s' "$response" | tail -n1)
|
||||||
|
body=$(printf '%s' "$response" | sed '$d')
|
||||||
|
if [ "$http_code" != "200" ] && [ "$http_code" != "204" ]; then
|
||||||
|
err "Delete failed (HTTP $http_code): $body"
|
||||||
|
log "ERROR" "delete $domain $record_id failed: HTTP $http_code"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Deleted: $domain recordId=$record_id"
|
||||||
|
log "INFO" "delete $domain $record_id OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── dispatch ────────────────────────────────────────────────
|
||||||
|
case "$SUBCMD" in
|
||||||
|
list) cmd_list ;;
|
||||||
|
add) cmd_add ;;
|
||||||
|
delete) cmd_delete ;;
|
||||||
|
"")
|
||||||
|
err "Subcommand required (list|add|delete). Try --help."
|
||||||
|
sed -n '2,28p' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
err "Unknown subcommand: $SUBCMD"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
57
src/app.css
Normal file
57
src/app.css
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/* Editorial — Phase 0 placeholder styles.
|
||||||
|
The full Editorial design system ships in Phase 1.
|
||||||
|
This file gives the hello-world enough typography to look intentional. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-void: #050312;
|
||||||
|
--bg-panel: #16213e;
|
||||||
|
--text-main: #e0e0e0;
|
||||||
|
--text-muted: #a0a0b0;
|
||||||
|
--accent-blush: #e0709a;
|
||||||
|
--accent-neon: #bc13fe;
|
||||||
|
--border-glow: #d4adfc33;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: 'Georgia', 'Times New Roman', 'Iowan Old Style', serif;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.7;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-blush);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(224, 112, 154, 0.4);
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #ffb3d9;
|
||||||
|
border-bottom-color: var(--accent-blush);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
12
src/app.d.ts
vendored
Normal file
12
src/app.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
15
src/app.html
Normal file
15
src/app.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>Project RP</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
src/hooks.server.ts
Normal file
43
src/hooks.server.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { redirect, type Handle } from '@sveltejs/kit';
|
||||||
|
import { getSessionUser, SESSION_COOKIE } from '$lib/server/auth';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = new Set<string>([
|
||||||
|
'/login',
|
||||||
|
'/auth/discord',
|
||||||
|
'/auth/discord/callback',
|
||||||
|
'/auth/local',
|
||||||
|
'/api/auth/me',
|
||||||
|
'/api/auth/local-enabled',
|
||||||
|
'/api/health'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PUBLIC_PREFIXES = ['/auth/discord/callback', '/_app/', '/favicon'];
|
||||||
|
|
||||||
|
function isPublic(pathname: string): boolean {
|
||||||
|
if (PUBLIC_PATHS.has(pathname)) return true;
|
||||||
|
return PUBLIC_PREFIXES.some((p) => pathname === p || pathname.startsWith(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
const { url, cookies } = event;
|
||||||
|
const cookie = cookies.get(SESSION_COOKIE);
|
||||||
|
const user = getSessionUser(cookie);
|
||||||
|
|
||||||
|
// Expose the user to all routes via event.locals
|
||||||
|
(event.locals as any).user = user;
|
||||||
|
|
||||||
|
if (!user && !isPublic(url.pathname)) {
|
||||||
|
// API routes: 401 JSON
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
return new Response(JSON.stringify({ error: 'authentication required' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Page routes: redirect to /login
|
||||||
|
const next = url.pathname + url.search;
|
||||||
|
throw redirect(302, `/login?next=${encodeURIComponent(next)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
37
src/lib/components/Badge.svelte
Normal file
37
src/lib/components/Badge.svelte
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let tone: 'default' | 'success' | 'warning' | 'accent' = 'default';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="badge {tone}">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
}
|
||||||
|
.badge.success {
|
||||||
|
background: rgba(127, 191, 127, 0.12);
|
||||||
|
color: var(--success);
|
||||||
|
border-color: rgba(127, 191, 127, 0.3);
|
||||||
|
}
|
||||||
|
.badge.warning {
|
||||||
|
background: rgba(232, 197, 71, 0.12);
|
||||||
|
color: var(--warning);
|
||||||
|
border-color: rgba(232, 197, 71, 0.3);
|
||||||
|
}
|
||||||
|
.badge.accent {
|
||||||
|
background: var(--accent-blush-soft);
|
||||||
|
color: var(--accent-blush);
|
||||||
|
border-color: rgba(224, 112, 154, 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
69
src/lib/components/Button.svelte
Normal file
69
src/lib/components/Button.svelte
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let variant: 'primary' | 'secondary' | 'ghost' = 'primary';
|
||||||
|
export let href: string | undefined = undefined;
|
||||||
|
export let type: 'button' | 'submit' = 'button';
|
||||||
|
export let disabled = false;
|
||||||
|
export let small = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a class="btn {variant} {small ? 'small' : ''}" {href} role="button">
|
||||||
|
<slot />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button class="btn {variant} {small ? 'small' : ''}" {type} {disabled} on:click>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn.small { padding: 5px 10px; font-size: var(--text-xs); }
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--accent-blush);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
.btn.primary:hover:not(:disabled) {
|
||||||
|
background: #ec8aae;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.btn.secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-blush);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.btn.ghost:hover:not(:disabled) {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
47
src/lib/components/EmptyState.svelte
Normal file
47
src/lib/components/EmptyState.svelte
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let icon: string | undefined = undefined;
|
||||||
|
export let title: string;
|
||||||
|
export let description: string | undefined = undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="empty">
|
||||||
|
{#if icon}
|
||||||
|
<div class="icon" aria-hidden="true">{icon}</div>
|
||||||
|
{/if}
|
||||||
|
<h3 class="title">{title}</h3>
|
||||||
|
{#if description}
|
||||||
|
<p class="description">{description}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="cta">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
max-width: 380px;
|
||||||
|
margin: 0 auto var(--space-5);
|
||||||
|
}
|
||||||
|
.cta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
src/lib/components/PageHeader.svelte
Normal file
65
src/lib/components/PageHeader.svelte
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let title: string;
|
||||||
|
export let kicker: string | undefined = undefined;
|
||||||
|
export let icon: string | undefined = undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="page-header">
|
||||||
|
{#if kicker}
|
||||||
|
<p class="kicker">{kicker}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="title-row">
|
||||||
|
<div class="title-block">
|
||||||
|
{#if icon}
|
||||||
|
<span class="icon" aria-hidden="true">{icon}</span>
|
||||||
|
{/if}
|
||||||
|
<h1>{title}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<slot name="meta" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
padding-bottom: var(--space-6);
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
}
|
||||||
|
.kicker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.title-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
src/lib/components/Panel.svelte
Normal file
18
src/lib/components/Panel.svelte
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let padded = true;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="panel" class:padded>
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.panel.padded {
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
161
src/lib/design/editorial.css
Normal file
161
src/lib/design/editorial.css
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
/* Editorial — the design system for rp / sandbox / play.
|
||||||
|
Typography-led, dark background, blush-violet accent. No glassmorphic, no glow. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Surface */
|
||||||
|
--bg-void: #050312;
|
||||||
|
--bg-deep: #0a0518;
|
||||||
|
--bg-panel: #14101f;
|
||||||
|
--bg-elevated: #1c1730;
|
||||||
|
--bg-hover: #251f3d;
|
||||||
|
|
||||||
|
/* Border */
|
||||||
|
--border-faint: rgba(255, 255, 255, 0.06);
|
||||||
|
--border-glow: #d4adfc33;
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.12);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-main: #e8e6ed;
|
||||||
|
--text-soft: #c8c5d0;
|
||||||
|
--text-muted: #8a8693;
|
||||||
|
--text-faint: #5a5663;
|
||||||
|
--text-on-accent: #0a0518;
|
||||||
|
|
||||||
|
/* Accent (blush-violet, used sparingly) */
|
||||||
|
--accent-blush: #e0709a;
|
||||||
|
--accent-blush-soft: rgba(224, 112, 154, 0.15);
|
||||||
|
--accent-blush-faint: rgba(224, 112, 154, 0.06);
|
||||||
|
--accent-neon: #bc13fe;
|
||||||
|
--accent-gold: #e8c547;
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--success: #7fbf7f;
|
||||||
|
--warning: #e8c547;
|
||||||
|
--danger: #d96a7a;
|
||||||
|
|
||||||
|
/* Type */
|
||||||
|
--font-serif: 'Iowan Old Style', 'Georgia', 'Times New Roman', serif;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-mono: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||||
|
|
||||||
|
/* Type scale (modular, ratio 1.25) */
|
||||||
|
--text-xs: 12px;
|
||||||
|
--text-sm: 13px;
|
||||||
|
--text-base: 15px;
|
||||||
|
--text-md: 17px;
|
||||||
|
--text-lg: 20px;
|
||||||
|
--text-xl: 26px;
|
||||||
|
--text-2xl: 34px;
|
||||||
|
--text-3xl: 44px;
|
||||||
|
--text-4xl: 56px;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--topbar-height: 56px;
|
||||||
|
--sidenav-width: 240px;
|
||||||
|
--footer-height: 44px;
|
||||||
|
--content-max-width: 920px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: var(--text-3xl); margin-bottom: var(--space-3); }
|
||||||
|
h2 { font-size: var(--text-2xl); margin-bottom: var(--space-3); }
|
||||||
|
h3 { font-size: var(--text-xl); margin-bottom: var(--space-2); }
|
||||||
|
h4 { font-size: var(--text-lg); margin-bottom: var(--space-2); }
|
||||||
|
|
||||||
|
p { color: var(--text-soft); margin-bottom: var(--space-3); }
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-blush);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--accent-blush-soft);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
border-bottom-color: var(--accent-blush);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.88em;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-faint);
|
||||||
|
margin: var(--space-6) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-blush);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.18); }
|
||||||
18
src/lib/server/auth/index.ts
Normal file
18
src/lib/server/auth/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// /var/www/rp/src/lib/server/auth/index.ts
|
||||||
|
// Re-exports for convenience.
|
||||||
|
|
||||||
|
export {
|
||||||
|
getSessionUser,
|
||||||
|
createSession,
|
||||||
|
destroySession,
|
||||||
|
upsertUser,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
SESSION_TTL_DURATION,
|
||||||
|
type SessionUser
|
||||||
|
} from './session';
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildAuthorizeUrl,
|
||||||
|
exchangeCodeForUser,
|
||||||
|
isUserAllowed
|
||||||
|
} from './providers/discord';
|
||||||
51
src/lib/server/auth/local.ts
Normal file
51
src/lib/server/auth/local.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// /var/www/rp/src/lib/server/auth/local.ts
|
||||||
|
// Local password login — fallback for when Discord OAuth is unavailable.
|
||||||
|
// Reads LOCAL_ADMIN_PASSWORD from env. If unset, the local login is disabled.
|
||||||
|
//
|
||||||
|
// The session created here is identical to a Discord session: same cookie,
|
||||||
|
// same DB row, same user record. So the user has full app access either way.
|
||||||
|
//
|
||||||
|
// SECURITY: this is a development convenience, not a public auth flow.
|
||||||
|
// Leave LOCAL_ADMIN_PASSWORD unset in production deployments.
|
||||||
|
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { getConfig } from '../db';
|
||||||
|
|
||||||
|
const LOCAL_USER_ID = 'local-admin';
|
||||||
|
|
||||||
|
export interface LocalUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
global_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
email: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalLoginEnabled(): boolean {
|
||||||
|
return !!env.LOCAL_ADMIN_PASSWORD && env.LOCAL_ADMIN_PASSWORD.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyLocalPassword(submitted: string): LocalUser | null {
|
||||||
|
if (!isLocalLoginEnabled()) return null;
|
||||||
|
const expected = env.LOCAL_ADMIN_PASSWORD!;
|
||||||
|
if (submitted.length !== expected.length) {
|
||||||
|
// Even on a length mismatch, do a constant-time-ish compare to avoid leaking length
|
||||||
|
timingSafeEqual(Buffer.from(submitted), Buffer.from(submitted));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ok = timingSafeEqual(Buffer.from(submitted), Buffer.from(expected));
|
||||||
|
if (!ok) return null;
|
||||||
|
|
||||||
|
// Read the local user's display name from the config table if previously saved
|
||||||
|
const displayName = getConfig('local_admin_display_name');
|
||||||
|
return {
|
||||||
|
id: LOCAL_USER_ID,
|
||||||
|
username: 'local-admin',
|
||||||
|
global_name: 'Local Admin',
|
||||||
|
avatar: null,
|
||||||
|
email: null,
|
||||||
|
display_name: displayName ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
125
src/lib/server/auth/providers/discord.ts
Normal file
125
src/lib/server/auth/providers/discord.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
// /var/www/rp/src/lib/server/auth/providers/discord.ts
|
||||||
|
// Discord OAuth2 — "identify" scope only.
|
||||||
|
// Used for rp / sandbox / play subdomain login.
|
||||||
|
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { getConfig, setConfig } from '../../db';
|
||||||
|
|
||||||
|
const DISCORD_API = 'https://discord.com/api';
|
||||||
|
|
||||||
|
interface DiscordTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
global_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
email: string | null;
|
||||||
|
verified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) throw new Error(`Missing env: ${name}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedirectUri(): string {
|
||||||
|
return requireEnv('DISCORD_REDIRECT_URI');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientId(): string {
|
||||||
|
return requireEnv('DISCORD_CLIENT_ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientSecret(): string {
|
||||||
|
return requireEnv('DISCORD_CLIENT_SECRET');
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
function storeState(state: string, redirectAfter: string): void {
|
||||||
|
setConfig(`discord_oauth_state:${state}`, JSON.stringify({ redirect: redirectAfter, created_at: Date.now() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeState(state: string): { redirect: string } | null {
|
||||||
|
const raw = getConfig(`discord_oauth_state:${state}`);
|
||||||
|
if (!raw) return null;
|
||||||
|
setConfig(`discord_oauth_state:${state}`, ''); // one-shot
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { redirect: string; created_at: number };
|
||||||
|
if (Date.now() - parsed.created_at > STATE_TTL_MS) return null;
|
||||||
|
return { redirect: parsed.redirect };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthorizeUrl(redirectAfter: string): string {
|
||||||
|
const state = randomBytes(24).toString('hex');
|
||||||
|
storeState(state, redirectAfter);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: getClientId(),
|
||||||
|
redirect_uri: getRedirectUri(),
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'identify',
|
||||||
|
state,
|
||||||
|
prompt: 'none'
|
||||||
|
});
|
||||||
|
return `${DISCORD_API}/oauth2/authorize?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCodeForUser(
|
||||||
|
code: string,
|
||||||
|
state: string
|
||||||
|
): Promise<{ user: DiscordUser; redirect: string }> {
|
||||||
|
const stateData = consumeState(state);
|
||||||
|
if (!stateData) {
|
||||||
|
throw new Error('Invalid or expired OAuth state. Please try logging in again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: getClientId(),
|
||||||
|
client_secret: getClientSecret(),
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: getRedirectUri()
|
||||||
|
}).toString()
|
||||||
|
});
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
const text = await tokenRes.text();
|
||||||
|
throw new Error(`Discord token exchange failed (${tokenRes.status}): ${text}`);
|
||||||
|
}
|
||||||
|
const token = (await tokenRes.json()) as DiscordTokenResponse;
|
||||||
|
|
||||||
|
const userRes = await fetch(`${DISCORD_API}/users/@me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.access_token}` }
|
||||||
|
});
|
||||||
|
if (!userRes.ok) {
|
||||||
|
const text = await userRes.text();
|
||||||
|
throw new Error(`Discord user fetch failed (${userRes.status}): ${text}`);
|
||||||
|
}
|
||||||
|
const user = (await userRes.json()) as DiscordUser;
|
||||||
|
|
||||||
|
return { user, redirect: stateData.redirect || '/' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUserAllowed(discordId: string): boolean {
|
||||||
|
const raw = getConfig('allowed_discord_ids');
|
||||||
|
if (!raw) return false;
|
||||||
|
try {
|
||||||
|
const ids = JSON.parse(raw) as string[];
|
||||||
|
return ids.includes(discordId);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/lib/server/auth/session.ts
Normal file
115
src/lib/server/auth/session.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
// /var/www/rp/src/lib/server/auth/session.ts
|
||||||
|
// Signed cookie sessions. SESSION_SECRET must be set in .env.
|
||||||
|
// Session ID is a random 32-byte hex string. The session row in the DB
|
||||||
|
// carries the user_id and expires_at. The cookie carries only the session ID.
|
||||||
|
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { db } from '../db';
|
||||||
|
|
||||||
|
const SESSION_TTL_DAYS = 30;
|
||||||
|
const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
const COOKIE_NAME = 'thw_rp_session';
|
||||||
|
|
||||||
|
function getSessionSecret(): string {
|
||||||
|
const s = process.env.SESSION_SECRET;
|
||||||
|
if (!s || s.length < 32) {
|
||||||
|
throw new Error('SESSION_SECRET must be set in .env (32+ chars)');
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(value: string): string {
|
||||||
|
return createHmac('sha256', getSessionSecret()).update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCookieValue(sessionId: string): string {
|
||||||
|
return `${sessionId}.${sign(sessionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCookieValue(cookieValue: string): string | null {
|
||||||
|
const dotIdx = cookieValue.lastIndexOf('.');
|
||||||
|
if (dotIdx < 1) return null;
|
||||||
|
const sessionId = cookieValue.slice(0, dotIdx);
|
||||||
|
const providedSig = cookieValue.slice(dotIdx + 1);
|
||||||
|
const expectedSig = sign(sessionId);
|
||||||
|
if (providedSig.length !== expectedSig.length) return null;
|
||||||
|
try {
|
||||||
|
if (!timingSafeEqual(Buffer.from(providedSig, 'hex'), Buffer.from(expectedSig, 'hex'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
global_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
email: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
last_login_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(userId: string): { id: string; cookie: string; expiresAt: Date } {
|
||||||
|
const id = randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||||
|
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
expiresAt.toISOString()
|
||||||
|
);
|
||||||
|
return { id, cookie: makeCookieValue(id), expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroySession(sessionId: string): void {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionUser(cookieValue: string | undefined | null): SessionUser | null {
|
||||||
|
if (!cookieValue) return null;
|
||||||
|
const sessionId = verifyCookieValue(cookieValue);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.username, u.global_name, u.avatar, u.email, u.display_name,
|
||||||
|
u.created_at, u.last_login_at, s.expires_at
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.id = ? AND s.expires_at > datetime('now')`
|
||||||
|
)
|
||||||
|
.get(sessionId) as
|
||||||
|
| (SessionUser & { expires_at: string })
|
||||||
|
| undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
global_name: row.global_name,
|
||||||
|
avatar: row.avatar,
|
||||||
|
email: row.email,
|
||||||
|
display_name: row.display_name ?? null,
|
||||||
|
created_at: row.created_at,
|
||||||
|
last_login_at: row.last_login_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertUser(user: SessionUser): void {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO users (id, username, global_name, avatar, email, display_name, last_login_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
username = excluded.username,
|
||||||
|
global_name = excluded.global_name,
|
||||||
|
avatar = excluded.avatar,
|
||||||
|
email = excluded.email,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
last_login_at = datetime('now')`
|
||||||
|
).run(user.id, user.username, user.global_name, user.avatar, user.email, user.display_name ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = COOKIE_NAME;
|
||||||
|
export const SESSION_TTL_DURATION = SESSION_TTL_DAYS * 24 * 60 * 60;
|
||||||
68
src/lib/server/db/index.ts
Normal file
68
src/lib/server/db/index.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// /var/www/rp/src/lib/server/db/index.ts
|
||||||
|
// Per-subdomain SQLite connection.
|
||||||
|
// One DB per subdomain (rp.db, sandbox.db, play.db) — separate from the main site's srh.db.
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DATABASE_PATH || './data/app.db';
|
||||||
|
|
||||||
|
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||||
|
|
||||||
|
export const db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
// Schema: run on every boot, idempotent
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
global_name TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_login_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Idempotent migration: add display_name column if it doesn't exist
|
||||||
|
-- (SQLite has no IF NOT EXISTS for ADD COLUMN before 3.35; we use try/catch via the wrapper)
|
||||||
|
PRAGMA table_info(users);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Apply the display_name column migration. The CREATE TABLE above already
|
||||||
|
// includes display_name for fresh DBs, but for existing DBs from Phase 0
|
||||||
|
// we need to ALTER. SQLite raises a "duplicate column" error if it exists,
|
||||||
|
// which we catch and ignore.
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN display_name TEXT');
|
||||||
|
} catch {
|
||||||
|
// Column already exists — fine.
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig(key: string): string | null {
|
||||||
|
const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key) as { value: string } | undefined;
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConfig(key: string, value: string): void {
|
||||||
|
db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run(key, value);
|
||||||
|
}
|
||||||
38
src/routes/+error.svelte
Normal file
38
src/routes/+error.svelte
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="error-block">
|
||||||
|
<p class="kicker">{$page.status} · Error</p>
|
||||||
|
<h1>{$page.status === 404 ? 'Page not found' : 'Something went wrong'}</h1>
|
||||||
|
<p class="lede">
|
||||||
|
{$page.error?.message || 'The page you requested does not exist or could not be loaded.'}
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<Button href="/" variant="primary">Go home</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-page {
|
||||||
|
min-height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.error-block { max-width: 540px; text-align: center; }
|
||||||
|
.kicker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
h1 { font-size: var(--text-3xl); margin-bottom: var(--space-4); }
|
||||||
|
.lede { color: var(--text-muted); margin-bottom: var(--space-6); }
|
||||||
|
.actions { display: flex; justify-content: center; }
|
||||||
|
</style>
|
||||||
307
src/routes/+layout.svelte
Normal file
307
src/routes/+layout.svelte
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
$: pathname = $page.url.pathname;
|
||||||
|
$: isChromeLess = pathname === '/login' || pathname === '/loading';
|
||||||
|
$: isActive = (p: string) => pathname === p || (p !== '/' && pathname.startsWith(p));
|
||||||
|
|
||||||
|
let user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
global_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
let sidenavOpen = false;
|
||||||
|
let userMenuOpen = false;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
user = data.user;
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isChromeLess}
|
||||||
|
<!-- /login and /loading: render only the slot, no chrome (clean auth surface) -->
|
||||||
|
<slot />
|
||||||
|
{:else}
|
||||||
|
<div class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<button class="hamburger" type="button" on:click={() => (sidenavOpen = !sidenavOpen)} aria-label="Toggle navigation">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
<a class="brand" href="/">
|
||||||
|
<span class="brand-main">TheHowlingWhispers</span>
|
||||||
|
<span class="brand-sub">· rp</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
{#if user}
|
||||||
|
<button class="avatar-btn" type="button" on:click={() => (userMenuOpen = !userMenuOpen)} aria-label="Account menu">
|
||||||
|
{#if user.avatar}
|
||||||
|
<img class="avatar" src="https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}.png?size=64" alt="" />
|
||||||
|
{:else}
|
||||||
|
<span class="avatar fallback">{(user.display_name || user.username || '?').charAt(0).toUpperCase()}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if userMenuOpen}
|
||||||
|
<div class="user-menu" on:click|self={() => (userMenuOpen = false)} role="menu">
|
||||||
|
<div class="user-menu-head">
|
||||||
|
<div class="user-menu-name">{user.display_name || user.global_name || user.username}</div>
|
||||||
|
<div class="user-menu-meta">@{user.username}</div>
|
||||||
|
</div>
|
||||||
|
<a class="user-menu-item" href="/profile" on:click={() => (userMenuOpen = false)}>Profile</a>
|
||||||
|
<a class="user-menu-item" href="/settings" on:click={() => (userMenuOpen = false)}>Settings</a>
|
||||||
|
<button class="user-menu-item danger" type="button" on:click={logout}>Sign out</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<aside class="sidenav" class:open={sidenavOpen}>
|
||||||
|
<nav>
|
||||||
|
<a class="nav-item" class:active={isActive('/')} href="/" on:click={() => (sidenavOpen = false)}>Dashboard</a>
|
||||||
|
<a class="nav-item" class:active={isActive('/characters')} href="/characters" on:click={() => (sidenavOpen = false)}>Characters</a>
|
||||||
|
<a class="nav-item" class:active={isActive('/rooms')} href="/rooms" on:click={() => (sidenavOpen = false)}>Rooms</a>
|
||||||
|
<a class="nav-item" class:active={isActive('/profile')} href="/profile" on:click={() => (sidenavOpen = false)}>Profile</a>
|
||||||
|
<a class="nav-item" class:active={isActive('/settings')} href="/settings" on:click={() => (sidenavOpen = false)}>Settings</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content" on:click={() => { if (sidenavOpen) sidenavOpen = false; if (userMenuOpen) userMenuOpen = false; }}>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<span>TheHowlingWhispers · <code>rp.thehowlingwhispers.com</code></span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Skip the global editorial.css import on the login page so the login
|
||||||
|
CSS uses its own dark backdrop. On the login page, the chrome CSS
|
||||||
|
below isn't used, but the import keeps the typography scale consistent. */
|
||||||
|
@import url('$lib/design/editorial.css');
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: var(--topbar-height) 1fr var(--footer-height);
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── topbar ─────────────────────────────────────── */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--space-6);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.hamburger {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.hamburger span {
|
||||||
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--text-soft);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: none;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.brand-main {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.brand-sub {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.topbar-right {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.avatar-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.avatar.fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-blush);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.user-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-1);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.user-menu-head {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
.user-menu-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.user-menu-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.user-menu-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.user-menu-item:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.user-menu-item.danger { color: var(--danger); }
|
||||||
|
.user-menu-item.danger:hover { background: rgba(217, 106, 122, 0.12); }
|
||||||
|
|
||||||
|
/* ── body / sidenav / content ───────────────────── */
|
||||||
|
.body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidenav-width) 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sidenav {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-right: 1px solid var(--border-faint);
|
||||||
|
padding: var(--space-6) var(--space-3);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidenav nav { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.nav-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-left-color: var(--accent-blush);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-8) var(--space-8);
|
||||||
|
}
|
||||||
|
.content > :global(*) { max-width: var(--content-max-width); }
|
||||||
|
|
||||||
|
/* ── footer ─────────────────────────────────────── */
|
||||||
|
.footer {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-top: 1px solid var(--border-faint);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.footer code {
|
||||||
|
font-style: normal;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── mobile ─────────────────────────────────────── */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.hamburger { display: flex; }
|
||||||
|
.body { grid-template-columns: 1fr; }
|
||||||
|
.sidenav {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--topbar-height);
|
||||||
|
bottom: var(--footer-height);
|
||||||
|
left: 0;
|
||||||
|
width: 260px;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.sidenav.open { transform: translateX(0); }
|
||||||
|
.content { padding: var(--space-6) var(--space-4); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
src/routes/+page.svelte
Normal file
96
src/routes/+page.svelte
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import Panel from '$lib/components/Panel.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
let user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
global_name: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_login_at: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
user = data.user;
|
||||||
|
});
|
||||||
|
|
||||||
|
function joinedAgo(iso: string): string {
|
||||||
|
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
|
||||||
|
if (days === 0) return 'today';
|
||||||
|
if (days === 1) return 'yesterday';
|
||||||
|
if (days < 30) return `${days} days ago`;
|
||||||
|
if (days < 365) return `${Math.floor(days / 30)} months ago`;
|
||||||
|
return `${Math.floor(days / 365)} years ago`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<PageHeader
|
||||||
|
title={`Welcome back, ${user.display_name || user.global_name || user.username}`}
|
||||||
|
kicker="Dashboard"
|
||||||
|
>
|
||||||
|
<div slot="actions">
|
||||||
|
<Button href="/profile" variant="secondary" small>View profile</Button>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<Panel>
|
||||||
|
<EmptyState
|
||||||
|
icon="🎭"
|
||||||
|
title="No characters yet"
|
||||||
|
description="Create your first character to start chatting. Soulforge will walk you through it."
|
||||||
|
>
|
||||||
|
<Button href="/characters" variant="primary" small>Create your first character</Button>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<EmptyState
|
||||||
|
icon="💬"
|
||||||
|
title="No rooms yet"
|
||||||
|
description="Create a room and invite a character. Or join a public one."
|
||||||
|
>
|
||||||
|
<Button href="/rooms" variant="primary" small>Browse or create a room</Button>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<EmptyState
|
||||||
|
icon="📜"
|
||||||
|
title="No activity yet"
|
||||||
|
description="Your first message, character creation, and room activity will appear here."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<p>
|
||||||
|
Member since {joinedAgo(user.created_at)} · last sign-in {joinedAgo(user.last_login_at)} ·
|
||||||
|
Discord ID <code>{user.id}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p>Loading…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.meta code { font-size: 11px; }
|
||||||
|
</style>
|
||||||
6
src/routes/api/auth/local-enabled/+server.ts
Normal file
6
src/routes/api/auth/local-enabled/+server.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
const { isLocalLoginEnabled } = await import('$lib/server/auth/local');
|
||||||
|
return json({ enabled: isLocalLoginEnabled() });
|
||||||
|
};
|
||||||
13
src/routes/api/auth/logout/+server.ts
Normal file
13
src/routes/api/auth/logout/+server.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { redirect, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ cookies }) => {
|
||||||
|
const { destroySession, SESSION_COOKIE } = await import('$lib/server/auth');
|
||||||
|
const cookie = cookies.get(SESSION_COOKIE);
|
||||||
|
if (cookie) {
|
||||||
|
const dotIdx = cookie.lastIndexOf('.');
|
||||||
|
const sessionId = dotIdx > 0 ? cookie.slice(0, dotIdx) : cookie;
|
||||||
|
destroySession(sessionId);
|
||||||
|
}
|
||||||
|
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||||
|
throw redirect(302, '/login');
|
||||||
|
};
|
||||||
11
src/routes/api/auth/me/+server.ts
Normal file
11
src/routes/api/auth/me/+server.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ cookies }) => {
|
||||||
|
const { getSessionUser, SESSION_COOKIE } = await import('$lib/server/auth');
|
||||||
|
const cookie = cookies.get(SESSION_COOKIE);
|
||||||
|
const user = getSessionUser(cookie);
|
||||||
|
if (!user) {
|
||||||
|
return json({ user: null }, { status: 200 });
|
||||||
|
}
|
||||||
|
return json({ user });
|
||||||
|
};
|
||||||
35
src/routes/api/profile/+server.ts
Normal file
35
src/routes/api/profile/+server.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { getSessionUser, SESSION_COOKIE, upsertUser } from '$lib/server/auth';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ cookies }) => {
|
||||||
|
const user = getSessionUser(cookies.get(SESSION_COOKIE));
|
||||||
|
return json({ user });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PUT: RequestHandler = async ({ cookies, request }) => {
|
||||||
|
const user = getSessionUser(cookies.get(SESSION_COOKIE));
|
||||||
|
if (!user) return json({ error: 'authentication required' }, { status: 401 });
|
||||||
|
|
||||||
|
let payload: { display_name?: string };
|
||||||
|
try {
|
||||||
|
payload = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'invalid json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newName = (payload.display_name ?? '').toString().trim();
|
||||||
|
if (newName.length > 80) {
|
||||||
|
return json({ error: 'display_name must be ≤80 chars' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertUser({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
global_name: user.global_name,
|
||||||
|
avatar: user.avatar,
|
||||||
|
email: user.email,
|
||||||
|
display_name: newName || null
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return json({ user: { ...user, display_name: newName || null } });
|
||||||
|
};
|
||||||
14
src/routes/auth/discord/+server.ts
Normal file
14
src/routes/auth/discord/+server.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { redirect, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
// The redirect target after a successful login.
|
||||||
|
// Default to root; can be overridden via ?next= for deep links.
|
||||||
|
const next = url.searchParams.get('next');
|
||||||
|
const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : '/';
|
||||||
|
const stateRedirect = encodeURIComponent(safeNext);
|
||||||
|
|
||||||
|
// Lazy import to avoid loading the OAuth module unless we need it.
|
||||||
|
const { buildAuthorizeUrl } = await import('$lib/server/auth');
|
||||||
|
const authorizeUrl = buildAuthorizeUrl(stateRedirect);
|
||||||
|
throw redirect(302, authorizeUrl);
|
||||||
|
};
|
||||||
64
src/routes/auth/discord/callback/+server.ts
Normal file
64
src/routes/auth/discord/callback/+server.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { redirect, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw redirect(302, `/login?error=${encodeURIComponent(error)}`);
|
||||||
|
}
|
||||||
|
if (!code || !state) {
|
||||||
|
throw redirect(302, '/login?error=missing_code_or_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { exchangeCodeForUser, isUserAllowed, createSession, upsertUser, SESSION_COOKIE, SESSION_TTL_DURATION } = await import('$lib/server/auth');
|
||||||
|
|
||||||
|
let user, redirectTo;
|
||||||
|
try {
|
||||||
|
const result = await exchangeCodeForUser(code, state);
|
||||||
|
user = result.user;
|
||||||
|
redirectTo = result.redirect;
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'unknown error';
|
||||||
|
throw redirect(302, `/login?error=${encodeURIComponent(msg)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowlist check — the user must be in ALLOWED_DISCORD_IDS to proceed
|
||||||
|
if (!isUserAllowed(user.id)) {
|
||||||
|
throw redirect(302, `/login?error=not_on_allowlist&discord_id=${encodeURIComponent(user.id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the user and create a session
|
||||||
|
upsertUser({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
global_name: user.global_name,
|
||||||
|
avatar: user.avatar,
|
||||||
|
email: user.email,
|
||||||
|
display_name: null
|
||||||
|
});
|
||||||
|
const session = createSession(user.id);
|
||||||
|
|
||||||
|
// Set the session cookie via SvelteKit's cookies API.
|
||||||
|
// SvelteKit's redirect() takes (status, location) — it does NOT accept headers.
|
||||||
|
// (The previous `throw redirect(302, loc, { 'set-cookie': ... })` silently
|
||||||
|
// dropped the cookie, which is why users got bounced back to /login after auth.)
|
||||||
|
// secure: true is conditional on the request scheme — allow http://127.0.0.1
|
||||||
|
// testing to set the cookie, force https:// in production.
|
||||||
|
const isHttps = env.ORIGIN?.startsWith('https://') ?? false;
|
||||||
|
cookies.set(SESSION_COOKIE, session.cookie, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isHttps,
|
||||||
|
maxAge: SESSION_TTL_DURATION
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route through /loading so the user sees a brief "Verifying your session…" buffer
|
||||||
|
// before landing on the destination. This replaces the previous direct-to-destination
|
||||||
|
// redirect, which showed a brief "Loading…" flash on the home page while the
|
||||||
|
// client-side fetch resolved.
|
||||||
|
throw redirect(302, `/loading?next=${encodeURIComponent(redirectTo)}`);
|
||||||
|
};
|
||||||
47
src/routes/auth/local/+server.ts
Normal file
47
src/routes/auth/local/+server.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// /var/www/rp/src/routes/auth/local/+server.ts
|
||||||
|
// Local password login endpoint. POST /auth/local with form-encoded `password`.
|
||||||
|
// On success, sets the same session cookie as Discord and redirects to next or /.
|
||||||
|
|
||||||
|
import { redirect, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, cookies, url }) => {
|
||||||
|
const { isLocalLoginEnabled, verifyLocalPassword } = await import('$lib/server/auth/local');
|
||||||
|
const { createSession, upsertUser, SESSION_COOKIE, SESSION_TTL_DURATION } = await import('$lib/server/auth');
|
||||||
|
|
||||||
|
if (!isLocalLoginEnabled()) {
|
||||||
|
throw redirect(302, '/login?error=local_login_disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const password = (formData.get('password') ?? '').toString();
|
||||||
|
|
||||||
|
const user = verifyLocalPassword(password);
|
||||||
|
if (!user) {
|
||||||
|
throw redirect(302, '/login?error=local_login_invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertUser({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
global_name: user.global_name,
|
||||||
|
avatar: user.avatar,
|
||||||
|
email: user.email,
|
||||||
|
display_name: user.display_name
|
||||||
|
});
|
||||||
|
const session = createSession(user.id);
|
||||||
|
|
||||||
|
const isHttps = env.ORIGIN?.startsWith('https://') ?? false;
|
||||||
|
cookies.set(SESSION_COOKIE, session.cookie, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isHttps,
|
||||||
|
maxAge: SESSION_TTL_DURATION
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = url.searchParams.get('next');
|
||||||
|
const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : '/';
|
||||||
|
// Route through /loading — same buffer as the Discord flow.
|
||||||
|
throw redirect(302, `/loading?next=${encodeURIComponent(safeNext)}`);
|
||||||
|
};
|
||||||
21
src/routes/characters/+page.svelte
Normal file
21
src/routes/characters/+page.svelte
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import Panel from '$lib/components/Panel.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import Badge from '$lib/components/Badge.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader title="Your Characters" kicker="Soulforge">
|
||||||
|
<Badge slot="meta" tone="warning">Coming soon</Badge>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<EmptyState
|
||||||
|
icon="🎭"
|
||||||
|
title="Soulforge ships in a later phase"
|
||||||
|
description="The streamlined character creator — 9 sections, live age-gate, auto-save, and the Codex AI assistant. The deprecated legacy editor was dropped from the new site in v4.5.3."
|
||||||
|
>
|
||||||
|
<Button variant="secondary" small disabled>Create your first character (coming soon)</Button>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
198
src/routes/loading/+page.svelte
Normal file
198
src/routes/loading/+page.svelte
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
let status: 'verifying' | 'success' | 'error' = 'verifying';
|
||||||
|
let user: { username: string; global_name: string | null; display_name: string | null; avatar: string | null; id: string } | null = null;
|
||||||
|
let errorMsg = '';
|
||||||
|
|
||||||
|
// Where to go after success
|
||||||
|
$: nextParam = $page.url.searchParams.get('next');
|
||||||
|
$: safeNext = nextParam && nextParam.startsWith('/') && !nextParam.startsWith('//') ? nextParam : '/';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.user) {
|
||||||
|
user = data.user;
|
||||||
|
// Brief "✓ Signed in" pause so the user sees the transition
|
||||||
|
setTimeout(() => {
|
||||||
|
status = 'success';
|
||||||
|
// Then navigate after another short beat
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = safeNext;
|
||||||
|
}, 350);
|
||||||
|
}, 250);
|
||||||
|
} else {
|
||||||
|
// No session — bounce to login with an error
|
||||||
|
errorMsg = 'Session not found. Please sign in again.';
|
||||||
|
status = 'error';
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/login?error=session_expired';
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg = e instanceof Error ? e.message : 'Network error';
|
||||||
|
status = 'error';
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/login?error=loading_failed';
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Verifying — TheHowlingWhispers</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="buffer">
|
||||||
|
<article class="card">
|
||||||
|
<p class="kicker">TheHowlingWhispers</p>
|
||||||
|
|
||||||
|
<div class="spinner-wrap">
|
||||||
|
{#if status === 'verifying'}
|
||||||
|
<div class="spinner" aria-hidden="true"></div>
|
||||||
|
{:else if status === 'success'}
|
||||||
|
<div class="check" aria-hidden="true">✓</div>
|
||||||
|
{:else}
|
||||||
|
<div class="xmark" aria-hidden="true">✕</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
{#if status === 'verifying'}
|
||||||
|
Verifying your session…
|
||||||
|
{:else if status === 'success'}
|
||||||
|
Signed in{#if user} as {user.display_name || user.global_name || user.username}{/if}
|
||||||
|
{:else}
|
||||||
|
Could not verify session
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<p class="who">
|
||||||
|
{#if user.avatar}
|
||||||
|
<img class="avatar" src="https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}.png?size=64" alt="" />
|
||||||
|
{:else}
|
||||||
|
<span class="avatar fallback">{(user.display_name || user.global_name || user.username).charAt(0).toUpperCase()}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="who-name">{user.display_name || user.global_name || user.username}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if status === 'error'}
|
||||||
|
<p class="error">{errorMsg}</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.buffer {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.kicker {
|
||||||
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-blush);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.spinner-wrap {
|
||||||
|
margin: 0 auto 28px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: var(--accent-blush);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.check, .xmark {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
animation: pop 0.3s ease;
|
||||||
|
}
|
||||||
|
.check {
|
||||||
|
background: rgba(127, 191, 127, 0.18);
|
||||||
|
border: 2px solid rgba(127, 191, 127, 0.5);
|
||||||
|
color: #b8e6c0;
|
||||||
|
}
|
||||||
|
.xmark {
|
||||||
|
background: rgba(217, 106, 122, 0.18);
|
||||||
|
border: 2px solid rgba(217, 106, 122, 0.5);
|
||||||
|
color: #ffb3c0;
|
||||||
|
}
|
||||||
|
@keyframes pop {
|
||||||
|
from { transform: scale(0.6); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.who {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.avatar.fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: var(--accent-blush);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.who-name { color: var(--text-main); font-weight: 500; }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ffb3c0;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/routes/login/+page.server.ts
Normal file
5
src/routes/login/+page.server.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { isLocalLoginEnabled } from '$lib/server/auth/local';
|
||||||
|
|
||||||
|
export const load = () => {
|
||||||
|
return { localLoginEnabled: isLocalLoginEnabled() };
|
||||||
|
};
|
||||||
260
src/routes/login/+page.svelte
Normal file
260
src/routes/login/+page.svelte
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
$: errorParam = $page.url.searchParams.get('error');
|
||||||
|
$: errorMessage = (() => {
|
||||||
|
if (!errorParam) return null;
|
||||||
|
if (errorParam === 'not_on_allowlist') return "Your Discord account is not on the allowlist for this site. Ask the owner to add your Discord ID.";
|
||||||
|
if (errorParam.startsWith('not_on_allowlist')) {
|
||||||
|
const id = errorParam.split('=')[1];
|
||||||
|
return `Your Discord account (ID ${id}) is not on the allowlist. Ask the owner to add it.`;
|
||||||
|
}
|
||||||
|
if (errorParam === 'missing_code_or_state') return 'The OAuth callback was missing required parameters. Please try logging in again.';
|
||||||
|
if (errorParam === 'local_login_invalid') return 'Wrong password. Try again.';
|
||||||
|
if (errorParam === 'local_login_disabled') return 'Local login is not enabled. Set LOCAL_ADMIN_PASSWORD in .env.';
|
||||||
|
if (errorParam === 'invalid_request' || errorParam.includes('redirect')) return 'Discord rejected the redirect URI. Check the Discord developer portal.';
|
||||||
|
return `OAuth error: ${errorParam}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Server-rendered prop from +page.server.ts: is local password login enabled?
|
||||||
|
export let data;
|
||||||
|
$: localLoginEnabled = data?.localLoginEnabled ?? false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Sign in — TheHowlingWhispers</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="login">
|
||||||
|
<article class="card">
|
||||||
|
<p class="kicker">TheHowlingWhispers</p>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p class="lede">
|
||||||
|
This site is locked away from normal users. Sign in to continue.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div class="error" role="alert">
|
||||||
|
<strong>Sign-in failed.</strong> {errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="providers">
|
||||||
|
<a class="provider discord" href="/auth/discord">
|
||||||
|
<span class="provider-icon" aria-hidden="true">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.058a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="provider-label">Continue with Discord</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="provider google" disabled type="button" aria-disabled="true" title="Coming soon">
|
||||||
|
<span class="provider-icon" aria-hidden="true">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M21.35 11.1H12v3.8h5.35c-.5 2.5-2.7 4.3-5.35 4.3-3.25 0-5.9-2.65-5.9-5.9s2.65-5.9 5.9-5.9c1.45 0 2.75.5 3.8 1.4l2.7-2.7C16.95 4.5 14.65 3.5 12 3.5 7.3 3.5 3.5 7.3 3.5 12s3.8 8.5 8.5 8.5c4.9 0 8.2-3.45 8.2-8.3 0-.55-.05-1.1-.15-1.6z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="provider-label">Continue with Google</span>
|
||||||
|
<span class="provider-badge">coming soon</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="provider email" disabled type="button" aria-disabled="true" title="Coming soon">
|
||||||
|
<span class="provider-icon" aria-hidden="true">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="provider-label">Continue with Email</span>
|
||||||
|
<span class="provider-badge">coming soon</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if localLoginEnabled}
|
||||||
|
<div class="divider"><span>or</span></div>
|
||||||
|
|
||||||
|
<form class="local-form" method="POST" action="/auth/local">
|
||||||
|
<label for="local-password">Local password (dev only)</label>
|
||||||
|
<input
|
||||||
|
id="local-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter local admin password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" class="local-submit">Sign in with local password</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
Access is restricted to a small allowlist. If you don't have access, you'll see
|
||||||
|
"Not on the allowlist" after signing in.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 24px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(20, 15, 35, 0.6);
|
||||||
|
border: 1px solid var(--border-glow);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
.kicker {
|
||||||
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-blush);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.providers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.provider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--border-glow);
|
||||||
|
transition: all 0.15s;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.provider.discord {
|
||||||
|
background: rgba(88, 101, 242, 0.15);
|
||||||
|
border-color: rgba(88, 101, 242, 0.4);
|
||||||
|
}
|
||||||
|
.provider.discord:hover {
|
||||||
|
background: rgba(88, 101, 242, 0.3);
|
||||||
|
border-color: rgba(88, 101, 242, 0.7);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.provider.google,
|
||||||
|
.provider.email {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.provider-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.provider-label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.provider-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(233, 69, 96, 0.12);
|
||||||
|
border: 1px solid rgba(233, 69, 96, 0.4);
|
||||||
|
color: #ffb3c0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.error strong {
|
||||||
|
color: #fff;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
text-align: center;
|
||||||
|
margin: 24px 0 16px;
|
||||||
|
position: relative;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.divider::before,
|
||||||
|
.divider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: calc(50% - 20px);
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-faint);
|
||||||
|
}
|
||||||
|
.divider::before { left: 0; }
|
||||||
|
.divider::after { right: 0; }
|
||||||
|
.divider span { background: rgba(20, 15, 35, 0.9); padding: 0 10px; position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
.local-form { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.local-form input {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.local-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-blush);
|
||||||
|
}
|
||||||
|
.local-submit {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.local-submit:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-blush);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
106
src/routes/profile/+page.svelte
Normal file
106
src/routes/profile/+page.svelte
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import Panel from '$lib/components/Panel.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
let user: any = null;
|
||||||
|
let displayName = '';
|
||||||
|
let saving = false;
|
||||||
|
let saveMessage = '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
user = data.user;
|
||||||
|
if (user) displayName = user.display_name || user.global_name || user.username;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!user) return;
|
||||||
|
saving = true;
|
||||||
|
saveMessage = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: displayName })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
saveMessage = `Error: ${data.error}`;
|
||||||
|
} else {
|
||||||
|
saveMessage = 'Saved.';
|
||||||
|
if (data.user) user = data.user;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
saveMessage = `Network error: ${e instanceof Error ? e.message : 'unknown'}`;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader title="Profile" kicker="Account" />
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<div class="grid">
|
||||||
|
<Panel>
|
||||||
|
<div class="user-card">
|
||||||
|
{#if user.avatar}
|
||||||
|
<img class="avatar" src="https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}.png?size=128" alt="" />
|
||||||
|
{:else}
|
||||||
|
<div class="avatar fallback">{(user.global_name || user.username || '?').charAt(0).toUpperCase()}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="info">
|
||||||
|
<h2 class="name">{user.global_name || user.username}</h2>
|
||||||
|
<p class="meta">@{user.username}</p>
|
||||||
|
<p class="meta">Discord ID <code>{user.id}</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<h3>Display name</h3>
|
||||||
|
<p class="hint">How you'd like to be addressed across the platform. Falls back to your Discord global name if blank.</p>
|
||||||
|
<form on:submit|preventDefault={save}>
|
||||||
|
<label for="display-name">Display name</label>
|
||||||
|
<input id="display-name" type="text" bind:value={displayName} maxlength="80" />
|
||||||
|
<div class="row">
|
||||||
|
<Button type="submit" variant="primary" small disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</Button>
|
||||||
|
{#if saveMessage}
|
||||||
|
<span class="message">{saveMessage}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p>Loading…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
|
||||||
|
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.user-card { display: flex; align-items: center; gap: var(--space-4); }
|
||||||
|
.avatar {
|
||||||
|
width: 72px; height: 72px; border-radius: 50%;
|
||||||
|
background: var(--accent-blush); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.avatar.fallback {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-on-accent); font-weight: 700; font-size: 32px;
|
||||||
|
}
|
||||||
|
.name { font-size: var(--text-xl); margin-bottom: 4px; }
|
||||||
|
.meta { color: var(--text-muted); font-size: var(--text-sm); margin-bottom: 2px; }
|
||||||
|
.meta code { font-size: 11px; }
|
||||||
|
|
||||||
|
.hint { color: var(--text-muted); font-size: var(--text-sm); margin-bottom: var(--space-3); }
|
||||||
|
form { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.row { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-2); }
|
||||||
|
.message { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
</style>
|
||||||
21
src/routes/rooms/+page.svelte
Normal file
21
src/routes/rooms/+page.svelte
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import Panel from '$lib/components/Panel.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import Badge from '$lib/components/Badge.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader title="Your Rooms" kicker="Conversations">
|
||||||
|
<Badge slot="meta" tone="warning">Coming soon</Badge>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<EmptyState
|
||||||
|
icon="💬"
|
||||||
|
title="The chat experience ships in a later phase"
|
||||||
|
description="Aether body stats, character mood, RP/stage direction, SSE streaming — all the things that make a conversation more than a chatbot. The chat sidebar from the main site (XP, reputation, alarm, dialogue colors) lives here."
|
||||||
|
>
|
||||||
|
<Button variant="secondary" small disabled>Create a new room (coming soon)</Button>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
57
src/routes/settings/+page.svelte
Normal file
57
src/routes/settings/+page.svelte
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import Panel from '$lib/components/Panel.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import Badge from '$lib/components/Badge.svelte';
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader title="Settings" kicker="Account" />
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<Panel>
|
||||||
|
<div class="row">
|
||||||
|
<h3>Account</h3>
|
||||||
|
<Badge>Discord OAuth</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="row-line">You sign in with Discord. Account-linking (email/password) ships in a later phase.</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<div class="row">
|
||||||
|
<h3>Appearance</h3>
|
||||||
|
<Badge>Editorial</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="row-line">The Editorial design system is locked. A 3-design vote was scoped out in Phase 0 — re-introducing it would ship the other two (Velvet Refined, Neon Noir) as alternates.</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<h3>Session</h3>
|
||||||
|
<p class="row-line">Sign out of this device. To sign out of all devices, clear the cookies in your browser.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<Button variant="secondary" small on:click={logout}>Sign out of this device</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<h3>Danger zone</h3>
|
||||||
|
<p class="row-line">Account deletion is disabled in this phase. Each subdomain keeps its own user table; deleting an account here only removes the row in this subdomain's database.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<Button variant="ghost" small disabled>Delete account (coming soon)</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid { display: flex; flex-direction: column; gap: var(--space-4); max-width: 720px; }
|
||||||
|
.row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: var(--space-3); margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.row-line { color: var(--text-muted); font-size: var(--text-sm); margin: 0; }
|
||||||
|
.actions { display: flex; gap: var(--space-2); margin-top: var(--space-3); }
|
||||||
|
</style>
|
||||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 B |
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
4
static/favicon.svg
Normal file
4
static/favicon.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#050312"/>
|
||||||
|
<circle cx="16" cy="16" r="12" fill="#e0709a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 173 B |
16
svelte.config.js
Normal file
16
svelte.config.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
out: 'build',
|
||||||
|
precompress: false
|
||||||
|
}),
|
||||||
|
csrf: { checkOrigin: false }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
vite.config.ts
Normal file
11
vite.config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
server: {
|
||||||
|
port: 3008,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
strictPort: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user