mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(crash): unify dashboard auth onto id.reasonix.io identity (#5701)
The crash dashboard ran its own account system — a separate D1 users/ sessions table with its own password login — which fragmented identity from id.reasonix.io. Make it consume the shared identity instead: it resolves the .reasonix.io rxid cookie via id.reasonix.io/me (Bearer) and keeps only a per-dashboard role in a new `access` table. Existing roles migrate by email (migrate-access.sql); ADMIN_EMAILS force-promotes the owner so a missed migration can't lock them out. Sign-in redirects to the shared login; sign-out revokes the shared session. Also: accounts /auth/logout accepts a Bearer token (so crash can revoke server-side), and the site login honours a next to any *.reasonix.io.
This commit is contained in:
10 files changed
+164
-228
No files matched your search
@@ -24,6 +24,19 @@ const $ = (id) => document.getElementById(id);
|
||||
const qp = new URLSearchParams(location.search);
|
||||
const withBase = (p) => (import.meta.env.BASE_URL.replace(/\/$/, "") + p) || p;
|
||||
|
||||
// A `next` is honoured only if it's a same-origin path or an absolute https URL
|
||||
// under reasonix.io, so a subdomain (e.g. crash.reasonix.io) can return here
|
||||
// after sign-in without opening a redirect to an arbitrary host.
|
||||
function safeNext(next) {
|
||||
if (!next) return null;
|
||||
if (next.startsWith("/")) return next;
|
||||
try {
|
||||
const u = new URL(next);
|
||||
if (u.protocol === "https:" && (u.host === "reasonix.io" || u.host.endsWith(".reasonix.io"))) return next;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function msg(el, kind, text) {
|
||||
if (!el) return;
|
||||
el.className = "auth-msg " + kind;
|
||||
@@ -52,8 +65,7 @@ if (loginForm) {
|
||||
busy(btn, true);
|
||||
try {
|
||||
await api("/auth/login", { method: "POST", body: { email: $("email").value.trim(), password: $("password").value } });
|
||||
const next = qp.get("next");
|
||||
location.href = next && next.startsWith("/") ? next : withBase("/account/");
|
||||
location.href = safeNext(qp.get("next")) || withBase("/account/");
|
||||
} catch (err) {
|
||||
msg(box, "error", err.message);
|
||||
busy(btn, false);
|
||||
|
||||
@@ -6,9 +6,9 @@ import { repos } from "../db";
|
||||
import { readSessionToken } from "../auth/cookies";
|
||||
import { ApiError } from "./errors";
|
||||
|
||||
// Non-browser clients (CLI/desktop) carry the session in an Authorization header
|
||||
// instead of the cookie.
|
||||
function readBearerToken(c: Context<AppEnv>): string | undefined {
|
||||
// Non-browser clients (CLI/desktop) and cross-service callers carry the session
|
||||
// in an Authorization header instead of the cookie.
|
||||
export function readBearerToken(c: Context<AppEnv>): string | undefined {
|
||||
const header = c.req.header("authorization");
|
||||
if (!header) return undefined;
|
||||
const token = /^Bearer\s+(.+)$/i.exec(header.trim())?.[1]?.trim();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { buildMailer, verifyEmail, resetEmail, accountExistsEmail } from "../ema
|
||||
import { hashPassword, verifyPassword, randomSuffix } from "../auth/crypto";
|
||||
import { setSessionCookie, clearSessionCookie, readSessionToken } from "../auth/cookies";
|
||||
import { authRateLimit } from "../http/ratelimit";
|
||||
import { readBearerToken } from "../http/auth";
|
||||
import { ApiError } from "../http/errors";
|
||||
import { deriveHandleBase, isValidHandle } from "../lib/handle";
|
||||
import { parseBody, RegisterSchema, LoginSchema, ForgotSchema, ResetSchema, ResendSchema } from "../lib/validation";
|
||||
@@ -96,7 +97,7 @@ auth.post("/login", async (c) => {
|
||||
});
|
||||
|
||||
auth.post("/logout", async (c) => {
|
||||
const token = readSessionToken(c);
|
||||
const token = readSessionToken(c) ?? readBearerToken(c);
|
||||
if (token) await repos(c.env).sessions.deleteByToken(token);
|
||||
clearSessionCookie(c);
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Unify dashboard auth onto id.reasonix.io: dashboard access is now a per-email
|
||||
-- role, not a local password/session. Identity is resolved from the shared
|
||||
-- account service; this table only records who may view the dashboard.
|
||||
-- Apply: wrangler d1 execute reasonix-crash --remote --file=migrate-access.sql
|
||||
CREATE TABLE IF NOT EXISTS access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
approved_at TEXT,
|
||||
approved_by TEXT
|
||||
);
|
||||
|
||||
-- Carry over every existing dashboard account's role by email, so current
|
||||
-- admins/viewers keep their access when they next sign in via id.reasonix.io.
|
||||
INSERT OR IGNORE INTO access (email, role, created_at, approved_at)
|
||||
SELECT lower(email), role, created_at, approved_at FROM users;
|
||||
@@ -87,6 +87,8 @@ CREATE TABLE IF NOT EXISTS metric_users (
|
||||
PRIMARY KEY (date, signal, bucket, install_id)
|
||||
);
|
||||
|
||||
-- Legacy local auth — superseded by id.reasonix.io identity + the `access`
|
||||
-- table below. Kept during the transition; migrate-access.sql copies roles over.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
@@ -106,6 +108,17 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id);
|
||||
|
||||
-- Dashboard authorization keyed by the shared account email. Identity (login,
|
||||
-- password, verification) lives in id.reasonix.io; this only maps email → role.
|
||||
CREATE TABLE IF NOT EXISTS access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
approved_at TEXT,
|
||||
approved_by TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Dashboard authorization. Identity comes from id.reasonix.io (the shared
|
||||
// account service); this worker only maps a signed-in identity to a per-dashboard
|
||||
// role via the `access` table.
|
||||
import type { Env } from "./env";
|
||||
import { esc } from "./shell";
|
||||
|
||||
@@ -17,72 +20,16 @@ export function atLeast(role: Role, min: Role): boolean {
|
||||
return RANK[role] >= RANK[min];
|
||||
}
|
||||
|
||||
const COOKIE = "rxsess";
|
||||
const SESSION_TTL_MS = 30 * 86_400_000;
|
||||
const PBKDF2_ITERS = 100_000;
|
||||
// The id.reasonix.io session cookie is scoped to `.reasonix.io`, so the browser
|
||||
// sends it here too; we hand it back as a Bearer token to resolve the identity.
|
||||
const SHARED_COOKIE = "rxid";
|
||||
|
||||
function b64(bytes: Uint8Array): string {
|
||||
let s = "";
|
||||
for (const byte of bytes) s += String.fromCharCode(byte);
|
||||
return btoa(s);
|
||||
function idOrigin(env: Env): string {
|
||||
return (env.ID_ORIGIN ?? "https://id.reasonix.io").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function unb64(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveBits(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
|
||||
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, key, 256);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const hash = await deriveBits(password, salt, PBKDF2_ITERS);
|
||||
return `pbkdf2$${PBKDF2_ITERS}$${b64(salt)}$${b64(hash)}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [scheme, iters, saltB64, hashB64] = stored.split("$");
|
||||
if (scheme !== "pbkdf2") return false;
|
||||
const got = await deriveBits(password, unb64(saltB64), Number(iters));
|
||||
const want = unb64(hashB64);
|
||||
return got.byteLength === want.byteLength && crypto.subtle.timingSafeEqual(got, want);
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
const b = crypto.getRandomValues(new Uint8Array(32));
|
||||
return [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function createSession(env: Env, userId: number): Promise<string> {
|
||||
const token = randomToken();
|
||||
const now = Date.now();
|
||||
await env.DB.prepare("INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)")
|
||||
.bind(token, userId, new Date(now).toISOString(), new Date(now + SESSION_TTL_MS).toISOString())
|
||||
.run();
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function destroySession(env: Env, token: string): Promise<void> {
|
||||
await env.DB.prepare("DELETE FROM sessions WHERE token = ?1").bind(token).run();
|
||||
}
|
||||
|
||||
export async function endSession(request: Request, env: Env): Promise<void> {
|
||||
const token = getCookie(request, COOKIE);
|
||||
if (token) await destroySession(env, token);
|
||||
}
|
||||
|
||||
export function sessionCookie(token: string): string {
|
||||
return `${COOKIE}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
|
||||
}
|
||||
|
||||
export function clearCookie(): string {
|
||||
return `${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
|
||||
function appOrigin(env: Env): string {
|
||||
return (env.APP_ORIGIN ?? "https://reasonix.io").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function getCookie(request: Request, name: string): string | null {
|
||||
@@ -94,21 +41,74 @@ export function getCookie(request: Request, name: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function currentUser(request: Request, env: Env): Promise<User | null> {
|
||||
const token = getCookie(request, COOKIE);
|
||||
// Where an unauthenticated visitor is sent to sign in: the shared login page,
|
||||
// with a same-site `next` back to the page they wanted.
|
||||
export function loginUrl(env: Env, request: Request): string {
|
||||
const here = new URL(request.url);
|
||||
const next = `${here.origin}${here.pathname}`;
|
||||
return `${appOrigin(env)}/login/?next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
interface Identity {
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
async function resolveIdentity(request: Request, env: Env): Promise<Identity | null> {
|
||||
const token = getCookie(request, SHARED_COOKIE);
|
||||
if (!token) return null;
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT u.id, u.email, u.role, u.created_at, u.approved_at, s.expires_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?1`,
|
||||
)
|
||||
.bind(token)
|
||||
.first<{ id: number; email: string; role: Role; created_at: string; approved_at: string | null; expires_at: string }>();
|
||||
if (!row) return null;
|
||||
if (row.expires_at < new Date().toISOString()) {
|
||||
await destroySession(env, token);
|
||||
return null;
|
||||
const res = await fetch(`${idOrigin(env)}/me`, { headers: { authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json<{ user?: { email?: string; emailVerified?: boolean } }>().catch(() => null);
|
||||
const email = data?.user?.email?.toLowerCase();
|
||||
if (!email) return null;
|
||||
return { email, emailVerified: data?.user?.emailVerified === true };
|
||||
}
|
||||
|
||||
// Resolves the signed-in dashboard user, or null. A first-seen identity is
|
||||
// recorded as `pending`; an ADMIN_EMAILS identity is force-promoted to admin so
|
||||
// the owner can never be locked out of their own dashboard.
|
||||
export async function currentUser(request: Request, env: Env): Promise<User | null> {
|
||||
const identity = await resolveIdentity(request, env);
|
||||
if (!identity) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const bootstrapAdmin = isAdminEmail(env, identity.email);
|
||||
let row = await selectAccess(env, identity.email);
|
||||
if (!row) {
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO access (email, role, created_at, approved_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(email) DO NOTHING",
|
||||
)
|
||||
.bind(identity.email, bootstrapAdmin ? "admin" : "pending", now, bootstrapAdmin ? now : null)
|
||||
.run();
|
||||
row = await selectAccess(env, identity.email);
|
||||
if (!row) return null;
|
||||
} else if (bootstrapAdmin && row.role !== "admin") {
|
||||
await env.DB.prepare("UPDATE access SET role = 'admin', approved_at = COALESCE(approved_at, ?2) WHERE id = ?1")
|
||||
.bind(row.id, now)
|
||||
.run();
|
||||
row.role = "admin";
|
||||
}
|
||||
return { id: row.id, email: row.email, role: row.role, created_at: row.created_at, approved_at: row.approved_at };
|
||||
return row;
|
||||
}
|
||||
|
||||
function selectAccess(env: Env, email: string): Promise<User | null> {
|
||||
return env.DB.prepare("SELECT id, email, role, created_at, approved_at FROM access WHERE email = ?1")
|
||||
.bind(email)
|
||||
.first<User>();
|
||||
}
|
||||
|
||||
// Ends the shared id.reasonix.io session (best-effort) and returns a Set-Cookie
|
||||
// that clears the shared cookie browser-side.
|
||||
export async function sharedLogout(request: Request, env: Env): Promise<string> {
|
||||
const token = getCookie(request, SHARED_COOKIE);
|
||||
if (token) {
|
||||
await fetch(`${idOrigin(env)}/auth/logout`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
}).catch(() => {});
|
||||
}
|
||||
return `${SHARED_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Domain=.reasonix.io; Max-Age=0`;
|
||||
}
|
||||
|
||||
export function isAdminEmail(env: Env, email: string): boolean {
|
||||
|
||||
@@ -1,43 +1,7 @@
|
||||
import { esc, page } from "./shell";
|
||||
import { type User, userNav } from "./auth";
|
||||
|
||||
function notice(n?: { kind: "err" | "ok"; text: string }): string {
|
||||
return n ? `<div class="notice ${n.kind}">${esc(n.text)}</div>` : "";
|
||||
}
|
||||
|
||||
function authShell(title: string, crumb: string, body: string): string {
|
||||
return page(title, crumb, `<div class="authwrap"><div class="authcard">${body}</div></div>`);
|
||||
}
|
||||
|
||||
export function renderLogin(n?: { kind: "err" | "ok"; text: string }): string {
|
||||
return authShell(
|
||||
"Reasonix · Sign in",
|
||||
"sign in",
|
||||
`<h1>Sign in</h1><p class="sub">Diagnostic reports & telemetry dashboard</p>${notice(n)}
|
||||
<form method="post" action="/login">
|
||||
<div class="field"><label>Email</label><input type="email" name="email" autocomplete="username" required></div>
|
||||
<div class="field"><label>Password</label><input type="password" name="password" autocomplete="current-password" required></div>
|
||||
<button class="btn block" type="submit">Sign in</button>
|
||||
</form>
|
||||
<div class="alt">No account? <a href="/register">Register</a></div>`,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderRegister(n?: { kind: "err" | "ok"; text: string }): string {
|
||||
return authShell(
|
||||
"Reasonix · Register",
|
||||
"register",
|
||||
`<h1>Create account</h1><p class="sub">New accounts start with no access until an admin approves them.</p>${notice(n)}
|
||||
<form method="post" action="/register">
|
||||
<div class="field"><label>Email</label><input type="email" name="email" autocomplete="username" required></div>
|
||||
<div class="field"><label>Password <span style="color:var(--ink-3)">— at least 8 characters</span></label><input type="password" name="password" autocomplete="new-password" minlength="8" required></div>
|
||||
<button class="btn block" type="submit">Register</button>
|
||||
</form>
|
||||
<div class="alt">Already have an account? <a href="/login">Sign in</a></div>`,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderAccount(user: User, n?: { kind: "err" | "ok"; text: string }): string {
|
||||
export function renderAccount(user: User): string {
|
||||
const pending = user.role === "pending";
|
||||
const status = pending
|
||||
? `<div class="notice err">Your account is awaiting approval. An admin needs to grant you access before the diagnostic reports dashboard becomes visible.</div>`
|
||||
@@ -46,13 +10,10 @@ export function renderAccount(user: User, n?: { kind: "err" | "ok"; text: string
|
||||
"Reasonix · Account",
|
||||
"account",
|
||||
`<h1>Account</h1><p class="sub">${esc(user.email)} · joined ${esc(user.created_at.slice(0, 10))}</p>
|
||||
${notice(n)}${status}
|
||||
<div class="card" style="max-width:480px;margin-top:24px"><h2>Change password</h2>
|
||||
<form method="post" action="/account/password">
|
||||
<div class="field"><label>Current password</label><input type="password" name="current" autocomplete="current-password" required></div>
|
||||
<div class="field"><label>New password</label><input type="password" name="next" autocomplete="new-password" minlength="8" required></div>
|
||||
<button class="btn" type="submit">Update password</button>
|
||||
</form></div>`,
|
||||
${status}
|
||||
<div class="card" style="max-width:480px;margin-top:24px"><h2>Identity</h2>
|
||||
<p class="sub">Your email and password are managed by your Reasonix account.</p>
|
||||
<a class="btn" href="https://reasonix.io/account/">Manage your account →</a></div>`,
|
||||
userNav(user),
|
||||
);
|
||||
}
|
||||
@@ -8,4 +8,8 @@ export interface Env {
|
||||
PING_LIMITER: RateLimiter;
|
||||
METRICS_LIMITER: RateLimiter;
|
||||
ADMIN_EMAILS?: string;
|
||||
// Shared identity service (id.reasonix.io) and the site that hosts its login
|
||||
// page (reasonix.io). Overridable for local dev.
|
||||
ID_ORIGIN?: string;
|
||||
APP_ORIGIN?: string;
|
||||
}
|
||||
@@ -5,20 +5,15 @@ import { z } from "zod";
|
||||
import type { Env } from "./env";
|
||||
import { html, redirect } from "./shell";
|
||||
import { renderGroup, renderStats, type Group, type StatsModule } from "./stats";
|
||||
import { renderLogin, renderRegister, renderAccount } from "./auth_pages";
|
||||
import { renderAccount } from "./auth_pages";
|
||||
import { renderUsers, renderAudit, type UserRow, type AuditRow } from "./admin";
|
||||
import {
|
||||
atLeast,
|
||||
createSession,
|
||||
currentUser,
|
||||
endSession,
|
||||
hashPassword,
|
||||
isAdminEmail,
|
||||
loginUrl,
|
||||
logAction,
|
||||
sameOrigin,
|
||||
sessionCookie,
|
||||
clearCookie,
|
||||
verifyPassword,
|
||||
sharedLogout,
|
||||
type Role,
|
||||
type User,
|
||||
} from "./auth";
|
||||
@@ -472,16 +467,6 @@ async function handleMetrics(request: Request, env: Env): Promise<Response> {
|
||||
return new Response("ok", { status: 202 });
|
||||
}
|
||||
|
||||
const Credentials = z.object({
|
||||
email: z.string().email().max(254),
|
||||
password: z.string().min(8).max(200),
|
||||
});
|
||||
|
||||
const PasswordChange = z.object({
|
||||
current: z.string().min(1).max(200),
|
||||
next: z.string().min(8).max(200),
|
||||
});
|
||||
|
||||
const UserAction = z.object({
|
||||
action: z.enum(["role", "delete"]),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
@@ -503,62 +488,6 @@ async function formObject(request: Request): Promise<Record<string, string>> {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function handleRegister(request: Request, env: Env): Promise<Response> {
|
||||
if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
|
||||
const parsed = Credentials.safeParse(await formObject(request));
|
||||
if (!parsed.success)
|
||||
return html(renderRegister({ kind: "err", text: "Enter a valid email and a password of at least 8 characters." }));
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
|
||||
const existing = await env.DB.prepare("SELECT id FROM users WHERE email = ?1").bind(email).first();
|
||||
if (existing) return html(renderRegister({ kind: "err", text: "That email is already registered — try signing in." }));
|
||||
|
||||
const role: Role = isAdminEmail(env, email) ? "admin" : "pending";
|
||||
const now = new Date().toISOString();
|
||||
const hash = await hashPassword(parsed.data.password);
|
||||
const res = await env.DB.prepare(
|
||||
"INSERT INTO users (email, password_hash, role, created_at, approved_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)
|
||||
.bind(email, hash, role, now, role === "admin" ? now : null)
|
||||
.run();
|
||||
|
||||
const token = await createSession(env, res.meta.last_row_id);
|
||||
return redirect(role === "pending" ? "/account" : "/stats", sessionCookie(token));
|
||||
}
|
||||
|
||||
async function handleLogin(request: Request, env: Env): Promise<Response> {
|
||||
if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
|
||||
const parsed = Credentials.safeParse(await formObject(request));
|
||||
if (!parsed.success) return html(renderLogin({ kind: "err", text: "Enter a valid email and password." }));
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
|
||||
const row = await env.DB.prepare("SELECT id, password_hash, role FROM users WHERE email = ?1")
|
||||
.bind(email)
|
||||
.first<{ id: number; password_hash: string; role: Role }>();
|
||||
const ok = row ? await verifyPassword(parsed.data.password, row.password_hash) : false;
|
||||
if (!row || !ok) return html(renderLogin({ kind: "err", text: "Wrong email or password." }));
|
||||
|
||||
const token = await createSession(env, row.id);
|
||||
return redirect(atLeast(row.role, "viewer") ? "/stats" : "/account", sessionCookie(token));
|
||||
}
|
||||
|
||||
async function handleAccountPassword(request: Request, env: Env, user: User): Promise<Response> {
|
||||
if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
|
||||
const parsed = PasswordChange.safeParse(await formObject(request));
|
||||
if (!parsed.success) return html(renderAccount(user, { kind: "err", text: "New password must be at least 8 characters." }));
|
||||
|
||||
const row = await env.DB.prepare("SELECT password_hash FROM users WHERE id = ?1")
|
||||
.bind(user.id)
|
||||
.first<{ password_hash: string }>();
|
||||
if (!row || !(await verifyPassword(parsed.data.current, row.password_hash)))
|
||||
return html(renderAccount(user, { kind: "err", text: "Current password is incorrect." }));
|
||||
|
||||
await env.DB.prepare("UPDATE users SET password_hash = ?1 WHERE id = ?2")
|
||||
.bind(await hashPassword(parsed.data.next), user.id)
|
||||
.run();
|
||||
return html(renderAccount(user, { kind: "ok", text: "Password updated." }));
|
||||
}
|
||||
|
||||
type StatsFilters = {
|
||||
status: string;
|
||||
source: string;
|
||||
@@ -899,24 +828,21 @@ async function handleAdminUsers(request: Request, env: Env, admin: User): Promis
|
||||
const a = parsed.data;
|
||||
if (a.userId === admin.id) return redirect("/admin");
|
||||
|
||||
const target = await env.DB.prepare("SELECT email, role FROM users WHERE id = ?1")
|
||||
const target = await env.DB.prepare("SELECT email, role FROM access WHERE id = ?1")
|
||||
.bind(a.userId)
|
||||
.first<{ email: string; role: Role }>();
|
||||
if (!target) return redirect("/admin");
|
||||
|
||||
if (a.action === "delete") {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare("DELETE FROM sessions WHERE user_id = ?1").bind(a.userId),
|
||||
env.DB.prepare("DELETE FROM users WHERE id = ?1").bind(a.userId),
|
||||
]);
|
||||
await env.DB.prepare("DELETE FROM access WHERE id = ?1").bind(a.userId).run();
|
||||
await logAction(env, admin, "delete_user", target.email);
|
||||
return redirect("/admin");
|
||||
}
|
||||
|
||||
const role: Role = a.role ?? "pending";
|
||||
const now = new Date().toISOString();
|
||||
await env.DB.prepare("UPDATE users SET role = ?1, approved_at = ?2, approved_by = ?3 WHERE id = ?4")
|
||||
.bind(role, role === "pending" ? null : now, admin.id, a.userId)
|
||||
await env.DB.prepare("UPDATE access SET role = ?1, approved_at = ?2, approved_by = ?3 WHERE id = ?4")
|
||||
.bind(role, role === "pending" ? null : now, admin.email, a.userId)
|
||||
.run();
|
||||
await logAction(env, admin, "set_role", target.email, `${target.role} → ${role}`);
|
||||
return redirect("/admin");
|
||||
@@ -924,7 +850,7 @@ async function handleAdminUsers(request: Request, env: Env, admin: User): Promis
|
||||
|
||||
async function handleAdminList(env: Env, admin: User): Promise<Response> {
|
||||
const users = await env.DB.prepare(
|
||||
"SELECT id, email, role, created_at, approved_at FROM users ORDER BY (role = 'pending') DESC, created_at DESC",
|
||||
"SELECT id, email, role, created_at, approved_at FROM access ORDER BY (role = 'pending') DESC, created_at DESC",
|
||||
).all<UserRow>();
|
||||
return html(renderUsers(admin, users.results));
|
||||
}
|
||||
@@ -936,8 +862,8 @@ async function handleAdminAudit(env: Env, admin: User): Promise<Response> {
|
||||
return html(renderAudit(admin, rows.results));
|
||||
}
|
||||
|
||||
function requireViewer(user: User | null): Response | null {
|
||||
if (!user) return redirect("/login");
|
||||
function requireViewer(user: User | null, login: string): Response | null {
|
||||
if (!user) return redirect(login);
|
||||
if (!atLeast(user.role, "viewer")) return redirect("/account");
|
||||
return null;
|
||||
}
|
||||
@@ -952,40 +878,34 @@ export default {
|
||||
if (path === "/v1/ping" && method === "POST") return handlePing(request, env);
|
||||
if (path === "/v1/metrics" && method === "POST") return handleMetrics(request, env);
|
||||
|
||||
if (path === "/register" && method === "GET") return html(renderRegister());
|
||||
if (path === "/register" && method === "POST") return handleRegister(request, env);
|
||||
if (path === "/login" && method === "GET") return html(renderLogin());
|
||||
if (path === "/login" && method === "POST") return handleLogin(request, env);
|
||||
if (path === "/logout" && method === "POST") {
|
||||
await endSession(request, env);
|
||||
return redirect("/login", clearCookie());
|
||||
}
|
||||
const login = loginUrl(env, request);
|
||||
|
||||
// Authentication moved to id.reasonix.io; these paths just bounce there.
|
||||
if ((path === "/login" || path === "/register") && method === "GET") return redirect(login);
|
||||
if (path === "/logout" && method === "POST") return redirect(login, await sharedLogout(request, env));
|
||||
|
||||
const user = await currentUser(request, env);
|
||||
|
||||
if (path === "/") return redirect(user ? (atLeast(user.role, "viewer") ? "/stats" : "/account") : "/login");
|
||||
if (path === "/") return redirect(user ? (atLeast(user.role, "viewer") ? "/stats" : "/account") : login);
|
||||
|
||||
if (path === "/account" && method === "GET")
|
||||
return user ? html(renderAccount(user)) : redirect("/login");
|
||||
if (path === "/account/password" && method === "POST")
|
||||
return user ? handleAccountPassword(request, env, user) : redirect("/login");
|
||||
if (path === "/account" && method === "GET") return user ? html(renderAccount(user)) : redirect(login);
|
||||
|
||||
const groupMatch = path.match(/^\/stats\/group\/([0-9a-f]{64})$/);
|
||||
const statsModuleMatch = path.match(/^\/stats\/(diagnostics|usage|preferences|health)$/);
|
||||
if ((path === "/stats" || statsModuleMatch) && method === "GET")
|
||||
return requireViewer(user) ?? handleStats(request, env, user as User, (statsModuleMatch?.[1] as StatsModule | undefined) ?? "usage");
|
||||
if (groupMatch && method === "GET") return requireViewer(user) ?? handleGroup(env, groupMatch[1], user as User);
|
||||
return requireViewer(user, login) ?? handleStats(request, env, user as User, (statsModuleMatch?.[1] as StatsModule | undefined) ?? "usage");
|
||||
if (groupMatch && method === "GET") return requireViewer(user, login) ?? handleGroup(env, groupMatch[1], user as User);
|
||||
if (groupMatch && method === "POST") {
|
||||
if (user?.role !== "admin") return new Response("forbidden", { status: 403 });
|
||||
return handleGroupAction(request, env, user, groupMatch[1]);
|
||||
}
|
||||
|
||||
if (path === "/admin" && method === "GET") {
|
||||
if (!user) return redirect("/login");
|
||||
if (!user) return redirect(login);
|
||||
return user.role === "admin" ? handleAdminList(env, user) : redirect("/account");
|
||||
}
|
||||
if (path === "/admin/audit" && method === "GET") {
|
||||
if (!user) return redirect("/login");
|
||||
if (!user) return redirect(login);
|
||||
return user.role === "admin" ? handleAdminAudit(env, user) : redirect("/account");
|
||||
}
|
||||
if (path === "/admin/users" && method === "POST") {
|
||||
@@ -1001,7 +921,6 @@ export default {
|
||||
path === "/register" ||
|
||||
path === "/logout" ||
|
||||
path === "/account" ||
|
||||
path === "/account/password" ||
|
||||
path.startsWith("/stats") ||
|
||||
path.startsWith("/admin")
|
||||
) {
|
||||
|
||||
@@ -5,6 +5,15 @@ compatibility_date = "2026-06-01"
|
||||
|
||||
routes = [{ pattern = "crash.reasonix.io", custom_domain = true }]
|
||||
|
||||
[vars]
|
||||
# Emails force-promoted to dashboard admin — the owner is never locked out even
|
||||
# if the role migration misses them.
|
||||
ADMIN_EMAILS = "[email protected]"
|
||||
# Shared identity service (resolves the session) and the site hosting its login
|
||||
# page. Override for local dev.
|
||||
ID_ORIGIN = "https://id.reasonix.io"
|
||||
APP_ORIGIN = "https://reasonix.io"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "reasonix-crash"
|
||||
|
||||
Reference in new issue
Block a user