From 8840502bd15a3c2d36e86dcab005fe0269b894bf Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Fri, 8 May 2026 03:50:41 +0000 Subject: [PATCH] Add protected admin dashboard --- README.md | 12 +- app/admin/page.tsx | 31 +++ app/api/admin/stats/route.ts | 24 ++ components/admin/AdminDashboard.tsx | 390 ++++++++++++++++++++++++++++ lib/adminSubmissions.ts | 123 +++++++++ 5 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/stats/route.ts create mode 100644 components/admin/AdminDashboard.tsx diff --git a/README.md b/README.md index 8b1edcf..315a28f 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ VPS / Node server notes: - Start with `npm run start`. - Set `PORT` if needed, for example `PORT=3000 npm run start`. - Use a reverse proxy such as Nginx or Caddy if exposing publicly. -- No database is required for this prototype. -- Data is local to the user’s browser. +- No database is required for participant-only local prototype testing. +- Data is local to the user’s browser unless optional server submission is explicitly configured. Docker server test: @@ -123,7 +123,7 @@ The prototype stores data locally in the browser. The JSON export can include: - Computed analytical metrics. - Completeness flags: `hasParticipantProfile`, `completedGame`, `completedGameRounds`, `hasPreRevealSurvey`, `hasSeenReveal`, `hasPostRevealSurvey`, and `isComplete`. -No backend is configured in this prototype. Data leaves the browser only if a user copies or downloads the export and shares it elsewhere. +Without optional server submission, data leaves the browser only if a user copies or downloads the export and shares it elsewhere. When server submission is enabled, completed exports are posted to the configured database for researcher download. ## Analytical metrics @@ -146,6 +146,12 @@ The metrics are simple derived values for prototype analysis. They should not be Protected admin export endpoints let a researcher retrieve server-submitted study data when `ENABLE_SERVER_SUBMISSION=true`, `DATABASE_URL`, and `ADMIN_EXPORT_TOKEN` are configured on the server. Keep `ADMIN_EXPORT_TOKEN` secret; requests must send it as a bearer token. These endpoints are intended for researcher use only and are not linked from the participant UI. +### Admin dashboard + +The browser dashboard is available at `/admin`. It requires `ADMIN_EXPORT_TOKEN` and asks the researcher to enter the token in the browser before loading data. By default, the dashboard keeps the entered token in `sessionStorage`; the optional “Remember for this browser” checkbox stores it in `localStorage` only when explicitly selected. + +The dashboard shows submission counts, first/last submission timestamps, high/low coverage counts, average computed metrics, and a recent-submissions table. It also allows JSON and CSV downloads from the browser. This is intentionally not a full authentication system: use it only behind HTTPS, and put `/admin` behind reverse proxy basic auth, a VPN, or another stronger access-control layer for real deployments. + JSON export example: ```bash diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..cf1fc7c --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { AdminDashboard } from "@/components/admin/AdminDashboard"; + +export const metadata: Metadata = { + title: "Admin dashboard | Hidden Cost Game", + robots: { + index: false, + follow: false, + }, +}; + +export default function AdminPage() { + return ( +
+
+ + ← Back to home + +
+

Research admin

+

Admin dashboard

+

+ Enter the server-side export token to view submission counts, recent records, and browser downloads. This page does not expose the configured token in source and is not linked from the participant flow. +

+
+
+ +
+ ); +} diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000..1b08faa --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,24 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateAdminRequest } from "@/lib/adminAuth.server"; +import { AdminSubmissionError, adminStatsJson, getAdminStats } from "@/lib/adminSubmissions"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const auth = validateAdminRequest(request); + if (!auth.ok) { + return auth.response; + } + + try { + const stats = await getAdminStats(); + return NextResponse.json(adminStatsJson(stats)); + } catch (error) { + if (error instanceof AdminSubmissionError) { + return NextResponse.json({ ok: false, error: error.message }, { status: error.status }); + } + + return NextResponse.json({ ok: false, error: "Unable to retrieve admin stats." }, { status: 500 }); + } +} diff --git a/components/admin/AdminDashboard.tsx b/components/admin/AdminDashboard.tsx new file mode 100644 index 0000000..c5c1b63 --- /dev/null +++ b/components/admin/AdminDashboard.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; + +type AdminStatsResponse = { + ok: boolean; + error?: string; + totalSubmissions: number; + completedSubmissions: number; + highCoverageCount: number; + lowCoverageCount: number; + firstSubmissionAt: string | null; + lastSubmissionAt: string | null; + averageBurden: number; + averageCareAvoidance: number; + averageResponsibilityShift: number; + averageConstraintRecognitionShift: number; + averageProtestLegitimacyShift: number; + averageRuleCorrectionSupportShift: number; + averageRedistributionSupportShift: number; +}; + +type AdminSubmission = { + id: string; + sessionId: string; + submittedAt: string; + assignedHiddenProfile: string | null; + payload: unknown; +}; + +type AdminSubmissionPageResponse = { + ok: boolean; + error?: string; + items: AdminSubmission[]; + nextCursor?: string; +}; + +type DownloadKind = "json" | "csv"; + +const SESSION_TOKEN_KEY = "hidden-cost-game-admin-export-token"; +const REMEMBERED_TOKEN_KEY = "hidden-cost-game-admin-export-token-remembered"; + +const overviewCards = [ + ["Total submissions", "totalSubmissions"], + ["Completed submissions", "completedSubmissions"], + ["High coverage count", "highCoverageCount"], + ["Low coverage count", "lowCoverageCount"], + ["First submission", "firstSubmissionAt"], + ["Last submission", "lastSubmissionAt"], +] as const; + +const metricCards = [ + ["Average burden", "averageBurden"], + ["Average care avoidance", "averageCareAvoidance"], + ["Average responsibility shift", "averageResponsibilityShift"], + ["Average constraint recognition shift", "averageConstraintRecognitionShift"], + ["Average protest legitimacy shift", "averageProtestLegitimacyShift"], + ["Average rule correction support shift", "averageRuleCorrectionSupportShift"], + ["Average redistribution support shift", "averageRedistributionSupportShift"], +] as const; + +export function AdminDashboard() { + const [token, setToken] = useState(""); + const [rememberToken, setRememberToken] = useState(false); + const [stats, setStats] = useState(null); + const [submissions, setSubmissions] = useState([]); + const [error, setError] = useState(""); + const [status, setStatus] = useState(""); + const [copyStatus, setCopyStatus] = useState(null); + const curlBaseUrl = useMemo(() => (typeof window === "undefined" ? "https://your-domain.com" : window.location.origin), []); + const jsonCurl = `curl -H "Authorization: Bearer $ADMIN_EXPORT_TOKEN" \\\n "${curlBaseUrl}/api/admin/submissions?limit=500" \\\n -o submissions.json`; + const csvCurl = `curl -H "Authorization: Bearer $ADMIN_EXPORT_TOKEN" \\\n "${curlBaseUrl}/api/admin/submissions.csv" \\\n -o submissions.csv`; + + useEffect(() => { + const sessionToken = window.sessionStorage.getItem(SESSION_TOKEN_KEY); + const rememberedToken = window.localStorage.getItem(REMEMBERED_TOKEN_KEY); + const savedToken = sessionToken || rememberedToken || ""; + + setToken(savedToken); + setRememberToken(Boolean(rememberedToken)); + }, []); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const trimmedToken = token.trim(); + + if (!trimmedToken) { + setError("Enter the admin export token."); + return; + } + + setStatus("Loading dashboard…"); + setError(""); + setStats(null); + setSubmissions([]); + + try { + const [nextStats, nextSubmissions] = await Promise.all([ + fetchAdminJson("/api/admin/stats", trimmedToken), + fetchAdminJson("/api/admin/submissions?limit=20", trimmedToken), + ]); + + window.sessionStorage.setItem(SESSION_TOKEN_KEY, trimmedToken); + if (rememberToken) { + window.localStorage.setItem(REMEMBERED_TOKEN_KEY, trimmedToken); + } else { + window.localStorage.removeItem(REMEMBERED_TOKEN_KEY); + } + + setStats(nextStats); + setSubmissions(nextSubmissions.items ?? []); + setStatus("Dashboard loaded."); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Unable to load dashboard."); + setStatus(""); + } + } + + async function handleDownload(kind: DownloadKind) { + const trimmedToken = token.trim(); + const url = kind === "json" ? "/api/admin/submissions?limit=500" : "/api/admin/submissions.csv"; + const fileName = kind === "json" ? "submissions.json" : "submissions.csv"; + + setStatus(`Preparing ${kind.toUpperCase()} download…`); + setError(""); + + try { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${trimmedToken}` }, + }); + + if (!response.ok) { + await throwAdminFetchError(response); + } + + const blob = kind === "json" ? new Blob([JSON.stringify(await response.json(), null, 2)], { type: "application/json" }) : await response.blob(); + const href = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = fileName; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(href); + setStatus(`${kind.toUpperCase()} download ready.`); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : `Unable to download ${kind.toUpperCase()}.`); + setStatus(""); + } + } + + async function handleCopy(label: string, value: string) { + await navigator.clipboard.writeText(value); + setCopyStatus(label); + window.setTimeout(() => setCopyStatus(null), 1600); + } + + function handleForgetToken() { + window.sessionStorage.removeItem(SESSION_TOKEN_KEY); + window.localStorage.removeItem(REMEMBERED_TOKEN_KEY); + setToken(""); + setRememberToken(false); + setStats(null); + setSubmissions([]); + setStatus("Token removed from this browser."); + setError(""); + } + + return ( +
+
+
+
+ + setToken(event.target.value)} + className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-slate-900 shadow-sm outline-none transition focus:border-research-600 focus:ring-2 focus:ring-research-100" + placeholder="Paste ADMIN_EXPORT_TOKEN" + /> + +
+
+ + +
+
+ {error ?

{error}

: null} + {status ?

{status}

: null} +
+ + {stats ? ( + <> +
+

Overview

+
+ {overviewCards.map(([label, key]) => ( + + ))} +
+
+ +
+

Average metrics

+
+ {metricCards.map(([label, key]) => ( + + ))} +
+
+ +
+
+
+

Recent submissions

+

First 20 submissions sorted newest first.

+
+
+ + +
+
+
+ + + + {["submittedAt", "sessionId", "assignedHiddenProfile", "finalFinancialScore", "finalHealthScore", "burden", "careAvoidance", "responsibilityShift", "protestLegitimacyShift"].map((heading) => ( + + ))} + + + + {submissions.map((submission) => ( + + + + + + + + + + + + ))} + +
{heading}
{formatDate(submission.submittedAt)}{shortenSessionId(submission.sessionId)}{submission.assignedHiddenProfile || "—"}{formatUnknownNumber(getPath(submission.payload, ["gameSummary", "finalFinancialScore"]))}{formatUnknownNumber(getPath(submission.payload, ["gameSummary", "finalHealthScore"]))}{formatUnknownNumber(getPath(submission.payload, ["computedMetrics", "burden"]))}{formatUnknownNumber(getPath(submission.payload, ["computedMetrics", "careAvoidance"]))}{formatUnknownNumber(getPath(submission.payload, ["computedMetrics", "responsibilityShift"]))}{formatUnknownNumber(getPath(submission.payload, ["computedMetrics", "protestLegitimacyShift"]))}
+
+
+ +
+ handleCopy("json", jsonCurl)} /> + handleCopy("csv", csvCurl)} /> +
+ + ) : null} +
+ ); +} + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function CurlCard({ title, command, copied, onCopy }: { title: string; command: string; copied: boolean; onCopy: () => void }) { + return ( +
+
+

{title}

+ +
+
{command}
+
+ ); +} + +async function fetchAdminJson(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + await throwAdminFetchError(response); + } + + const data = (await response.json()) as T; + if (!data.ok) { + throw new Error(data.error || "Unable to load admin data."); + } + + return data; +} + +async function throwAdminFetchError(response: Response): Promise { + if (response.status === 401) { + throw new Error("Invalid admin token."); + } + + let message = response.status === 500 ? "Database is unavailable or admin export is not configured." : "Unable to load admin data."; + const contentType = response.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + const data = (await response.json()) as { error?: string }; + if (data.error) { + message = data.error; + } + } + + throw new Error(message); +} + +function formatStatValue(value: number | string | null): string { + if (typeof value === "number") { + return value.toLocaleString(); + } + + return formatDate(value); +} + +function formatUnknownNumber(value: unknown): string { + return typeof value === "number" && Number.isFinite(value) ? formatNumber(value) : "—"; +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 3 }).format(value); +} + +function formatDate(value: string | null): string { + if (!value) { + return "—"; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +function shortenSessionId(sessionId: string): string { + if (sessionId.length <= 12) { + return sessionId; + } + + return `${sessionId.slice(0, 8)}…${sessionId.slice(-4)}`; +} + +function getPath(value: unknown, path: string[]): unknown { + let current = value; + + for (const segment of path) { + if (!isRecord(current) || !(segment in current)) { + return undefined; + } + + current = current[segment]; + } + + return current; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/lib/adminSubmissions.ts b/lib/adminSubmissions.ts index 6fa8ded..7926237 100644 --- a/lib/adminSubmissions.ts +++ b/lib/adminSubmissions.ts @@ -20,6 +20,36 @@ export type AdminSubmissionPage = { nextCursor?: string; }; +export type AdminStats = { + totalSubmissions: number; + completedSubmissions: number; + highCoverageCount: number; + lowCoverageCount: number; + firstSubmissionAt: string | null; + lastSubmissionAt: string | null; + averageBurden: number; + averageCareAvoidance: number; + averageResponsibilityShift: number; + averageConstraintRecognitionShift: number; + averageProtestLegitimacyShift: number; + averageRuleCorrectionSupportShift: number; + averageRedistributionSupportShift: number; +}; + +const AVERAGE_METRIC_KEYS = [ + "burden", + "careAvoidance", + "responsibilityShift", + "constraintRecognitionShift", + "protestLegitimacyShift", + "ruleCorrectionSupportShift", + "redistributionSupportShift", +] as const; + +type AverageMetricKey = (typeof AVERAGE_METRIC_KEYS)[number]; + +type AverageAccumulator = Record; + type CursorPayload = { submittedAt: string; id: string; @@ -69,6 +99,65 @@ export async function listAdminSubmissions(searchParams: URLSearchParams): Promi }; } +export async function getAdminStats(): Promise { + assertDatabaseConfigured(); + + const prisma = getPrismaClient(); + const rows = await prisma.researchSubmission.findMany({ + orderBy: [{ submittedAt: "asc" }, { id: "asc" }], + select: { + id: true, + submittedAt: true, + assignedHiddenProfile: true, + payload: true, + }, + }); + + const averages = createAverageAccumulator(); + let completedSubmissions = 0; + let highCoverageCount = 0; + let lowCoverageCount = 0; + + for (const row of rows) { + const payload = row.payload; + const hiddenProfile = readString(row.assignedHiddenProfile) || readString(getPath(payload, ["assignedProfile", "hiddenProfile"])); + + if (hiddenProfile === "High coverage") { + highCoverageCount += 1; + } else if (hiddenProfile === "Low coverage") { + lowCoverageCount += 1; + } + + if (getPath(payload, ["completeness", "isComplete"]) === true) { + completedSubmissions += 1; + } + + for (const key of AVERAGE_METRIC_KEYS) { + const value = readNumber(getPath(payload, ["computedMetrics", key])); + if (value !== null) { + averages[key].sum += value; + averages[key].count += 1; + } + } + } + + return { + totalSubmissions: rows.length, + completedSubmissions, + highCoverageCount, + lowCoverageCount, + firstSubmissionAt: rows[0]?.submittedAt.toISOString() ?? null, + lastSubmissionAt: rows[rows.length - 1]?.submittedAt.toISOString() ?? null, + averageBurden: average(averages.burden), + averageCareAvoidance: average(averages.careAvoidance), + averageResponsibilityShift: average(averages.responsibilityShift), + averageConstraintRecognitionShift: average(averages.constraintRecognitionShift), + averageProtestLegitimacyShift: average(averages.protestLegitimacyShift), + averageRuleCorrectionSupportShift: average(averages.ruleCorrectionSupportShift), + averageRedistributionSupportShift: average(averages.redistributionSupportShift), + }; +} + export async function listAllAdminSubmissions(): Promise { assertDatabaseConfigured(); @@ -87,6 +176,13 @@ function assertDatabaseConfigured() { } } +export function adminStatsJson(stats: AdminStats) { + return { + ok: true, + ...stats, + }; +} + export function adminSubmissionPageJson(page: AdminSubmissionPage) { return { ok: true, @@ -258,6 +354,33 @@ function payloadColumn(header: string, path: string[]): CsvColumn { }; } +function createAverageAccumulator(): AverageAccumulator { + return AVERAGE_METRIC_KEYS.reduce((accumulator, key) => { + accumulator[key] = { sum: 0, count: 0 }; + return accumulator; + }, {} as AverageAccumulator); +} + +function average(metric: { sum: number; count: number }): number { + if (metric.count === 0) { + return 0; + } + + return roundMetric(metric.sum / metric.count); +} + +function roundMetric(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function readNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + function getPath(value: Prisma.JsonValue, path: string[]): unknown { let current: unknown = value;