diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index b6285e6..ce3b55e 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -8,19 +8,77 @@ import type { Metadata } from "next"; */ export const metadata: Metadata = { robots: { index: false, follow: false } }; -import AppTopNav from "@/components/patterns/app-nav"; +import { AppSidebar, AppTopBar, type AppNavSection } from "@/components/patterns/app-sidebar"; +import { SessionTimeout } from "@/components/patterns/session-timeout"; import { requireOnboardedUser } from "@/lib/auth/guards"; +import { sessionLimitsFor } from "@/lib/auth/session-policy"; +/** Builder links every authenticated user gets. */ +const BUILDER_ITEMS = [ + { href: "/dashboard", label: "Dashboard" }, + { href: "/dashboard/hackathons", label: "My Hackathons" }, + { href: "/dashboard/teams", label: "Teams" }, + { href: "/dashboard/winnings", label: "Winnings" }, + { href: "/dashboard/intros", label: "Intros" }, + { href: "/dashboard/notifications", label: "Alerts" }, +]; + +/** Role surfaces, shown only while the user holds the grant (RBAC mirrors lib/auth/rbac). */ +const ROLE_SECTIONS: { role: "ORGANIZER" | "JUDGE" | "HIRING"; title: string; items: { href: string; label: string }[] }[] = [ + { + role: "ORGANIZER", + title: "Organize", + items: [ + { href: "/organizer", label: "Overview" }, + { href: "/organizer/organization", label: "Organization" }, + { href: "/organizer/verification", label: "Verification" }, + { href: "/organizer/hackathons/new", label: "New Hackathon" }, + ], + }, + { + role: "JUDGE", + title: "Judge", + items: [{ href: "/judge", label: "Assignments" }], + }, + { + role: "HIRING", + title: "Hiring", + items: [ + { href: "/hiring", label: "Talent" }, + { href: "/hiring/requests", label: "Requests" }, + ], + }, +]; + +const ACCOUNT_ITEMS = [ + { href: "/dashboard/profile", label: "Profile" }, + { href: "/settings", label: "Settings" }, +]; + +/** + * The authenticated app shell: the marketing ruled frame with a sidebar + * column inside it (plan §13.3: sidebar ≥ lg). Below 1024px the rules drop + * out and the top bar's drawer carries the same sections. + */ export default async function AppLayout({ children }: { children: React.ReactNode }) { const user = await requireOnboardedUser(); + const sections: AppNavSection[] = [ + { title: "Build", items: BUILDER_ITEMS }, + ...ROLE_SECTIONS.filter((section) => user.roles.includes(section.role)), + { title: "Account", items: ACCOUNT_ITEMS }, + ]; + const shellUser = { handle: user.handle }; + return ( -
- -
{children}
-
- HackVillage: trust is the product. -
+
+ +
+ +
{children}
+
HackVillage: trust is the product.
+
+
); } diff --git a/app/(auth)/signin/page.tsx b/app/(auth)/signin/page.tsx index 7e53935..73b9ec0 100644 --- a/app/(auth)/signin/page.tsx +++ b/app/(auth)/signin/page.tsx @@ -8,6 +8,8 @@ export const metadata: Metadata = { title: "Sign In" }; const NOTICES: Record = { registered: "Account created, check your email for the verification link.", reset: "Password updated, sign in with your new password.", + signedout: "You're signed out on every device.", + expired: "For your security, you were signed out. Sign in again to continue.", }; const OAUTH_ERROR_NOTICES: Record = { @@ -23,10 +25,20 @@ const OAUTH_ERROR_NOTICES: Record = { export default async function SignInPage({ searchParams, }: { - searchParams: Promise<{ registered?: string; reset?: string; error?: string }>; + searchParams: Promise<{ + registered?: string; + reset?: string; + signedout?: string; + expired?: string; + error?: string; + }>; }) { const params = await searchParams; - const notice = (params.registered && NOTICES.registered) || (params.reset && NOTICES.reset); + const notice = + (params.registered && NOTICES.registered) || + (params.reset && NOTICES.reset) || + (params.signedout && NOTICES.signedout) || + (params.expired && NOTICES.expired); const errorNotice = params.error && (OAUTH_ERROR_NOTICES[params.error] ?? "Something went wrong with that sign-in, try again."); diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 79539fe..cc64a25 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { notFound } from "next/navigation"; /** * Private surface — not search-facing. Crawlers that follow links here should @@ -8,37 +9,53 @@ import type { Metadata } from "next"; */ export const metadata: Metadata = { robots: { index: false, follow: false } }; -import { notFound } from "next/navigation"; - +import { AppSidebar, AppTopBar, type AppNavSection } from "@/components/patterns/app-sidebar"; +import { SessionTimeout } from "@/components/patterns/session-timeout"; import { requireSurface } from "@/lib/auth/guards"; -import { ThemeSwitcher } from "@/components/patterns/theme-switcher"; +import { sessionLimitsFor } from "@/lib/auth/session-policy"; + +/** The admin surfaces (plan §7.8), one section in the shared shell. */ +const ADMIN_SECTIONS: AppNavSection[] = [ + { + title: "Platform", + items: [ + { href: "/admin", label: "Snapshot" }, + { href: "/admin/users", label: "Users" }, + { href: "/admin/hackathons", label: "Hackathons" }, + { href: "/admin/kyb", label: "KYB Review" }, + { href: "/admin/organizations", label: "Organizations" }, + { href: "/admin/payments", label: "Payment Ops" }, + { href: "/admin/trust", label: "Trust" }, + { href: "/admin/disputes", label: "Disputes" }, + ], + }, +]; /** - * Basic admin (Phase 2 scope — plan §7.8). ADMIN is a DB-granted role; there - * is deliberately no self-service path to it. 2FA + full admin console - * arrive with Phase 9 hardening. + * Admin (plan §7.8) on the same ruled sidebar shell as the (app) surfaces. + * ADMIN is a DB-granted role; there is deliberately no self-service path to + * it. 2FA + full admin console arrive with Phase 9 hardening. */ export default async function AdminLayout({ children }: { children: React.ReactNode }) { const user = await requireSurface("admin"); if (!user.roles.includes("ADMIN")) notFound(); + const shellUser = { handle: user.handle }; + return ( -
-
-
- - HackVillage{" "} - - admin - - -
- signed in as @{user.handle} - -
-
-
-
{children}
+
+ +
+ +
{children}
+
HackVillage: trust is the product.
+
+
); } diff --git a/app/globals.css b/app/globals.css index 756b415..0c6f504 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4168,3 +4168,287 @@ input::-ms-reveal, input::-ms-clear { display: none; } + +/* + * Authenticated app shell (app/(app)/layout.tsx, app/admin/layout.tsx, + * components/patterns/app-sidebar.tsx): the marketing ruled frame, same + * width, edge and hairline side rules, with the sidebar as its first column. + * The sidebar's brand cell and the content's top bar share one height, so a + * single hairline runs across the frame like the site header's. Nav + * sections and the active link use rules and a tick, not rounded pills. + * Below 1024px the rules drop out (as on the marketing pages) and the top + * bar's drawer carries the nav. + */ +.app-shell { + --app-line: color-mix(in srgb, var(--color-ink) 10%, transparent); + --app-sidebar-width: 256px; + width: min(100% - 2 * var(--frame-edge), var(--frame-max)); + min-height: 100dvh; + margin-inline: auto; + border-inline: 1px solid var(--app-line); +} +@media (min-width: 1024px) { + .app-shell { + display: grid; + grid-template-columns: var(--app-sidebar-width) minmax(0, 1fr); + } +} +@media (max-width: 1023px) { + .app-shell { + border-inline-color: transparent; + } +} + +/* ── Sidebar (≥1024px) ───────────────────────────────────────────────── */ +.app-sidebar { + display: none; +} +@media (min-width: 1024px) { + .app-sidebar { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + height: 100dvh; + border-right: 1px solid var(--app-line); + } +} +.app-sidebar-brand { + display: flex; + flex-shrink: 0; + align-items: center; + height: var(--site-header-height); + padding-inline: var(--frame-gutter); + border-bottom: 1px solid var(--app-line); +} +.app-sidebar-scroll { + flex: 1; + overflow-y: auto; + /* Scrolling the sidebar must never chain into the page beside it. */ + overscroll-behavior: contain; +} + +.app-brand { + display: inline-flex; + align-items: center; + gap: 10px; +} +.app-brand-note { + padding-left: 10px; + border-left: 1px solid var(--app-line, color-mix(in srgb, var(--color-ink) 10%, transparent)); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-muted); +} + +/* Nav sections: each a ruled band; the rules run edge to edge. */ +.app-nav-section { + padding-block: 16px; +} +.app-nav-section + .app-nav-section { + border-top: 1px solid var(--app-line, color-mix(in srgb, var(--color-ink) 10%, transparent)); +} +.app-nav-title { + padding-inline: var(--frame-gutter); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-muted); +} +.app-nav-section ul { + margin-top: 6px; +} + +/* One nav link, full width. The current page (aria-current, so sight and + screen readers agree) gets a tint and a brand tick on the left edge. */ +.app-nav-link { + position: relative; + display: block; + padding: 8px var(--frame-gutter); + font-size: 14px; + font-weight: 600; + color: var(--color-nav-idle); + transition: + color 150ms ease, + background-color 150ms ease; +} +.app-nav-link:hover { + color: var(--color-ink); + background: color-mix(in srgb, var(--color-ink) 4%, transparent); +} +.app-nav-link[aria-current="page"] { + color: var(--color-nav-active); + background: color-mix(in srgb, var(--color-brand) 8%, transparent); +} +.app-nav-link[aria-current="page"]::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 2px; + background: var(--color-nav-active); +} +.app-nav-link:focus-visible { + outline: 2px solid var(--color-brand); + outline-offset: -2px; +} + +/* ── Content column ──────────────────────────────────────────────────── */ +.app-content { + display: flex; + min-width: 0; + min-height: 100dvh; + flex-direction: column; +} +.app-topbar { + position: sticky; + top: 0; + z-index: 50; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 12px; + height: var(--site-header-height); + padding-inline: var(--frame-gutter); + border-bottom: 1px solid var(--app-line); + background: color-mix(in srgb, var(--color-paper) 92%, transparent); + backdrop-filter: blur(10px); +} +.app-topbar-mobile { + display: flex; + align-items: center; + gap: 4px; +} +@media (min-width: 1024px) { + /* The sidebar carries the brand and the nav here. */ + .app-topbar-mobile { + visibility: hidden; + } +} +.app-topbar-actions { + display: flex; + align-items: center; + gap: 8px; +} +.app-main { + flex: 1; + padding: clamp(24px, 3vw, 40px) var(--frame-gutter) 64px; +} +.app-foot { + padding: 14px var(--frame-gutter); + border-top: 1px solid var(--app-line); + text-align: center; + font-size: 12px; + color: var(--color-muted); +} + +.app-menu-button { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 999px; + color: var(--color-ink); +} +.app-menu-button:hover { + background: color-mix(in srgb, var(--color-ink) 6%, transparent); +} + +/* ── Drawer (<1024px), portaled to body ──────────────────────────────── */ +.app-drawer-overlay { + position: fixed; + inset: 0; + z-index: 70; + background: color-mix(in srgb, var(--color-ink) 45%, transparent); + opacity: 0; + visibility: hidden; + pointer-events: none; + touch-action: none; + transition: + opacity 220ms ease, + visibility 0s linear 220ms; +} +.app-drawer-overlay-open { + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: + opacity 220ms ease, + visibility 0s; +} +.app-drawer-panel { + position: fixed; + inset: 0 auto 0 0; + z-index: 71; + display: flex; + flex-direction: column; + width: min(300px, 84vw); + height: 100dvh; + border-right: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); + background: var(--color-paper); + transform: translateX(-100%); + transition: transform 280ms cubic-bezier(0.65, 0, 0.35, 1); + overflow-y: auto; + overscroll-behavior: contain; +} +.app-drawer-panel-open { + transform: translateX(0); +} +.app-drawer-head { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + height: var(--site-header-height); + padding-inline: var(--frame-gutter) 8px; + border-bottom: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + .app-drawer-overlay, + .app-drawer-panel, + .app-nav-link { + transition: none; + } +} + +/* + * Inside the app shell, cards follow the ruled frame too: square, flat and + * outlined with the same hairline as the frame. Unlayered on purpose (like + * the auth form slot) so it outranks the Card's radius and shadow utilities. + * Buttons, badges and inputs keep their own shapes, as on marketing pages. + */ +.app-main .rounded-card { + border-radius: 0; + border-color: color-mix(in srgb, var(--color-ink) 10%, transparent); +} +.app-main .shadow-card, +.app-main .rounded-card.shadow-card { + box-shadow: none; +} + +/* Inactivity warning (components/patterns/session-timeout.tsx). */ +.session-timeout { + position: fixed; + right: var(--frame-gutter); + bottom: var(--frame-gutter); + left: var(--frame-gutter); + z-index: 80; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + max-width: 560px; + margin-inline: auto; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--color-ink) 12%, transparent); + background: var(--color-surface); + box-shadow: var(--shadow-card); + font-size: 14px; + color: var(--color-ink); +} diff --git a/components/patterns/app-nav-link.tsx b/components/patterns/app-nav-link.tsx deleted file mode 100644 index eb2e16b..0000000 --- a/components/patterns/app-nav-link.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import type { ReactNode } from "react"; - -import { isActivePath } from "@/lib/nav"; -import { cn } from "@/lib/utils"; - -/** - * App chrome nav link: marks itself aria-current="page" when it points at the - * page being viewed, and the active styling keys off that state so what you - * see and what screen readers announce can never disagree. /dashboard matches - * exactly (its siblings live under it); other links also match sub-pages. - */ -export function AppNavLink({ href, children }: { href: string; children: ReactNode }) { - const pathname = usePathname(); - const active = href === "/dashboard" ? pathname === href : isActivePath(pathname, href); - return ( - - {children} - - ); -} diff --git a/components/patterns/app-nav.tsx b/components/patterns/app-nav.tsx deleted file mode 100644 index 2afc2bd..0000000 --- a/components/patterns/app-nav.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import Link from "next/link"; - -import { AppNavLink } from "@/components/patterns/app-nav-link"; -import { UserMenu } from "@/components/patterns/user-menu"; -import { ThemeSwitcher } from "@/components/patterns/theme-switcher"; -import { cn } from "@/lib/utils"; - -/** - * Authenticated app chrome (mobile-first): brand link, role-aware primary - * nav, and the user menu. The (app) layout wraps every protected surface; - * layouts under it call the stricter requireSurface guards. - */ -export default function AppTopNav({ - user, -}: { - user: { name?: string | null; handle: string; roles: string[] }; -}) { - const links: { href: string; label: string }[] = [ - { href: "/dashboard", label: "Dashboard" }, - { href: "/dashboard/hackathons", label: "My Hackathons" }, - { href: "/dashboard/teams", label: "Teams" }, - { href: "/dashboard/winnings", label: "Winnings" }, - { href: "/dashboard/intros", label: "Intros" }, - { href: "/dashboard/notifications", label: "Alerts" }, - ]; - - if (user.roles.includes("ORGANIZER")) { - links.push({ href: "/organizer", label: "Organize" }); - } - if (user.roles.includes("JUDGE")) { - links.push({ href: "/judge", label: "Judge" }); - } - if (user.roles.includes("HIRING")) { - links.push({ href: "/hiring", label: "Talent" }); - } - links.push( - { href: "/dashboard/profile", label: "Profile" }, - { href: "/settings", label: "Settings" }, - ); - - return ( -
-
- - {/* eslint-disable-next-line @next/next/no-img-element -- animated brand lockup, no static/SVG source */} - HackVillage - - - - -
- - -
-
-
- ); -} diff --git a/components/patterns/app-sidebar.tsx b/components/patterns/app-sidebar.tsx new file mode 100644 index 0000000..5a50061 --- /dev/null +++ b/components/patterns/app-sidebar.tsx @@ -0,0 +1,217 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { createPortal } from "react-dom"; +import { useEffect, useState } from "react"; +import { Menu, X } from "lucide-react"; + +import { ThemeSwitcher } from "@/components/patterns/theme-switcher"; +import { UserMenu } from "@/components/patterns/user-menu"; +import { isActivePath } from "@/lib/nav"; +import { cn } from "@/lib/utils"; + +export interface AppNavItem { + href: string; + label: string; +} + +export interface AppNavSection { + title: string; + items: AppNavItem[]; +} + +export interface AppShellUser { + handle: string; +} + +/** Links whose siblings live under them path-wise, so they only match exactly. */ +const EXACT_HREFS = new Set(["/dashboard", "/admin"]); + +function useLinkActive(href: string): boolean { + const pathname = usePathname(); + if (EXACT_HREFS.has(href)) return pathname === href; + return isActivePath(pathname, href); +} + +function AppNavLink({ href, label, onNavigate }: AppNavItem & { onNavigate?: () => void }) { + const active = useLinkActive(href); + return ( + + {label} + + ); +} + +function AppBrand({ surfaceLabel }: { surfaceLabel?: string }) { + return ( + + {/* eslint-disable-next-line @next/next/no-img-element -- animated brand lockup, no static/SVG source */} + HackVillage + {surfaceLabel ? {surfaceLabel} : null} + + ); +} + +function AppNavSections({ + sections, + navLabel, + onNavigate, +}: { + sections: AppNavSection[]; + navLabel: string; + onNavigate?: () => void; +}) { + return ( + + ); +} + +/** + * Desktop sidebar: the left column of the ruled app frame (hidden below + * 1024px, where the top bar's drawer takes over). Its brand cell is the same + * height as the top bar, so one hairline runs across the whole frame. + */ +export function AppSidebar({ + sections, + navLabel, + surfaceLabel, +}: { + sections: AppNavSection[]; + navLabel: string; + surfaceLabel?: string; +}) { + return ( + + ); +} + +/** + * The content column's top bar, aligned with the sidebar's brand cell. It + * always carries the theme switcher and the user menu; below 1024px it also + * shows the brand and a hamburger that opens the same sections in a drawer. + * The drawer + overlay are portaled to document.body: the bar has + * `backdrop-filter`, which makes it the containing block for `position: + * fixed` descendants (the same trap the marketing MobileNav documents). + */ +export function AppTopBar({ + sections, + navLabel, + user, + surfaceLabel, +}: { + sections: AppNavSection[]; + navLabel: string; + user: AppShellUser; + surfaceLabel?: string; +}) { + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!open) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", onKeyDown); + document.documentElement.classList.add("overflow-hidden"); + + return () => { + document.removeEventListener("keydown", onKeyDown); + document.documentElement.classList.remove("overflow-hidden"); + }; + }, [open]); + + return ( +
+
+ + +
+
+ + +
+ + {mounted + ? createPortal( + <> +
+ ); +} diff --git a/components/patterns/session-timeout.tsx b/components/patterns/session-timeout.tsx new file mode 100644 index 0000000..90be52a --- /dev/null +++ b/components/patterns/session-timeout.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; + +/** Shared across tabs, so activity in one tab keeps the others signed in. */ +const ACTIVITY_KEY = "hv:last-activity"; +const ACTIVITY_EVENTS = ["pointerdown", "keydown", "scroll", "touchstart"] as const; +/** How often activity is reported to the server (keeps its idle clock within a minute). */ +const PING_EVERY_MS = 60 * 1000; +const WARN_BEFORE_MS = 2 * 60 * 1000; +const CHECK_EVERY_MS = 10 * 1000; +const SIGNED_OUT_URL = "/signin?expired=1"; + +function readSharedActivity(): number { + try { + return Number(window.localStorage.getItem(ACTIVITY_KEY)) || 0; + } catch { + return 0; + } +} + +function writeSharedActivity(at: number): void { + try { + window.localStorage.setItem(ACTIVITY_KEY, String(at)); + } catch { + // Private mode or blocked storage: this tab still tracks its own activity. + } +} + +/** Reading the session refreshes it server-side; an empty answer means it already ended. */ +async function pingSession(): Promise { + try { + const response = await fetch("/api/auth/session", { cache: "no-store" }); + if (!response.ok) return true; // A failed request isn't proof the session ended. + const session: unknown = await response.json(); + return typeof session === "object" && session !== null && "user" in session; + } catch { + return true; + } +} + +/** + * Inactivity sign-out for the signed-in app (limits in lib/auth/session-policy). + * The server enforces the limit on its own; this keeps it informed while the + * user is active, warns two minutes before an idle sign-out, and moves an + * unattended screen to the sign-in page so account details don't stay up. + */ +export function SessionTimeout({ idleMs }: { idleMs: number }) { + const lastActivity = useRef(0); + const lastPing = useRef(0); + const [warning, setWarning] = useState(false); + + const signOutForInactivity = useCallback(() => { + window.location.assign(SIGNED_OUT_URL); + }, []); + + const recordActivity = useCallback(() => { + const now = Date.now(); + lastActivity.current = now; + writeSharedActivity(now); + setWarning(false); + if (now - lastPing.current < PING_EVERY_MS) return; + lastPing.current = now; + void pingSession().then((alive) => { + if (!alive) signOutForInactivity(); + }); + }, [signOutForInactivity]); + + useEffect(() => { + const now = Date.now(); + lastActivity.current = now; + lastPing.current = now; // The page load itself just refreshed the session. + writeSharedActivity(now); + + for (const name of ACTIVITY_EVENTS) { + window.addEventListener(name, recordActivity, { passive: true }); + } + const timer = window.setInterval(() => { + const latest = Math.max(lastActivity.current, readSharedActivity()); + const remaining = latest + idleMs - Date.now(); + if (remaining <= 0) signOutForInactivity(); + else setWarning(remaining <= WARN_BEFORE_MS); + }, CHECK_EVERY_MS); + + return () => { + for (const name of ACTIVITY_EVENTS) window.removeEventListener(name, recordActivity); + window.clearInterval(timer); + }; + }, [idleMs, recordActivity, signOutForInactivity]); + + if (!warning) return null; + + return ( +
+

You've been inactive for a while, so you'll be signed out in two minutes.

+ +
+ ); +} diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index b7e657b..88439a7 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState, useState } from "react"; +import { useActionState, useState, useTransition } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -10,6 +10,7 @@ import { PasswordInput } from "@/components/ui/password-input"; import { changePasswordAction, deactivateAccountAction, + signOutEverywhereAction, type AccountActionState, } from "@/lib/account/actions"; @@ -31,6 +32,7 @@ export function AccountSettings({ {} ); const [confirming, setConfirming] = useState(false); + const [signingOut, startSignOut] = useTransition(); return (
@@ -69,7 +71,10 @@ export function AccountSettings({ Change Password - At least 10 characters, with a letter and a number. + + At least 10 characters, with a letter and a number. You'll be signed out on every + device and sign in again with the new password. +
@@ -98,6 +103,22 @@ export function AccountSettings({ + + Sessions + + Signed in on a shared or lost device? Sign out everywhere ends every session, this one + included. Changing or resetting your password does the same. + + + + Danger Zone diff --git a/db/migrations/9_session_version/migration.sql b/db/migrations/9_session_version/migration.sql new file mode 100644 index 0000000..1d8c324 --- /dev/null +++ b/db/migrations/9_session_version/migration.sql @@ -0,0 +1,3 @@ +-- Session revocation counter (see User.sessionVersion in db/schema.prisma). +-- Additive with a default: existing sessions are issued under version 0. +ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0; diff --git a/db/schema.prisma b/db/schema.prisma index 7081e2e..b29963a 100644 --- a/db/schema.prisma +++ b/db/schema.prisma @@ -143,6 +143,10 @@ model User { updatedAt DateTime @updatedAt // Soft delete: anonymize on account deletion, payout obligations remain. deletedAt DateTime? + // Session revocation: every session token carries the version it was + // issued under; bumping it (password change/reset, "sign out everywhere", + // deactivation) ends every existing session on its next request. + sessionVersion Int @default(0) accounts Account[] sessions Session[] diff --git a/lib/account/actions.ts b/lib/account/actions.ts index 678c6e6..e7c97fd 100644 --- a/lib/account/actions.ts +++ b/lib/account/actions.ts @@ -7,6 +7,7 @@ import { signOut } from "@/lib/auth"; import { accountDeactivatedEmail, passwordChangedEmail } from "@/lib/auth/mail-templates"; import { passwordSchema } from "@/lib/auth/password-policy"; import { requireUser } from "@/lib/auth/guards"; +import { revokeAllSessions } from "@/lib/auth/session-version"; import { prisma } from "@/lib/db"; import { sendMail } from "@/lib/ports/mail"; import { rateLimit } from "@/lib/rate-limit"; @@ -43,14 +44,28 @@ export async function changePasswordAction( if (!valid) return { error: "Your current password doesn't match." }; const { hash } = await import("@node-rs/argon2"); + // The new password and the session revocation land together: every + // session, including one an attacker may hold, ends now. await prisma.user.update({ where: { id: user.id }, - data: { passwordHash: await hash(parsed.data.newPassword) }, + data: { + passwordHash: await hash(parsed.data.newPassword), + sessionVersion: { increment: 1 }, + }, }); await sendMail({ to: record.email, ...passwordChangedEmail() }); - return { message: "Password updated." }; + // This device's session ended with the rest; sign in again with the new password. + await signOut({ redirectTo: "/signin?reset=1" }); + return {}; +} + +/** Ends every session on every device (this one included) and signs out. */ +export async function signOutEverywhereAction(): Promise { + const user = await requireUser(); + await revokeAllSessions(user.id); + await signOut({ redirectTo: "/signin?signedout=1" }); } /** @@ -79,6 +94,8 @@ export async function deactivateAccountAction( name: "Deleted account", email: `deleted+${user.id}@hackvillage.invalid`, avatarUrl: null, + // Ends sessions on every other device too, not just this one. + sessionVersion: { increment: 1 }, }, }); await tx.session.deleteMany({ where: { userId: user.id } }); diff --git a/lib/auth/actions.ts b/lib/auth/actions.ts index 184487f..60d1275 100644 --- a/lib/auth/actions.ts +++ b/lib/auth/actions.ts @@ -304,7 +304,9 @@ export async function resetPasswordAction( await prisma.$transaction([ prisma.user.update({ where: { email: record.identifier }, - data: { passwordHash: await hash(parsed.data.password) }, + // A reset ends every existing session: whoever prompted it may not be + // the only one holding a signed-in device. + data: { passwordHash: await hash(parsed.data.password), sessionVersion: { increment: 1 } }, }), prisma.verificationToken.deleteMany({ where: { identifier: record.identifier } }), ]); diff --git a/lib/auth/guards.ts b/lib/auth/guards.ts index 5cb1204..a0ed0be 100644 --- a/lib/auth/guards.ts +++ b/lib/auth/guards.ts @@ -1,4 +1,5 @@ import { redirect } from "next/navigation"; +import { cache } from "react"; import { auth } from "@/lib/auth"; import type { Role, SessionUserLike, Surface } from "@/lib/auth/rbac"; @@ -7,8 +8,8 @@ import { canAccessSurface, isOnboarded } from "@/lib/auth/rbac"; /** * Server-side guards (Phase 1). Layouts and server actions call these — * enforcement NEVER lives in the client. Middleware is deliberately unused: - * database sessions + Prisma don't belong on the edge runtime; the layout - * tree is the single, stronger gate. + * the session check reads Prisma (revocation, roles), which doesn't belong in + * middleware; the layout tree is the single, stronger gate. */ export interface CurrentUser extends SessionUserLike { @@ -18,7 +19,12 @@ export interface CurrentUser extends SessionUserLike { emailVerified: Date | null; } -export async function currentUser(): Promise { +/** + * The signed-in user for this request. Cached per request: a layout, its page + * and their server components all share one session check (two queries) + * instead of repeating it for every guard call. + */ +export const currentUser = cache(async (): Promise => { const session = await auth(); const user = session?.user; if (!user?.id || !user.email) return null; @@ -34,7 +40,7 @@ export async function currentUser(): Promise { emailVerified: user.emailVerified, deletedAt: null, }; -} +}); export async function requireUser(): Promise { const user = await currentUser(); diff --git a/lib/auth/index.ts b/lib/auth/index.ts index a4e169d..9636339 100644 --- a/lib/auth/index.ts +++ b/lib/auth/index.ts @@ -9,6 +9,8 @@ import { z } from "zod"; import { HackVillageAdapter } from "@/lib/auth/adapter"; import { pickVerifiedGithubEmail } from "@/lib/auth/github-email"; import { oauthSignInAllowed } from "@/lib/auth/oauth-linking"; +import { MAX_IDLE_SECONDS, sessionAge, sessionLimitsFor } from "@/lib/auth/session-policy"; +import { isSessionCurrent } from "@/lib/auth/session-version"; import { sendSignInAlert, sendWelcomeEmail } from "@/lib/auth/sign-in-alert"; import type { PrimaryRole, Role } from "@/lib/auth/rbac"; import { prisma } from "@/lib/db"; @@ -26,6 +28,27 @@ declare module "next-auth" { } } +/** Fresh per-request account state, re-read in the jwt callback. */ +interface SessionProfile { + handle: string; + primaryRole: PrimaryRole; + roles: Role[]; + onboardingCompletedAt: string | null; + emailVerified: string | null; +} + +/** Our fields on the Auth.js JWT (typed loosely by the library). */ +interface HackVillageToken { + id?: string; + /** User.sessionVersion this token was issued under (see session-version.ts). */ + sv?: number; + /** When the user signed in (ms), for the overall session limit. */ + authTime?: number; + /** The user's last request (ms), for the inactivity limit. */ + lastActive?: number; + profile?: SessionProfile; +} + const credentialsSchema = z.object({ email: z.string().email(), password: z.string().min(1), @@ -108,10 +131,13 @@ if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: HackVillageAdapter(prisma), // Auth.js v5 constraint: the Credentials provider requires JWT sessions. - // Revocation is preserved in practice — the session callback re-reads the - // user from the DB on every request and neuters deleted accounts (ADR-005 - // amended; documented in the session callback). - session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60 }, // 30 days + // Revocation still works: every token carries User.sessionVersion, and the + // jwt callback ends any session whose version is stale or whose account is + // gone (ADR-005 amended; see lib/auth/session-version.ts). + // The cookie lives as long as the longest inactivity window and slides on + // every request (middleware.ts refreshes it). The exact per-role idle and + // overall limits are enforced in the jwt callback (lib/auth/session-policy). + session: { strategy: "jwt", maxAge: MAX_IDLE_SECONDS }, pages: { signIn: "/signin", error: "/signin" }, trustHost: true, providers, @@ -135,21 +161,23 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ async signIn({ user, account, profile }) { return oauthSignInAllowed({ provider: account?.provider, email: user.email, profile }); }, - async jwt({ token, user }) { - // First sign-in: capture the DB id so the session callback never has to - // resolve by email (OAuth and credentials both provide it here). + async jwt({ token: rawToken, user }) { + const token = rawToken as typeof rawToken & HackVillageToken; + const now = Date.now(); + // First sign-in: capture the DB id so later requests never resolve by + // email (OAuth and credentials both provide it here), and start both + // session clocks. if (user?.id) { token.id = user.id; + token.authTime = now; + token.lastActive = now; } - return token; - }, - async session({ session, token }) { - const userId = token.id as string | undefined; - if (!userId) return session; + const userId = token.id; + if (!userId) return null; - // Re-read our fields on EVERY request (the adapter types don't carry - // them) and attach the role set. This is also the revocation check: - // deleted accounts get a neutered session until sign-out is forced. + // Every request re-reads the account (roles, onboarding, deletion) and + // checks the session version. Returning null ends the session: Auth.js + // treats the visitor as signed out and clears the cookie. const [dbUser, grants] = await Promise.all([ prisma.user.findUnique({ where: { id: userId }, @@ -159,29 +187,44 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ onboardingCompletedAt: true, emailVerified: true, deletedAt: true, + sessionVersion: true, }, }), - prisma.roleGrant.findMany({ - where: { userId }, - select: { role: true }, - }), + prisma.roleGrant.findMany({ where: { userId }, select: { role: true } }), ]); - session.user.id = userId; - if (!dbUser || dbUser.deletedAt) { - session.user.handle = ""; - session.user.primaryRole = "DEVELOPER"; - session.user.roles = []; - session.user.onboardingCompletedAt = null; - session.user.emailVerified = null; - return session; - } + // A fresh sign-in adopts the account's current version. + if (user?.id && dbUser) token.sv = dbUser.sessionVersion; + if (!dbUser || !isSessionCurrent(token.sv, dbUser)) return null; + + // Inactivity and overall limits (stricter for admins). Past either one + // the session ends and the user signs in again. + const roles = grants.map((grant) => grant.role); + if (sessionAge(token, sessionLimitsFor(roles), now) !== "active") return null; + token.lastActive = now; + + token.profile = { + handle: dbUser.handle, + primaryRole: dbUser.primaryRole, + roles, + onboardingCompletedAt: dbUser.onboardingCompletedAt?.toISOString() ?? null, + emailVerified: dbUser.emailVerified?.toISOString() ?? null, + }; + return token; + }, + session({ session, token: rawToken }) { + const token = rawToken as typeof rawToken & HackVillageToken; + const profile = token.profile; + if (!token.id || !profile) return session; - session.user.handle = dbUser.handle; - session.user.primaryRole = dbUser.primaryRole; - session.user.roles = grants.map((grant) => grant.role); - session.user.onboardingCompletedAt = dbUser.onboardingCompletedAt; - session.user.emailVerified = dbUser.emailVerified; + session.user.id = token.id; + session.user.handle = profile.handle; + session.user.primaryRole = profile.primaryRole; + session.user.roles = profile.roles; + session.user.onboardingCompletedAt = profile.onboardingCompletedAt + ? new Date(profile.onboardingCompletedAt) + : null; + session.user.emailVerified = profile.emailVerified ? new Date(profile.emailVerified) : null; return session; }, }, diff --git a/lib/auth/session-policy.ts b/lib/auth/session-policy.ts new file mode 100644 index 0000000..448e50c --- /dev/null +++ b/lib/auth/session-policy.ts @@ -0,0 +1,45 @@ +/** + * Session lifetime policy, set from NIST SP 800-63B-4 reauthentication rules: + * - Every signed-in account controls money (payout destinations, organizer + * deposits), so all accounts get the AAL2 limits: 1 hour of inactivity, + * 24 hours overall (§2.2.3). + * - Admins operate the whole platform, so they get the AAL3 limits: + * 15 minutes of inactivity, 12 hours overall (§2.3.3). + * After either limit the user signs in again. + */ + +const MINUTE_MS = 60 * 1000; +const HOUR_MS = 60 * MINUTE_MS; + +export interface SessionLimits { + idleMs: number; + absoluteMs: number; +} + +export const STANDARD_SESSION_LIMITS: SessionLimits = { idleMs: HOUR_MS, absoluteMs: 24 * HOUR_MS }; +export const ADMIN_SESSION_LIMITS: SessionLimits = { idleMs: 15 * MINUTE_MS, absoluteMs: 12 * HOUR_MS }; + +/** The longest idle window any session can have: the session cookie's lifetime. */ +export const MAX_IDLE_SECONDS = STANDARD_SESSION_LIMITS.idleMs / 1000; + +export function sessionLimitsFor(roles: readonly string[]): SessionLimits { + return roles.includes("ADMIN") ? ADMIN_SESSION_LIMITS : STANDARD_SESSION_LIMITS; +} + +export type SessionAge = "active" | "idle" | "expired"; + +/** + * Where a session stands. `authTime` is when the user last signed in, + * `lastActive` when they last made a request. Tokens from before this policy + * carry neither and count as expired: only a fresh sign-in records them. + */ +export function sessionAge( + token: { authTime?: number; lastActive?: number }, + limits: SessionLimits, + now: number +): SessionAge { + if (typeof token.authTime !== "number" || typeof token.lastActive !== "number") return "expired"; + if (now - token.authTime > limits.absoluteMs) return "expired"; + if (now - token.lastActive > limits.idleMs) return "idle"; + return "active"; +} diff --git a/lib/auth/session-version.ts b/lib/auth/session-version.ts new file mode 100644 index 0000000..43f20fb --- /dev/null +++ b/lib/auth/session-version.ts @@ -0,0 +1,35 @@ +import type { Prisma } from "@prisma/client"; + +import { prisma } from "@/lib/db"; + +/** + * Session revocation (JWT sessions can't be deleted server-side, so each token + * carries the User.sessionVersion it was issued under). Bumping the version + * ends every existing session for that user on its next request. + */ + +interface SessionOwner { + sessionVersion: number; + deletedAt: Date | null; +} + +/** A token is valid only for a live account still on the version it was issued under. */ +export function isSessionCurrent( + tokenVersion: number | undefined, + owner: SessionOwner | null +): boolean { + if (!owner || owner.deletedAt) return false; + // Tokens issued before versioning existed carry no version: they match 0. + return (tokenVersion ?? 0) === owner.sessionVersion; +} + +/** Ends every session the user has, on every device. Pass `tx` inside a transaction. */ +export async function revokeAllSessions( + userId: string, + tx: Prisma.TransactionClient = prisma +): Promise { + await tx.user.update({ + where: { id: userId }, + data: { sessionVersion: { increment: 1 } }, + }); +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..d17406b --- /dev/null +++ b/middleware.ts @@ -0,0 +1,46 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { auth } from "@/lib/auth"; + +/** + * Session upkeep only, never access control (the layout guards in + * lib/auth/guards.ts decide who sees what). Each real request from a + * signed-in visitor re-checks the session and slides its cookie, which is + * what makes the inactivity timeout measure inactivity. It runs on Node.js + * because the session check reads the database (revocation, roles). + */ +export const config = { + runtime: "nodejs", + matcher: [ + // Everything except static assets, Auth.js's own endpoints, payment + // webhooks and the health probe. + "/((?!_next/static|_next/image|favicon|branding/|images/|api/auth|api/webhooks|api/health).*)", + ], +}; + +const SESSION_COOKIE = /^(__Secure-)?authjs\.session-token(\.\d+)?$/; + +const refreshSession = auth(() => NextResponse.next()); + +/** + * Browser speculative prefetches (rel=prefetch, speculation rules) can fire + * without the user doing anything, so they aren't activity. App Router link + * prefetches can't be told apart here (Next.js strips its router headers + * before middleware), but they only happen as the user navigates or scrolls. + */ +function isSpeculativePrefetch(request: NextRequest): boolean { + return ( + request.headers.get("purpose") === "prefetch" || + request.headers.get("sec-purpose")?.includes("prefetch") === true + ); +} + +// Auth.js types its wrapper's second argument as a route context; the wrapped +// callback above never reads it, so an empty one is passed. +const NO_ROUTE_CONTEXT = { params: Promise.resolve({}) }; + +export default function middleware(request: NextRequest) { + const signedIn = request.cookies.getAll().some((cookie) => SESSION_COOKIE.test(cookie.name)); + if (!signedIn || isSpeculativePrefetch(request)) return NextResponse.next(); + return refreshSession(request, NO_ROUTE_CONTEXT); +} diff --git a/tests/integration/session-revocation.test.ts b/tests/integration/session-revocation.test.ts new file mode 100644 index 0000000..7fc39b4 --- /dev/null +++ b/tests/integration/session-revocation.test.ts @@ -0,0 +1,64 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { isSessionCurrent, revokeAllSessions } from "@/lib/auth/session-version"; +import { prisma } from "@/lib/db"; + +/** + * Session revocation against a live Postgres: a token issued before + * revokeAllSessions stops being current, and a fresh sign-in (which adopts + * the new version) is current again. + */ +const KEY = `sessrev-${Date.now().toString(36)}`; +let userId = ""; + +beforeAll(async () => { + const user = await prisma.user.create({ + data: { + email: `${KEY}@hackvillage.test`, + handle: KEY.slice(-24), + primaryRole: "DEVELOPER", + emailVerified: new Date(), + onboardingCompletedAt: new Date(), + }, + }); + userId = user.id; +}); + +afterAll(async () => { + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.$disconnect(); +}); + +async function owner() { + return prisma.user.findUnique({ + where: { id: userId }, + select: { sessionVersion: true, deletedAt: true }, + }); +} + +describe("session revocation", () => { + it("new accounts start at version 0, so existing tokens stay valid", async () => { + expect((await owner())?.sessionVersion).toBe(0); + expect(isSessionCurrent(0, await owner())).toBe(true); + }); + + it("revokeAllSessions ends tokens issued under the old version", async () => { + const issuedUnder = (await owner())!.sessionVersion; + await revokeAllSessions(userId); + expect(isSessionCurrent(issuedUnder, await owner())).toBe(false); + }); + + it("a fresh sign-in adopts the new version and is current", async () => { + const current = (await owner())!.sessionVersion; + expect(current).toBe(1); + expect(isSessionCurrent(current, await owner())).toBe(true); + }); + + it("works inside a transaction alongside the change that triggers it", async () => { + await prisma.$transaction(async (tx) => { + await tx.user.update({ where: { id: userId }, data: { name: "Renamed" } }); + await revokeAllSessions(userId, tx); + }); + expect((await owner())?.sessionVersion).toBe(2); + }); +}); diff --git a/tests/unit/session-policy.test.ts b/tests/unit/session-policy.test.ts new file mode 100644 index 0000000..d53bbcf --- /dev/null +++ b/tests/unit/session-policy.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + ADMIN_SESSION_LIMITS, + STANDARD_SESSION_LIMITS, + sessionAge, + sessionLimitsFor, +} from "@/lib/auth/session-policy"; + +const MIN = 60 * 1000; +const HOUR = 60 * MIN; +const NOW = Date.UTC(2026, 9, 1, 12); + +describe("session limits (NIST SP 800-63B-4)", () => { + it("gives every account the AAL2 limits: 1 hour idle, 24 hours overall", () => { + expect(sessionLimitsFor(["ORGANIZER", "JUDGE"])).toEqual({ idleMs: HOUR, absoluteMs: 24 * HOUR }); + }); + + it("gives admins the AAL3 limits: 15 minutes idle, 12 hours overall", () => { + expect(sessionLimitsFor(["ORGANIZER", "ADMIN"])).toEqual({ idleMs: 15 * MIN, absoluteMs: 12 * HOUR }); + }); +}); + +describe("sessionAge", () => { + it("is active inside both windows", () => { + const token = { authTime: NOW - 5 * HOUR, lastActive: NOW - 10 * MIN }; + expect(sessionAge(token, STANDARD_SESSION_LIMITS, NOW)).toBe("active"); + }); + + it("goes idle after the inactivity window, even mid-day", () => { + const token = { authTime: NOW - 2 * HOUR, lastActive: NOW - 61 * MIN }; + expect(sessionAge(token, STANDARD_SESSION_LIMITS, NOW)).toBe("idle"); + }); + + it("expires after the overall limit even for a constantly active user", () => { + const token = { authTime: NOW - 24 * HOUR - 1, lastActive: NOW - MIN }; + expect(sessionAge(token, STANDARD_SESSION_LIMITS, NOW)).toBe("expired"); + }); + + it("holds admins to their shorter windows", () => { + expect( + sessionAge({ authTime: NOW - HOUR, lastActive: NOW - 16 * MIN }, ADMIN_SESSION_LIMITS, NOW) + ).toBe("idle"); + expect( + sessionAge({ authTime: NOW - 13 * HOUR, lastActive: NOW - MIN }, ADMIN_SESSION_LIMITS, NOW) + ).toBe("expired"); + }); + + it("treats tokens from before the policy as expired, forcing one fresh sign-in", () => { + expect(sessionAge({}, STANDARD_SESSION_LIMITS, NOW)).toBe("expired"); + }); +}); diff --git a/tests/unit/session-version.test.ts b/tests/unit/session-version.test.ts new file mode 100644 index 0000000..8a69756 --- /dev/null +++ b/tests/unit/session-version.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { isSessionCurrent } from "@/lib/auth/session-version"; + +describe("isSessionCurrent", () => { + it("accepts a token issued under the account's current version", () => { + expect(isSessionCurrent(3, { sessionVersion: 3, deletedAt: null })).toBe(true); + }); + + it("rejects a token once the version has been bumped (password change, sign out everywhere)", () => { + expect(isSessionCurrent(3, { sessionVersion: 4, deletedAt: null })).toBe(false); + }); + + it("treats tokens from before versioning as version 0", () => { + expect(isSessionCurrent(undefined, { sessionVersion: 0, deletedAt: null })).toBe(true); + expect(isSessionCurrent(undefined, { sessionVersion: 1, deletedAt: null })).toBe(false); + }); + + it("rejects sessions for deleted or missing accounts", () => { + expect(isSessionCurrent(0, { sessionVersion: 0, deletedAt: new Date() })).toBe(false); + expect(isSessionCurrent(0, null)).toBe(false); + }); +});