From fd7527bdae2bb9107f2b97a0a6d943b228ecbc22 Mon Sep 17 00:00:00 2001 From: Yax Patel Date: Sat, 11 Apr 2026 14:13:45 -0400 Subject: [PATCH] add account authentication --- .env.example | 6 ++ .gitignore | 2 + app/[[...slug]]/page.jsx | 8 +- app/api/auth/confirm/route.js | 44 +++++++++ app/api/auth/login/route.js | 76 +++++++++++++++ app/api/auth/logout/route.js | 45 +++++++++ app/api/auth/session/route.js | 54 +++++++++++ app/api/auth/signup/route.js | 88 ++++++++++++++++++ middleware.js | 11 +++ package-lock.json | 157 ++++++++++++++++++++++++++++++- package.json | 2 + src/App.jsx | 4 +- src/components/Navbar.jsx | 17 +++- src/engine/lessonRouting.js | 36 +------ src/lib/auth/request.js | 165 +++++++++++++++++++++++++++++++++ src/lib/basePath.js | 43 +++++++++ src/lib/supabase/config.js | 58 ++++++++++++ src/lib/supabase/middleware.js | 45 +++++++++ src/lib/supabase/server.js | 105 +++++++++++++++++++++ src/views/AccountPage.jsx | 160 +++++++++++++++++++++++++++++--- 20 files changed, 1067 insertions(+), 59 deletions(-) create mode 100644 .env.example create mode 100644 app/api/auth/confirm/route.js create mode 100644 app/api/auth/login/route.js create mode 100644 app/api/auth/logout/route.js create mode 100644 app/api/auth/session/route.js create mode 100644 app/api/auth/signup/route.js create mode 100644 middleware.js create mode 100644 src/lib/auth/request.js create mode 100644 src/lib/basePath.js create mode 100644 src/lib/supabase/config.js create mode 100644 src/lib/supabase/middleware.js create mode 100644 src/lib/supabase/server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5efbc2b --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NEXT_PUBLIC_BASE_PATH= +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +# Optional fallback for projects still using legacy Supabase anon keys. +# NEXT_PUBLIC_SUPABASE_ANON_KEY= diff --git a/.gitignore b/.gitignore index 08a77e3..caac1c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # dependencies node_modules/ +.claude/ + # build output dist/ dist-ssr/ diff --git a/app/[[...slug]]/page.jsx b/app/[[...slug]]/page.jsx index 829ca7d..1e7e9bc 100644 --- a/app/[[...slug]]/page.jsx +++ b/app/[[...slug]]/page.jsx @@ -1,5 +1,6 @@ import App from "../../src/App"; import { getPageTitleLabel } from "../../src/engine/pageTitles"; +import { getServerAuthStatus } from "../../src/lib/supabase/server"; function buildInitialPathname(slug) { if (!slug || slug.length === 0) { @@ -19,8 +20,11 @@ export async function generateMetadata({ params }) { } export default async function CatchAllPage({ params }) { - const resolvedParams = await params; + const [resolvedParams, initialAuthStatus] = await Promise.all([ + params, + getServerAuthStatus(), + ]); const initialPathname = buildInitialPathname(resolvedParams?.slug); - return ; + return ; } diff --git a/app/api/auth/confirm/route.js b/app/api/auth/confirm/route.js new file mode 100644 index 0000000..6fa46b4 --- /dev/null +++ b/app/api/auth/confirm/route.js @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { buildAbsoluteUrl, getSafeNextPath } from "@/src/lib/auth/request"; +import { createRouteHandlerSupabaseClient } from "@/src/lib/supabase/server"; + +const ALLOWED_EMAIL_OTP_TYPES = new Set([ + "signup", + "invite", + "magiclink", + "recovery", + "email_change", + "email", +]); + +export async function GET(request) { + const requestUrl = new URL(request.url); + const tokenHash = requestUrl.searchParams.get("token_hash"); + const type = requestUrl.searchParams.get("type"); + const nextPath = getSafeNextPath(requestUrl.searchParams.get("next")); + const redirectUrl = new URL(nextPath, buildAbsoluteUrl(request, "/")); + + if (!tokenHash || !type || !ALLOWED_EMAIL_OTP_TYPES.has(type)) { + return NextResponse.redirect(redirectUrl); + } + + try { + const { supabase, responseHeaders } = await createRouteHandlerSupabaseClient(); + const { error } = await supabase.auth.verifyOtp({ + token_hash: tokenHash, + type, + }); + + if (error) { + return NextResponse.redirect(redirectUrl, { + headers: responseHeaders, + }); + } + + return NextResponse.redirect(redirectUrl, { + headers: responseHeaders, + }); + } catch { + return NextResponse.redirect(redirectUrl); + } +} diff --git a/app/api/auth/login/route.js b/app/api/auth/login/route.js new file mode 100644 index 0000000..6274e3e --- /dev/null +++ b/app/api/auth/login/route.js @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { + SUPABASE_CONFIG_ERROR_MESSAGE, + readJsonBody, + serializeUser, + validateLoginPayload, + validateSameOriginRequest, +} from "@/src/lib/auth/request"; +import { createNoStoreHeaders } from "@/src/lib/supabase/config"; +import { createRouteHandlerSupabaseClient } from "@/src/lib/supabase/server"; + +function createErrorResponse(message, status, headers) { + return NextResponse.json( + { error: message }, + { status, headers: createNoStoreHeaders(headers) } + ); +} + +function getLoginErrorResponse(error, headers) { + if (error?.status === 429 || /rate limit/i.test(error?.message || "")) { + return createErrorResponse("Too many login attempts. Please wait and try again.", 429, headers); + } + + if (/email not confirmed/i.test(error?.message || "")) { + return createErrorResponse("Please confirm your email before logging in.", 403, headers); + } + + return createErrorResponse("Invalid email or password.", 401, headers); +} + +export async function POST(request) { + const originError = validateSameOriginRequest(request); + + if (originError) { + return createErrorResponse(originError, 403); + } + + let payload; + + try { + payload = await readJsonBody(request); + } catch (error) { + return createErrorResponse(error.message, 400); + } + + const validation = validateLoginPayload(payload); + + if (validation.error) { + return createErrorResponse(validation.error, 400); + } + + let supabaseContext; + + try { + supabaseContext = await createRouteHandlerSupabaseClient(); + } catch { + return createErrorResponse(SUPABASE_CONFIG_ERROR_MESSAGE, 500); + } + + const { supabase, responseHeaders } = supabaseContext; + const { data, error } = await supabase.auth.signInWithPassword(validation.data); + + if (error) { + return getLoginErrorResponse(error, responseHeaders); + } + + return NextResponse.json( + { + user: serializeUser(data.user), + }, + { + status: 200, + headers: responseHeaders, + } + ); +} diff --git a/app/api/auth/logout/route.js b/app/api/auth/logout/route.js new file mode 100644 index 0000000..8542757 --- /dev/null +++ b/app/api/auth/logout/route.js @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { + SUPABASE_CONFIG_ERROR_MESSAGE, + validateSameOriginRequest, +} from "@/src/lib/auth/request"; +import { createNoStoreHeaders } from "@/src/lib/supabase/config"; +import { createRouteHandlerSupabaseClient } from "@/src/lib/supabase/server"; + +function createErrorResponse(message, status, headers) { + return NextResponse.json( + { error: message }, + { status, headers: createNoStoreHeaders(headers) } + ); +} + +export async function POST(request) { + const originError = validateSameOriginRequest(request); + + if (originError) { + return createErrorResponse(originError, 403); + } + + let supabaseContext; + + try { + supabaseContext = await createRouteHandlerSupabaseClient(); + } catch { + return createErrorResponse(SUPABASE_CONFIG_ERROR_MESSAGE, 500); + } + + const { supabase, responseHeaders } = supabaseContext; + const { error } = await supabase.auth.signOut(); + + if (error) { + return createErrorResponse("Unable to log out right now.", 400, responseHeaders); + } + + return NextResponse.json( + { success: true }, + { + status: 200, + headers: responseHeaders, + } + ); +} diff --git a/app/api/auth/session/route.js b/app/api/auth/session/route.js new file mode 100644 index 0000000..b402070 --- /dev/null +++ b/app/api/auth/session/route.js @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { + SUPABASE_CONFIG_ERROR_MESSAGE, + serializeUser, +} from "@/src/lib/auth/request"; +import { createNoStoreHeaders } from "@/src/lib/supabase/config"; +import { createRouteHandlerSupabaseClient } from "@/src/lib/supabase/server"; + +function createErrorResponse(message, status, headers) { + return NextResponse.json( + { error: message, authenticated: false, user: null }, + { status, headers: createNoStoreHeaders(headers) } + ); +} + +export async function GET() { + let supabaseContext; + + try { + supabaseContext = await createRouteHandlerSupabaseClient(); + } catch { + return createErrorResponse(SUPABASE_CONFIG_ERROR_MESSAGE, 500); + } + + const { supabase, responseHeaders } = supabaseContext; + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + return NextResponse.json( + { + authenticated: false, + user: null, + }, + { + status: 200, + headers: responseHeaders, + } + ); + } + + return NextResponse.json( + { + authenticated: true, + user: serializeUser(user), + }, + { + status: 200, + headers: responseHeaders, + } + ); +} diff --git a/app/api/auth/signup/route.js b/app/api/auth/signup/route.js new file mode 100644 index 0000000..b53318f --- /dev/null +++ b/app/api/auth/signup/route.js @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { + SUPABASE_CONFIG_ERROR_MESSAGE, + buildAbsoluteUrl, + readJsonBody, + validateSameOriginRequest, + validateSignupPayload, +} from "@/src/lib/auth/request"; +import { createNoStoreHeaders } from "@/src/lib/supabase/config"; +import { createRouteHandlerSupabaseClient } from "@/src/lib/supabase/server"; + +function createErrorResponse(message, status, headers) { + return NextResponse.json( + { error: message }, + { status, headers: createNoStoreHeaders(headers) } + ); +} + +function createAcceptedSignupResponse(headers) { + return NextResponse.json( + { accepted: true }, + { status: 200, headers: createNoStoreHeaders(headers) } + ); +} + +function getSignupErrorResponse(error, headers) { + if (error?.status === 429 || /rate limit/i.test(error?.message || "")) { + return createErrorResponse( + "Too many signup attempts. Please wait a moment and try again.", + 429, + headers + ); + } + + if (/already registered/i.test(error?.message || "")) { + return createAcceptedSignupResponse(headers); + } + + return createErrorResponse(error?.message || "Unable to create account.", 400, headers); +} + +export async function POST(request) { + const originError = validateSameOriginRequest(request); + + if (originError) { + return createErrorResponse(originError, 403); + } + + let payload; + + try { + payload = await readJsonBody(request); + } catch (error) { + return createErrorResponse(error.message, 400); + } + + const validation = validateSignupPayload(payload); + + if (validation.error) { + return createErrorResponse(validation.error, 400); + } + + let supabaseContext; + + try { + supabaseContext = await createRouteHandlerSupabaseClient(); + } catch { + return createErrorResponse(SUPABASE_CONFIG_ERROR_MESSAGE, 500); + } + + const { supabase, responseHeaders } = supabaseContext; + const { data, error } = await supabase.auth.signUp({ + ...validation.data, + options: { + emailRedirectTo: buildAbsoluteUrl(request, "/api/auth/confirm?next=/account"), + }, + }); + + if (error) { + return getSignupErrorResponse(error, responseHeaders); + } + + if (data.session) { + await supabase.auth.signOut(); + } + + return createAcceptedSignupResponse(responseHeaders); +} diff --git a/middleware.js b/middleware.js new file mode 100644 index 0000000..3cdfcdc --- /dev/null +++ b/middleware.js @@ -0,0 +1,11 @@ +import { updateSession } from "@/src/lib/supabase/middleware"; + +export async function middleware(request) { + return updateSession(request); +} + +export const config = { + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ttf|woff|woff2)$).*)", + ], +}; diff --git a/package-lock.json b/package-lock.json index 7670dea..4c27f99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "khan-inspired-template", "version": "1.0.0", "dependencies": { + "@supabase/ssr": "^0.10.2", + "@supabase/supabase-js": "^2.103.0", "csv-parse": "^5.5.6", "next": "^15.0.0", "nodemailer": "^6.10.1", @@ -632,6 +634,104 @@ "node": ">= 10" } }, + "node_modules/@supabase/auth-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz", + "integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz", + "integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", + "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz", + "integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz", + "integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.0", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.2.tgz", + "integrity": "sha512-JFbchN63CXLFHJRNT7udec4/RoD9PmXkSGko3QSO6vUuqGBtSzdmxR7FPfQNr7SuFd65I7Xv46q66ALjEN1cgQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.102.1" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz", + "integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz", + "integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.103.0", + "@supabase/functions-js": "2.103.0", + "@supabase/postgrest-js": "2.103.0", + "@supabase/realtime-js": "2.103.0", + "@supabase/storage-js": "2.103.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -645,7 +745,6 @@ "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -664,7 +763,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -680,6 +778,15 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001785", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", @@ -706,6 +813,19 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -729,6 +849,15 @@ "node": ">=8" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -865,7 +994,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -878,7 +1006,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1019,8 +1146,28 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 40bee28..fed347d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "outreach:send": "node outreacher/send-survey-outreach.mjs --send" }, "dependencies": { + "@supabase/ssr": "^0.10.2", + "@supabase/supabase-js": "^2.103.0", "csv-parse": "^5.5.6", "next": "^15.0.0", "nodemailer": "^6.10.1", diff --git a/src/App.jsx b/src/App.jsx index 3e6a402..adbea5c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,8 +3,8 @@ import React from "react"; import Navbar from "./components/Navbar"; -function App({ initialPathname = "/" }) { - return ; +function App({ initialPathname = "/", initialAuthStatus = "anonymous" }) { + return ; } export default App; diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 60b14b9..e9e86f1 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -13,6 +13,7 @@ import HomePage from "../views/HomePage"; import MissionPage from "../views/MissionPage"; import ContactPage from "../views/ContactPage"; import DonatePage from "../views/DonatePage"; +import AccountPage from "../views/AccountPage"; import PageNotFound from "../views/PageNotFound"; import SectionNav from "./SectionNav"; import TopicLayer from "./TopicLayer"; @@ -121,7 +122,10 @@ function resolvePathStatus(pathname) { return { status: "coming-soon", matchedLesson }; } -function Navbar({ initialPathname: initialPathnameProp = null }) { +function Navbar({ + initialPathname: initialPathnameProp = null, + initialAuthStatus = "anonymous", +}) { const initialPathname = initialPathnameProp ?? (typeof window === "undefined" ? "/" : window.location.pathname); const initialRouteStatus = resolvePathStatus(initialPathname).status; @@ -137,6 +141,7 @@ function Navbar({ initialPathname: initialPathnameProp = null }) { const [hoveredTopic, setHoveredTopic] = useState(null); const [activePathname, setActivePathname] = useState(initialPathname); const [routeStatus, setRouteStatus] = useState(initialRouteStatus); + const [accountAuthStatus, setAccountAuthStatus] = useState(initialAuthStatus); const [dealtCards, setDealtCards] = useState(() => [ { id: 0, @@ -678,6 +683,10 @@ function Navbar({ initialPathname: initialPathnameProp = null }) { return () => window.removeEventListener("popstate", handlePopState); }, []); + useEffect(() => { + setAccountAuthStatus(initialAuthStatus); + }, [initialAuthStatus]); + const handleSectionHover = (section) => { setHoveredSection(section); setSelectedSection(section); @@ -853,7 +862,11 @@ function Navbar({ initialPathname: initialPathnameProp = null }) { {card.status === "contact" && } {card.status === "donate" && } {card.status === "account" && ( - + )} {card.status !== "home" && card.status !== "mission" && diff --git a/src/engine/lessonRouting.js b/src/engine/lessonRouting.js index 3dcff5b..50d02c9 100644 --- a/src/engine/lessonRouting.js +++ b/src/engine/lessonRouting.js @@ -1,7 +1,7 @@ import { PROJECTOR_SECTIONS, PROJECTOR_TOPICS } from "./Lessons"; +import { stripBasePath, withBasePath } from "../lib/basePath"; -const RAW_BASE_URL = process.env.NEXT_PUBLIC_BASE_PATH || "/"; -const BASE_PREFIX = RAW_BASE_URL === "/" ? "" : `/${RAW_BASE_URL.replace(/^\/|\/$/g, "")}`; +export { stripBasePath, withBasePath }; export const toKebabCase = (value) => value @@ -12,38 +12,6 @@ export const toKebabCase = (value) => .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); -export const withBasePath = (path = "/") => { - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - - if (!BASE_PREFIX) { - return normalizedPath; - } - - if (normalizedPath === "/") { - return `${BASE_PREFIX}/`; - } - - return `${BASE_PREFIX}${normalizedPath}`; -}; - -export const stripBasePath = (pathname = "/") => { - const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`; - - if (!BASE_PREFIX) { - return normalizedPath; - } - - if (normalizedPath === BASE_PREFIX || normalizedPath === `${BASE_PREFIX}/`) { - return "/"; - } - - if (normalizedPath.startsWith(`${BASE_PREFIX}/`)) { - return normalizedPath.slice(BASE_PREFIX.length); - } - - return normalizedPath; -}; - export const buildLessonPath = (section, topic, subtopic) => withBasePath(`/${[section, topic, subtopic].map(toKebabCase).join("/")}`); diff --git a/src/lib/auth/request.js b/src/lib/auth/request.js new file mode 100644 index 0000000..b3a5ed2 --- /dev/null +++ b/src/lib/auth/request.js @@ -0,0 +1,165 @@ +import { withBasePath } from "../basePath"; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MAX_EMAIL_LENGTH = 320; +const MAX_PASSWORD_LENGTH = 1024; + +export const SUPABASE_CONFIG_ERROR_MESSAGE = + "Supabase auth is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY."; + +function getRequestOrigin(request) { + try { + return new URL(request.url).origin; + } catch { + return null; + } +} + +function getConfiguredSiteOrigin() { + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL; + + if (!siteUrl) { + return null; + } + + try { + return new URL(siteUrl).origin; + } catch { + return null; + } +} + +export function resolveRequestOrigin(request) { + return getRequestOrigin(request) ?? getConfiguredSiteOrigin(); +} + +export function buildAbsoluteUrl(request, path = "/") { + const origin = getConfiguredSiteOrigin() ?? getRequestOrigin(request); + return new URL(withBasePath(path), origin).toString(); +} + +export function getSafeNextPath(nextPath, fallbackPath = "/account") { + const safeFallback = withBasePath(fallbackPath); + + if (!nextPath || typeof nextPath !== "string") { + return safeFallback; + } + + try { + const resolved = new URL(nextPath, "http://gradient.local"); + + if (resolved.origin !== "http://gradient.local") { + return safeFallback; + } + + return withBasePath(`${resolved.pathname}${resolved.search}${resolved.hash}`); + } catch { + return safeFallback; + } +} + +export function validateSameOriginRequest(request) { + const origin = request.headers.get("origin"); + + if (!origin || origin === "null") { + return "Invalid request origin."; + } + + const allowedOrigins = new Set( + [getConfiguredSiteOrigin(), getRequestOrigin(request)].filter(Boolean) + ); + + return allowedOrigins.has(origin) ? null : "Invalid request origin."; +} + +export async function readJsonBody(request) { + const contentType = request.headers.get("content-type") || ""; + + if (!contentType.toLowerCase().includes("application/json")) { + throw new Error("Requests must use application/json."); + } + + try { + return await request.json(); + } catch { + throw new Error("Request body must be valid JSON."); + } +} + +function normalizeEmail(email) { + return typeof email === "string" ? email.trim().toLowerCase() : ""; +} + +function normalizePassword(password) { + return typeof password === "string" ? password : ""; +} + +function isValidEmail(email) { + return email.length <= MAX_EMAIL_LENGTH && EMAIL_PATTERN.test(email); +} + +function isValidPassword(password) { + return password.length > 0 && password.length <= MAX_PASSWORD_LENGTH; +} + +export function validateLoginPayload(payload) { + const email = normalizeEmail(payload?.email); + const password = normalizePassword(payload?.password); + + if (!isValidEmail(email)) { + return { error: "Please enter a valid email address." }; + } + + if (!isValidPassword(password)) { + return { error: "Please enter a valid password." }; + } + + return { + data: { + email, + password, + }, + }; +} + +export function validateSignupPayload(payload) { + const email = normalizeEmail(payload?.email); + const password = normalizePassword(payload?.password); + const confirmPassword = normalizePassword(payload?.confirmPassword); + + if (!isValidEmail(email)) { + return { error: "Please enter a valid email address." }; + } + + if (!isValidPassword(password)) { + return { error: "Please enter a valid password." }; + } + + if (!confirmPassword) { + return { error: "Please confirm your password." }; + } + + if (password !== confirmPassword) { + return { error: "Passwords don't match." }; + } + + return { + data: { + email, + password, + }, + }; +} + +export function serializeUser(user) { + if (!user) { + return null; + } + + return { + id: user.id, + email: user.email ?? null, + emailConfirmedAt: user.email_confirmed_at ?? null, + lastSignInAt: user.last_sign_in_at ?? null, + }; +} diff --git a/src/lib/basePath.js b/src/lib/basePath.js new file mode 100644 index 0000000..7f244b7 --- /dev/null +++ b/src/lib/basePath.js @@ -0,0 +1,43 @@ +const RAW_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || "/"; +const BASE_PATH = + RAW_BASE_PATH === "/" ? "" : `/${RAW_BASE_PATH.replace(/^\/|\/$/g, "")}`; + +export function getBasePath() { + return BASE_PATH; +} + +export function withBasePath(path = "/") { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + + if (!BASE_PATH) { + return normalizedPath; + } + + if (normalizedPath === "/") { + return `${BASE_PATH}/`; + } + + if (normalizedPath === BASE_PATH || normalizedPath.startsWith(`${BASE_PATH}/`)) { + return normalizedPath; + } + + return `${BASE_PATH}${normalizedPath}`; +} + +export function stripBasePath(pathname = "/") { + const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`; + + if (!BASE_PATH) { + return normalizedPath; + } + + if (normalizedPath === BASE_PATH || normalizedPath === `${BASE_PATH}/`) { + return "/"; + } + + if (normalizedPath.startsWith(`${BASE_PATH}/`)) { + return normalizedPath.slice(BASE_PATH.length); + } + + return normalizedPath; +} diff --git a/src/lib/supabase/config.js b/src/lib/supabase/config.js new file mode 100644 index 0000000..f6fab9e --- /dev/null +++ b/src/lib/supabase/config.js @@ -0,0 +1,58 @@ +import { withBasePath } from "../basePath"; + +const DEFAULT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; + +export const NO_STORE_HEADERS = { + "Cache-Control": "private, no-cache, no-store, must-revalidate, max-age=0", + Expires: "0", + Pragma: "no-cache", +}; + +function resolvePublishableKey() { + return ( + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || + "" + ); +} + +export function getSupabaseConfig() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; + const publishableKey = resolvePublishableKey(); + + return { + isConfigured: Boolean(url && publishableKey), + url, + publishableKey, + cookieOptions: { + name: "gradient-auth", + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: withBasePath("/"), + maxAge: DEFAULT_COOKIE_MAX_AGE_SECONDS, + }, + }; +} + +export function getSupabaseCookieWriteOptions(options = {}) { + const { cookieOptions } = getSupabaseConfig(); + + return { + ...options, + httpOnly: true, + sameSite: "lax", + secure: cookieOptions.secure, + path: cookieOptions.path, + }; +} + +export function createNoStoreHeaders(init) { + const headers = new Headers(init); + + Object.entries(NO_STORE_HEADERS).forEach(([key, value]) => { + headers.set(key, value); + }); + + return headers; +} diff --git a/src/lib/supabase/middleware.js b/src/lib/supabase/middleware.js new file mode 100644 index 0000000..62a05a9 --- /dev/null +++ b/src/lib/supabase/middleware.js @@ -0,0 +1,45 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse } from "next/server"; +import { getSupabaseConfig, getSupabaseCookieWriteOptions } from "./config"; + +export async function updateSession(request) { + const config = getSupabaseConfig(); + let response = NextResponse.next({ + request, + }); + + if (!config.isConfigured) { + return response; + } + + const supabase = createServerClient(config.url, config.publishableKey, { + cookieOptions: config.cookieOptions, + cookies: { + encode: "tokens-only", + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet, headers = {}) { + cookiesToSet.forEach(({ name, value }) => { + request.cookies.set(name, value); + }); + + response = NextResponse.next({ + request, + }); + + cookiesToSet.forEach(({ name, value, options }) => { + response.cookies.set(name, value, getSupabaseCookieWriteOptions(options)); + }); + + Object.entries(headers).forEach(([key, value]) => { + response.headers.set(key, value); + }); + }, + }, + }); + + await supabase.auth.getClaims(); + + return response; +} diff --git a/src/lib/supabase/server.js b/src/lib/supabase/server.js new file mode 100644 index 0000000..0cadcca --- /dev/null +++ b/src/lib/supabase/server.js @@ -0,0 +1,105 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { + createNoStoreHeaders, + getSupabaseConfig, + getSupabaseCookieWriteOptions, +} from "./config"; + +function createCookieAdapter(cookieStore, { onSetAll = null } = {}) { + return { + encode: "tokens-only", + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet, headers = {}) { + if (!onSetAll) { + return; + } + + onSetAll(cookiesToSet, headers); + }, + }; +} + +function hasSupabaseSessionCookie(cookieStore, cookieName) { + return cookieStore + .getAll() + .some( + ({ name }) => + name === cookieName || name.startsWith(`${cookieName}.`) || name.startsWith(`${cookieName}-`) + ); +} + +export async function createRouteHandlerSupabaseClient(initHeaders) { + const config = getSupabaseConfig(); + + if (!config.isConfigured) { + throw new Error("Supabase auth is not configured."); + } + + const responseHeaders = createNoStoreHeaders(initHeaders); + const cookieStore = await cookies(); + + const supabase = createServerClient(config.url, config.publishableKey, { + cookieOptions: config.cookieOptions, + cookies: createCookieAdapter(cookieStore, { + onSetAll(cookiesToSet, headers = {}) { + cookiesToSet.forEach(({ name, value, options }) => { + cookieStore.set(name, value, getSupabaseCookieWriteOptions(options)); + }); + + Object.entries(headers).forEach(([key, value]) => { + responseHeaders.set(key, value); + }); + }, + }), + }); + + return { supabase, responseHeaders }; +} + +export async function createServerComponentSupabaseClient(cookieStoreOverride = null) { + const config = getSupabaseConfig(); + + if (!config.isConfigured) { + throw new Error("Supabase auth is not configured."); + } + + const cookieStore = cookieStoreOverride ?? (await cookies()); + + return createServerClient(config.url, config.publishableKey, { + cookieOptions: config.cookieOptions, + cookies: createCookieAdapter(cookieStore), + }); +} + +export async function getServerAuthStatus() { + try { + const config = getSupabaseConfig(); + + if (!config.isConfigured) { + return "anonymous"; + } + + const cookieStore = await cookies(); + + if (!hasSupabaseSessionCookie(cookieStore, config.cookieOptions.name)) { + return "anonymous"; + } + + const supabase = await createServerComponentSupabaseClient(cookieStore); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + return "anonymous"; + } + + return "authenticated"; + } catch { + return "anonymous"; + } +} diff --git a/src/views/AccountPage.jsx b/src/views/AccountPage.jsx index 8bb24c1..08030d8 100644 --- a/src/views/AccountPage.jsx +++ b/src/views/AccountPage.jsx @@ -1,12 +1,40 @@ import React, { useEffect, useRef, useState } from "react"; import { useInfoCardDealStyle } from "../components/InfoCard"; +import { withBasePath } from "../lib/basePath"; const ORBIT_DURATION_MS = 880; +const JSON_HEADERS = { + "Content-Type": "application/json", +}; const clamp = (value, min, max) => Math.min(Math.max(value, min), max); const smootherStep = (value) => value * value * value * (value * (value * 6 - 15) + 10); +const readErrorMessage = async (response, fallbackMessage) => { + try { + const body = await response.json(); + return body?.error || fallbackMessage; + } catch { + return fallbackMessage; + } +}; + +const postAuthRequest = async (path, payload) => { + const response = await fetch(withBasePath(path), { + method: "POST", + headers: JSON_HEADERS, + credentials: "same-origin", + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Something went wrong.")); + } + + return response.json(); +}; + const setMotionVariables = (sceneEl, orbitEl, cardEl, direction, progress) => { if (!sceneEl || !orbitEl || !cardEl) { return; @@ -74,7 +102,12 @@ const clearMotionVariables = (sceneEl, orbitEl, cardEl, finalMode = null) => { cardEl.style.removeProperty("--account-card-rotate-y"); }; -function AccountPage({ dealIndex = null }) { +function AccountPage({ + dealIndex = null, + initialAuthStatus = "anonymous", + onAuthStatusChange = null, +}) { + const [authStatus, setAuthStatus] = useState(initialAuthStatus); const [mode, setMode] = useState("login"); const [pendingMode, setPendingMode] = useState(null); const [orbitDirection, setOrbitDirection] = useState(null); @@ -93,10 +126,35 @@ function AccountPage({ dealIndex = null }) { const animationFrameRef = useRef(0); const animationStartRef = useRef(0); + const isSessionLoading = authStatus === "loading"; + const isAuthenticated = authStatus === "authenticated"; const isOrbiting = orbitDirection !== null; const selectedMode = pendingMode ?? mode; const isCardFlipped = selectedMode !== "login"; const dealStyle = useInfoCardDealStyle(dealIndex); + const showLoginSuccess = isAuthenticated || loginSubmitted; + const canCreateAccount = !isSessionLoading && !showLoginSuccess; + const showLoginForm = !isSessionLoading && !showLoginSuccess; + const showSignupForm = canCreateAccount && !signupSubmitted; + const reportAuthStatus = (nextStatus) => { + onAuthStatusChange?.(nextStatus); + }; + + const applyAuthenticatedState = () => { + setAuthStatus("authenticated"); + reportAuthStatus("authenticated"); + setMode("login"); + setPendingMode(null); + setOrbitDirection(null); + clearMotionVariables(sceneRef.current, orbitRef.current, cardRef.current, "login"); + setLoginSubmitted(true); + setSignupSubmitted(false); + setLoginError(""); + setSignupError(""); + setLoginPassword(""); + setSignupPassword(""); + setSignupConfirmPassword(""); + }; useEffect(() => { if (!orbitDirection || !pendingMode) { @@ -150,8 +208,49 @@ function AccountPage({ dealIndex = null }) { }; }, []); + useEffect(() => { + const abortController = new AbortController(); + + const loadSession = async () => { + try { + const response = await fetch(withBasePath("/api/auth/session"), { + method: "GET", + credentials: "same-origin", + cache: "no-store", + signal: abortController.signal, + }); + + if (!response.ok) { + return; + } + + const payload = await response.json(); + + if (payload?.authenticated) { + applyAuthenticatedState(); + return; + } + + setAuthStatus("anonymous"); + reportAuthStatus("anonymous"); + } catch (error) { + if (error.name !== "AbortError") { + setAuthStatus("anonymous"); + reportAuthStatus("anonymous"); + setLoginSubmitted(false); + } + } + }; + + loadSession(); + + return () => { + abortController.abort(); + }; + }, []); + const switchMode = (nextMode) => { - if (nextMode === mode || isOrbiting) { + if (nextMode === mode || isOrbiting || isSessionLoading || showLoginSuccess) { return; } @@ -174,7 +273,7 @@ function AccountPage({ dealIndex = null }) { setSignupError(""); }; - const handleLoginSubmit = (event) => { + const handleLoginSubmit = async (event) => { event.preventDefault(); setLoginError(""); @@ -183,10 +282,21 @@ function AccountPage({ dealIndex = null }) { return; } - setLoginSubmitted(true); + try { + await postAuthRequest("/api/auth/login", { + email: loginEmail, + password: loginPassword, + }); + applyAuthenticatedState(); + } catch (error) { + setAuthStatus("anonymous"); + reportAuthStatus("anonymous"); + setLoginSubmitted(false); + setLoginError(error.message || "Unable to log in right now."); + } }; - const handleSignupSubmit = (event) => { + const handleSignupSubmit = async (event) => { event.preventDefault(); setSignupError(""); @@ -200,10 +310,27 @@ function AccountPage({ dealIndex = null }) { return; } - setSignupSubmitted(true); + try { + await postAuthRequest("/api/auth/signup", { + email: signupEmail, + password: signupPassword, + confirmPassword: signupConfirmPassword, + }); + setSignupSubmitted(true); + setLoginSubmitted(false); + setSignupPassword(""); + setSignupConfirmPassword(""); + } catch (error) { + setSignupSubmitted(false); + setSignupError(error.message || "Unable to create your account."); + } }; const renderModeSwitch = (activeMode) => { + if (isSessionLoading || showLoginSuccess) { + return null; + } + const isLoginFace = activeMode === "login"; const nextMode = isLoginFace ? "signup" : "login"; const switchCopy = isLoginFace @@ -223,7 +350,7 @@ function AccountPage({ dealIndex = null }) { }; return ( -
+
-
+

- {loginSubmitted ? "Welcome back." : "Login"} + {isSessionLoading ? "Account" : showLoginSuccess ? "Welcome back." : "Login"}

- {loginSubmitted + {isSessionLoading + ? "Checking your saved progress." + : showLoginSuccess ? "You've logged in successfully." : "Pick up where you left off and get back to your saved lessons."}

- {!loginSubmitted ? ( + {showLoginForm ? (