diff --git a/.env.example b/.env.example
index 93fda00..71715ab 100644
--- a/.env.example
+++ b/.env.example
@@ -1,9 +1,14 @@
# Node environment (development, production, test)
NODE_ENV=development
+
+# App base path
+NEXT_PUBLIC_APP_URL=http://localhost:3000
+NEXT_PUBLIC_BASE_PATH=/bills
+
# NextAuth Configuration
# Required for authentication
-NEXTAUTH_URL=http://localhost:3000
+NEXTAUTH_URL=http://localhost:3000/bills/api/auth
NEXTAUTH_SECRET=your-nextauth-secret-here
# Alternative: AUTH_SECRET can be used instead of NEXTAUTH_SECRET
# AUTH_SECRET=your-auth-secret-here
@@ -18,7 +23,7 @@ GOOGLE_CLIENT_SECRET=your-google-client-secret
# MongoDB Configuration
# Required for database connection
-MONGO_URI=mongodb://localhost:27017/billstracker
+MONGO_URI=mongodb://localhost:27017
# Production DB connection used with local development
PROD_MONGO_URI=
diff --git a/next.config.ts b/next.config.ts
index ed83a10..cbf43d9 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,11 +1,28 @@
import type { NextConfig } from "next";
+const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || "/bills";
+
const nextConfig: NextConfig = {
- basePath: "/bills",
+ basePath: BASE_PATH,
+ assetPrefix: BASE_PATH,
/* config options here */
output: "standalone",
+ // Ensure incoming requests scoped under BASE_PATH resolve to root routes
+ async rewrites() {
+ return [
+ {
+ source: `${BASE_PATH}`,
+ destination: "/",
+ },
+ {
+ source: `${BASE_PATH}/:path*`,
+ destination: "/:path*",
+ },
+ ];
+ },
+
// Performance optimizations
experimental: {
optimizePackageImports: ["lucide-react", "react-markdown"],
diff --git a/src/app/[id]/edit/page.tsx b/src/app/[id]/edit/page.tsx
index 97dbb8e..7408c14 100644
--- a/src/app/[id]/edit/page.tsx
+++ b/src/app/[id]/edit/page.tsx
@@ -1,9 +1,8 @@
import { redirect } from "next/navigation";
import { getBillByIdFromDB } from "@/server/get-bill-by-id-from-db";
-import { getServerSession } from "next-auth";
-import { authOptions } from "@/lib/auth";
-import { connectToDatabase } from "@/lib/mongoose";
-import { User } from "@/models/User";
+import { requireAuthenticatedUser } from "@/lib/auth-guards";
+import { BASE_PATH } from "@/utils/basePath";
+import { Button } from "@/components/ui/button";
interface Params {
params: Promise<{ id: string }>;
@@ -12,19 +11,8 @@ interface Params {
export default async function EditBillPage({ params }: Params) {
const { id } = await params;
- const session = await getServerSession(authOptions);
- if (!session?.user?.email) {
- redirect(`/unauthorized`);
- }
-
- // Verify the signed-in user exists in DB; do not create
- await connectToDatabase();
- const dbUser = await User.findOne({
- emailLower: session.user.email.toLowerCase(),
- });
- if (!dbUser) {
- redirect(`/unauthorized`);
- }
+ // Use reusable auth guard for consistent authentication
+ await requireAuthenticatedUser();
const bill = await getBillByIdFromDB(id);
if (!bill) {
@@ -37,7 +25,11 @@ export default async function EditBillPage({ params }: Params) {
return (
);
diff --git a/src/app/api/[id]/route.ts b/src/app/api/[id]/route.ts
index c3f6353..e953709 100644
--- a/src/app/api/[id]/route.ts
+++ b/src/app/api/[id]/route.ts
@@ -4,6 +4,7 @@ import { connectToDatabase } from "@/lib/mongoose";
import { Bill } from "@/models/Bill";
import { User } from "@/models/User";
import { authOptions } from "@/lib/auth";
+import { BASE_PATH } from "@/utils/basePath";
export async function POST(
request: Request,
@@ -306,5 +307,8 @@ export async function POST(
await Bill.updateOne({ billId: id }, { $set: update }, { upsert: false });
- return NextResponse.redirect(new URL(`/bills/${id}`, request.url));
+ // API routes need to manually include basePath in redirects
+ const url = new URL(request.url);
+ const redirectUrl = new URL(`${BASE_PATH}/${id}`, url.origin);
+ return NextResponse.redirect(redirectUrl);
}
diff --git a/src/app/bills/[id]/edit/page.tsx b/src/app/bills/[id]/edit/page.tsx
deleted file mode 100644
index cf04961..0000000
--- a/src/app/bills/[id]/edit/page.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-import { redirect } from "next/navigation";
-import { getServerSession } from "next-auth";
-import { authOptions } from "@/auth";
-import { getBillByIdFromDB } from "@/server/get-bill-by-id-from-db";
-
-interface Params {
- params: Promise<{ id: string }>;
-}
-
-export default async function EditBillPage({ params }: Params) {
- const { id } = await params;
- const session = await getServerSession(authOptions);
- if (!session?.user) {
- redirect("/");
- }
-
- const bill = await getBillByIdFromDB(id);
- if (!bill) {
- redirect(`/bills/${id}`);
- }
-
- const questionPeriodQuestions = bill.question_period_questions || [];
- const questionFields = [...questionPeriodQuestions, { question: "" }];
-
- return (
-
- );
-}
diff --git a/src/app/sign-in/page.tsx b/src/app/sign-in/page.tsx
index 4a3e787..0c31b04 100644
--- a/src/app/sign-in/page.tsx
+++ b/src/app/sign-in/page.tsx
@@ -3,6 +3,7 @@ import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { signIn } from "next-auth/react";
+import { BASE_PATH } from "@/utils/basePath";
function SignInContent() {
const [loading, setLoading] = useState(false);
@@ -16,7 +17,7 @@ function SignInContent() {
const onGoogle = async () => {
try {
setLoading(true);
- await signIn("google", { callbackUrl: "/" });
+ await signIn("google", { callbackUrl: BASE_PATH || "/" });
} finally {
setLoading(false);
}
diff --git a/src/app/unauthorized/page.tsx b/src/app/unauthorized/page.tsx
index 658419e..5bfa1f0 100644
--- a/src/app/unauthorized/page.tsx
+++ b/src/app/unauthorized/page.tsx
@@ -1,4 +1,5 @@
import Link from "next/link";
+import { BASE_PATH } from "@/utils/basePath";
export default function UnauthorizedPage() {
return (
@@ -6,7 +7,7 @@ export default function UnauthorizedPage() {
Sorry!
You don’t have access to view this page.
-
+
Go home
diff --git a/src/auth.ts b/src/auth.ts
deleted file mode 100644
index 7e42dd0..0000000
--- a/src/auth.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import NextAuth, { type NextAuthOptions } from "next-auth";
-import Google from "next-auth/providers/google";
-import { env, assertServerEnv } from "@/env";
-import { connectToDatabase } from "@/lib/mongoose";
-import { User } from "@/models/User";
-
-if (env.NODE_ENV !== "production") {
- try {
- assertServerEnv();
- } catch (e) {
- console.warn("[auth] env check:", e);
- }
- if (!env.NEXTAUTH_URL)
- console.warn(
- "[auth] Missing NEXTAUTH_URL (e.g. http://localhost:3000 in dev).",
- );
-}
-
-export const authOptions: NextAuthOptions = {
- providers: [
- Google({
- clientId: env.GOOGLE_CLIENT_ID || "",
- clientSecret: env.GOOGLE_CLIENT_SECRET || "",
- authorization: {
- params: { scope: "openid email profile", prompt: "consent" },
- },
- }),
- ],
- debug: process.env.NODE_ENV !== "production",
- session: { strategy: "jwt" },
- callbacks: {
- async signIn({ user }) {
- const email = user?.email?.trim().toLowerCase();
- if (!email) return false;
-
- // Prefer DB-backed allowlist. Fall back to stub if DB not configured.
- try {
- await connectToDatabase();
- const now = new Date();
- const existing = await User.findOne({ emailLower: email });
- if (!existing) {
- if (env.NODE_ENV !== "production") {
- console.warn(
- `[auth] User ${email} not found. No auto-creation. Denying sign-in.`,
- );
- }
- return false;
- }
- existing.name = user?.name ?? existing.name;
- (existing as any).image = (user as any)?.image ?? existing.image;
- existing.lastLoginAt = now;
- await existing.save();
- return !!existing.allowed;
- } catch (err) {
- if (env.NODE_ENV !== "production") {
- console.warn("[auth] DB check failed, denying sign-in:", err);
- }
- return false;
- }
- },
- async jwt({ token, user }) {
- if (user?.email) {
- token.email = user.email;
- token.name = user.name;
- token.picture = (user as any)?.image as string | undefined;
- }
- return token;
- },
- async session({ session, token }) {
- if (session?.user) {
- session.user.email = token.email as string | undefined;
- session.user.name = token.name as string | undefined;
- (session.user as any).image = token.picture as string | undefined;
- }
- return session;
- },
- },
- pages: {
- signIn: "/sign-in",
- },
- secret: env.NEXTAUTH_SECRET || env.AUTH_SECRET,
-};
-
-const handler = NextAuth(authOptions);
-export { handler as GET, handler as POST };
diff --git a/src/components/Nav/nav.component.tsx b/src/components/Nav/nav.component.tsx
index 269b8b3..cb94993 100644
--- a/src/components/Nav/nav.component.tsx
+++ b/src/components/Nav/nav.component.tsx
@@ -1,6 +1,7 @@
"use client";
import { PROJECT_NAME } from "@/consts/general";
+import { BASE_PATH } from "@/utils/basePath";
import { Session } from "next-auth";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
@@ -10,7 +11,8 @@ export const Nav = () => {
const { data: session }: { data: Session | null } = useSession();
const handleSignOut = () => {
- signOut({ callbackUrl: "/bills" });
+ const redirect = BASE_PATH || "/";
+ signOut({ callbackUrl: redirect });
};
return (
diff --git a/src/components/SessionProvider.tsx b/src/components/SessionProvider.tsx
index bbc0448..1f34529 100644
--- a/src/components/SessionProvider.tsx
+++ b/src/components/SessionProvider.tsx
@@ -2,14 +2,16 @@
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
import { ReactNode } from "react";
+import { BASE_PATH } from "@/utils/basePath";
interface SessionProviderProps {
children: ReactNode;
}
export function SessionProvider({ children }: SessionProviderProps) {
+ const authBasePath = `${BASE_PATH || ""}/api/auth`;
return (
-
+
{children}
);
diff --git a/src/components/SignIn/sign-in.component.tsx b/src/components/SignIn/sign-in.component.tsx
index ff64f20..c1a842e 100644
--- a/src/components/SignIn/sign-in.component.tsx
+++ b/src/components/SignIn/sign-in.component.tsx
@@ -1,17 +1,10 @@
-"use client";
-import { signIn } from "next-auth/react";
+import Link from "next/link";
export const SignIn = () => {
- const handleSignInClick = () => {
- signIn("google");
- };
return (
);
diff --git a/src/lib/auth-guards.ts b/src/lib/auth-guards.ts
new file mode 100644
index 0000000..0f0510e
--- /dev/null
+++ b/src/lib/auth-guards.ts
@@ -0,0 +1,35 @@
+import { redirect } from "next/navigation";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { connectToDatabase } from "@/lib/mongoose";
+import { User } from "@/models/User";
+
+/**
+ * Server-side authentication guard that requires a valid authenticated user.
+ * Redirects to /unauthorized if:
+ * - No session exists
+ * - User email is not provided
+ * - User does not exist in the database
+ *
+ * @returns Object containing the session and database user
+ * @throws Redirects to /unauthorized if authentication fails
+ */
+export async function requireAuthenticatedUser() {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user?.email) {
+ redirect("/unauthorized");
+ }
+
+ // Verify the signed-in user exists in DB; do not create
+ await connectToDatabase();
+ const dbUser = await User.findOne({
+ emailLower: session.user.email.toLowerCase(),
+ });
+
+ if (!dbUser) {
+ redirect("/unauthorized");
+ }
+
+ return { session, dbUser };
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index c31a28c..936a21e 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,62 +1,83 @@
import { NextAuthOptions } from "next-auth";
-import GoogleProvider from "next-auth/providers/google";
+import Google from "next-auth/providers/google";
+import { env, assertServerEnv } from "@/env";
import { connectToDatabase } from "@/lib/mongoose";
import { User } from "@/models/User";
-import { env } from "@/env";
+import { BASE_PATH } from "@/utils/basePath";
+
+if (env.NODE_ENV !== "production") {
+ try {
+ assertServerEnv();
+ } catch (e) {
+ console.warn("[auth] env check:", e);
+ }
+ if (!env.NEXTAUTH_URL)
+ console.warn(
+ "[auth] Missing NEXTAUTH_URL (e.g. http://localhost:3000 in dev).",
+ );
+}
export const authOptions: NextAuthOptions = {
providers: [
- GoogleProvider({
+ Google({
clientId: env.GOOGLE_CLIENT_ID || "",
clientSecret: env.GOOGLE_CLIENT_SECRET || "",
+ authorization: {
+ params: { scope: "openid email profile", prompt: "consent" },
+ },
}),
],
+ debug: process.env.NODE_ENV !== "production",
+ session: { strategy: "jwt" },
callbacks: {
async signIn({ user }) {
- // Only allow sign-in if email is provided
- if (!user.email) {
- return false;
- }
+ const email = user?.email?.trim().toLowerCase();
+ if (!email) return false;
+ // Prefer DB-backed allowlist. Fall back to stub if DB not configured.
try {
await connectToDatabase();
-
- // Check if user exists in our database
- const existingUser = await User.findOne({
- emailLower: user.email.toLowerCase(),
- });
-
- if (existingUser) {
- // Only allow sign-in if the user is approved
- return existingUser.allowed === true;
+ const now = new Date();
+ const existing = await User.findOne({ emailLower: email });
+ if (!existing) {
+ if (env.NODE_ENV !== "production") {
+ console.warn(
+ `[auth] User ${email} not found. No auto-creation. Denying sign-in.`,
+ );
+ }
+ return false;
+ }
+ existing.name = user?.name ?? existing.name;
+ (existing as any).image = (user as any)?.image ?? existing.image;
+ existing.lastLoginAt = now;
+ await existing.save();
+ return !!existing.allowed;
+ } catch (err) {
+ if (env.NODE_ENV !== "production") {
+ console.warn("[auth] DB check failed, denying sign-in:", err);
}
-
- return false;
- } catch (error) {
- console.error("Error during sign-in:", error);
return false;
}
},
- async session({ session }) {
- // Add user ID to session if needed
- if (session.user?.email) {
- try {
- await connectToDatabase();
- const dbUser = await User.findOne({
- emailLower: session.user.email.toLowerCase(),
- });
- if (dbUser) {
- (session.user as any).id = dbUser._id.toString();
- (session.user as any).allowed = dbUser.allowed;
- }
- } catch (error) {
- console.error("Error fetching user from DB:", error);
- }
+ async jwt({ token, user }) {
+ if (user?.email) {
+ token.email = user.email;
+ token.name = user.name;
+ token.picture = (user as any)?.image as string | undefined;
+ }
+ return token;
+ },
+ async session({ session, token }) {
+ if (session?.user) {
+ session.user.email = token.email as string | undefined;
+ session.user.name = token.name as string | undefined;
+ (session.user as any).image = token.picture as string | undefined;
}
return session;
},
},
pages: {
- signIn: "/auth/signin",
+ signIn: `${BASE_PATH}/sign-in`,
},
+ secret: env.NEXTAUTH_SECRET || env.AUTH_SECRET,
};