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
72 changes: 65 additions & 7 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex min-h-dvh flex-col">
<AppTopNav user={{ name: user.name, handle: user.handle, roles: user.roles }} />
<main className="site-container flex-1 py-8">{children}</main>
<footer className="border-t border-ink/10 py-4 text-center text-xs text-muted">
HackVillage: trust is the product.
</footer>
<div className="app-shell">
<AppSidebar sections={sections} navLabel="App" />
<div className="app-content">
<AppTopBar sections={sections} navLabel="App" user={shellUser} />
<main className="app-main">{children}</main>
<footer className="app-foot">HackVillage: trust is the product.</footer>
</div>
<SessionTimeout idleMs={sessionLimitsFor(user.roles).idleMs} />
</div>
);
}
16 changes: 14 additions & 2 deletions app/(auth)/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const metadata: Metadata = { title: "Sign In" };
const NOTICES: Record<string, string> = {
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<string, string> = {
Expand All @@ -23,10 +25,20 @@ const OAUTH_ERROR_NOTICES: Record<string, string> = {
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.");
Expand Down
61 changes: 39 additions & 22 deletions app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<div className="flex min-h-dvh flex-col">
<header className="sticky top-0 z-50 shrink-0 bg-inverse text-on-inverse">
<div className="site-container flex h-16 items-center justify-between">
<span className="font-display text-lg font-bold">
Hack<span className="text-brand">Village</span>{" "}
<span className="text-xs font-medium tracking-widest text-on-inverse/50 uppercase">
admin
</span>
</span>
<div className="admin-header-actions flex items-center gap-3">
<span className="text-xs text-on-inverse/60">signed in as @{user.handle}</span>
<ThemeSwitcher />
</div>
</div>
</header>
<main className="site-container flex-1 py-8">{children}</main>
<div className="app-shell">
<AppSidebar sections={ADMIN_SECTIONS} navLabel="Admin" surfaceLabel="admin" />
<div className="app-content">
<AppTopBar
sections={ADMIN_SECTIONS}
navLabel="Admin"
user={shellUser}
surfaceLabel="admin"
/>
<main className="app-main">{children}</main>
<footer className="app-foot">HackVillage: trust is the product.</footer>
</div>
<SessionTimeout idleMs={sessionLimitsFor(user.roles).idleMs} />
</div>
);
}
Loading
Loading