SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
5.2 KB · 117 lines tsx
Raw Blame History
1'use client';23import Link from 'next/link';4import { usePathname } from 'next/navigation';5import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';6import { Logo } from '@/components/chrome/Logo';7import { TimeToggle } from '@/components/chrome/TimeToggle';8import { AdminError, adminFetch, getAdminToken, setAdminToken } from '@/lib/admin-fetch';910const NAV: [string, string][] = [11  ['/admin', 'Overview'],12  ['/admin/targets', 'Targets'],13  ['/admin/probes', 'Probes'],14  ['/admin/config', 'Scoring config'],15  ['/admin/baselines', 'Baselines'],16  ['/admin/raw', 'Raw explorer'],17  ['/admin/incidents', 'Incidents review'],18  ['/admin/annotations', 'Annotations'],19  ['/admin/replay', 'Replay'],20  ['/admin/boost', 'Boost'],21];2223const AuthCtx = createContext<{ authed: boolean; logout: () => void }>({ authed: false, logout: () => {} });24export const useAdminAuth = () => useContext(AuthCtx);2526/** Token gate: the token lives in sessionStorage only and is validated against /api/admin/overview. */27export function AdminShell({ children }: { children: ReactNode }) {28  const [state, setState] = useState<'checking' | 'locked' | 'ok'>('checking');29  const [input, setInput] = useState('');30  const [err, setErr] = useState<string | null>(null);31  const path = usePathname();3233  const verify = useCallback(async (token: string) => {34    setAdminToken(token);35    try {36      await adminFetch('/overview');37      setState('ok');38      setErr(null);39    } catch (e) {40      setAdminToken('');41      setState('locked');42      setErr(e instanceof AdminError && e.status === 401 ? 'Invalid token.' : 'Admin API unreachable.');43    }44  }, []);4546  useEffect(() => {47    const t = getAdminToken();48    // Defer to a microtask so no state is set synchronously inside the effect body.49    void Promise.resolve().then(() => (t ? verify(t) : setState('locked')));50  }, [verify]);5152  const logout = useCallback(() => {53    setAdminToken('');54    setState('locked');55  }, []);5657  return (58    <AuthCtx.Provider value={{ authed: state === 'ok', logout }}>59      <div className="flex min-h-screen flex-col">60        <header className="border-b border-line">61          <div className="mx-auto flex h-[var(--header-h)] max-w-[1600px] items-center gap-4 px-3 sm:px-5">62            <Link href="/admin" className="flex items-center gap-2 text-[14px] font-medium tracking-tight text-ink">63              <Logo size={20} color="var(--warn)" />64              InternetPressure <span className="label text-warn">admin</span>65            </Link>66            <div className="ml-auto flex items-center gap-3">67              <TimeToggle className="hidden sm:inline-flex" />68              <Link href="/" className="hidden text-[11.5px] text-ink-2 hover:text-ink sm:inline">69                public site →70              </Link>71              {state === 'ok' && (72                <button type="button" onClick={logout} className="rounded-[3px] border border-line px-2 py-1 text-[11px] text-ink-2 hover:text-ink">73                  lock74                </button>75              )}76            </div>77          </div>78          {state === 'ok' && (79            <nav aria-label="Admin" className="scroll-x mx-auto flex max-w-[1600px] gap-1 px-3 sm:px-5">80              {NAV.map(([href, label]) => {81                const active = href === '/admin' ? path === '/admin' : path.startsWith(href);82                return (83                  <Link key={href} href={href} aria-current={active ? 'page' : undefined} className={`whitespace-nowrap px-2 py-2 text-[11.5px] tracking-[0.04em] ${active ? 'text-ink' : 'text-ink-2 hover:text-ink'}`} style={active ? { boxShadow: 'inset 0 -1px 0 var(--warn)' } : undefined}>84                    {label}85                  </Link>86                );87              })}88            </nav>89          )}90        </header>91        <main className="mx-auto w-full max-w-[1600px] flex-1 px-3 py-5 sm:px-5">92          {state === 'checking' && <p className="text-[12px] text-ink-3">Checking token…</p>}93          {state === 'locked' && (94            <form95              className="mx-auto mt-[12vh] max-w-[380px]"96              onSubmit={(e) => {97                e.preventDefault();98                if (input.trim()) void verify(input.trim());99              }}100            >101              <p className="label">Restricted</p>102              <h1 className="mt-1 text-[22px] font-medium tracking-tight">Admin console</h1>103              <p className="mt-1 text-[12.5px] text-ink-2">Enter the admin token. It is kept in this tab&apos;s session storage only and sent as X-IP-Admin-Token.</p>104              <input type="password" value={input} onChange={(e) => setInput(e.target.value)} autoFocus autoComplete="off" placeholder="token" className="num mt-4 h-10 w-full rounded-[4px] border border-line bg-panel px-3 text-[14px] text-ink placeholder:text-ink-3" aria-label="Admin token" />105              {err && <p className="mt-2 text-[12px] text-bad">{err}</p>}106              <button type="submit" className="mt-3 h-9 w-full rounded-[4px] bg-warn text-[13px] font-medium text-bg hover:opacity-90">107                Unlock108              </button>109            </form>110          )}111          {state === 'ok' && children}112        </main>113      </div>114    </AuthCtx.Provider>115  );116}117