From 6f4aaf2446eb650029d667dccf630d4bf15789fe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:29:46 +1100 Subject: [PATCH] feat(frontend): per-user sign-in; the browser no longer holds the API key (#96) The frontend now requires signing in. /auth posts to /api/auth/login and keeps the session in sessionStorage; every API call sends it as a bearer token, and no call sends X-API-Key. A 401 ends the session and returns to /auth; the upload page and /audit redirect there when signed out. The header shows user and tenant with a sign-out button. VITE_API_KEY is gone from the code, the offline bundle and the docs. The offline installer creates the first tenant and admin (password to a root-only file, not the log) and the smoke test now checks sign-in and waits for the frontend to start. QUICKSTART gains the one-time create_tenant_admin step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019YPoXzBSj89ZHgw7nfTmbB --- README.md | 11 +- api/main.py | 8 +- .../src/__tests__/session.test.ts | 140 ++++++++++++++++++ .../src/__tests__/sign-in-page.test.tsx | 71 +++++++++ .../src/components/session-menu.tsx | 29 ++++ csv-table-hub-main/src/lib/csv.functions.ts | 17 ++- csv-table-hub-main/src/lib/session.ts | 91 ++++++++++++ .../src/routes/_authenticated/index.tsx | 2 + .../src/routes/_authenticated/route.tsx | 3 + csv-table-hub-main/src/routes/audit.tsx | 8 +- csv-table-hub-main/src/routes/auth.tsx | 116 +++++++++++---- docs/API_INTEGRATION.md | 24 +-- docs/ENV_VARIABLES.md | 7 +- docs/QUICKSTART.md | 20 ++- docs/offline-install.md | 40 ++++- env.dev.example | 3 +- scripts/offline/build_bundle.sh | 5 +- scripts/offline/install.sh | 23 ++- scripts/offline/smoke_test.sh | 13 ++ 19 files changed, 553 insertions(+), 78 deletions(-) create mode 100644 csv-table-hub-main/src/__tests__/session.test.ts create mode 100644 csv-table-hub-main/src/__tests__/sign-in-page.test.tsx create mode 100644 csv-table-hub-main/src/components/session-menu.tsx create mode 100644 csv-table-hub-main/src/lib/session.ts diff --git a/README.md b/README.md index 1bfc124..43ecd1a 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,11 @@ (BR-21) are deferred and untested. - **Audit timing:** the upload audit row is written after the load commits. A crash in that window leaves a loaded table with no audit row. -- **Authentication:** per-user sign-in exists in the API: local accounts with - lockout, OIDC, four roles (`docs/ROLES.md`) and a schema per tenant. The - browser still uses the shared API key until frontend sign-in (#96) lands. - The API refuses to start outside `dev` without `API_KEY`. +- **Authentication:** people sign in per user: local accounts with lockout, + OIDC, four roles (`docs/ROLES.md`) and a schema per tenant. The shared + `API_KEY` is for scripts and services only; the browser never holds it. The + API refuses to start outside `dev` without `API_KEY`. Requests that use the + key reach the default schemas, not any tenant's. - **Rate limiting:** in-process only, so it suits a single API instance. - **Frontend audit page:** shows this browser's local import history, not the server audit log (see the naming note under Frontend Features). @@ -103,7 +104,7 @@ PostgreDataMigrationAppWithCSVLoader/ │ │ ├── mapping-templates.ts ← saved column mapping templates │ │ └── csv-preview.ts ← CSV parsing utilities │ ├── src/__tests__/ ← 102 vitest tests -│ ├── .env.local ← VITE_API_BASE, VITE_API_KEY (not committed) +│ ├── .env.local ← VITE_API_BASE (not committed) │ └── package.json │ ├── build/ ← SQL schema, loaders, deployment scripts diff --git a/api/main.py b/api/main.py index 34ec2d9..788a9e7 100644 --- a/api/main.py +++ b/api/main.py @@ -53,13 +53,13 @@ async def lifespan(app: FastAPI): "variable before deploying anywhere reachable beyond localhost." ) else: - # Fingerprint (first 4 chars + length) so mismatches with the frontend's - # VITE_API_KEY are diagnosable without leaking the secret to the log. - # Fixes BUG-006 in docs/BUG_REPORT.md. + # Fingerprint (first 4 chars + length) so a script's key mismatch is + # diagnosable without leaking the secret to the log (BUG-006). Browsers + # no longer hold this key; they sign in per user (#96). fp = settings.API_KEY[:4] + "..." if len(settings.API_KEY) >= 4 else "***" logger.info( "API_KEY is set (fingerprint: %s, length: %d). " - "Frontend must send matching VITE_API_KEY via the X-API-Key header.", + "Scripts and services send it in the X-API-Key header; browsers sign in.", fp, len(settings.API_KEY), ) if not settings.PG_PASSWORD: diff --git a/csv-table-hub-main/src/__tests__/session.test.ts b/csv-table-hub-main/src/__tests__/session.test.ts new file mode 100644 index 0000000..20bc5f5 --- /dev/null +++ b/csv-table-hub-main/src/__tests__/session.test.ts @@ -0,0 +1,140 @@ +// src/__tests__/session.test.ts +// Frontend sign-in (#96): the browser holds a per-user session token, never +// the shared API key; an ended session sends the user back to sign-in. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { isRedirect } from "@tanstack/react-router"; +import { + authHeaders, + clearSession, + getSession, + requireSession, + signIn, + signOut, + type Session, +} from "@/lib/session"; +import { listCsvFiles } from "@/lib/csv.functions"; + +const future = () => new Date(Date.now() + 3_600_000).toISOString(); +const session = (over: Partial = {}): Session => ({ + token: "tok-123", + expiresAt: future(), + username: "alice", + tenant: "acme", + role: "uploader", + ...over, +}); +const store = (s: Session) => sessionStorage.setItem("te.session", JSON.stringify(s)); +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +beforeEach(() => clearSession()); +afterEach(() => vi.unstubAllGlobals()); + +describe("getSession", () => { + it("is null when nobody has signed in", () => { + expect(getSession()).toBeNull(); + }); + + it("returns a live session", () => { + store(session()); + expect(getSession()?.username).toBe("alice"); + }); + + it("drops an expired session", () => { + store(session({ expiresAt: new Date(Date.now() - 1000).toISOString() })); + expect(getSession()).toBeNull(); + expect(sessionStorage.getItem("te.session")).toBeNull(); + }); + + it("treats unreadable storage as signed out", () => { + sessionStorage.setItem("te.session", "{not json"); + expect(getSession()).toBeNull(); + }); +}); + +describe("signIn / signOut", () => { + it("stores the session the API returns", async () => { + const fetchMock = vi.fn(async () => json(session())); + vi.stubGlobal("fetch", fetchMock); + await signIn("acme", "alice", "pw"); + expect(getSession()?.tenant).toBe("acme"); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toMatch(/\/api\/auth\/login$/); + expect(JSON.parse(init.body as string)).toEqual({ + tenant: "acme", + username: "alice", + password: "pw", + }); + }); + + it("rejects with one generic message on 401 and stores nothing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ detail: "Invalid tenant, username or password" }, 401)), + ); + await expect(signIn("acme", "alice", "wrong")).rejects.toThrow( + "Invalid tenant, username or password", + ); + expect(getSession()).toBeNull(); + }); + + it("sign-out revokes the token on the server and forgets it here", async () => { + store(session()); + const fetchMock = vi.fn(async () => json({ status: "ok" })); + vi.stubGlobal("fetch", fetchMock); + await signOut(); + expect(getSession()).toBeNull(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toMatch(/\/api\/auth\/logout$/); + expect((init.headers as Record).Authorization).toBe("Bearer tok-123"); + }); +}); + +describe("API calls", () => { + it("send the bearer token and never an X-API-Key", async () => { + store(session()); + const fetchMock = vi.fn(async () => json([])); + vi.stubGlobal("fetch", fetchMock); + await listCsvFiles(); + const headers = (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1] + .headers as Record; + expect(headers.Authorization).toBe("Bearer tok-123"); + expect(Object.keys(headers).map((h) => h.toLowerCase())).not.toContain("x-api-key"); + }); + + it("send no Authorization header when signed out", () => { + expect(authHeaders()).toEqual({}); + }); + + it("a 401 ends the session and sends the user to sign-in", async () => { + store(session()); + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ detail: "Session expired or invalid; sign in again" }, 401)), + ); + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + await expect(listCsvFiles()).rejects.toThrow("Your session has ended"); + expect(getSession()).toBeNull(); + expect(assign).toHaveBeenCalledWith("/auth"); + }); +}); + +describe("requireSession (route guard)", () => { + it("redirects to /auth when signed out", () => { + let thrown: unknown; + try { + requireSession(); + } catch (e) { + thrown = e; + } + expect(isRedirect(thrown)).toBe(true); + expect((thrown as { options: { to: string } }).options.to).toBe("/auth"); + }); + + it("lets a signed-in user through", () => { + store(session()); + expect(() => requireSession()).not.toThrow(); + }); +}); diff --git a/csv-table-hub-main/src/__tests__/sign-in-page.test.tsx b/csv-table-hub-main/src/__tests__/sign-in-page.test.tsx new file mode 100644 index 0000000..a87c499 --- /dev/null +++ b/csv-table-hub-main/src/__tests__/sign-in-page.test.tsx @@ -0,0 +1,71 @@ +// src/__tests__/sign-in-page.test.tsx — the sign-in form (#96). + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { clearSession, getSession } from "@/lib/session"; + +const navigate = vi.fn(); +vi.mock("@tanstack/react-router", async (orig) => ({ + ...(await orig()), + useNavigate: () => navigate, +})); + +const { SignInPage } = await import("@/routes/auth"); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +async function fill(password = "pw") { + const user = userEvent.setup(); + await user.type(screen.getByLabelText("Tenant"), "acme"); + await user.type(screen.getByLabelText("Username"), "alice"); + await user.type(screen.getByLabelText("Password"), password); + await user.click(screen.getByRole("button", { name: /sign in/i })); +} + +beforeEach(() => { + clearSession(); + navigate.mockReset(); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("SignInPage", () => { + it("keeps the button disabled until every field is filled", () => { + render(); + expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled(); + }); + + it("signs in and goes to the upload page", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + json({ + token: "t", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + username: "alice", + tenant: "acme", + role: "uploader", + }), + ), + ); + render(); + await fill(); + await vi.waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: "/" })); + expect(getSession()?.username).toBe("alice"); + }); + + it("shows the error, clears the password and stays put on a failed sign-in", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ detail: "x" }, 401)), + ); + render(); + await fill("wrong"); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Invalid tenant, username or password", + ); + expect(screen.getByLabelText("Password")).toHaveValue(""); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/csv-table-hub-main/src/components/session-menu.tsx b/csv-table-hub-main/src/components/session-menu.tsx new file mode 100644 index 0000000..b2a8e4a --- /dev/null +++ b/csv-table-hub-main/src/components/session-menu.tsx @@ -0,0 +1,29 @@ +// Signed-in user, tenant and role, with sign-out (#96). +import { useNavigate } from "@tanstack/react-router"; +import { LogOut } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { getSession, signOut } from "@/lib/session"; + +export function SessionMenu() { + const navigate = useNavigate(); + const s = getSession(); + if (!s) return null; + return ( +
+ + {s.username} · {s.tenant} + + +
+ ); +} diff --git a/csv-table-hub-main/src/lib/csv.functions.ts b/csv-table-hub-main/src/lib/csv.functions.ts index eec724b..6850516 100644 --- a/csv-table-hub-main/src/lib/csv.functions.ts +++ b/csv-table-hub-main/src/lib/csv.functions.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { QueryClient } from "@tanstack/react-query"; +import { apiBase, authHeaders, clearSession } from "@/lib/session"; const columnTypeSchema = z.enum(["int8", "numeric", "date", "timestamptz", "boolean", "text"]); export type ColumnType = z.infer; @@ -98,19 +99,24 @@ const previewRowsSchema = z.object({ rows: z.array(z.record(previewRowValueSchema)), }); -const apiBase = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, ""); -const apiKey = import.meta.env.VITE_API_KEY ?? ""; - async function apiFetch(path: string, schema: z.ZodType, options?: RequestInit): Promise { const response = await fetch(`${apiBase}${path}`, { ...options, headers: { "content-type": "application/json", - ...(apiKey ? { "X-API-Key": apiKey } : {}), + ...authHeaders(), ...options?.headers, }, }); + // #96: an expired or revoked session ends here; the route guard then sends + // the user to the sign-in page. + if (response.status === 401) { + clearSession(); + if (typeof window !== "undefined") window.location.assign("/auth"); + throw new Error("Your session has ended. Please sign in again."); + } + const text = await response.text(); let payload: unknown = null; if (text) { @@ -122,7 +128,8 @@ async function apiFetch(path: string, schema: z.ZodType, options?: Request } if (!response.ok) { - const message = (payload as any)?.message || (payload as any)?.error || response.statusText; + const body = payload as { message?: unknown; error?: unknown; detail?: unknown } | null; + const message = body?.message || body?.error || body?.detail || response.statusText; throw new Error(typeof message === "string" ? message : String(payload)); } diff --git a/csv-table-hub-main/src/lib/session.ts b/csv-table-hub-main/src/lib/session.ts new file mode 100644 index 0000000..7729d46 --- /dev/null +++ b/csv-table-hub-main/src/lib/session.ts @@ -0,0 +1,91 @@ +// src/lib/session.ts — the signed-in session (ART M2, #96, BR-36). +// +// The browser holds a per-user session token from POST /api/auth/login and +// sends it as `Authorization: Bearer `. It never holds the shared API +// key. The token lives in sessionStorage, so it ends when the tab closes. + +import { redirect } from "@tanstack/react-router"; + +export interface Session { + token: string; + expiresAt: string; + username: string; + tenant: string; + role: string; +} + +const KEY = "te.session"; + +export const apiBase = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, ""); + +export function getSession(now: Date = new Date()): Session | null { + try { + const raw = sessionStorage.getItem(KEY); + if (!raw) return null; + const s = JSON.parse(raw) as Session; + if (!s.token || new Date(s.expiresAt) <= now) { + sessionStorage.removeItem(KEY); + return null; + } + return s; + } catch { + return null; + } +} + +export function clearSession(): void { + try { + sessionStorage.removeItem(KEY); + } catch { + /* storage unavailable: nothing to clear */ + } +} + +/** Sign in with a local account. Throws with the server's message on failure. */ +export async function signIn(tenant: string, username: string, password: string): Promise { + const res = await fetch(`${apiBase}/api/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tenant, username, password }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error( + res.status === 401 + ? "Invalid tenant, username or password" + : (body?.detail ?? res.statusText), + ); + } + const session = body as Session; + try { + sessionStorage.setItem(KEY, JSON.stringify(session)); + } catch { + throw new Error( + "This browser blocks session storage, so sign-in can't be kept. Allow site data and try again.", + ); + } + return session; +} + +/** Revoke the session on the server (best effort) and forget it here. */ +export async function signOut(): Promise { + const s = getSession(); + clearSession(); + if (s) { + await fetch(`${apiBase}/api/auth/logout`, { + method: "POST", + headers: { Authorization: `Bearer ${s.token}` }, + }).catch(() => undefined); + } +} + +/** Headers for an API call: the bearer token when signed in. */ +export function authHeaders(): Record { + const s = getSession(); + return s ? { Authorization: `Bearer ${s.token}` } : {}; +} + +/** Route guard (#96): send anyone without a live session to the sign-in page. */ +export function requireSession(): void { + if (!getSession()) throw redirect({ to: "/auth" }); +} diff --git a/csv-table-hub-main/src/routes/_authenticated/index.tsx b/csv-table-hub-main/src/routes/_authenticated/index.tsx index 5eb31c3..ae9f15a 100644 --- a/csv-table-hub-main/src/routes/_authenticated/index.tsx +++ b/csv-table-hub-main/src/routes/_authenticated/index.tsx @@ -58,6 +58,7 @@ import { } from "lucide-react"; import { Progress } from "@/components/ui/progress"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { SessionMenu } from "@/components/session-menu"; export const Route = createFileRoute("/_authenticated/")({ head: () => ({ meta: [{ title: "CSV Migrator — Upload CSV files to your database" }, { name: "description", content: "Upload any number of CSV files. Each becomes its own database table, with duplicates automatically skipped." }, { property: "og:title", content: "CSV Migrator" }] }), @@ -82,6 +83,7 @@ function Home() {
{files.length} file{files.length===1?"":"s"} migrated Import history +
diff --git a/csv-table-hub-main/src/routes/_authenticated/route.tsx b/csv-table-hub-main/src/routes/_authenticated/route.tsx index 7e9aa56..ae2c19b 100644 --- a/csv-table-hub-main/src/routes/_authenticated/route.tsx +++ b/csv-table-hub-main/src/routes/_authenticated/route.tsx @@ -1,6 +1,9 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { requireSession } from "@/lib/session"; +// #96: every page under this layout needs a signed-in session. export const Route = createFileRoute("/_authenticated")({ ssr: false, + beforeLoad: requireSession, component: () => , }); diff --git a/csv-table-hub-main/src/routes/audit.tsx b/csv-table-hub-main/src/routes/audit.tsx index 4ed1760..65951b0 100644 --- a/csv-table-hub-main/src/routes/audit.tsx +++ b/csv-table-hub-main/src/routes/audit.tsx @@ -16,8 +16,14 @@ import { CheckCircle2, AlertTriangle, XCircle, PauseCircle, ChevronDown, Chevron import { toast } from "sonner"; import { downloadErrorReport, downloadAuditReport, buildErrorRows, mappingSummary, type ExportFormat, type AuditReportRow } from "@/lib/export"; import type { Job, JobStatus } from "@/lib/job-types"; +import { requireSession } from "@/lib/session"; -export const Route = createFileRoute("/audit")({ head: () => ({ meta: [{ title: "Audit log — CSV Migrator" }] }), component: AuditPage }); +export const Route = createFileRoute("/audit")({ + ssr: false, + beforeLoad: requireSession, + head: () => ({ meta: [{ title: "Audit log — CSV Migrator" }] }), + component: AuditPage, +}); const STATUS_LABEL: Record = { reading:"Reading", uploading:"Uploading", processing:"Processing", done:"Imported", duplicate:"Skipped duplicate", error:"Invalid structure", interrupted:"Interrupted", cancelled:"Cancelled" }; const FILTER_STATUSES = [ {value:"all",label:"All statuses"}, {value:"done",label:"Imported"}, {value:"overwritten",label:"Overwritten"}, {value:"duplicate",label:"Skipped duplicate"}, {value:"error",label:"Invalid structure"}, {value:"interrupted",label:"Interrupted"}, {value:"cancelled",label:"Cancelled"} ]; diff --git a/csv-table-hub-main/src/routes/auth.tsx b/csv-table-hub-main/src/routes/auth.tsx index 64f253c..247a1f6 100644 --- a/csv-table-hub-main/src/routes/auth.tsx +++ b/csv-table-hub-main/src/routes/auth.tsx @@ -1,41 +1,99 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState, type FormEvent } from "react"; +import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { signIn } from "@/lib/session"; +// #96: per-user sign-in. The account and tenant come from the API's local +// accounts (scripts/create_tenant_admin.py creates the first admin). export const Route = createFileRoute("/auth")({ - head: () => ({ - meta: [ - { title: "CSV Migrator — Upload without authentication" }, - { name: "description", content: "Authentication is disabled. Upload CSV files directly to your backend." }, - ], - }), - component: AuthPage, + ssr: false, + head: () => ({ meta: [{ title: "Sign in — CSV Migrator" }] }), + component: SignInPage, }); -function AuthPage() { +export function SignInPage() { + const navigate = useNavigate(); + const [tenant, setTenant] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + await signIn(tenant.trim(), username.trim(), password); + navigate({ to: "/" }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setPassword(""); + } finally { + setBusy(false); + } + } + return (
- - - Authentication disabled - - -

- This Lovable frontend has been configured to use a PostgreSQL-backed backend API directly, so user sign-in is not required. -

-
- -

- Go back to the home screen and upload CSV files directly. Configure VITE_API_BASE to point to your backend. -

-
-
- - - + +
+ + Sign in to CSV Migrator + + +
+ + setTenant(e.target.value)} + /> +
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + + +
); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 014c2fd..eccf0a5 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -47,17 +47,19 @@ CSV content is sent as a JSON string, not multipart. ## Authentication -Every endpoint (including `/api/health`) requires an `X-API-Key` header -matching the `API_KEY` environment variable. If `API_KEY` is unset the check -is skipped — that's the local-dev default, and `api/main.py` logs a warning -at startup when it's unset so this isn't silently forgotten in a real -deployment. Set `API_KEY` (backend) and `VITE_API_KEY` (frontend, same value) -before deploying anywhere reachable beyond localhost. See -`csv-table-hub-main/.env.local`. - -> **Known DX gap** — if `API_KEY` and `VITE_API_KEY` don't match, every request -> returns 401 with no hint from either process. Tracked as BUG-006 in -> `docs/BUG_REPORT.md`. +Two kinds of caller (ART Milestone 2, BR-36): + +- **People, through the browser,** sign in per user with `POST /api/auth/login` + (local accounts) or `POST /api/auth/oidc` (the deployment's IdP). They send + `Authorization: Bearer `. Each request runs in the user's tenant + schema and is checked against the role matrix in `docs/ROLES.md`. The + browser never holds the shared key (#96). +- **Scripts and services** send `X-API-Key` matching `API_KEY`. These requests + use the deployment's default schemas, not any tenant's. If `API_KEY` is unset + the check is skipped. That's the local-dev default, and the API refuses to + start outside `dev` without it. + +`/api/health` needs neither. ## What it does well diff --git a/docs/ENV_VARIABLES.md b/docs/ENV_VARIABLES.md index 7a7acb1..da8868d 100644 --- a/docs/ENV_VARIABLES.md +++ b/docs/ENV_VARIABLES.md @@ -90,20 +90,20 @@ Create it by copying the table below. | Variable | Default | Required | Description | |---|---|---|---| | `VITE_API_BASE` | _(empty — same origin)_ | Yes in prod | Base URL of the FastAPI backend. Empty = calls relative to the page origin (works when frontend and API are served from the same host). Set to `https://your-api.example.com` when they are separate. | -| `VITE_API_KEY` | _(empty)_ | Yes if API_KEY is set | API key sent in `X-API-Key` header. Must match `API_KEY` on the backend. | **Example `csv-table-hub-main/.env.local` for local development:** ``` VITE_API_BASE=http://localhost:8000 -VITE_API_KEY= ``` **Example for production:** ``` VITE_API_BASE=https://your-api.azurecontainerapps.io -VITE_API_KEY=your-strong-api-key-here ``` +There is no `VITE_API_KEY` any more: the browser signs in per user (#96) and +never holds the shared `API_KEY`, which is for scripts and services. + --- ## 5. CI (GitHub Actions) @@ -180,5 +180,4 @@ API_ALLOW_DESTRUCTIVE = false And in the frontend build / Static Web App settings: ``` VITE_API_BASE = https://your-api.azurecontainerapps.io -VITE_API_KEY = ``` diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 070b6fc..7b1462c 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -104,8 +104,19 @@ Verify it's running: open `http://localhost:8000/api/health` — should return > **Expected startup warnings (safe to ignore):** > - `API_KEY is not set` — fine for local dev -> - `alembic.ini not found — skipping migration` — schema managed by -> `provision_full_test_env.sh`, not Alembic + +### Create your sign-in (first run only) + +The frontend requires signing in (#96). Create a tenant and its admin once: + +```powershell +cd "C:\Users\User\OneDrive\Desktop\Migration using ai\PostgreDataMigrationAppWithCSVLoader" +python scripts\create_tenant_admin.py --tenant dev --name "Dev tenant" --username admin +``` + +It prompts for a password of at least 12 characters, or reads it from +`TE_ADMIN_PASSWORD`. Sign in with tenant `dev`, username `admin` and that +password. --- @@ -117,7 +128,6 @@ cd csv-table-hub-main # First run only — create the env file @" VITE_API_BASE=http://localhost:8000 -VITE_API_KEY= "@ | Set-Content .env.local # Install dependencies (first run only) @@ -128,7 +138,7 @@ npm run dev ``` The frontend starts on **`http://localhost:8080`**. Open that URL in your -browser — you should see the CSV Table Hub upload interface. +browser. You should see the sign-in page; after signing in, the upload page. Or use the convenience script from the repo root: @@ -235,7 +245,7 @@ PostgreDataMigrationAppWithCSVLoader/ │ ├── src/routes/ ← file-based routes │ ├── src/lib/ ← export, mapping-templates, csv-preview │ ├── src/__tests__/ ← 76 vitest tests -│ ├── .env.local ← VITE_API_BASE, VITE_API_KEY (not committed) +│ ├── .env.local ← VITE_API_BASE (not committed) │ └── package.json │ ├── build/ ← SQL schema, loaders, deploy scripts diff --git a/docs/offline-install.md b/docs/offline-install.md index 8d75988..15c1559 100644 --- a/docs/offline-install.md +++ b/docs/offline-install.md @@ -54,19 +54,27 @@ sudo bash app/scripts/offline/install.sh localhost 2>&1 | tee ~/install.log The argument is the address browsers use to reach the API. Use `localhost` when the browser runs on the VM itself; otherwise use the VM's hostname or IP address. +The first tenant and its admin come from three optional variables, set before `sudo` with `sudo -E` or on the same line: + +| Variable | Default | +|---|---| +| `TE_TENANT` | `main` | +| `TE_ADMIN_USER` | `admin` | +| `TE_ADMIN_PASSWORD` | Generated and written to `/root/te-initial-admin` (mode 600, root only). It is not printed, so it stays out of `install.log`. | + The script runs six steps: 1. Checks every file against `SHA256SUMS`. 2. Installs the Ubuntu packages, using the bundle as the only apt source. The VM's own apt sources are ignored. 3. Copies the app to `/opt/te-migration`, creates the `te-app` system user, and builds a Python virtual environment from `wheels/` without network access. 4. Deploys database `te_mgmt_prod` with schema `te_prod` and its login role `te_prod_user`, using a generated password. -5. Writes `/opt/te-migration/env` (mode 600) with that password and a generated API key, and starts the `te-api` (port 8000) and `te-frontend` (port 3000) services under systemd. -6. Runs the smoke test. +5. Writes `/opt/te-migration/env` (mode 600) with that password and a generated API key, and starts the `te-api` (port 8000) and `te-frontend` (port 3000) services under systemd. The API key is for scripts only. The frontend holds no key; people sign in. +6. Creates the first tenant and its tenant admin, then runs the smoke test. During step 2, apt may print `W: Download is performed unsandboxed as root`. This only means apt's sandbox user can't read the bundle folder, and it's harmless. ## 4. Verify -The install finishes by printing these five lines. Every one must read PASS: +The install finishes by printing these seven lines. Every one must read PASS: ``` PASS API health @@ -74,8 +82,12 @@ PASS API rejects a missing key PASS upload a CSV (2 rows inserted) PASS upload listed with persisted counts PASS frontend serves the app +PASS admin signs in +PASS session is tenant_admin ``` +Then open `http://:3000` in a browser. You should land on the sign-in page. Sign in with the tenant, username and password from `/root/te-initial-admin`, or the values you set. + You can re-run the check at any time: ```bash @@ -87,7 +99,7 @@ sudo bash app/scripts/offline/smoke_test.sh "$(sudo sed -n 's/^API_KEY=//p' env) Attach the following to #60: -1. `~/install.log` from step 3, showing all five PASS lines. +1. `~/install.log` from step 3, showing all seven PASS lines. 2. Proof that the VM has no outside network. This command should print `000` or a connection error: ```bash cd ~ @@ -100,11 +112,27 @@ Once that evidence is on #60, the README can move to the "fully offline" wording ## Known limits - **Fresh VM only.** Re-running `install.sh` replaces `/opt/te-migration`. The database script has only been run against an empty database. -- **The API key is shared and visible to browsers.** The frontend still sends one API key from the browser, so anyone who can load the page can read it. Per-user sign-in replaces it in ART Milestone 2 (#92, #96). Until then, run this only on a trusted network. +- **Change the first admin's password.** The installer generates it. There's no self-service password change yet, so rerun `scripts/create_tenant_admin.py` for a new admin if needed. - **The API address is fixed at install.** It's written into the frontend files. To change it, re-run the install. - **Ubuntu 24.04 x86_64 only.** Other operating systems need different packages and wheels. -## How it was rehearsed (26 Oct 2026) +## How it was rehearsed + +### Second rehearsal (26 Oct 2026, with frontend sign-in, #96) + +The second rehearsal ran the same way as the first: inside a network namespace with only a loopback interface, where `curl https://pypi.org` returned `000`. + +- **Install:** `install.sh` ran end to end. The first admin was created in tenant `main`, and `/root/te-initial-admin` was mode 600, owned by root. +- **Smoke test:** the in-install run had 6 of 7 pass. The frontend check ran before Node had finished starting, because the test only waited for the API. The smoke test now waits for both. Rerun against the same install, it passed **7 of 7**. +- **Browser (Playwright, inside the same namespace):** + - signed out, both `/` and `/audit` land on `/auth`; + - a wrong password shows the generic error; + - signing in reaches the upload page, with the header showing `admin · main` (role `tenant_admin`); + - the page contains no API key, and the token is in `sessionStorage`; + - signing out returns to `/auth`, and `/` redirects there again. +- **Upload through the UI:** every API call carried `Authorization: Bearer` and none carried `X-API-Key`. The file landed in `tenant_main.csv_files` with 2 rows. + +### First rehearsal (26 Oct 2026) The rehearsal ran in Claude's Ubuntu 24.04 workspace, inside a Linux network namespace that had only a loopback interface. `curl https://pypi.org` returned `000` from inside it. diff --git a/env.dev.example b/env.dev.example index ebe9947..18a4cc9 100644 --- a/env.dev.example +++ b/env.dev.example @@ -37,4 +37,5 @@ API_ALLOW_DESTRUCTIVE=false # ── Frontend (csv-table-hub-main/.env.local) ────────────────────────────────── # Create csv-table-hub-main/.env.local with: # VITE_API_BASE=http://localhost:8000 -# VITE_API_KEY= +# The browser signs in per user (#96); it never holds API_KEY. Create a sign-in +# once with: python scripts/create_tenant_admin.py --tenant dev --name Dev --username admin diff --git a/scripts/offline/build_bundle.sh b/scripts/offline/build_bundle.sh index 5b3a333..ed15196 100755 --- a/scripts/offline/build_bundle.sh +++ b/scripts/offline/build_bundle.sh @@ -26,11 +26,12 @@ python3 -m pip download -q -r api/requirements.txt -d "$OUT/wheels" \ python3 -m pip download -q pip -d "$OUT/wheels" --only-binary=:all: --python-version 3.12 echo "== 3/4 frontend, built for a plain Node server" -# Placeholders are replaced by install.sh with the real API address and key. +# The placeholder is replaced by install.sh with the real API address. No API key +# is built in: browsers sign in per user (#96). ( cd csv-table-hub-main [ -d node_modules ] || npm ci rm -rf .output - NITRO_PRESET=node-server VITE_API_BASE=__OFFLINE_API_BASE__ VITE_API_KEY=__OFFLINE_API_KEY__ npm run build >/dev/null + NITRO_PRESET=node-server VITE_API_BASE=__OFFLINE_API_BASE__ npm run build >/dev/null cp -r .output "$OUT/frontend" ) echo "== 4/4 Ubuntu packages: PostgreSQL 16, Python venv, Node.js, with dependencies" diff --git a/scripts/offline/install.sh b/scripts/offline/install.sh index d841711..a0beed5 100755 --- a/scripts/offline/install.sh +++ b/scripts/offline/install.sh @@ -4,6 +4,9 @@ # From inside the extracted bundle directory: # sudo bash app/scripts/offline/install.sh [API_HOST] # API_HOST is the address browsers use to reach the API (default: localhost). +# The first tenant and its admin come from TE_TENANT (default: main), +# TE_ADMIN_USER (default: admin) and TE_ADMIN_PASSWORD (default: generated and +# written to /root/te-initial-admin, readable by root only). # Installs to /opt/te-migration, deploys database te_mgmt_prod (schema te_prod), # writes /opt/te-migration/env, and installs systemd units te-api and te-frontend. set -euo pipefail @@ -55,9 +58,8 @@ CORS_ORIGINS=http://$API_HOST:3000 PORT=3000 ENV chmod 600 "$DEST/env" -# The frontend still sends the shared key from the browser (replaced by sign-in in M2, #96). -grep -rl "__OFFLINE_API_" "$DEST/frontend" | xargs sed -i \ - -e "s#__OFFLINE_API_BASE__#http://$API_HOST:8000#g" -e "s#__OFFLINE_API_KEY__#$API_KEY#g" +# Browsers sign in per user (#96); the API key stays server-side for scripts. +grep -rl "__OFFLINE_API_BASE__" "$DEST/frontend" | xargs sed -i "s#__OFFLINE_API_BASE__#http://$API_HOST:8000#g" chown -R te-app:te-app "$DEST" cat > /etc/systemd/system/te-api.service </dev/null -echo "== 6/6 smoke test" -bash "$DEST/app/scripts/offline/smoke_test.sh" "$API_KEY" +echo "== 6/6 first tenant admin and smoke test" +TE_TENANT="${TE_TENANT:-main}"; TE_ADMIN_USER="${TE_ADMIN_USER:-admin}" +if [ -z "${TE_ADMIN_PASSWORD:-}" ]; then + TE_ADMIN_PASSWORD="$(openssl rand -base64 18)" + ( umask 077; printf 'tenant=%s\nusername=%s\npassword=%s\n' "$TE_TENANT" "$TE_ADMIN_USER" "$TE_ADMIN_PASSWORD" \ + > /root/te-initial-admin ) + echo "First admin's password written to /root/te-initial-admin (root only). Change it after first sign-in." +fi +( cd "$DEST/app" && set -a && . "$DEST/env" && set +a && export TE_ADMIN_PASSWORD && + runuser -u te-app -- "$DEST/venv/bin/python" scripts/create_tenant_admin.py \ + --tenant "$TE_TENANT" --name "$TE_TENANT" --username "$TE_ADMIN_USER" ) +SMOKE_TENANT="$TE_TENANT" SMOKE_USER="$TE_ADMIN_USER" SMOKE_PASSWORD="$TE_ADMIN_PASSWORD" \ + bash "$DEST/app/scripts/offline/smoke_test.sh" "$API_KEY" diff --git a/scripts/offline/smoke_test.sh b/scripts/offline/smoke_test.sh index 3de05a8..c0df807 100755 --- a/scripts/offline/smoke_test.sh +++ b/scripts/offline/smoke_test.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash # smoke_test.sh [API_URL] [FRONTEND_URL]: prove the installed stack works. +# Optional SMOKE_TENANT/SMOKE_USER/SMOKE_PASSWORD add the sign-in checks. # Prints PASS/FAIL per check and exits non-zero on any failure. set -uo pipefail KEY="$1"; API="${2:-http://localhost:8000}"; FE="${3:-http://localhost:3000}" fail=0 pass() { echo "PASS $1"; } nope() { echo "FAIL $1"; fail=1; } +# Services may still be starting: wait up to 30 s for each. for _ in $(seq 1 30); do curl -sf "$API/api/health" >/dev/null && break; sleep 1; done +for _ in $(seq 1 30); do curl -sf -o /dev/null "$FE/" && break; sleep 1; done curl -sf "$API/api/health" >/dev/null && pass "API health" || nope "API health" @@ -22,4 +25,14 @@ curl -sf -H "X-API-Key: $KEY" "$API/api/csv/files" | grep -q '"total_rows":2' \ && pass "upload listed with persisted counts" || nope "upload listed with persisted counts" curl -sf "$FE/" | grep -qi '