diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 56fb951..f50d30b 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -2,6 +2,8 @@ import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; + // Always redirect to the canonical domain after OAuth so that arriving via // any auto-assigned Vercel URL (e.g. studymapp-student-suite.vercel.app) // doesn't leave the user stranded on the wrong domain. @@ -13,6 +15,11 @@ export async function GET(request: Request) { const code = searchParams.get("code"); const next = searchParams.get("next") ?? "/"; + // No Supabase configured (self-host / preview mode): nothing to exchange. + if (!isSupabaseConfigured()) { + return NextResponse.redirect(`${SITE_URL}/`); + } + if (code) { const cookieStore = await cookies(); const supabase = createServerClient( diff --git a/src/app/calendar/CalendarView.tsx b/src/app/calendar/CalendarView.tsx index 3037a95..97143c5 100644 --- a/src/app/calendar/CalendarView.tsx +++ b/src/app/calendar/CalendarView.tsx @@ -126,6 +126,7 @@ export function CalendarView() { useEffect(() => { const supabase = createClient(); + if (!supabase) return; // self-host / preview mode: no auth, no personal events supabase.auth.getUser().then(({ data }) => setUser(data.user)); const { data: { subscription }, diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 7c7ee53..694d164 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import { Loader2 } from "lucide-react"; @@ -23,19 +24,41 @@ export function LoginForm() { const [password, setPassword] = React.useState(""); const [loading, setLoading] = React.useState(false); + // Self-host / preview mode: Supabase isn't configured, so there's no auth. + if (!supabase) { + return ( +
+
+

+ Sign-in is not available on this deployment. StudyMap is running in + preview mode without accounts. The map and calendar work fully + without signing in. +

+ +
+
+ ); + } + + // Non-null past the guard above; captured so the handler closures below + // don't re-widen it back to `SupabaseClient | null`. + const client = supabase; + async function handleEmailAuth(e: React.FormEvent) { e.preventDefault(); setLoading(true); if (mode === "signup") { - const { error } = await supabase.auth.signUp({ email, password }); + const { error } = await client.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({ + const { error } = await client.auth.signInWithPassword({ email, password, }); @@ -55,7 +78,7 @@ export function LoginForm() { // fall back to the hardcoded canonical domain so any auto-assigned // Vercel URL never leaks into the OAuth redirectTo. const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? site.url; - const { error } = await supabase.auth.signInWithOAuth({ + const { error } = await client.auth.signInWithOAuth({ provider: "google", options: { redirectTo: `${siteUrl}/auth/callback?next=${encodeURIComponent(next)}`, diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index a74d410..d88e625 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; import { ThemeToggle } from "@/components/layout/theme-toggle"; import { createClient } from "@/lib/supabase/client"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; export function Navbar() { const pathname = usePathname(); @@ -18,8 +19,13 @@ export function Navbar() { const [open, setOpen] = React.useState(false); const [loggedIn, setLoggedIn] = React.useState(false); + // Auth is only available when Supabase is configured. In self-host / preview + // mode we hide sign-in entirely and skip the auth listener. + const authEnabled = isSupabaseConfigured(); + React.useEffect(() => { const supabase = createClient(); + if (!supabase) return; supabase.auth.getUser().then(({ data }) => setLoggedIn(!!data.user)); const { data: { subscription } } = supabase.auth.onAuthStateChange( (_, session) => setLoggedIn(!!session), @@ -29,6 +35,7 @@ export function Navbar() { async function handleSignOut() { const supabase = createClient(); + if (!supabase) return; await supabase.auth.signOut(); router.refresh(); } @@ -63,29 +70,30 @@ export function Navbar() {
- {loggedIn ? ( - - ) : ( - - )} + {authEnabled && + (loggedIn ? ( + + ) : ( + + ))} @@ -109,24 +117,25 @@ export function Navbar() { {link.label} ))} - {loggedIn ? ( - - ) : ( - setOpen(false)} - className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" - > - - Sign in - - )} + {authEnabled && + (loggedIn ? ( + + ) : ( + setOpen(false)} + className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + + Sign in + + ))} diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index fcf0955..1f11e91 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -95,6 +95,7 @@ export function PlacesMap({ places }: PlacesMapProps) { React.useEffect(() => { const supabase = createClient(); + if (!supabase) return; // self-host / preview mode: no auth, no private layer supabase.auth.getUser().then(({ data }) => setUser(data.user)); const { data: { subscription }, diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 2ad2591..8232913 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -1,6 +1,14 @@ import { createBrowserClient } from "@supabase/ssr"; +import { isSupabaseConfigured } from "./config"; + +/** + * Browser Supabase client, or `null` when Supabase isn't configured. + * Callers must handle the null case: in self-host / preview mode the app + * runs without auth or any private features. + */ export function createClient() { + if (!isSupabaseConfigured()) return null; return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, diff --git a/src/lib/supabase/config.ts b/src/lib/supabase/config.ts new file mode 100644 index 0000000..16fe8d7 --- /dev/null +++ b/src/lib/supabase/config.ts @@ -0,0 +1,14 @@ +/** + * True when Supabase environment variables are set. + * + * When false, the app runs in self-host / preview mode: no sign-in, no saved + * places, no personal calendar events - just the public map and calendar. + * This lets contributors run StudyMap locally to preview their map entries + * without provisioning a Supabase project. See SELF-HOSTING.md and issue #72. + */ +export function isSupabaseConfigured(): boolean { + return Boolean( + process.env.NEXT_PUBLIC_SUPABASE_URL && + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + ); +} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index 731e0af..b366103 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -1,7 +1,15 @@ import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; +import { isSupabaseConfigured } from "./config"; + +/** + * Server Supabase client, or `null` when Supabase isn't configured + * (self-host / preview mode). + */ export async function createClient() { + if (!isSupabaseConfigured()) return null; + const cookieStore = await cookies(); return createServerClient( diff --git a/src/lib/user-events.ts b/src/lib/user-events.ts index b7df515..3eedef0 100644 --- a/src/lib/user-events.ts +++ b/src/lib/user-events.ts @@ -1,5 +1,16 @@ import { createClient } from "@/lib/supabase/client"; +/** + * Supabase client for the private-data calls below. These only ever run for a + * signed-in user, which is impossible without Supabase configured, so a null + * client here means something is badly misconfigured - throw rather than guess. + */ +function requireClient() { + const supabase = createClient(); + if (!supabase) throw new Error("Supabase is not configured"); + return supabase; +} + export type PersonalEventCategory = | "deadline" | "exam" @@ -36,7 +47,7 @@ export interface PersonalEventInput { } export async function fetchUserEvents(): Promise { - const supabase = createClient(); + const supabase = requireClient(); const { data, error } = await supabase .from("user_events") .select("*") @@ -48,7 +59,7 @@ export async function fetchUserEvents(): Promise { export async function createUserEvent( input: PersonalEventInput, ): Promise { - const supabase = createClient(); + const supabase = requireClient(); const { data, error } = await supabase .from("user_events") .insert(input) @@ -62,7 +73,7 @@ export async function updateUserEvent( id: string, input: PersonalEventInput, ): Promise { - const supabase = createClient(); + const supabase = requireClient(); const { data, error } = await supabase .from("user_events") .update(input) @@ -74,7 +85,7 @@ export async function updateUserEvent( } export async function deleteUserEvent(id: string): Promise { - const supabase = createClient(); + const supabase = requireClient(); const { error } = await supabase.from("user_events").delete().eq("id", id); if (error) throw error; } diff --git a/src/lib/user-places.ts b/src/lib/user-places.ts index e7c14a3..7c75a7e 100644 --- a/src/lib/user-places.ts +++ b/src/lib/user-places.ts @@ -53,15 +53,26 @@ export function userPlaceToPlace(row: UserPlaceRow): Place { }; } -async function currentUserId(): Promise { +/** + * Supabase client for the private-data calls below. These only ever run for a + * signed-in user, which is impossible without Supabase configured, so a null + * client here means something is badly misconfigured - throw rather than guess. + */ +function requireClient() { const supabase = createClient(); + if (!supabase) throw new Error("Supabase is not configured"); + return supabase; +} + +async function currentUserId(): Promise { + const supabase = requireClient(); const { data } = await supabase.auth.getUser(); if (!data.user) throw new Error("Not signed in"); return data.user.id; } export async function fetchUserPlaces(): Promise { - const supabase = createClient(); + const supabase = requireClient(); const { data, error } = await supabase .from("user_places") .select("*") @@ -71,7 +82,7 @@ export async function fetchUserPlaces(): Promise { } export async function createUserPlace(input: UserPlaceInput): Promise { - const supabase = createClient(); + const supabase = requireClient(); const user_id = await currentUserId(); const { data, error } = await supabase .from("user_places") @@ -86,7 +97,7 @@ export async function updateUserPlace( id: string, input: UserPlaceInput, ): Promise { - const supabase = createClient(); + const supabase = requireClient(); const { data, error } = await supabase .from("user_places") .update(input) @@ -98,20 +109,20 @@ export async function updateUserPlace( } export async function deleteUserPlace(id: string): Promise { - const supabase = createClient(); + const supabase = requireClient(); const { error } = await supabase.from("user_places").delete().eq("id", id); if (error) throw error; } export async function fetchUserHome(): Promise { - const supabase = createClient(); + const supabase = requireClient(); const { data, error } = await supabase.from("user_home").select("*").maybeSingle(); if (error) throw error; return data; } export async function upsertUserHome(input: UserHomeInput): Promise { - const supabase = createClient(); + const supabase = requireClient(); const user_id = await currentUserId(); const { data, error } = await supabase .from("user_home") @@ -123,7 +134,7 @@ export async function upsertUserHome(input: UserHomeInput): Promise { } export async function deleteUserHome(): Promise { - const supabase = createClient(); + const supabase = requireClient(); const user_id = await currentUserId(); const { error } = await supabase.from("user_home").delete().eq("user_id", user_id); if (error) throw error; diff --git a/src/proxy.ts b/src/proxy.ts index b4d3039..2f6b617 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,6 +1,8 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; + const CANONICAL = "https://studymapp.vercel.app"; // Vercel auto-assigns this URL based on the team name — it can't be deleted, // so we intercept every request on it and redirect to the canonical domain. @@ -20,6 +22,12 @@ export async function proxy(request: NextRequest) { let proxyResponse = NextResponse.next({ request }); + // Self-host / preview mode: no Supabase, so there's no session to refresh. + // Skipping this keeps every route working without Supabase credentials. + if (!isSupabaseConfigured()) { + return proxyResponse; + } + const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,