fix: final review pass — email collision, orphaned-run loop, null confidence guards
- changeEmail validates and refuses an address another account owns (users.email is UNIQUE; a collision was a 500 inside the settings form). - /app/[id] no longer redirects a 'running' row to itself; it renders what exists and says the run was interrupted. - ScoreBar / OptionRow / VerdictBlock survive null numbers from the model instead of throwing and losing the block (seen live: '[openui] runtime/render-error: Component ScoreBar render failed'). - drop the ad-hoc render-debug scripts now that Playwright covers the browser. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
07dd3d6bf8
commit
5160a00db5
+6
-4
@@ -2,7 +2,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { userByEmail, getSettings, renameAnalysis, deleteAnalysis, db } from "@/lib/db";
|
||||
import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, emailOk, hashPassword } from "@/lib/auth";
|
||||
import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, changeEmail, emailOk, hashPassword } from "@/lib/auth";
|
||||
import { saveSettingsForm } from "@/lib/settings";
|
||||
|
||||
export async function signUp(_: unknown, fd: FormData) {
|
||||
@@ -36,16 +36,18 @@ export async function remove(_: unknown, fd: FormData) {
|
||||
|
||||
export async function saveSettingsAction(_: unknown, fd: FormData) {
|
||||
const u = await requireUser();
|
||||
const email = String(fd.get("email") ?? "").trim().toLowerCase();
|
||||
const email = String(fd.get("email") ?? "").trim();
|
||||
const password = String(fd.get("password") ?? "");
|
||||
if (email && !emailOk(email)) return { ok: false, error: "Enter a valid email address." };
|
||||
if (password && password.length < 10) return { ok: false, error: "Use at least 10 characters." };
|
||||
saveSettingsForm(u.id, fd);
|
||||
// The wizard sends `after`: a configured user must land on the readiness screen, and the setup page
|
||||
// forwards configured users away from steps 1-2, so "Saved." would never be seen there.
|
||||
if (String(fd.get("after") ?? "").startsWith("/setup")) redirect("/setup?step=3");
|
||||
if (password) db.prepare("UPDATE users SET pass = ? WHERE id = ?").run(hashPassword(password), u.id);
|
||||
if (email) db.prepare("UPDATE users SET email = ? WHERE id = ?").run(email, u.id);
|
||||
if (email) {
|
||||
const r = changeEmail(u.id, email);
|
||||
if (!r.ok) return r;
|
||||
}
|
||||
revalidatePath("/settings");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
+11
-1
@@ -1,5 +1,5 @@
|
||||
import { randomBytes, scryptSync, createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createUser, userByEmail, userEmail, putSession, sessionUserId, dropSession, getSettings } from "./db.ts";
|
||||
import { createUser, userByEmail, userEmail, putSession, sessionUserId, dropSession, getSettings, db } from "./db.ts";
|
||||
|
||||
export const SESSION_COOKIE = "qai_s";
|
||||
export const SESSION_DAYS = 30;
|
||||
@@ -34,6 +34,16 @@ export function signUpNewUser(email: string, password: string): { ok: true; id:
|
||||
// request-scoped binding must not be touched at import time.
|
||||
const jar = async () => (await import("next/headers")).cookies();
|
||||
|
||||
export function changeEmail(userId: string, email: string): { ok: true } | { ok: false; error: string } {
|
||||
const e = email.trim().toLowerCase();
|
||||
if (!emailOk(e)) return { ok: false, error: "Enter a valid email address." };
|
||||
const taken = userByEmail(e);
|
||||
if (taken && taken.id !== userId) return { ok: false, error: "That email already has an account." };
|
||||
// users.email is UNIQUE: without this check a collision is a 500 inside the settings form.
|
||||
if (!taken) db.prepare("UPDATE users SET email = ? WHERE id = ?").run(e, userId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function startSession(userId: string) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5);
|
||||
|
||||
+10
-5
@@ -10,6 +10,11 @@ const muted = { color: "var(--qai-ink-muted)" };
|
||||
const small = { fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" };
|
||||
const disp = { fontFamily: "var(--qai-font-display)" };
|
||||
|
||||
// Model output is untrusted: the schema says a number is optional, models still send null, and one throw
|
||||
// deletes the whole block from the report. 0 is the honest stand-in for "the model did not give a number".
|
||||
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||
const conf = (v: unknown) => (typeof v === "number" ? ` ${v.toFixed(2)}` : " not reported");
|
||||
|
||||
const renderers: Record<string, (x: any) => any> = {
|
||||
Headline: ({ props }) => el("header", null,
|
||||
el("p", { className: "label" }, props.severity === "critical" ? "Critical" : props.severity === "warning" ? "Warning" : "Note"),
|
||||
@@ -50,14 +55,14 @@ const renderers: Record<string, (x: any) => any> = {
|
||||
props.caption ? el("p", { style: small }, props.caption) : null),
|
||||
|
||||
ScoreBar: ({ props }) => el("div", null,
|
||||
el("p", { className: "label" }, props.label + (props.confidence !== undefined
|
||||
? ` · confidence ${props.confidence.toFixed(2)}` : " · no confidence reported")),
|
||||
el("div", { className: "bar", style: { width: `${Math.round(props.value * 100)}%` } }),
|
||||
el("p", { className: "label" }, props.label + (props.confidence === undefined
|
||||
? " · no confidence reported" : ` · confidence${conf(props.confidence)}`)),
|
||||
el("div", { className: "bar", style: { width: `${Math.round(num(props.value) * 100)}%` } }),
|
||||
props.note ? el("p", { style: small }, props.note) : null),
|
||||
|
||||
OptionRow: ({ props }) => el("tr", { "data-headline": props.headline || undefined },
|
||||
el("td", null, String(props.rank)), el("td", null, props.title),
|
||||
el("td", { className: "sql" }, props.composite.toFixed(2)),
|
||||
el("td", { className: "sql" }, num(props.composite).toFixed(2)),
|
||||
el("td", null, props.flags || "—")),
|
||||
|
||||
OptionTable: ({ props, renderNode }: any) => el("section", { className: "card" },
|
||||
@@ -67,7 +72,7 @@ const renderers: Record<string, (x: any) => any> = {
|
||||
|
||||
VerdictBlock: ({ props }) => el("section", { className: "card", "data-verdict": "" },
|
||||
el("p", { className: "label" }, "First move · " + (props.source === "jev"
|
||||
? `Jev, confidence ${props.confidence.toFixed(2)}` : "composite order — Jev did not decide")),
|
||||
? `Jev, confidence${conf(props.confidence)}` : "composite order — Jev did not decide")),
|
||||
p({ ...disp, fontSize: "var(--qai-text-lg)" }, props.headline),
|
||||
p({}, props.why)),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user