Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/app/calendar/CalendarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
29 changes: 26 additions & 3 deletions src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div className="flex min-h-[calc(100dvh-3.5rem)] items-center justify-center px-4">
<div className="w-full max-w-sm rounded-xl border bg-card p-6 text-center shadow-sm">
<p className="text-sm text-muted-foreground">
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.
</p>
<Button asChild className="mt-4">
<Link href="/map">Go to the map</Link>
</Button>
</div>
</div>
);
}

// 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,
});
Expand All @@ -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)}`,
Expand Down
91 changes: 50 additions & 41 deletions src/components/layout/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ 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();
const router = useRouter();
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),
Expand All @@ -29,6 +35,7 @@ export function Navbar() {

async function handleSignOut() {
const supabase = createClient();
if (!supabase) return;
await supabase.auth.signOut();
router.refresh();
}
Expand Down Expand Up @@ -63,29 +70,30 @@ export function Navbar() {

<div className="ml-auto flex items-center gap-1">
<ThemeToggle />
{loggedIn ? (
<Button
variant="ghost"
size="icon"
onClick={handleSignOut}
aria-label="Sign out"
className="hidden md:inline-flex"
>
<LogOut className="size-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
asChild
className="hidden md:inline-flex gap-1.5"
>
<Link href={`/login?next=${encodeURIComponent(pathname)}`}>
<LogIn className="size-4" />
Sign in
</Link>
</Button>
)}
{authEnabled &&
(loggedIn ? (
<Button
variant="ghost"
size="icon"
onClick={handleSignOut}
aria-label="Sign out"
className="hidden md:inline-flex"
>
<LogOut className="size-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
asChild
className="hidden md:inline-flex gap-1.5"
>
<Link href={`/login?next=${encodeURIComponent(pathname)}`}>
<LogIn className="size-4" />
Sign in
</Link>
</Button>
))}

<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
Expand All @@ -109,24 +117,25 @@ export function Navbar() {
{link.label}
</Link>
))}
{loggedIn ? (
<button
onClick={() => { setOpen(false); handleSignOut(); }}
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 text-left"
>
<LogOut className="size-4" />
Sign out
</button>
) : (
<Link
href={`/login?next=${encodeURIComponent(pathname)}`}
onClick={() => 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"
>
<LogIn className="size-4" />
Sign in
</Link>
)}
{authEnabled &&
(loggedIn ? (
<button
onClick={() => { setOpen(false); handleSignOut(); }}
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 text-left"
>
<LogOut className="size-4" />
Sign out
</button>
) : (
<Link
href={`/login?next=${encodeURIComponent(pathname)}`}
onClick={() => 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"
>
<LogIn className="size-4" />
Sign in
</Link>
))}
</nav>
</SheetContent>
</Sheet>
Expand Down
1 change: 1 addition & 0 deletions src/components/map/places-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
8 changes: 8 additions & 0 deletions src/lib/supabase/client.ts
Original file line number Diff line number Diff line change
@@ -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!,
Expand Down
14 changes: 14 additions & 0 deletions src/lib/supabase/config.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
8 changes: 8 additions & 0 deletions src/lib/supabase/server.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
19 changes: 15 additions & 4 deletions src/lib/user-events.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -36,7 +47,7 @@ export interface PersonalEventInput {
}

export async function fetchUserEvents(): Promise<PersonalEvent[]> {
const supabase = createClient();
const supabase = requireClient();
const { data, error } = await supabase
.from("user_events")
.select("*")
Expand All @@ -48,7 +59,7 @@ export async function fetchUserEvents(): Promise<PersonalEvent[]> {
export async function createUserEvent(
input: PersonalEventInput,
): Promise<PersonalEvent> {
const supabase = createClient();
const supabase = requireClient();
const { data, error } = await supabase
.from("user_events")
.insert(input)
Expand All @@ -62,7 +73,7 @@ export async function updateUserEvent(
id: string,
input: PersonalEventInput,
): Promise<PersonalEvent> {
const supabase = createClient();
const supabase = requireClient();
const { data, error } = await supabase
.from("user_events")
.update(input)
Expand All @@ -74,7 +85,7 @@ export async function updateUserEvent(
}

export async function deleteUserEvent(id: string): Promise<void> {
const supabase = createClient();
const supabase = requireClient();
const { error } = await supabase.from("user_events").delete().eq("id", id);
if (error) throw error;
}
Loading
Loading