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
2 changes: 1 addition & 1 deletion apps/app-portal/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ const config: Config = {
},
};

export default config;
export default config;
6 changes: 5 additions & 1 deletion apps/app-portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@google-cloud/storage": "^7.21.0",
"@hookform/resolvers": "^5.2.2",
"@next-auth/mongodb-adapter": "^1.1.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
Expand All @@ -26,8 +27,10 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.14.0",
"mongodb": "^7.2.0",
"mongodb": "^5.9.2",
"next": "^14.2.3",
"next-auth": "^4.24.14",
"nodemailer": "^7.0.7",
"react": "^18.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^18.2.0",
Expand All @@ -47,6 +50,7 @@
"@repo/tailwind-config": "*",
"@repo/typescript-config": "*",
"@types/node": "^20.11.24",
"@types/nodemailer": "^8.0.1",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"autoprefixer": "^10.4.18",
Expand Down
Binary file modified apps/app-portal/public/email_logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 12 additions & 10 deletions apps/app-portal/src/app/(admin)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@ import React from "react";
import UserMenu from "@/components/auth/UserMenu";
import AdminSidebar from "@/components/admin/AdminSidebar";
import { redirect } from "next/navigation";

// TODO: pull the real email from session later
const email = "admin@example.com";

async function requireAdmin(): Promise<boolean> {
return true;
}
import { getSession } from "@/lib/auth/session";

export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const isAdmin = await requireAdmin();
const session = await getSession();
const user = session?.user as
| { email?: string | null; isAdmin?: boolean }
| undefined;

if (!isAdmin) {
redirect("/");
if (!user) {
redirect("/auth/signin");
}
if (!user.isAdmin) {
redirect("/dashboard");
}

const email = user.email ?? "";

return (
<div className="flex min-h-screen">
Expand Down
4 changes: 3 additions & 1 deletion apps/app-portal/src/app/(applicant)/rsvp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { redirect } from "next/navigation";
import { fetchPortalStatus } from "../../../lib/status/fetchPortalStatus";
import RsvpExperience from "../../../components/dashboard/RsvpExperience";

export const dynamic = "force-dynamic";

export default async function RsvpPage(): Promise<JSX.Element> {
const { branch, status, decisionDates } = await fetchPortalStatus();
const confirmBy = new Date(decisionDates.confirmBy);
Expand All @@ -14,7 +16,7 @@ export default async function RsvpPage(): Promise<JSX.Element> {

return (
<RsvpExperience
alreadySubmitted={status.rsvpStatus === "confirmed"}
alreadySubmitted={status.rsvpStatus === "confirmed"}
confirmBy={confirmBy.toISOString()}
/>
);
Expand Down
10 changes: 0 additions & 10 deletions apps/app-portal/src/app/api/auth/[...nextauth]/route.ts

This file was deleted.

2 changes: 1 addition & 1 deletion apps/app-portal/src/app/api/v1/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ export async function GET(req: NextRequest) {

export async function POST() {
return NextResponse.json({ error: "error 501" }, { status: 501 });
}
}
10 changes: 8 additions & 2 deletions apps/app-portal/src/app/api/v1/uploads/sign/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// POST --> returns signed upload URL for GCS; stub returns 501

import { createSignedUploadUrl, InvalidUploadError } from "@/lib/uploads/service";
import {
createSignedUploadUrl,
InvalidUploadError,
} from "@/lib/uploads/service";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
Expand All @@ -12,7 +15,10 @@ export async function POST(request: Request) {

try {
const { uploadId, uploadUrl, expiresAt } = await createSignedUploadUrl({
userId, filename, mime, size,
userId,
filename,
mime,
size,
});
return NextResponse.json({ uploadId, uploadUrl: uploadUrl, expiresAt });
} catch (err) {
Expand Down
7 changes: 7 additions & 0 deletions apps/app-portal/src/app/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//NextAuth handler, email provider + Mongo adapter wired up
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth/config";

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };
11 changes: 8 additions & 3 deletions apps/app-portal/src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import useIsMobile from "@repo/util/hooks/useIsMobile";
import { SessionProvider } from "next-auth/react";
import React, { createContext } from "react";

export const MobileContext = createContext({ isMobile: false });
Expand All @@ -9,8 +10,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile();

return (
<MobileContext.Provider value={{ isMobile }}>
{children}
</MobileContext.Provider>
// basePath MUST match the auth route location (/auth). Without this, the
// next-auth/react client (signIn/signOut/useSession) posts to /api/auth → 404.
<SessionProvider basePath="/auth">
<MobileContext.Provider value={{ isMobile }}>
{children}
</MobileContext.Provider>
</SessionProvider>
);
}
28 changes: 13 additions & 15 deletions apps/app-portal/src/components/auth/SignInForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//email input form, calls signIn("email")
import React, { useEffect, useState } from "react";
import Image from "next/image";
import { signIn } from "next-auth/react";
import { isAdminEmail } from "@/lib/auth/roles";
import icon from "@/app/icon.ico";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
Expand Down Expand Up @@ -32,21 +34,17 @@ export function SignInForm() {
setStatus("loading");

try {
//TODO: wire to the real endpoint
const res = await fetch("/api/auth/signin/email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() }),
// signIn() handles CSRF, form-encoding, and the redirect for us.
// redirect:false → we drive the UI state ourselves instead of navigating.
// The callbackUrl is baked into the magic link; the admin layout
// re-checks the role server-side, so this is routing, not authorization.
const res = await signIn("email", {
email: email.trim(),
redirect: false,
callbackUrl: isAdminEmail(email) ? "/admin" : "/dashboard",
});

// rate-limit -> toast
if (res.status === 429) {
toast.error("Too many attempts. Please wait a minute and try again.");
setStatus("idle");
return;
}

if (!res.ok) {
if (res?.error) {
toast.error(
"Something went wrong sending your sign-in link. Please try again.",
);
Expand All @@ -56,7 +54,7 @@ export function SignInForm() {

setStatus("sent");
} catch {
// network failure (fetch threw err)
// network failure (signIn threw)
toast.error("Network error. Check your connection and try again.");
setStatus("idle");
}
Expand All @@ -69,7 +67,7 @@ export function SignInForm() {
{/*icon*/}
<Image src={icon} alt="HBP Logo" width={64} height={64} />
{/*title*/}
<p className={"mt-4 text-black text-2xl"}>HackBeanpot</p>
<p className={"mt-4 text-black text-2xl"}>Login to HackBeanpot!</p>

{/*input area*/}
<div className={"w-[250px]"}>
Expand Down
2 changes: 1 addition & 1 deletion apps/app-portal/src/components/auth/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function UserMenu({ email }: UserMenuProps): JSX.Element {
async function handleSignOut() {
setOpen(false);
// TODO: wire to NextAuth — signOut({ callbackUrl: "/" }) once next-auth is installed.
await fetch("/api/auth/signout", { method: "POST" }).catch(() => {});
await fetch("/auth/signout", { method: "POST" }).catch(() => {});
window.location.href = "/";
}

Expand Down
8 changes: 8 additions & 0 deletions apps/app-portal/src/lib/auth/adapter.ts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
//Mongo adapter instance
import { MongoDBAdapter } from "@next-auth/mongodb-adapter";
import { mongoClientPromise } from "@/lib/db";

// Stores users, accounts, sessions, and the magic-link verification tokens
// that EmailProvider depends on.
export const authAdapter = MongoDBAdapter(mongoClientPromise, {
databaseName: process.env.MONGO_SERVER_DBNAME,
});
43 changes: 43 additions & 0 deletions apps/app-portal/src/lib/auth/config.ts
Original file line number Diff line number Diff line change
@@ -1 +1,44 @@
//NextAuth options (providers, callbacks, session strategy)

import type { NextAuthOptions } from "next-auth";
import MainEmailProvider from "./email-transport";
import { authAdapter } from "./adapter";
import { isAdminEmail } from "./roles";

export const authOptions: NextAuthOptions = {
adapter: authAdapter,
providers: [MainEmailProvider],
secret: process.env.NEXTAUTH_SECRET,
session: { strategy: "jwt" },

pages: {
signIn: "/auth/signin",
verifyRequest: "/auth/verify-request",
error: "/auth/error",
},

callbacks: {
// Gatekeeper: return false here to reject a sign-in.
async signIn() {
return true;
},
// Runs when the JWT (the cookie's contents) is created/updated.
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.isAdmin = isAdminEmail(user.email);
}
return token;
},
// Runs when app code reads the session; shapes what the app sees.
async session({ session, token }) {
if (session.user) {
(session.user as { id?: string; isAdmin?: boolean }).id =
token.id as string;
(session.user as { id?: string; isAdmin?: boolean }).isAdmin =
token.isAdmin === true;
}
return session;
},
},
};
50 changes: 50 additions & 0 deletions apps/app-portal/src/lib/auth/email-template.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!--
Magic-link sign-in email for the HackBeanpot application portal.
Placeholders ({{...}}) are filled in by html() in email-transport.ts.
IMPORTANT: {{url}} is the unique signed link NextAuth generates per request —
it must come from the `url` param, never be hardcoded.
-->
<body style="margin: 0; padding: 0;">
<table
style="width:100%; border:0; border-spacing:20px; background: {{mainBackground}}; max-width: 600px; margin: auto; border-radius: 10px; font-family: Helvetica, Arial, sans-serif;">
<tr>
<td style="text-align: center; padding: 10px 0;">
<!-- Logo. alt text acts as the wordmark fallback when images are blocked. -->
<img src="{{logoUrl}}" alt="HackBeanpot" width="180" style="display: block; border: 0; margin: 0 auto;" />
</td>
</tr>
<tr>
<td style="text-align:center; padding: 10px 0; font-size: 16px; line-height: 22px; color: {{textColor}};">
Click the button below to sign in to the HackBeanpot application portal.
</td>
</tr>
<tr>
<td style="text-align:center; padding: 20px 0;">
<table style="border:0; margin: 0 auto;">
<tr>
<td style="text-align:center; border-radius: 5px; background:{{buttonBackground}}">
<a href="{{url}}" target="_blank"
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: {{buttonText}}; text-decoration: none; border-radius: 5px; padding: 12px 28px; border: 1px solid {{buttonBorder}}; display: inline-block; font-weight: bold;">
Sign in to HackBeanpot
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td
style="text-align:center; padding: 0 0 10px; font-size: 13px; line-height: 20px; color: {{textColor}};">
<!-- Fallback plaintext link: works even if the button doesn't render. -->
Or copy and paste this link into your browser:<br />
<a href="{{url}}" style="color: {{buttonBackground}}; word-break: break-all;">{{url}}</a>
</td>
</tr>
<tr>
<td
style="text-align:center; padding: 0 0 10px; font-size: 14px; line-height: 22px; color: {{textColor}};">
If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</body>
Loading