diff --git a/apps/app-portal/src/app/(admin)/layout.tsx b/apps/app-portal/src/app/(admin)/layout.tsx index 10552a96..31336bcd 100644 --- a/apps/app-portal/src/app/(admin)/layout.tsx +++ b/apps/app-portal/src/app/(admin)/layout.tsx @@ -21,8 +21,6 @@ export default async function AdminLayout({ redirect("/dashboard"); } - const email = user.email ?? ""; - return (
@@ -30,7 +28,7 @@ export default async function AdminLayout({

Admin Portal

- +
{children}
diff --git a/apps/app-portal/src/app/(applicant)/layout.tsx b/apps/app-portal/src/app/(applicant)/layout.tsx index 9a2d4421..06c42d6e 100644 --- a/apps/app-portal/src/app/(applicant)/layout.tsx +++ b/apps/app-portal/src/app/(applicant)/layout.tsx @@ -12,8 +12,6 @@ export default function ApplicantLayout({ }: { children: React.ReactNode; }): JSX.Element { - const email = "applicant@example.com"; - return (
@@ -54,7 +52,7 @@ export default function ApplicantLayout({ > Application - +
diff --git a/apps/app-portal/src/app/api/v1/user/route.ts b/apps/app-portal/src/app/api/v1/user/route.ts index 5b2c78c1..2d03a57a 100644 --- a/apps/app-portal/src/app/api/v1/user/route.ts +++ b/apps/app-portal/src/app/api/v1/user/route.ts @@ -1,6 +1,20 @@ //GET current session user; returns 401 if no session import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/auth/guards"; export async function GET() { - return NextResponse.json({ error: "not implemented" }, { status: 501 }); + try { + const user = (await requireUser()) as { + email?: string | null; + id?: string; + isAdmin?: boolean; + }; + return NextResponse.json({ + email: user.email ?? null, + id: user.id, + isAdmin: user.isAdmin ?? false, + }); + } catch { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } } diff --git a/apps/app-portal/src/app/auth/signin/page.tsx b/apps/app-portal/src/app/auth/signin/page.tsx index ec82f52f..ac1c9ca7 100644 --- a/apps/app-portal/src/app/auth/signin/page.tsx +++ b/apps/app-portal/src/app/auth/signin/page.tsx @@ -1,13 +1,16 @@ -//magic-link email entry form -"use client"; +//magic-link email entry — skips straight to /dashboard if already signed in import React from "react"; - +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth/session"; import { SignInForm } from "@/components/auth/SignInForm"; +import {isAdminEmail} from "@/lib/auth/roles.ts"; + +export default async function Page(): Promise { + // read cookie - see if valid session in DB - if so, automatically redir user to logged in part + const session = await getSession(); + if (session?.user) { + redirect(isAdminEmail(session.user.email) ? "/admin" : "/dashboard"); + } -export default function Page(): JSX.Element { - return ( - <> - - - ); + return ; } diff --git a/apps/app-portal/src/components/auth/SignInForm.tsx b/apps/app-portal/src/components/auth/SignInForm.tsx index c99e6957..d2ef54aa 100644 --- a/apps/app-portal/src/components/auth/SignInForm.tsx +++ b/apps/app-portal/src/components/auth/SignInForm.tsx @@ -1,3 +1,4 @@ +"use client" //email input form, calls signIn("email") import React, { useEffect, useState } from "react"; import Image from "next/image"; @@ -102,10 +103,14 @@ export function SignInForm() { ) : status === "loading" ? (

Loading{".".repeat(dotCount)} - {" ".repeat(4 - dotCount)} + {" ".repeat(4 - dotCount)}

) : status === "sent" ? ( -

+

{/*todo: change the green*/} Check your email for a sign-in link!

diff --git a/apps/app-portal/src/components/auth/UserMenu.tsx b/apps/app-portal/src/components/auth/UserMenu.tsx index e1fc2040..ad0d3acc 100644 --- a/apps/app-portal/src/components/auth/UserMenu.tsx +++ b/apps/app-portal/src/components/auth/UserMenu.tsx @@ -1,13 +1,12 @@ "use client"; //avatar + sign-out dropdown for header import React, { useEffect, useRef, useState } from "react"; +import { signOut } from "next-auth/react"; +import useCurrentUser from "@/lib/auth/useCurrentUser"; -interface UserMenuProps { - /** Signed-in user's email. Shown in the dropdown; first letter is the avatar. */ - email: string; -} - -export default function UserMenu({ email }: UserMenuProps): JSX.Element { +export default function UserMenu(): JSX.Element { + const { user } = useCurrentUser(); + const email = user?.email ?? ""; const [open, setOpen] = useState(false); const containerRef = useRef(null); @@ -40,9 +39,8 @@ 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("/auth/signout", { method: "POST" }).catch(() => {}); - window.location.href = "/"; + // hits /auth/signout, NextAuth automatically invalidates the session + await signOut({ callbackUrl: "/" }); } return ( diff --git a/apps/app-portal/src/lib/auth/config.ts b/apps/app-portal/src/lib/auth/config.ts index 54d41de9..15b93bfc 100644 --- a/apps/app-portal/src/lib/auth/config.ts +++ b/apps/app-portal/src/lib/auth/config.ts @@ -9,7 +9,7 @@ export const authOptions: NextAuthOptions = { adapter: authAdapter, providers: [MainEmailProvider], secret: process.env.NEXTAUTH_SECRET, - session: { strategy: "jwt" }, + session: { strategy: "database", maxAge: 30 * 24 * 60 * 60 }, pages: { signIn: "/auth/signin", @@ -22,21 +22,12 @@ export const authOptions: NextAuthOptions = { 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 }) { + async session({ session, user }) { if (session.user) { - (session.user as { id?: string; isAdmin?: boolean }).id = - token.id as string; + (session.user as { id?: string; isAdmin?: boolean }).id = user.id; (session.user as { id?: string; isAdmin?: boolean }).isAdmin = - token.isAdmin === true; + isAdminEmail(user.email); } return session; }, diff --git a/apps/app-portal/src/lib/auth/guards.ts b/apps/app-portal/src/lib/auth/guards.ts index 7102573c..c5242e6b 100644 --- a/apps/app-portal/src/lib/auth/guards.ts +++ b/apps/app-portal/src/lib/auth/guards.ts @@ -1,6 +1,10 @@ //requireUser() and requireAdmin() helpers for route handlers import { getSession } from "./session"; +/** + * Check if current user is signed in and signed in as (basic) user + * @throws Error if unauthorized + */ export async function requireUser() { const session = await getSession(); if (!session?.user) { @@ -9,6 +13,10 @@ export async function requireUser() { return session.user; } +/** + * Check if current user is signed in, then checks if user is admin + * @throws Error if unauthorized or if not an admin + */ export async function requireAdmin() { const user = await requireUser(); if (!(user as { isAdmin?: boolean }).isAdmin) { diff --git a/apps/app-portal/src/lib/auth/useCurrentUser.ts b/apps/app-portal/src/lib/auth/useCurrentUser.ts new file mode 100644 index 00000000..081d9f5a --- /dev/null +++ b/apps/app-portal/src/lib/auth/useCurrentUser.ts @@ -0,0 +1,36 @@ +"use client"; +//client hook: fetches the current user from /api/v1/user; 401 -> null +import { useEffect, useState } from "react"; + +export interface CurrentUser { + email: string | null; + id?: string; + isAdmin?: boolean; +} + +export default function useCurrentUser() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + + fetch("/api/v1/user", { cache: "no-store" }) + .then((res) => (res.ok ? (res.json() as Promise) : null)) + .then((data) => { + if (active) setUser(data); + }) + .catch(() => { + if (active) setUser(null); + }) + .finally(() => { + if (active) setLoading(false); + }); + + return () => { + active = false; + }; + }, []); + + return { user, loading }; +}