diff --git a/.gitignore b/.gitignore index e78b35da..d17cd66d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ # Editor and agent session state # Shared instructions live in the tracked .agents directory. +/.claude/ /.codex/ /.cursor/ diff --git a/README.md b/README.md index f56ac33c..7f3b6686 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,16 @@ pnpm install cp apps/web/.env.example apps/web/.env.local pnpm db:start # local Supabase stack pnpm db:reset # migrations plus demonstration fixtures -pnpm dev:local # http://127.0.0.1:3000 +pnpm dev # http://127.0.0.1:3000 +# Or build and run the production server against the same local stack: +pnpm build +pnpm start ``` Sign up at `/signup` and the local stack issues a session straight away. To run against a hosted Supabase project instead, configure its URL, publishable key -and your application origin in `apps/web/.env.local`, then use `pnpm dev`. +and your application origin in `apps/web/.env.local`, then use the explicit +`pnpm dev:prod`, `pnpm build:prod` and `pnpm start:prod` commands. The [environment template](apps/web/.env.example) explains the required settings, optional import credentials and map-service defaults. @@ -94,16 +98,22 @@ Level Security, and the service-role key never reaches the browser. ## Commands -| Command | What it does | -| ---------------- | ---------------------------------------------- | -| `pnpm dev:local` | Development server against local Supabase | -| `pnpm check` | Formatting, lint and strict types | -| `pnpm test` | Unit and component tests | -| `pnpm test:e2e` | Authenticated browser journeys | -| `pnpm db:reset` | Rebuild the local database and reseed fixtures | -| `pnpm db:test` | pgTAP database tests | -| `pnpm db:types` | Regenerate committed database types | -| `pnpm verify` | Local application delivery checks | +| Command | What it does | +| -------------------- | ------------------------------------------------------ | +| `pnpm dev` | Development server against local Supabase | +| `pnpm dev:prod` | Development server using configured hosted credentials | +| `pnpm build` | Production build against local Supabase | +| `pnpm start` | Built production server against local Supabase | +| `pnpm preview:local` | Build and start against local Supabase in one step | +| `pnpm build:prod` | Production build using configured hosted credentials | +| `pnpm start:prod` | Built server using configured hosted credentials | +| `pnpm check` | Formatting, lint and strict types | +| `pnpm test` | Unit and component tests | +| `pnpm test:e2e` | Authenticated browser journeys | +| `pnpm db:reset` | Rebuild the local database and reseed fixtures | +| `pnpm db:test` | pgTAP database tests | +| `pnpm db:types` | Regenerate committed database types | +| `pnpm verify` | Local application delivery checks | Run `pnpm verify` before opening a pull request. CI additionally runs database checks, authenticated browser journeys and a production dependency audit. See diff --git a/apps/web/.env.example b/apps/web/.env.example index 5b6c0b1a..a98cb357 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -5,9 +5,10 @@ # NEXT_PUBLIC_* values are browser-visible and baked into production builds. # Rebuild after changing them. Other values below are server-only. # -# Local setup: pnpm db:start, pnpm db:reset, then pnpm dev:local. -# dev:local reads the local Supabase URL and keys automatically and supplies the -# site URL. When using pnpm dev directly, fill in the required section yourself. +# Local setup: pnpm db:start, pnpm db:reset, then pnpm dev. +# dev, build, start and preview:local read the local Supabase URL and keys +# automatically and supply the site URL. Only the explicit :prod commands read +# the hosted values below. # Optional overrides stay commented out until needed. # ============================================================================= @@ -21,14 +22,14 @@ # project's Connect dialog. Keep this URL and the public key on the same project. NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 -# Browser-safe publishable key. Required when using pnpm dev directly. -# dev:local supplies this from Supabase; never substitute a server secret here. +# Browser-safe publishable key. Required by the explicit :prod commands. +# Local commands supply this from Supabase; never substitute a server secret. NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= # Public application origin for authentication redirects and cookie settings. # Use HTTPS outside localhost/127.0.0.1, with no path, query or fragment. # Match the allowed authentication URLs in your Supabase configuration. -# dev:local overrides this with http://127.0.0.1:3000. +# Local commands override this with http://127.0.0.1:3000. NEXT_PUBLIC_SITE_URL=http://localhost:3000 # ============================================================================= @@ -38,7 +39,7 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Configure them when checking detailed ANU source material. # Privileged Supabase server key for local preview and browser test user setup. -# dev:local supplies the local server key automatically. +# Local commands supply the local server key automatically. # Use a key from the same project as NEXT_PUBLIC_SUPABASE_URL. Keep it server-only. # Used by: scripts/local/dev-preview.mjs and playwright/fixtures.ts. # SUPABASE_SECRET_KEY= diff --git a/apps/web/app/academic/page.tsx b/apps/web/app/academic/page.tsx index 808fec03..26875317 100644 --- a/apps/web/app/academic/page.tsx +++ b/apps/web/app/academic/page.tsx @@ -11,7 +11,7 @@ export default async function AcademicPage() { } catch { return ( ); diff --git a/apps/web/app/admin/operations/catalogue/error.tsx b/apps/web/app/admin/operations/catalogue/error.tsx new file mode 100644 index 00000000..2b60ce99 --- /dev/null +++ b/apps/web/app/admin/operations/catalogue/error.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { CatalogueOperationsError as default } from "@/ui/admin/operations/operations-error"; diff --git a/apps/web/app/auth/password/route.ts b/apps/web/app/auth/password/route.ts new file mode 100644 index 00000000..0d15cac3 --- /dev/null +++ b/apps/web/app/auth/password/route.ts @@ -0,0 +1,81 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { safeInternalRedirect } from "@/lib/auth/redirect"; +import { + getSiteOriginForRequest, + getSupabaseConfig, +} from "@/lib/supabase/config"; +import { createRequestClient } from "@/lib/supabase/request"; + +function noStore(response: NextResponse) { + response.headers.set( + "Cache-Control", + "private, no-cache, no-store, must-revalidate, max-age=0", + ); + response.headers.set("Expires", "0"); + response.headers.set("Pragma", "no-cache"); + return response; +} + +function loginRedirect(origin: string, next: string) { + const url = new URL("/login", origin); + url.searchParams.set("next", next); + url.searchParams.set("error", "invalid-login"); + return noStore(NextResponse.redirect(url, 303)); +} + +export async function POST(request: NextRequest) { + const siteOrigin = getSiteOriginForRequest( + request.nextUrl, + request.headers.get("x-forwarded-host") ?? request.headers.get("host"), + request.headers.get("x-forwarded-proto"), + ); + if (!siteOrigin || !getSupabaseConfig()) { + return new NextResponse("Coursemap authentication is not configured.", { + status: 503, + headers: { "Cache-Control": "private, no-store" }, + }); + } + + if (request.headers.get("origin") !== siteOrigin) { + return new NextResponse("Invalid request origin.", { + status: 403, + headers: { "Cache-Control": "private, no-store" }, + }); + } + + const formData = await request.formData(); + const emails = formData.getAll("email"); + const passwords = formData.getAll("password"); + const nextValues = formData.getAll("next"); + const email = emails.length === 1 ? emails[0] : null; + const password = passwords.length === 1 ? passwords[0] : null; + const next = safeInternalRedirect( + nextValues.length === 1 && typeof nextValues[0] === "string" + ? nextValues[0] + : null, + ); + + if ( + typeof email !== "string" || + typeof password !== "string" || + !email.trim() || + email.length > 254 || + password.length < 8 || + password.length > 128 + ) { + return loginRedirect(siteOrigin, next); + } + + const response = noStore( + NextResponse.redirect(new URL(next, siteOrigin), 303), + ); + const { supabase, applyTo } = createRequestClient(request, response); + const { error } = await supabase.auth.signInWithPassword({ + email: email.trim(), + password, + }); + + if (error) return applyTo(loginRedirect(siteOrigin, next)); + return response; +} diff --git a/apps/web/app/auth/sign-in/sign-in-form.tsx b/apps/web/app/auth/sign-in/sign-in-form.tsx index 24b5c87e..fac9032c 100644 --- a/apps/web/app/auth/sign-in/sign-in-form.tsx +++ b/apps/web/app/auth/sign-in/sign-in-form.tsx @@ -13,14 +13,16 @@ import { createClient } from "@/lib/supabase/browser"; export function SignInForm({ next, configured, + initialError = null, }: { next: string; configured: boolean; + initialError?: string | null; }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(initialError); const submit = async (event: FormEvent) => { event.preventDefault(); @@ -56,7 +58,12 @@ export function SignInForm({ }; return ( -
+