diff --git a/.env.example b/.env.example index 3e0088a..ddd82a4 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ -# No environment variables are required to run StudyMap locally. -# npm run dev works out of the box with no .env.local file. +# --------------------------------------------------------------------------- +# Supabase (required for auth — sign in / sign up / Google OAuth) +# Get these from: Supabase dashboard > Settings > API +# --------------------------------------------------------------------------- + +NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-public-key # --------------------------------------------------------------------------- # Analytics (optional) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 56b8dc8..b6b9eb2 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: General question or feedback - url: mailto:dhawansanay@gmail.com + url: mailto:studentsuite0@gmail.com about: Questions and general feedback — from CONTRIBUTING.md. - name: Security vulnerability - url: mailto:dhawansanay@gmail.com + url: mailto:studentsuite0@gmail.com about: Report security issues privately — do not open a public issue. diff --git a/README.md b/README.md index 0105592..52fc321 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A crowdsourced map of student-important places across the Mumbai Metropolitan Region (Mumbai, Thane, Navi Mumbai). Open source, zero setup, free forever. -**Live:** [study-map-psi.vercel.app](https://study-map-psi.vercel.app) +**Live:** [studymapp.vercel.app](https://studymapp.vercel.app) --- diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..bad4641 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,66 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse, type NextRequest } from "next/server"; + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value), + ); + supabaseResponse = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options), + ); + }, + }, + }, + ); + + // Refresh session — must not run any code between createServerClient and getUser + const { + data: { user }, + } = await supabase.auth.getUser(); + + const { pathname } = request.nextUrl; + + // Public paths that never require auth + const isPublicPath = + pathname.startsWith("/login") || pathname.startsWith("/auth"); + + if (isPublicPath) { + // Already logged in — send them to the map instead of showing login again + if (user && pathname === "/login") { + return NextResponse.redirect(new URL("/map", request.url)); + } + return supabaseResponse; + } + + // Everything else requires a session + if (!user) { + const loginUrl = new URL("/login", request.url); + return NextResponse.redirect(loginUrl); + } + + return supabaseResponse; +} + +export const config = { + matcher: [ + /* + * Match all paths except: + * - _next/static (static files) + * - _next/image (image optimisation) + * - favicon, icons, brand, logo assets, manifest + */ + "/((?!_next/static|_next/image|favicon|icons|brand|logo|manifest).*)", + ], +}; diff --git a/package-lock.json b/package-lock.json index 9f76220..024751b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "studymap", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "studymap", - "version": "1.0.0", + "version": "1.2.0", "dependencies": { "@base-ui/react": "^1.5.0", + "@supabase/ssr": "^0.12.0", + "@supabase/supabase-js": "^2.108.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "leaflet": "^1.9.4", @@ -3494,6 +3496,115 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@supabase/auth-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz", + "integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz", + "integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", + "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz", + "integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz", + "integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz", + "integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.108.0" + } + }, + "node_modules/@supabase/ssr/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/@supabase/storage-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz", + "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz", + "integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.108.2", + "@supabase/functions-js": "2.108.2", + "@supabase/postgrest-js": "2.108.2", + "@supabase/realtime-js": "2.108.2", + "@supabase/storage-js": "2.108.2" + }, + "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", @@ -7235,6 +7346,15 @@ "node": ">=18.18.0" } }, + "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/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", diff --git a/package.json b/package.json index 96d4f02..17df1a4 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@base-ui/react": "^1.5.0", + "@supabase/ssr": "^0.12.0", + "@supabase/supabase-js": "^2.108.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "leaflet": "^1.9.4", diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts new file mode 100644 index 0000000..7c4c5f1 --- /dev/null +++ b/src/app/auth/callback/route.ts @@ -0,0 +1,36 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get("code"); + const next = searchParams.get("next") ?? "/map"; + + if (code) { + const cookieStore = await cookies(); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ); + }, + }, + }, + ); + + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + return NextResponse.redirect(`${origin}${next}`); + } + } + + return NextResponse.redirect(`${origin}/login?error=auth_error`); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..7533c81 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,196 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +import { createClient } from "@/lib/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function LoginPage() { + const router = useRouter(); + const supabase = createClient(); + + const [mode, setMode] = React.useState<"signin" | "signup">("signin"); + const [email, setEmail] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [loading, setLoading] = React.useState(false); + + async function handleEmailAuth(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + if (mode === "signup") { + const { error } = await supabase.auth.signUp({ email, password }); + if (error) { + toast.error(error.message); + } else { + toast.success("Check your email to confirm your account."); + } + } else { + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + if (error) { + toast.error(error.message); + } else { + router.push("/map"); + router.refresh(); + } + } + + setLoading(false); + } + + async function handleGoogle() { + const { error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: `${window.location.origin}/auth/callback`, + }, + }); + if (error) toast.error(error.message); + } + + return ( +
+ {mode === "signin" + ? "Sign in to continue" + : "Create your account"} +
++ {mode === "signin" ? ( + <> + No account?{" "} + + > + ) : ( + <> + Already have an account?{" "} + + > + )} +
+