From 5f8746cfae0926c858e63264c767e4d990e4d4bf Mon Sep 17 00:00:00 2001 From: ahmed mohammed Date: Tue, 22 Sep 2026 07:32:07 +0300 Subject: [PATCH] feat: email+password auth with db-backed sessions Co-Authored-By: Claude Opus 4.7 --- app/(auth)/layout.tsx | 6 ++++ app/(auth)/login/page.tsx | 11 +++++++ app/(auth)/signup/page.tsx | 12 +++++++ app/app/layout.tsx | 5 ++- app/page.tsx | 3 +- components/auth-form.tsx | 21 +++++++++++++ lib/actions.ts | 34 ++++++++++++++++++++ lib/auth.ts | 64 ++++++++++++++++++++++++++++++++++++++ lib/db.ts | 3 +- package-lock.json | 12 ++++--- package.json | 2 +- tests/auth.test.ts | 20 ++++++++++++ tsconfig.json | 19 ++++++++--- 13 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/signup/page.tsx create mode 100644 components/auth-form.tsx create mode 100644 lib/actions.ts create mode 100644 lib/auth.ts create mode 100644 tests/auth.test.ts diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..f4ec431 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,6 @@ +export default function Auth({ children }: { children: React.ReactNode }) { + return
+

SQL Server qAI

{children} +

ShowPlan XML in · Jev-ranked remediation out · self-hosted

+
; +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..9a078a7 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,11 @@ +import { signIn } from "@/lib/actions"; +import { AuthForm } from "@/components/auth-form"; +export default function Login() { + return <> +

Sign in

+ +

Create an account

+ ; +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..4163020 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,12 @@ +import { signUp } from "@/lib/actions"; +import { AuthForm } from "@/components/auth-form"; +export default function Signup() { + return <> +

Create account

+ +

Sign in instead

+ ; +} diff --git a/app/app/layout.tsx b/app/app/layout.tsx index 5d3376e..2562259 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -1,4 +1,7 @@ -export default function Console({ children }: { children: React.ReactNode }) { +import { requireUser } from "@/lib/auth"; + +export default async function Console({ children }: { children: React.ReactNode }) { + await requireUser(); return
{children}
diff --git a/app/page.tsx b/app/page.tsx index ccb8628..5b6dc07 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,2 +1,3 @@ import { redirect } from "next/navigation"; -export default function Home() { redirect("/login"); } +import { sessionUser } from "@/lib/auth"; +export default async function Home() { redirect((await sessionUser()) ? "/app" : "/login"); } diff --git a/components/auth-form.tsx b/components/auth-form.tsx new file mode 100644 index 0000000..4ff2127 --- /dev/null +++ b/components/auth-form.tsx @@ -0,0 +1,21 @@ +"use client"; +import { useActionState } from "react"; + +export type FormResult = { ok?: boolean; error?: string } | null; + +export function AuthForm({ action, submit, fields }: { + action: (state: FormResult, fd: FormData) => Promise; + submit: string; + fields: { name: string; label: string; type: string; autoComplete?: string; help?: string }[]; +}) { + const [state, formAction, pending] = useActionState(action, null); + return
+ {fields.map((f) =>
+ + + {f.help && {f.help}} +
)} + {state && !state.ok &&

{state.error}

} + +
; +} diff --git a/lib/actions.ts b/lib/actions.ts new file mode 100644 index 0000000..5d36430 --- /dev/null +++ b/lib/actions.ts @@ -0,0 +1,34 @@ +"use server"; +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { userByEmail, getSettings, renameAnalysis, deleteAnalysis } from "@/lib/db"; +import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, emailOk } from "@/lib/auth"; + +export async function signUp(_: unknown, fd: FormData) { + const r = signUpNewUser(String(fd.get("email") ?? ""), String(fd.get("password") ?? "")); + if (!r.ok) return r; + await startSession(r.id); + redirect("/setup"); +} +export async function signIn(_: unknown, fd: FormData) { + const e = String(fd.get("email") ?? "").trim().toLowerCase(); + const u = emailOk(e) ? userByEmail(e) : null; + if (!u || !verifyPassword(String(fd.get("password") ?? ""), u.pass)) + return { ok: false, error: "Email or password is wrong." }; + await startSession(u.id); + redirect(getSettings(u.id)?.onboarded === 1 ? "/app" : "/setup"); +} +export async function signOut() { await endSession(); redirect("/login"); } + +export async function rename(_: unknown, fd: FormData) { + const u = await requireUser(); + const id = String(fd.get("id") ?? ""); + const title = String(fd.get("title") ?? "").trim().slice(0, 120); + if (id && title) renameAnalysis(id, u.id, title); + revalidatePath("/app"); +} +export async function remove(_: unknown, fd: FormData) { + const u = await requireUser(); + deleteAnalysis(String(fd.get("id") ?? ""), u.id); + revalidatePath("/app"); +} diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..465c33a --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,64 @@ +import { randomBytes, scryptSync, createHash, timingSafeEqual } from "node:crypto"; +import { createUser, userByEmail, userEmail, putSession, sessionUserId, dropSession, getSettings } from "./db.ts"; + +export const SESSION_COOKIE = "qai_s"; +export const SESSION_DAYS = 30; +export const emailOk = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e); +export const passwordOk = (p: string) => p.length >= 10; // trust boundary: signup + password change only + +export function hashPassword(pw: string): string { + const salt = randomBytes(16); + return `${salt.toString("hex")}:${scryptSync(pw, salt, 64).toString("hex")}`; +} +export function verifyPassword(pw: string, stored: string): boolean { + const [salt, hash] = stored.split(":"); + if (!salt || !hash) return false; + try { + const a = scryptSync(pw, Buffer.from(salt, "hex"), 64); + const b = Buffer.from(hash, "hex"); + return a.length === b.length && timingSafeEqual(a, b); + } catch { return false; } +} +export const hashToken = (t: string) => createHash("sha256").update(t).digest("hex"); + +export function signUpNewUser(email: string, password: string): { ok: true; id: string } | { ok: false; error: string } { + const e = email.trim().toLowerCase(); + if (!emailOk(e)) return { ok: false, error: "Enter a valid email address." }; + if (!passwordOk(password)) return { ok: false, error: "Use at least 10 characters." }; + if (userByEmail(e)) return { ok: false, error: "That email already has an account." }; + // ponytail: no rate limiting — single-container tool. Add a token bucket before exposing this to a network. + return { ok: true, id: createUser(e, hashPassword(password)) }; +} + +// next/headers is imported lazily: `node --test` loads this module to test the crypto above, and the +// request-scoped binding must not be touched at import time. +const jar = async () => (await import("next/headers")).cookies(); + +export async function startSession(userId: string) { + const token = randomBytes(32).toString("hex"); + putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5); + (await jar()).set(SESSION_COOKIE, token, { + httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", + path: "/", maxAge: SESSION_DAYS * 86400 }); +} +export async function endSession() { + const store = await jar(); + const t = store.get(SESSION_COOKIE)?.value; + if (t) dropSession(hashToken(t)); + store.delete(SESSION_COOKIE); +} +export async function sessionUser(): Promise<{ id: string; email: string } | null> { + const t = (await jar()).get(SESSION_COOKIE)?.value; + if (!t) return null; + const id = sessionUserId(hashToken(t)); + if (!id) return null; + const row = userEmail(id); + return row ? { id, email: row.email } : null; +} +export async function requireUser(): Promise<{ id: string; email: string }> { + const { redirect } = await import("next/navigation"); // lazy for the same reason as jar() + const u = await sessionUser(); + if (!u) { redirect("/login"); throw new Error("redirecting to /login"); } // redirect throws; this only satisfies the type + return u; +} +export const needsOnboarding = (userId: string) => getSettings(userId)?.onboarded !== 1; diff --git a/lib/db.ts b/lib/db.ts index d0024cf..9ddcdda 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -29,7 +29,8 @@ export const db = (g.__qaiDb ??= (() => { })()); const now = () => Date.now(); -const one = (sql: string, ...p: unknown[]): T | null => (db.prepare(sql).get(...p) as T) ?? null; +type Row = string | number | null; +const one = (sql: string, ...p: Row[]): T | null => (db.prepare(sql).get(...p) as T | undefined) ?? null; // node:sqlite binds null/number/string/bigint/Buffer only: pass 0/1, never booleans. export type SettingsInput = { analyst_base_url: string; analyst_key: string; analyst_model: string; diff --git a/package-lock.json b/package-lock.json index ca5396b..8a06514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "zod": "^4.6.5" }, "devDependencies": { - "@types/node": "^20", + "@types/node": "^24.13.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -1319,11 +1319,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.43", + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/react": { @@ -5419,7 +5421,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 28332ab..9fb0486 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "zod": "^4.6.5" }, "devDependencies": { - "@types/node": "^20", + "@types/node": "^24.13.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..df73f8d --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-auth-`); +const { hashPassword, verifyPassword, hashToken } = await import("../lib/auth.ts"); + +test("password hash round-trips and rejects", () => { + const h = hashPassword("correct horse battery staple"); + assert.ok(h.includes(":"), "salt:hash shape"); + assert.ok(verifyPassword("correct horse battery staple", h)); + assert.ok(!verifyPassword("wrong", h)); +}); +test("malformed stored hash never verifies", () => { + for (const bad of ["", "nosep", "a:", ":b", "zz:zz"]) assert.ok(!verifyPassword("x", bad)); +}); +test("token hash is stable, 64 hex", () => { + assert.equal(hashToken("abc"), hashToken("abc")); + assert.match(hashToken("abc"), /^[0-9a-f]{64}$/); +}); diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..742340f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,8 +23,11 @@ } ], "paths": { - "@/*": ["./*"] - } + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true }, "include": [ "next-env.d.ts", @@ -30,5 +37,7 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] -} + "exclude": [ + "node_modules" + ] +} \ No newline at end of file