feat: setup wizard + settings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
dd6b880f49
commit
1c3f222f9d
@@ -0,0 +1,19 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { settingsView } from "@/lib/settings";
|
||||||
|
import { listAnalyses } from "@/lib/db";
|
||||||
|
import { saveSettingsAction, signOut } from "@/lib/actions";
|
||||||
|
import { SettingsForm } from "@/components/settings-form";
|
||||||
|
|
||||||
|
export default async function Settings() {
|
||||||
|
const u = await requireUser();
|
||||||
|
const rows = listAnalyses(u.id);
|
||||||
|
return <main className="main" style={{ maxWidth: 640, margin: "0 auto" }}>
|
||||||
|
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)" }}>Settings</h1>
|
||||||
|
<SettingsForm action={saveSettingsAction} view={settingsView(u.id)} account />
|
||||||
|
<section className="card">
|
||||||
|
<p className="label">History · {rows.length}</p>
|
||||||
|
<p style={{ fontSize: "var(--qai-text-sm)" }}>Rename or delete individual runs from the left rail. Nothing here deletes them in bulk — that is on purpose.</p>
|
||||||
|
</section>
|
||||||
|
<form action={signOut}><button className="btn">Sign out</button></form>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { settingsView } from "@/lib/settings";
|
||||||
|
import { getSettings } from "@/lib/db";
|
||||||
|
import { saveSettingsAction } from "@/lib/actions";
|
||||||
|
import { SettingsForm } from "@/components/settings-form";
|
||||||
|
|
||||||
|
const TITLES = { 1: "Paste a plan. Get a ranked fix.", 2: "Say which models to ask.", 3: "Ready." } as const;
|
||||||
|
|
||||||
|
export default async function Setup({ searchParams }: { searchParams: Promise<{ step?: string }> }) {
|
||||||
|
const u = await requireUser();
|
||||||
|
const step = (await searchParams).step ?? "";
|
||||||
|
const n = (["1", "2", "3"].includes(step) ? step : "1") as "1" | "2" | "3";
|
||||||
|
const view = settingsView(u.id);
|
||||||
|
// Step 3 stays reachable after onboarding: it is the "are both models actually ready?" screen.
|
||||||
|
if (n !== "3" && getSettings(u.id)?.onboarded === 1 && view.has_analyst && view.has_jev) redirect("/app");
|
||||||
|
return <main style={{ maxWidth: 560, margin: "0 auto", padding: "var(--qai-space-3xl) var(--qai-space-l)",
|
||||||
|
display: "grid", gap: "var(--qai-space-xl)" }}>
|
||||||
|
<p className="label">Step {n} of 3</p>
|
||||||
|
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-display)", letterSpacing: "-0.02em" }}>
|
||||||
|
{TITLES[n]}</h1>
|
||||||
|
{n === "1" && <p>qAI reads a SQL Server ShowPlan XML, works out the bottleneck in code, asks your model for several ways to fix it, then has Jev score each one. You configure nothing on this screen.</p>}
|
||||||
|
{n === "2" && <SettingsForm action={saveSettingsAction} view={view} step="Analyst model and Jev key" />}
|
||||||
|
{n === "3" && <>
|
||||||
|
<p>One run = one digest, a few model calls, four Jev questions per option. If the analyst or Jev is unreachable, the report says so instead of inventing a verdict.</p>
|
||||||
|
<p className="label">Analyst {view.has_analyst ? "ready" : "missing"} · Jev {view.has_jev ? "ready" : "missing"}</p>
|
||||||
|
<a className="btn btn-primary" href="/app" style={{ justifySelf: "start", textDecoration: "none" }}>Go</a>
|
||||||
|
</>}
|
||||||
|
<p className="colophon">Keys stay in this container, encrypted with APP_SECRET · <a href="/settings">settings</a></p>
|
||||||
|
{n !== "3" && <a className="btn" href={`/setup?step=${Number(n) + 1}`}
|
||||||
|
style={{ justifySelf: "start", textDecoration: "none" }}>Next</a>}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import type { SettingsView } from "@/lib/settings";
|
||||||
|
|
||||||
|
type Result = { ok?: boolean; error?: string } | null;
|
||||||
|
|
||||||
|
export function SettingsForm({ action, view, step, account }: {
|
||||||
|
action: (state: Result, fd: FormData) => Promise<Result>;
|
||||||
|
view: SettingsView; step?: string; account?: boolean;
|
||||||
|
}) {
|
||||||
|
const [state, formAction, pending] = useActionState<Result, FormData>(action, null);
|
||||||
|
const F = (name: string, label: string, value: string, help: string, type = "text") => (
|
||||||
|
<div key={name} style={{ display: "grid", gap: "var(--qai-space-xs)" }}>
|
||||||
|
<label className="label" htmlFor={name}>{label}</label>
|
||||||
|
<input className="field" id={name} name={name} type={type} defaultValue={value} />
|
||||||
|
<span style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{help}</span>
|
||||||
|
</div>);
|
||||||
|
const Group = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||||
|
<fieldset style={{ border: 0, padding: 0, margin: 0, display: "grid", gap: "var(--qai-space-md)" }}>
|
||||||
|
<legend className="label">{title}</legend>{children}</fieldset>);
|
||||||
|
return <form action={formAction} className="card" style={{ display: "grid", gap: "var(--qai-space-l)" }}>
|
||||||
|
{step && <p className="label">{step}</p>}
|
||||||
|
<Group title="Analyst model — OpenAI-compatible">
|
||||||
|
{F("analyst_base_url", "Base URL", view.analyst_base_url, "e.g. https://api.openai.com/v1")}
|
||||||
|
{F("analyst_key", "API key", "", view.has_analyst
|
||||||
|
? `Saved ${view.analyst_key_masked} — leave blank to keep it` : "Encrypted with APP_SECRET before it hits disk")}
|
||||||
|
{F("analyst_model", "Model name", view.analyst_model, "e.g. gpt-4o-mini, llama-3.3-70b")}
|
||||||
|
{F("analyst_extra", "Extra params (JSON)", view.analyst_extra, 'Merged into the request body, e.g. {"temperature":0.2}')}
|
||||||
|
</Group>
|
||||||
|
<Group title="Jev — TypeSafe System One">
|
||||||
|
{F("jev_key", "TypeSafe API key", "", view.has_jev
|
||||||
|
? `Saved ${view.jev_key_masked} — leave blank to keep it` : "console.typesafe.ai/keys")}
|
||||||
|
{F("jev_model", "Jev model", view.jev_model, "jev-latest unless you know better")}
|
||||||
|
</Group>
|
||||||
|
{account && <Group title="Account">
|
||||||
|
{F("email", "Email", "", "Used to sign in", "email")}
|
||||||
|
{F("password", "New password", "", "Leave blank to keep the current one", "password")}
|
||||||
|
</Group>}
|
||||||
|
{state && !state.ok && <p role="alert" style={{ color: "var(--qai-danger)" }}>{state.error}</p>}
|
||||||
|
{state?.ok && <p role="status" style={{ color: "var(--qai-accent)" }}>Saved.</p>}
|
||||||
|
<button className="btn btn-primary" disabled={pending}>Save</button>
|
||||||
|
</form>;
|
||||||
|
}
|
||||||
+16
-2
@@ -1,8 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { userByEmail, getSettings, renameAnalysis, deleteAnalysis } from "@/lib/db";
|
import { userByEmail, getSettings, renameAnalysis, deleteAnalysis, db } from "@/lib/db";
|
||||||
import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, emailOk } from "@/lib/auth";
|
import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, emailOk, hashPassword } from "@/lib/auth";
|
||||||
|
import { saveSettingsForm } from "@/lib/settings";
|
||||||
|
|
||||||
export async function signUp(_: unknown, fd: FormData) {
|
export async function signUp(_: unknown, fd: FormData) {
|
||||||
const r = signUpNewUser(String(fd.get("email") ?? ""), String(fd.get("password") ?? ""));
|
const r = signUpNewUser(String(fd.get("email") ?? ""), String(fd.get("password") ?? ""));
|
||||||
@@ -32,3 +33,16 @@ export async function remove(_: unknown, fd: FormData) {
|
|||||||
deleteAnalysis(String(fd.get("id") ?? ""), u.id);
|
deleteAnalysis(String(fd.get("id") ?? ""), u.id);
|
||||||
revalidatePath("/app");
|
revalidatePath("/app");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveSettingsAction(_: unknown, fd: FormData) {
|
||||||
|
const u = await requireUser();
|
||||||
|
const email = String(fd.get("email") ?? "").trim().toLowerCase();
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
revalidatePath("/settings");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user