SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
3.4 KB · 85 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { cookies } from 'next/headers';4import { redirect } from 'next/navigation';5import { ADMIN_COOKIE, adminConfigured, cookieValueFor, isAdmin, tokenMatches } from '@/lib/admin/auth';67export const metadata: Metadata = { title: 'Admin', robots: { index: false, follow: false } };8export const dynamic = 'force-dynamic';910const ADMIN_NAV = [11  { href: '/admin', label: 'Overview' },12  { href: '/admin/connectors', label: 'Connectors' },13  { href: '/admin/unresolved', label: 'Unresolved labels' },14  { href: '/admin/trace', label: 'Trace' },15  { href: '/admin/rankings', label: 'Rankings' },16];1718/**19 * Admin console (§140-141, §253). Token via `?token=` once → httpOnly cookie (sha256 of the token)20 * compared to ADMIN_TOKEN. Wrong or missing token renders a login hint instead of the console.21 */22export default async function AdminLayout({ children }: { children: React.ReactNode }) {23  if (!adminConfigured()) {24    return (25      <div className="py-10">26        <p className="ci-kicker">Admin</p>27        <h1 className="text-3xl">Admin console disabled</h1>28        <p className="mt-2 text-[14px] text-ink-2">Set ADMIN_TOKEN in the environment to enable the console.</p>29      </div>30    );31  }32  // Token exchange (`?token=` → cookie + redirect) is handled by src/proxy.ts before this renders.33  const authed = await isAdmin();34  if (!authed) {35    return (36      <div className="py-10">37        <p className="ci-kicker">Admin</p>38        <h1 className="text-3xl">Sign in</h1>39        <p className="mt-2 max-w-xl text-[14px] text-ink-2">Append <code className="ci-mono">?token=&lt;ADMIN_TOKEN&gt;</code> to an admin URL once; a session cookie (hash of the token, httpOnly) is then set and the token is removed from the URL.</p>40        <form action={login} className="mt-4 flex max-w-md gap-2">41          <input name="token" type="password" placeholder="ADMIN_TOKEN" className="flex-1 border border-rule-strong bg-white px-3 py-1.5 text-[14px] outline-none focus:border-accent" aria-label="Admin token" />42          <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-[14px] text-paper hover:bg-ink-2">43            Sign in44          </button>45        </form>46      </div>47    );48  }49  return (50    <div className="pt-6">51      <div className="flex flex-wrap items-baseline justify-between gap-3 border-b border-rule pb-2">52        <p className="ci-kicker">Admin console · not indexed</p>53        <nav aria-label="Admin" className="flex flex-wrap gap-4 text-[13.5px]">54          {ADMIN_NAV.map((n) => (55            <Link key={n.href} href={n.href} className="text-ink-2 no-underline hover:text-accent">56              {n.label}57            </Link>58          ))}59          <form action={logout}>60            <button type="submit" className="text-ink-3 hover:text-danger">61              Sign out62            </button>63          </form>64        </nav>65      </div>66      {children}67    </div>68  );69}7071async function login(formData: FormData) {72  'use server';73  const token = String(formData.get('token') ?? '');74  if (tokenMatches(token)) {75    (await cookies()).set(ADMIN_COOKIE, cookieValueFor(token), { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 12 });76  }77  redirect('/admin');78}7980async function logout() {81  'use server';82  (await cookies()).delete(ADMIN_COOKIE);83  redirect('/admin');84}85