Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# dependencies
node_modules/

.claude/

# build output
dist/
dist-ssr/
Expand Down
8 changes: 6 additions & 2 deletions app/[[...slug]]/page.jsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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 <App initialPathname={initialPathname} />;
return <App initialPathname={initialPathname} initialAuthStatus={initialAuthStatus} />;
}
44 changes: 44 additions & 0 deletions app/api/auth/confirm/route.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
76 changes: 76 additions & 0 deletions app/api/auth/login/route.js
Original file line number Diff line number Diff line change
@@ -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,
}
);
}
45 changes: 45 additions & 0 deletions app/api/auth/logout/route.js
Original file line number Diff line number Diff line change
@@ -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,
}
);
}
54 changes: 54 additions & 0 deletions app/api/auth/session/route.js
Original file line number Diff line number Diff line change
@@ -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,
}
);
}
88 changes: 88 additions & 0 deletions app/api/auth/signup/route.js
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 11 additions & 0 deletions middleware.js
Original file line number Diff line number Diff line change
@@ -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)$).*)",
],
};
Loading