-
- Queue pressure
-
-
-
- Kind
- Topic
- Priority
-
-
-
- {demoTickets.map((ticket) => (
-
-
- {ticket.kind}
-
- {ticket.topic}
- {ticket.priority}
-
- ))}
-
-
-
-
-
- Organizer rhythm
-
-
-
-
-
-
-
Every 15 minutes
-
- Clear escalated mentor/tech tickets before they age into
- event-wide blockers.
-
-
-
Live
-
-
-
-
-
-
-
Before judging
-
- Export teams and verify every group has a demo queue status.
-
-
-
Ready
-
+
+
+
+
+
+ {session
+ ? `Connected as ${session.user.globalName ?? session.user.username}`
+ : "Discord is not connected"}
+
+
+ Only servers you own or can manage are shown. Permissions are
+ checked again before every change.
+
+
+
+
+
+
Servers you manage
+ {session?.guilds.length ?? 0}
-
-
+
+
PipHackLup installed
+
+ {botApiReady && !installationStatusError ? installedCount : "—"}
+
+
+
+
Bot management
+ {authReady && botApiReady ? "Available" : "Needs setup"}
+
+
+
+
+
);
}
diff --git a/apps/web/app/dev-fixtures/control-room/page.tsx b/apps/web/app/dev-fixtures/control-room/page.tsx
new file mode 100644
index 0000000..4b17411
--- /dev/null
+++ b/apps/web/app/dev-fixtures/control-room/page.tsx
@@ -0,0 +1,102 @@
+import { CheckCircle2 } from "lucide-react";
+import { notFound } from "next/navigation";
+import { AppShell } from "@/components/AppShell";
+import { PageHeader } from "@/components/PageHeader";
+import {
+ ServerManager,
+ type ManagedServerView,
+} from "@/components/ServerManager";
+import type { DiscordSession } from "@/lib/discord-auth";
+
+const session: DiscordSession = {
+ user: {
+ id: "1512918151313231983",
+ username: "eventorganizer",
+ globalName: "Event Organizer",
+ },
+ guilds: [
+ {
+ id: "1512918151313231984",
+ name: "North Star Hackathon",
+ isOwner: true,
+ permissions: "32",
+ canManage: true,
+ },
+ {
+ id: "1512918151313231985",
+ name: "Weekend Builders",
+ isOwner: false,
+ permissions: "32",
+ canManage: true,
+ },
+ {
+ id: "1512918151313231986",
+ name: "Campus Demo Day",
+ isOwner: true,
+ permissions: "32",
+ canManage: true,
+ },
+ ],
+ issuedAt: Date.now(),
+};
+
+const servers: ManagedServerView[] = session.guilds.map((guild, index) => ({
+ ...guild,
+ installed: index === 0 ? true : index === 1 ? false : null,
+ installUrl:
+ "https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands",
+}));
+
+export default function ControlRoomFixture() {
+ assertDevelopmentFixture();
+ return (
+
+
+
+
+
+
+ Connected as Event Organizer
+
+ Only servers you own or can manage are shown. Permissions are
+ checked again before every change.
+
+
+
+
+
+
Servers you manage
+ 3
+
+
+
PipHackLup installed
+ 1
+
+
+
Bot management
+ Available
+
+
+
+
+
+ );
+}
+
+function assertDevelopmentFixture(): void {
+ if (
+ process.env.NODE_ENV !== "development" ||
+ process.env.PIPHACKLUP_UI_TEST_MODE !== "1"
+ ) {
+ notFound();
+ }
+}
diff --git a/apps/web/app/dev-fixtures/setup/page.tsx b/apps/web/app/dev-fixtures/setup/page.tsx
new file mode 100644
index 0000000..c75188c
--- /dev/null
+++ b/apps/web/app/dev-fixtures/setup/page.tsx
@@ -0,0 +1,75 @@
+import { ServerCog } from "lucide-react";
+import { notFound } from "next/navigation";
+import type { EventConfig } from "@piphacklup/core";
+import { SetupEditor } from "@/app/setup/SetupEditor";
+import { AppShell } from "@/components/AppShell";
+import { PageHeader } from "@/components/PageHeader";
+import type { DiscordSession, ManagedDiscordGuild } from "@/lib/discord-auth";
+
+const guild: ManagedDiscordGuild = {
+ id: "1512918151313231984",
+ name: "North Star Hackathon",
+ isOwner: true,
+ permissions: "32",
+ canManage: true,
+};
+
+const session: DiscordSession = {
+ user: {
+ id: "1512918151313231983",
+ username: "eventorganizer",
+ globalName: "Event Organizer",
+ },
+ guilds: [guild],
+ issuedAt: Date.now(),
+};
+
+const config: EventConfig = {
+ guildId: guild.id,
+ eventName: "North Star Hackathon",
+ onboardingMode: "gated",
+ teamSizeMin: 2,
+ teamSizeMax: 5,
+ queueKinds: ["mentor", "tech", "judging", "staff"],
+ roles: { participant: "1512918151313231986" },
+ channels: { helpDesk: "1512918151313231987" },
+ resources: { eventCategoryId: "1512918151313231988" },
+};
+
+export default function SetupFixture() {
+ assertDevelopmentFixture();
+ return (
+
+
+
+
+
+
+
Then run this in North Star Hackathon
+
/setup
+
+ PipHackLup will create or reconcile the saved workspace without
+ duplicating resources.
+
+
+
+
+ );
+}
+
+function assertDevelopmentFixture(): void {
+ if (
+ process.env.NODE_ENV !== "development" ||
+ process.env.PIPHACKLUP_UI_TEST_MODE !== "1"
+ ) {
+ notFound();
+ }
+}
diff --git a/apps/web/app/dev-fixtures/training/page.tsx b/apps/web/app/dev-fixtures/training/page.tsx
new file mode 100644
index 0000000..430b5fa
--- /dev/null
+++ b/apps/web/app/dev-fixtures/training/page.tsx
@@ -0,0 +1,98 @@
+import { BookOpenCheck } from "lucide-react";
+import { notFound } from "next/navigation";
+import type {
+ HackathonKnowledgeEntry,
+ KnowledgeAssistantSettings,
+} from "@piphacklup/core";
+import { AppShell } from "@/components/AppShell";
+import { PageHeader } from "@/components/PageHeader";
+import { TrainingConsole } from "@/app/training/TrainingConsole";
+import type { DiscordSession, ManagedDiscordGuild } from "@/lib/discord-auth";
+
+const guild: ManagedDiscordGuild = {
+ id: "1512918151313231984",
+ name: "North Star Hackathon",
+ isOwner: true,
+ permissions: "32",
+ canManage: true,
+};
+
+const session: DiscordSession = {
+ user: {
+ id: "1512918151313231983",
+ username: "eventorganizer",
+ globalName: "Event Organizer",
+ },
+ guilds: [guild],
+ issuedAt: Date.now(),
+};
+
+const entries: HackathonKnowledgeEntry[] = [
+ {
+ id: "know_fixture01",
+ guildId: guild.id,
+ title: "Where is participant check-in?",
+ answer: "Participant check-in is beside the main auditorium from 8:00 AM.",
+ tags: ["check-in", "registration"],
+ escalationTarget: "none",
+ createdBy: session.user.id,
+ createdAt: "2026-08-09T12:00:00.000Z",
+ updatedAt: "2026-08-09T12:00:00.000Z",
+ },
+];
+
+const settings: KnowledgeAssistantSettings = {
+ minConfidence: 45,
+ publicAnswers: true,
+};
+
+export default async function TrainingFixture({
+ searchParams,
+}: Readonly<{
+ searchParams: Promise<{ installation?: string }>;
+}>) {
+ assertDevelopmentFixture();
+ const { installation } = await searchParams;
+ const botInstallation = installation === "unknown" ? null : false;
+ return (
+
+
+
+
+
+ 1 saved answer
+
+ Changes apply only to North Star Hackathon. PipHackLup filters
+ instruction-override attempts before saving them.
+
+
+
+ {botInstallation === null
+ ? "Install status unavailable"
+ : "Bot not installed"}
+
+
+
+
+ );
+}
+
+function assertDevelopmentFixture(): void {
+ if (
+ process.env.NODE_ENV !== "development" ||
+ process.env.PIPHACKLUP_UI_TEST_MODE !== "1"
+ ) {
+ notFound();
+ }
+}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index a9fee06..dda046f 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -10,8 +10,8 @@
--button-text: #334155;
--button-hover-bg: #eef6fb;
--nav-text: #475569;
- --nav-bg: rgba(255, 255, 255, 0.72);
- --nav-border: rgba(47, 143, 216, 0.12);
+ --nav-bg: transparent;
+ --nav-border: transparent;
--nav-hover-bg: #eef6fb;
--nav-active-bg: #e0f2fe;
--nav-active-border: #8bd3ec;
@@ -49,6 +49,18 @@ html[data-dashboard-theme="dark"] .theme-label-light {
box-sizing: border-box;
}
+.visually-hidden {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
html,
body {
margin: 0;
@@ -89,8 +101,8 @@ textarea {
--button-text: #334155;
--button-hover-bg: #eef6fb;
--nav-text: #475569;
- --nav-bg: rgba(255, 255, 255, 0.72);
- --nav-border: rgba(47, 143, 216, 0.12);
+ --nav-bg: transparent;
+ --nav-border: transparent;
--nav-hover-bg: #eef6fb;
--nav-active-bg: #e0f2fe;
--nav-active-border: #8bd3ec;
@@ -120,10 +132,10 @@ html[data-dashboard-theme="dark"] .shell {
--button-text: #d9f7ff;
--button-hover-bg: #123651;
--nav-text: #b7d6e4;
- --nav-bg: rgba(13, 42, 66, 0.58);
- --nav-border: rgba(142, 231, 255, 0.11);
+ --nav-bg: transparent;
+ --nav-border: transparent;
--nav-hover-bg: #102f49;
- --nav-active-bg: linear-gradient(135deg, #153f5c, #0d2a42);
+ --nav-active-bg: #102f49;
--nav-active-border: #2f8fd8;
--nav-active-text: #f8f4df;
--field-bg: #071a2b;
@@ -135,6 +147,8 @@ html[data-dashboard-theme="dark"] .shell {
}
.sidebar {
+ display: flex;
+ flex-direction: column;
min-width: 0;
border-right: 1px solid var(--line);
background: var(--sidebar);
@@ -153,6 +167,7 @@ html[data-dashboard-theme="dark"] .shell {
display: flex;
align-items: center;
gap: 10px;
+ min-height: 44px;
font-weight: 800;
color: var(--ink);
}
@@ -162,11 +177,12 @@ html[data-dashboard-theme="dark"] .shell {
width: 36px;
height: 36px;
place-items: center;
- border: 1px solid #a8dcf0;
- border-radius: 8px;
- background: linear-gradient(135deg, #dff7ff, #2f8fd8 58%, #14213d);
+ border: 2px solid #8bd3ec;
+ border-radius: 7px;
+ background: #1e78b5;
color: white;
font-weight: 900;
+ box-shadow: 3px 3px 0 color-mix(in srgb, var(--ink) 22%, transparent);
}
.nav {
@@ -176,74 +192,77 @@ html[data-dashboard-theme="dark"] .shell {
margin-top: 26px;
}
-.nav a {
- position: relative;
+.nav a,
+.nav-more summary {
display: flex;
align-items: center;
gap: 10px;
- min-height: 42px;
+ min-height: 44px;
border: 1px solid var(--nav-border);
border-radius: 8px;
background: var(--nav-bg);
padding: 0 12px;
color: var(--nav-text);
- box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
- overflow: hidden;
- transform: translate3d(0, 0, 0);
+ box-shadow: none;
+ list-style: none;
+ cursor: pointer;
transition:
background 180ms ease,
border-color 180ms ease,
- box-shadow 180ms ease,
- color 180ms ease,
- transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
-}
-
-.nav a::before {
- position: absolute;
- inset: 0;
- content: "";
- background: radial-gradient(
- circle at 18% 20%,
- rgba(255, 255, 255, 0.46),
- transparent 34%
- );
- opacity: 0;
- transition: opacity 180ms ease;
+ color 180ms ease;
}
-.nav a svg,
-.nav a span {
- position: relative;
- z-index: 1;
+.nav-more summary::-webkit-details-marker {
+ display: none;
}
-.nav a:hover {
+.nav a:hover,
+.nav-more summary:hover {
background: var(--nav-hover-bg);
color: var(--ink);
- box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
- transform: translateY(-2px) scale(1.012);
-}
-
-.nav a:hover::before,
-.nav a.active::before {
- opacity: 1;
}
-.nav a:active {
- transform: translateY(0) scale(0.985);
+.nav a:active,
+.nav-more summary:active {
+ background: var(--soft-panel);
}
.nav a.active {
border-color: var(--nav-active-border);
background: var(--nav-active-bg);
color: var(--nav-active-text);
- box-shadow:
- 0 14px 28px rgba(47, 143, 216, 0.2),
- inset 0 1px 0 rgba(255, 255, 255, 0.24);
+ box-shadow: inset 3px 0 0 var(--blue);
}
.nav a.active svg {
- filter: drop-shadow(0 0 8px rgba(87, 199, 212, 0.45));
+ color: var(--blue);
+}
+
+.nav-more {
+ position: relative;
+}
+
+.nav-more-menu {
+ position: absolute;
+ z-index: 30;
+ right: 0;
+ bottom: calc(100% + 6px);
+ display: grid;
+ gap: 5px;
+ width: 210px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 7px;
+ box-shadow: 0 16px 44px rgba(2, 6, 23, 0.2);
+}
+
+.nav-more:not([open]) .nav-more-menu {
+ display: none;
+}
+
+.mobile-nav-only {
+ display: none !important;
}
.main {
@@ -253,7 +272,7 @@ html[data-dashboard-theme="dark"] .shell {
.page-surface {
min-width: 0;
- animation: page-settle 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
+ animation: page-settle 160ms ease-out;
transform-origin: 50% 18px;
}
@@ -299,7 +318,7 @@ html[data-dashboard-theme="dark"] .shell {
justify-content: center;
gap: 8px;
min-width: 0;
- min-height: 38px;
+ min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--button-bg);
@@ -320,12 +339,46 @@ html[data-dashboard-theme="dark"] .shell {
.button:hover {
background: var(--button-hover-bg);
color: var(--ink);
- box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
- transform: translateY(-1px);
+ box-shadow: none;
}
.button:active {
- transform: translateY(0) scale(0.98);
+ transform: translateY(1px);
+}
+
+.button:disabled {
+ opacity: 0.56;
+ cursor: not-allowed;
+ box-shadow: none;
+ transform: none;
+}
+
+.button.danger {
+ border-color: #be123c;
+ background: #be123c;
+ color: white;
+}
+
+.button.danger:hover {
+ border-color: #e11d48;
+ background: #e11d48;
+ color: white;
+}
+
+.button.danger-ghost {
+ border-color: color-mix(in srgb, var(--rose) 38%, var(--line));
+ color: var(--rose);
+}
+
+.button.danger-ghost:hover {
+ border-color: var(--rose);
+ background: color-mix(in srgb, var(--rose) 10%, var(--panel));
+ color: var(--rose);
+}
+
+:where(a, button, input, select, textarea):focus-visible {
+ outline: 3px solid color-mix(in srgb, var(--blue) 70%, white);
+ outline-offset: 3px;
}
.button.primary {
@@ -335,52 +388,44 @@ html[data-dashboard-theme="dark"] .shell {
}
.button.primary:hover {
- border-color: #2f8fd8;
- background: #2f8fd8;
+ border-color: #176a9f;
+ background: #176a9f;
color: white;
}
+.shell[data-theme="dark"] .button.danger-ghost,
+html[data-dashboard-theme="dark"] .button.danger-ghost {
+ color: #ff9db2;
+}
+
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
- min-height: 38px;
+ min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
- background:
- radial-gradient(
- circle at 18% 20%,
- rgba(255, 255, 255, 0.3),
- transparent 34%
- ),
- var(--button-bg);
+ background: var(--button-bg);
color: var(--button-text);
- box-shadow:
- 0 10px 22px rgba(47, 143, 216, 0.12),
- inset 0 1px 0 rgba(255, 255, 255, 0.18);
+ box-shadow: none;
font-weight: 800;
cursor: pointer;
transition:
background 180ms ease,
border-color 180ms ease,
- box-shadow 180ms ease,
- color 180ms ease,
- transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
+ color 180ms ease;
}
.theme-toggle:hover {
background: var(--button-hover-bg);
color: var(--ink);
- box-shadow:
- 0 14px 26px rgba(47, 143, 216, 0.18),
- inset 0 1px 0 rgba(255, 255, 255, 0.24);
- transform: translateY(-1px) scale(1.01);
+ box-shadow: none;
}
.theme-toggle:active {
- transform: translateY(0) scale(0.985);
+ transform: translateY(1px);
}
.grid {
@@ -434,6 +479,7 @@ html[data-dashboard-theme="dark"] .shell {
.table {
width: 100%;
+ min-width: 680px;
border-collapse: collapse;
table-layout: fixed;
font-size: 14px;
@@ -547,6 +593,139 @@ html[data-dashboard-theme="dark"] .shell {
margin-top: 16px;
}
+.training-context {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ border-block: 1px solid var(--line);
+ padding: 14px 0;
+}
+
+.training-context > svg {
+ color: var(--green);
+}
+
+.training-context > div {
+ display: grid;
+ gap: 3px;
+}
+
+.training-context strong {
+ color: var(--ink);
+}
+
+.training-context span:not(.badge) {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.training-notice {
+ grid-column: 1 / -1;
+ border-left: 3px solid var(--blue);
+ border-radius: 4px;
+ background: var(--status-bg);
+ padding: 10px 12px;
+ color: var(--foreground);
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.training-notice.error {
+ border-color: var(--rose);
+ background: color-mix(in srgb, var(--rose) 9%, var(--panel));
+}
+
+.training-help,
+.field-help {
+ margin: -5px 0 2px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.field-help {
+ margin: 0;
+ border-left: 2px solid var(--amber);
+ padding-left: 9px;
+}
+
+.training-library {
+ display: grid;
+ gap: 10px;
+}
+
+.training-library article {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px;
+ align-items: start;
+ border-top: 1px solid var(--line);
+ padding-top: 13px;
+}
+
+.training-library article:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.training-library h3,
+.training-library p {
+ margin: 0;
+}
+
+.training-library h3 {
+ color: var(--ink);
+ font-size: 15px;
+}
+
+.training-library p {
+ margin-top: 4px;
+ color: var(--answer-text);
+ line-height: 1.5;
+}
+
+.training-delete-confirm {
+ display: grid;
+ gap: 9px;
+ width: min(320px, 100%);
+ border: 1px solid color-mix(in srgb, var(--rose) 36%, var(--line));
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--rose) 7%, var(--panel));
+ padding: 11px;
+}
+
+.training-delete-confirm > p {
+ margin: 0;
+ color: var(--foreground);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.training-delete-confirm > div {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+
+.training-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 9px;
+}
+
+.training-tags span {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--soft-panel);
+ padding: 3px 8px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
.training-panel {
display: grid;
align-content: start;
@@ -571,6 +750,7 @@ html[data-dashboard-theme="dark"] .shell {
.field select,
.field textarea {
width: 100%;
+ min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--field-bg);
@@ -643,9 +823,11 @@ html[data-dashboard-theme="dark"] .shell {
}
.toggle-row {
+ min-height: 46px;
color: var(--button-text);
font-size: 14px;
font-weight: 700;
+ cursor: pointer;
}
.toggle-row input {
@@ -667,104 +849,1381 @@ html[data-dashboard-theme="dark"] .shell {
line-height: 1.5;
}
-.button.full {
- width: 100%;
+.skip-link {
+ position: fixed;
+ z-index: 100;
+ top: 10px;
+ left: 10px;
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+ border-radius: 8px;
+ background: var(--ink);
+ padding: 10px 14px;
+ color: var(--panel);
+ font-weight: 800;
+ transform: translateY(-160%);
}
-@media (max-width: 900px) {
- .shell {
- grid-template-columns: 1fr;
- }
-
- .sidebar {
- position: sticky;
- top: 0;
- z-index: 10;
- width: 100%;
- overflow: hidden;
- border-right: 0;
- border-bottom: 1px solid var(--line);
- }
-
- .sidebar-head {
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
- }
+.skip-link:focus {
+ transform: translateY(0);
+}
- .theme-toggle {
- width: auto;
- padding-inline: 12px;
- }
+.server-switcher {
+ display: grid;
+ gap: 7px;
+ margin-top: 18px;
+}
- .nav {
- grid-auto-flow: column;
- grid-auto-columns: max-content;
- width: 100%;
- max-width: 100%;
- overflow-x: auto;
- margin-top: 14px;
- padding-bottom: 2px;
- }
+.server-switcher > span {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+ text-transform: uppercase;
+}
- .nav a {
- min-height: 38px;
- white-space: nowrap;
- }
+.server-switcher select {
+ width: 100%;
+ min-height: 44px;
+ border: 1px solid var(--nav-active-border);
+ border-radius: 8px;
+ background: var(--field-bg);
+ padding: 0 34px 0 11px;
+ color: var(--ink);
+ font-weight: 800;
+}
- .topbar {
- display: grid;
- }
+.account-dock {
+ display: grid;
+ gap: 10px;
+ margin-top: auto;
+ padding-top: 20px;
+}
- .grid.metrics,
- .grid.two,
- .training-grid,
- .status-cards,
- .form-grid {
- grid-template-columns: 1fr;
- }
+.account-identity {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr);
+ gap: 10px;
+ align-items: center;
+ border-top: 1px solid var(--line);
+ padding-top: 16px;
+}
- .training-panel.wide {
- grid-column: auto;
- }
+.account-identity.signed-out {
+ color: var(--muted);
}
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 1ms !important;
- scroll-behavior: auto !important;
- transition-duration: 1ms !important;
- }
+.account-identity > span:last-child {
+ display: grid;
+ min-width: 0;
}
-@keyframes page-settle {
- from {
- opacity: 0;
- transform: translateY(8px) scale(0.992);
- }
+.account-identity strong,
+.account-identity small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
+.account-identity strong {
+ color: var(--ink);
+ font-size: 13px;
}
-.site {
- min-height: 100vh;
- background: #061423;
- color: white;
+.account-identity small {
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 11px;
}
-.hero {
- position: relative;
- min-height: 88vh;
- overflow: hidden;
- background: #061423;
- color: white;
+.account-action,
+.account-dock form {
+ width: 100%;
}
-.hero-bg {
+.auth-gate {
+ display: grid;
+ justify-items: start;
+ align-content: center;
+ max-width: 720px;
+ min-height: calc(100vh - 48px);
+ margin: 0 auto;
+ padding: 48px 24px;
+}
+
+.auth-gate-mark,
+.danger-mark {
+ display: grid;
+ width: 54px;
+ height: 54px;
+ place-items: center;
+ border: 1px solid var(--nav-active-border);
+ border-radius: 12px;
+ background: var(--nav-active-bg);
+ color: var(--nav-active-text);
+}
+
+.auth-gate h1 {
+ max-width: 620px;
+ margin: 4px 0 12px;
+ color: var(--ink);
+ font-size: clamp(32px, 5vw, 52px);
+ line-height: 1.05;
+}
+
+.auth-gate > p:not(.eyebrow, .auth-alert) {
+ max-width: 620px;
+ margin: 0 0 22px;
+ color: var(--muted);
+ font-size: 17px;
+ line-height: 1.6;
+}
+
+.auth-gate > small {
+ max-width: 560px;
+ margin-top: 16px;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.auth-alert,
+.dependency-alert {
+ width: 100%;
+ max-width: 620px;
+ border: 1px solid color-mix(in srgb, var(--amber) 46%, var(--line));
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--amber) 9%, var(--panel));
+ padding: 12px 14px;
+ color: var(--foreground);
+}
+
+.dependency-alert {
+ display: grid;
+ gap: 4px;
+}
+
+.dependency-alert span {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.mini-status {
+ display: flex;
+ align-items: flex-start;
+ gap: 9px;
+ line-height: 1.45;
+}
+
+.mini-status svg {
+ flex: 0 0 auto;
+ color: var(--blue);
+}
+
+.server-manager-section {
+ margin-top: 24px;
+}
+
+.server-manager-filters {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) minmax(180px, 260px);
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.server-search,
+.server-filter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 44px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--field-bg);
+ padding: 0 11px;
+ color: var(--muted);
+}
+
+.server-search input,
+.server-filter select {
+ width: 100%;
+ min-width: 0;
+ min-height: 42px;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--ink);
+}
+
+.server-search:focus-within,
+.server-filter:focus-within {
+ border-color: var(--blue);
+ outline: 3px solid color-mix(in srgb, var(--blue) 70%, white);
+ outline-offset: 3px;
+}
+
+.server-filter > span {
+ flex: 0 0 auto;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.filtered-empty {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ border: 1px dashed var(--line);
+ border-radius: 8px;
+ margin-top: 14px;
+ padding: 16px;
+ color: var(--muted);
+}
+
+.workspace-state,
+.workspace-restoring,
+.friendly-empty,
+.setup-command-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 22px;
+ box-shadow: var(--shadow-soft);
+}
+
+.workspace-state > svg,
+.workspace-restoring > svg,
+.friendly-empty > svg,
+.setup-command-card > svg {
+ flex: 0 0 auto;
+ color: var(--blue);
+}
+
+.workspace-state > div,
+.workspace-restoring > div,
+.friendly-empty > div,
+.setup-command-card > div {
+ flex: 1;
+ min-width: 0;
+}
+
+.workspace-state h2,
+.workspace-state p,
+.workspace-restoring h1,
+.workspace-restoring p,
+.friendly-empty h2,
+.friendly-empty p,
+.setup-command-card h2,
+.setup-command-card p {
+ margin: 0;
+}
+
+.workspace-state h2,
+.workspace-restoring h1,
+.friendly-empty h2,
+.setup-command-card h2 {
+ color: var(--ink);
+ font-size: 19px;
+}
+
+.workspace-state p,
+.workspace-restoring p,
+.friendly-empty p,
+.setup-command-card p {
+ margin-top: 5px;
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.workspace-restoring {
+ max-width: 680px;
+}
+
+.setup-command-card {
+ border-color: var(--nav-active-border);
+ background: var(--status-bg);
+}
+
+.setup-command-card .eyebrow {
+ margin-bottom: 2px;
+}
+
+.setup-command-card h2 {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 24px;
+}
+
+.setup-editor {
+ display: grid;
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-top: 4px solid var(--cyan);
+ border-radius: 12px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.setup-editor-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 22px;
+}
+
+.setup-editor-heading > div {
+ min-width: 0;
+}
+
+.setup-editor-heading h2,
+.setup-editor-heading p,
+.setup-section-label h3,
+.setup-section-label p,
+.setup-protected-resources h3,
+.setup-protected-resources p,
+.setup-editor-actions p {
+ margin: 0;
+}
+
+.setup-editor-heading h2 {
+ margin-top: 2px;
+ color: var(--ink);
+ font-size: clamp(22px, 3vw, 30px);
+ line-height: 1.16;
+}
+
+.setup-editor-heading > div > p:last-child {
+ max-width: 720px;
+ margin-top: 8px;
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.setup-notice {
+ margin: 0 22px 4px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--status-bg);
+ padding: 11px 13px;
+ color: var(--nav-text);
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.setup-notice.warning {
+ border-color: color-mix(in srgb, var(--amber) 45%, var(--line));
+ background: color-mix(in srgb, var(--amber) 9%, var(--panel));
+ color: var(--foreground);
+}
+
+.setup-notice.error {
+ border-color: color-mix(in srgb, var(--rose) 45%, var(--line));
+ background: color-mix(in srgb, var(--rose) 8%, var(--panel));
+ color: var(--foreground);
+}
+
+.setup-editor-section {
+ display: grid;
+ gap: 18px;
+ min-width: 0;
+ border-top: 1px solid var(--line);
+ padding: 22px;
+}
+
+.setup-section-label {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr);
+ gap: 10px;
+ align-items: start;
+}
+
+.setup-section-label > svg {
+ width: 36px;
+ height: 36px;
+ border: 1px solid var(--nav-active-border);
+ border-radius: 9px;
+ background: var(--nav-active-bg);
+ padding: 8px;
+ color: var(--nav-active-text);
+}
+
+.setup-section-label h3,
+.setup-protected-resources h3 {
+ color: var(--ink);
+ font-size: 16px;
+}
+
+.setup-section-label p,
+.setup-protected-resources p {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.setup-field-wide {
+ grid-column: 1 / -1;
+}
+
+.setup-team-size,
+.setup-mapping-column {
+ min-width: 0;
+ margin: 0;
+ border: 0;
+ padding: 0;
+}
+
+.setup-team-size > legend {
+ margin-bottom: 6px;
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.setup-team-size > div {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ gap: 9px;
+ align-items: end;
+}
+
+.setup-team-size > div > span {
+ min-height: 44px;
+ padding-top: 13px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.field-error {
+ color: var(--rose);
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.4;
+}
+
+.field :where(input, select, textarea)[aria-invalid="true"] {
+ border-color: var(--rose);
+ background: color-mix(in srgb, var(--rose) 5%, var(--field-bg));
+}
+
+.setup-options-state {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 9px;
+ min-height: 44px;
+ border: 1px dashed var(--line);
+ border-radius: 8px;
+ background: var(--status-bg);
+ padding: 8px 11px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.setup-options-state.ready {
+ border-style: solid;
+}
+
+.setup-options-state.error {
+ justify-content: space-between;
+ border-color: color-mix(in srgb, var(--amber) 42%, var(--line));
+}
+
+.status-dot {
+ width: 9px;
+ height: 9px;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ background: var(--green);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 15%, transparent);
+}
+
+.spin {
+ display: inline-flex;
+ animation: setup-spin 900ms linear infinite;
+}
+
+@keyframes setup-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.setup-mapping-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+ min-width: 0;
+}
+
+.setup-mapping-column {
+ display: grid;
+ align-content: start;
+ gap: 14px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--status-bg);
+ padding: 16px;
+}
+
+.setup-mapping-column > legend {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ margin-left: -4px;
+ padding: 0 4px;
+ color: var(--ink);
+ font-size: 14px;
+ font-weight: 900;
+}
+
+.setup-mapping-column > legend svg {
+ color: var(--blue);
+}
+
+.setup-discord-field {
+ border-bottom: 1px solid var(--line);
+ padding-bottom: 14px;
+}
+
+.setup-discord-field:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
+}
+
+.setup-protected-resources {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr);
+ gap: 11px;
+ margin: 0 22px 22px;
+ border: 1px solid var(--line);
+ border-left: 4px solid var(--green);
+ border-radius: 8px;
+ background: var(--status-bg);
+ padding: 14px;
+}
+
+.setup-protected-resources > svg {
+ color: var(--green);
+}
+
+.setup-editor-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ border-top: 1px solid var(--line);
+ background: var(--status-bg);
+ padding: 16px 22px;
+}
+
+.setup-editor-actions p {
+ max-width: 680px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.setup-editor-actions .button {
+ flex: 0 0 auto;
+}
+
+.setup-editor + .setup-command-card {
+ margin-top: 16px;
+}
+
+.setup-checklist,
+.setup-details {
+ margin-top: 16px;
+}
+
+.section-heading,
+.record-card-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
+.section-heading h2,
+.section-heading p,
+.record-card-heading h2 {
+ margin: 0;
+}
+
+.section-heading p {
+ margin-top: 3px;
+}
+
+.detail-list,
+.record-meta {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+ margin: 0;
+}
+
+.detail-list > div,
+.record-meta > div {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.detail-list dt,
+.record-meta dt,
+.compact-records dt {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.detail-list dd,
+.record-meta dd,
+.compact-records dd {
+ margin: 0;
+ color: var(--ink);
+ font-weight: 700;
+ overflow-wrap: anywhere;
+}
+
+.record-list {
+ display: grid;
+ gap: 12px;
+}
+
+.record-card {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 17px;
+ box-shadow: var(--shadow-soft);
+}
+
+.record-card-heading h2 {
+ margin-top: 4px;
+ color: var(--ink);
+ font-size: 18px;
+}
+
+.record-card > p {
+ margin: 0 0 16px;
+ color: var(--foreground);
+ line-height: 1.55;
+}
+
+.record-id {
+ color: var(--muted);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 11px;
+}
+
+.empty-hint {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.compact-records {
+ display: grid;
+ gap: 10px;
+}
+
+.compact-records article {
+ display: grid;
+ gap: 12px;
+ border-top: 1px solid var(--line);
+ padding-top: 12px;
+}
+
+.compact-records article:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.compact-records h3,
+.compact-records p,
+.compact-records dl,
+.compact-records dt,
+.compact-records dd {
+ margin: 0;
+}
+
+.compact-records h3 {
+ color: var(--ink);
+ font-size: 15px;
+}
+
+.compact-records p {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.compact-records dl {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.compact-records dl > div {
+ display: grid;
+ gap: 3px;
+}
+
+.inline-empty {
+ margin: 0;
+ border: 1px dashed var(--line);
+ border-radius: 8px;
+ padding: 14px;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.inline-empty.with-icon {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+}
+
+.inline-empty.with-icon svg {
+ flex: 0 0 auto;
+ color: var(--green);
+}
+
+.inline-empty.with-icon p {
+ margin: 0;
+}
+
+.workspace-summary {
+ display: grid;
+ grid-template-columns: minmax(260px, 1.25fr) minmax(360px, 1fr);
+ gap: 22px;
+ align-items: center;
+ border-block: 1px solid var(--line);
+ padding: 18px 0;
+}
+
+.workspace-summary-intro {
+ display: flex;
+ gap: 11px;
+ align-items: flex-start;
+}
+
+.workspace-summary-intro > svg {
+ flex: 0 0 auto;
+ margin-top: 1px;
+ color: var(--green);
+}
+
+.workspace-summary-intro > div {
+ display: grid;
+ gap: 4px;
+}
+
+.workspace-summary-intro strong {
+ color: var(--ink);
+}
+
+.workspace-summary-intro span,
+.workspace-summary dt {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.workspace-summary dl {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ margin: 0;
+}
+
+.workspace-summary dl > div {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+}
+
+.workspace-summary dt,
+.workspace-summary dd {
+ margin: 0;
+}
+
+.workspace-summary dd {
+ color: var(--ink);
+ font-size: 17px;
+ font-weight: 850;
+ overflow-wrap: anywhere;
+}
+
+.server-manager-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 12px;
+}
+
+.server-manager-toolbar h2,
+.server-manager-toolbar p {
+ margin: 0;
+}
+
+.server-manager-toolbar h2 {
+ color: var(--ink);
+ font-size: 20px;
+}
+
+.server-manager-toolbar p {
+ margin-top: 4px;
+}
+
+.sr-status {
+ border-left: 3px solid var(--blue);
+ margin: 0 0 14px;
+ background: var(--status-bg);
+ padding: 9px 12px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.server-card-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.server-card {
+ display: grid;
+ gap: 16px;
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 17px;
+ box-shadow: var(--shadow-soft);
+}
+
+.server-card-head {
+ display: grid;
+ grid-template-columns: 48px minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+}
+
+.server-card-head h3,
+.server-card-head p {
+ margin: 0;
+}
+
+.server-card-head h3 {
+ color: var(--ink);
+ font-size: 16px;
+ overflow-wrap: anywhere;
+}
+
+.server-card-head p {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.server-avatar {
+ display: grid;
+ width: 48px;
+ height: 48px;
+ place-items: center;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--soft-panel);
+ color: var(--blue);
+ object-fit: cover;
+ font-weight: 900;
+}
+
+.server-permission-note {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.server-permission-note svg {
+ color: var(--green);
+}
+
+.server-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.server-actions .button {
+ flex: 1 1 150px;
+}
+
+.server-action-note {
+ flex: 1 0 100%;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+}
+
+.badge.gray {
+ background: var(--soft-panel);
+ color: var(--nav-text);
+}
+
+.empty-state {
+ display: grid;
+ justify-items: start;
+ gap: 8px;
+ max-width: 720px;
+ padding: 28px;
+}
+
+.empty-state h2,
+.empty-state p {
+ margin: 0;
+}
+
+.empty-state p {
+ max-width: 620px;
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.empty-state .button-row {
+ margin-top: 8px;
+}
+
+.confirm-dialog {
+ width: min(520px, calc(100% - 32px));
+ max-height: calc(100dvh - 32px);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel);
+ padding: 0;
+ color: var(--foreground);
+ box-shadow: 0 24px 80px rgba(2, 6, 23, 0.38);
+}
+
+.confirm-dialog::backdrop {
+ background: rgba(2, 6, 23, 0.62);
+ backdrop-filter: blur(2px);
+}
+
+.confirm-dialog-body {
+ position: relative;
+ display: grid;
+ gap: 15px;
+ padding: 24px;
+}
+
+.confirm-dialog-body h2,
+.confirm-dialog-body p {
+ margin: 0;
+}
+
+.confirm-dialog-body h2 {
+ padding-right: 36px;
+ color: var(--ink);
+ font-size: 22px;
+}
+
+.confirm-dialog-body p {
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.confirm-dialog-body .dialog-error {
+ border-left: 3px solid var(--rose);
+ border-radius: 4px;
+ background: color-mix(in srgb, var(--rose) 9%, var(--panel));
+ padding: 10px 12px;
+ color: var(--foreground);
+ font-weight: 700;
+}
+
+.danger-mark {
+ border-color: color-mix(in srgb, var(--rose) 35%, var(--line));
+ background: color-mix(in srgb, var(--rose) 10%, var(--panel));
+ color: var(--rose);
+}
+
+.dialog-close {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ display: grid;
+ width: 44px;
+ height: 44px;
+ place-items: center;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+}
+
+.dialog-close:hover {
+ background: var(--soft-panel);
+ color: var(--ink);
+}
+
+.dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.button.full {
+ width: 100%;
+}
+
+@media (max-width: 900px) {
+ .shell {
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ width: 100%;
+ overflow: visible;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .sidebar-head {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ }
+
+ .theme-toggle {
+ width: auto;
+ padding-inline: 12px;
+ }
+
+ .nav {
+ display: flex;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+ width: 100%;
+ max-width: 100%;
+ margin-top: 14px;
+ padding-bottom: 2px;
+ }
+
+ .nav a {
+ flex: 0 0 auto;
+ justify-content: center;
+ min-height: 44px;
+ padding: 0 8px;
+ font-size: 12px;
+ }
+
+ .nav-more {
+ flex: 0 0 auto;
+ }
+
+ .nav-more summary {
+ min-height: 44px;
+ padding: 0 10px;
+ font-size: 12px;
+ }
+
+ .nav-more-menu {
+ position: fixed;
+ top: 138px;
+ right: 12px;
+ bottom: auto;
+ }
+
+ .server-switcher {
+ max-width: 480px;
+ }
+
+ .account-dock {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding-top: 12px;
+ }
+
+ .account-identity {
+ flex: 1;
+ border-top: 0;
+ padding-top: 0;
+ }
+
+ .account-dock form,
+ .account-action {
+ width: auto;
+ }
+
+ .main {
+ padding: 18px 16px;
+ }
+
+ .topbar {
+ display: grid;
+ }
+
+ .grid.metrics,
+ .grid.two,
+ .training-grid,
+ .status-cards,
+ .form-grid,
+ .setup-mapping-grid,
+ .workspace-summary,
+ .server-card-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .training-context {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+
+ .training-context > .badge {
+ grid-column: 2;
+ justify-self: start;
+ }
+
+ .server-manager-filters {
+ grid-template-columns: 1fr;
+ }
+
+ .detail-list,
+ .record-meta {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .server-manager-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .training-panel.wide {
+ grid-column: auto;
+ }
+}
+
+@media (max-width: 560px) {
+ .sidebar {
+ padding: 14px 12px;
+ }
+
+ .nav {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 6px;
+ overflow: visible;
+ }
+
+ .nav a,
+ .nav-more summary {
+ flex-direction: column;
+ gap: 3px;
+ width: 100%;
+ padding: 5px 2px;
+ font-size: 11px;
+ line-height: 1.1;
+ }
+
+ .nav > .mobile-overflow {
+ display: none;
+ }
+
+ .nav-more-menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ bottom: auto;
+ width: min(240px, calc(100vw - 24px));
+ }
+
+ .nav-more-menu a,
+ .nav-more-menu a.mobile-nav-only {
+ display: flex !important;
+ flex-direction: row;
+ justify-content: flex-start;
+ gap: 10px;
+ padding: 0 12px;
+ font-size: 13px;
+ }
+
+ .account-identity small {
+ display: none;
+ }
+
+ .topbar .button-row,
+ .topbar .button-row > *,
+ .topbar .button-row .button {
+ width: 100%;
+ }
+
+ .server-card-head {
+ grid-template-columns: 44px minmax(0, 1fr);
+ }
+
+ .workspace-summary dl {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ }
+
+ .server-card-head .badge {
+ grid-column: 1 / -1;
+ justify-self: start;
+ }
+
+ .dialog-actions {
+ align-items: stretch;
+ flex-direction: column-reverse;
+ }
+
+ .field input,
+ .field select,
+ .field textarea,
+ .server-search input,
+ .server-filter select {
+ font-size: 16px;
+ }
+
+ .filtered-empty {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .workspace-state,
+ .workspace-restoring,
+ .friendly-empty,
+ .setup-command-card {
+ display: grid;
+ }
+
+ .setup-editor-heading,
+ .setup-editor-actions,
+ .setup-options-state.error {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .setup-editor-heading .badge {
+ align-self: flex-start;
+ }
+
+ .setup-editor-actions .button,
+ .setup-options-state.error .button {
+ width: 100%;
+ }
+
+ .training-library article {
+ grid-template-columns: 1fr;
+ }
+
+ .training-library .button {
+ width: 100%;
+ }
+
+ .workspace-state > .button {
+ width: 100%;
+ }
+
+ .detail-list,
+ .record-meta,
+ .compact-records dl {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .spin {
+ animation: none;
+ }
+}
+
+@media (max-width: 900px) and (max-height: 600px) {
+ .sidebar {
+ position: static;
+ }
+
+ .confirm-dialog-body {
+ gap: 10px;
+ padding: 16px;
+ }
+
+ .confirm-dialog .danger-mark {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 1ms !important;
+ scroll-behavior: auto !important;
+ transition-duration: 1ms !important;
+ }
+}
+
+@keyframes page-settle {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.992);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.site {
+ min-height: 100vh;
+ background: #061423;
+ color: white;
+}
+
+.site .eyebrow {
+ color: #9eeaff;
+}
+
+.hero {
+ position: relative;
+ min-height: 88vh;
+ overflow: hidden;
+ background: #061423;
+ color: white;
+}
+
+.hero-bg {
position: absolute;
inset: 0;
background:
@@ -874,6 +2333,7 @@ html[data-dashboard-theme="dark"] .shell {
margin: 0 auto;
padding: 54px 0 60px;
color: white;
+ scroll-margin-top: 24px;
}
.ops-intro {
@@ -1019,9 +2479,42 @@ html[data-dashboard-theme="dark"] .shell {
max-width: 720px;
}
+.site-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ border-top: 1px solid rgba(176, 230, 255, 0.18);
+ width: min(1120px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 22px 0 28px;
+ color: #8fb6c8;
+ font-size: 13px;
+}
+
+.site-footer nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+}
+
+.site-footer a {
+ display: inline-flex;
+ align-items: center;
+ min-height: 44px;
+ color: #d9f7ff;
+ font-weight: 700;
+}
+
+.site-footer a:hover {
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
@media (max-width: 900px) {
.site-nav,
- .public-band {
+ .public-band,
+ .site-footer {
align-items: flex-start;
flex-direction: column;
}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 2059ae4..014b338 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -20,7 +20,7 @@ export const metadata: Metadata = {
"Discord moderation bot",
"Discord onboarding bot",
"hackathon organizer dashboard",
- "hackathon AI assistant",
+ "hackathon Q&A assistant",
"hackathon FAQ bot",
],
authors: [{ name: "Rupayon Haldar", url: "https://github.com/rupayon123" }],
@@ -77,16 +77,14 @@ export default function RootLayout({
diff --git a/apps/web/app/moderation/page.tsx b/apps/web/app/moderation/page.tsx
index 23e8ee4..b89c950 100644
--- a/apps/web/app/moderation/page.tsx
+++ b/apps/web/app/moderation/page.tsx
@@ -1,62 +1,135 @@
-import { defaultAutoModTemplates } from "@piphacklup/core";
+import { ShieldCheck } from "lucide-react";
+import { defaultAutoModTemplates, type ModerationCase } from "@piphacklup/core";
+import { listModerationCasesFromDb } from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
import { PageHeader } from "@/components/PageHeader";
-import { demoCases } from "@/lib/demo-data";
+import { WorkspaceState } from "@/components/WorkspaceState";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
+
+interface ModerationPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function ModerationPage({
+ searchParams,
+}: ModerationPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let cases: ModerationCase[] = [];
+ let dataUnavailable = false;
+
+ if (workspace.guild) {
+ try {
+ cases = await listModerationCasesFromDb(workspace.guild.id);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load moderation cases.");
+ }
+ }
+
+ const openCases = cases.filter(
+ (moderationCase) => moderationCase.status === "open",
+ );
-export default function ModerationPage() {
return (
-
+
-
-
- Open cases
-
-
-
- Case
- Action
- Reason
- Status
-
-
-
- {demoCases.map((moderationCase) => (
-
-
- {moderationCase.id}
-
-
- {moderationCase.action}
-
- {moderationCase.reason}
- {moderationCase.status}
-
- ))}
-
-
-
-
-
- AutoMod templates
-
- {defaultAutoModTemplates().map((rule) => (
-
-
A
-
-
{rule.name}
-
{rule.goal}
-
-
{rule.trigger}
+ {workspace.requestedGuildUnavailable ? (
+
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
+ ) : (
+
+
+
+
+
Open cases
+
{openCases.length} need staff review
- ))}
-
-
-
+
+ {openCases.length ? "Review needed" : "Clear"}
+
+
+ {openCases.length ? (
+
+ {openCases.map((moderationCase) => (
+
+
+
{moderationCase.id}
+
{moderationCase.action}
+
{moderationCase.reason}
+
+
+
+
Discord member
+ {moderationCase.targetUserId}
+
+
+
Opened
+ {formatDate(moderationCase.createdAt)}
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ No open cases. Reports created with{" "}
+ /mod report
+ will appear here for staff.
+
+
+ )}
+
+
+
+
+
+
Recommended Discord protections
+
+ Review these templates before applying them in Discord.
+
+
+
+
+ {defaultAutoModTemplates().map((rule) => (
+
+
A
+
+
{rule.name}
+
{rule.goal}
+
+
Template
+
+ ))}
+
+
+
+ )}
);
}
+
+function formatDate(value: string): string {
+ return new Intl.DateTimeFormat("en-CA", {
+ dateStyle: "medium",
+ timeZone: "America/Toronto",
+ }).format(new Date(value));
+}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 6a327a1..669093a 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -4,7 +4,7 @@ import {
ClipboardCheck,
Clock3,
Github,
- LayoutDashboard,
+ LogIn,
MessageCircleQuestion,
Radio,
Shield,
@@ -14,8 +14,7 @@ import {
} from "lucide-react";
export default function HomePage() {
- const installUrl =
- "https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands&permissions=1117094267958";
+ const signInUrl = "/api/auth/discord/start";
const structuredData = {
"@context": "https://schema.org",
@@ -62,15 +61,15 @@ export default function HomePage() {
icon: Users,
label: "Team table",
title: "Solo builders find the right group faster.",
- copy: "Profiles, recruiting teams, join requests, and matching keep team formation from turning into a chaotic introductions channel.",
+ copy: "Profiles, recruiting teams, team listings, and matching keep team formation from turning into a chaotic introductions channel.",
},
];
const operatorSteps = [
- "Create the Discord app under your account.",
- "Invite PipHackLup with the required bot permissions.",
- "Run setup, choose roles and channels, and enable onboarding.",
- "Train event details before doors open.",
+ "Sign in with the Discord account you already use.",
+ "Choose a server you own or have permission to manage.",
+ "Add PipHackLup to that server with the guided install.",
+ "Run setup, add event details, and try a participant check-in.",
];
return (
@@ -84,20 +83,15 @@ export default function HomePage() {
- P
+
+ P
+
PipHackLup
@@ -111,40 +105,43 @@ export default function HomePage() {
repeat traffic and calls staff when a human needs the wheel.
-
100-500
- person events
+ 4
+ help lines
-
4
- queue types
+ 1
+ guided setup
-
0
- secret tokens in repo
+ Human
+ fallback built in
-
+
Event-day dispatch board
-
Built for the moments organizers dread
+
Built for the busiest parts of event day
- PipHackLup is not a shiny dashboard costume. It is a Discord
- operator that helps staff absorb the first wave of confusion,
- route the important stuff, and keep receipts.
+ PipHackLup gives staff a calm place to handle the first wave of
+ questions, route the important requests, and see what still needs
+ a person.
@@ -221,8 +218,8 @@ export default function HomePage() {
+
+
>
);
diff --git a/apps/web/app/privacy/page.tsx b/apps/web/app/privacy/page.tsx
index 1911626..540f0bb 100644
--- a/apps/web/app/privacy/page.tsx
+++ b/apps/web/app/privacy/page.tsx
@@ -3,7 +3,7 @@ import { PageHeader } from "@/components/PageHeader";
export default function PrivacyPage() {
return (
-
+
Data We Process
- PipHackLup may process Discord server IDs, user IDs, display names, roles, team profiles, queue tickets, moderation
- case details, audit events, and organizer settings needed to run hackathon workflows.
+ PipHackLup may process Discord server IDs, user IDs, display names,
+ roles, team profiles, queue tickets, moderation case details, audit
+ events, and organizer settings needed to run hackathon workflows.
How Data Is Used
- Data is used to provide onboarding, team formation, help queues, moderation workflows, dashboards, exports, and
- bot diagnostics. PipHackLup does not sell personal data.
+ Data is used to provide onboarding, team formation, help queues,
+ moderation workflows, dashboards, exports, and bot diagnostics.
+ PipHackLup does not sell personal data.
+
+
+
+
+ Discord Login and Sessions
+
+ When an organizer signs in, PipHackLup stores their Discord account
+ ID, username, display name, avatar reference, and the servers where
+ Discord currently shows Owner, Administrator, or Manage Server access.
+ Discord access and refresh tokens are stored server-side in encrypted
+ form so PipHackLup can refresh that list and recheck permission before
+ each organizer action. The browser receives only an opaque, HttpOnly
+ session cookie; the database stores a hash of its random session token
+ rather than the token itself.
+
+
+ Dashboard sessions expire after 12 hours and are revoked when sign-out
+ completes. The encrypted Discord account record can remain after a
+ session expires so an active login can refresh safely; PipHackLup does
+ not currently promise automatic deletion on a fixed schedule. You can
+ revoke the app in Discord and use the private deletion path below to
+ request removal of the stored account and event records.
Message Content
- PipHackLup is designed to work without Discord's Message Content intent for the first public release. Reports and
- moderation cases may include details submitted by users or staff, such as message links or written reasons.
+ PipHackLup is designed to work without Discord's Message Content
+ intent for the first public release. Reports and moderation cases may
+ include details submitted by users or staff, such as message links or
+ written reasons. A question that needs human follow-up may be shared
+ with authorized event staff in a staff-only channel. Choosing a
+ private reply keeps the response out of the participant channel; it
+ does not hide an escalated question from the staff handling it.
Retention and Removal
- Event data is retained until an organizer exports, deletes, or requests removal. To request data removal or report
- a privacy concern, open an issue at
- {" "}
- github.com/rupayon123/PipHackLup .
+ Removing PipHackLup from a Discord server does not delete that
+ server's saved event data; the data is retained so an organizer
+ can reinstall the bot later. To request deletion or report a privacy
+ concern, use the repository's{" "}
+
+ private reporting page
+
+ . Do not post Discord user or server IDs, moderation-case details, or
+ other personal data in a public GitHub issue.
diff --git a/apps/web/app/queues/page.tsx b/apps/web/app/queues/page.tsx
index bc19f29..41e4e56 100644
--- a/apps/web/app/queues/page.tsx
+++ b/apps/web/app/queues/page.tsx
@@ -1,49 +1,117 @@
+import { Clock3, TicketCheck } from "lucide-react";
+import type { QueueTicket } from "@piphacklup/core";
+import { listQueueTicketsFromDb } from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
import { PageHeader } from "@/components/PageHeader";
-import { demoTickets } from "@/lib/demo-data";
+import { WorkspaceState } from "@/components/WorkspaceState";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
+
+interface QueuesPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function QueuesPage({ searchParams }: QueuesPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let tickets: QueueTicket[] = [];
+ let dataUnavailable = false;
+
+ if (workspace.guild) {
+ try {
+ tickets = await listQueueTicketsFromDb(workspace.guild.id);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load queue tickets.");
+ }
+ }
-export default function QueuesPage() {
return (
-
+
-
-
-
-
- Ticket
- Kind
- Topic
- Status
- Priority
- Created
-
-
-
- {demoTickets.map((ticket) => (
-
-
- {ticket.id}
-
-
- {ticket.kind}
-
-
- {ticket.topic}
- {ticket.description}
-
- {ticket.status}
- {ticket.priority}
- {new Date(ticket.createdAt).toLocaleTimeString()}
-
- ))}
-
-
-
+ {workspace.requestedGuildUnavailable ? (
+
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
+ ) : tickets.length ? (
+
+ {tickets.map((ticket) => (
+
+
+
+ {ticket.id}
+
{ticket.topic}
+
+
+ {ticket.status}
+
+
+ {ticket.description}
+
+
+
Queue
+ {ticket.kind}
+
+
+
Priority
+ {ticket.priority}
+
+
+
Opened
+ {formatTimestamp(ticket.createdAt)}
+
+
+
Assigned to
+ {ticket.assignedTo ?? "Waiting for staff"}
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
No help requests yet
+
+ When someone uses /queue open in{" "}
+ {workspace.guild.name}, their request will appear here.
+
+
+
+ Live queue updates are saved per
+ server
+
+
+ )}
);
}
+
+function ticketBadge(status: string): "green" | "amber" | "blue" {
+ if (status === "closed" || status === "canceled") return "green";
+ if (status === "escalated") return "amber";
+ return "blue";
+}
+
+function formatTimestamp(value: string): string {
+ return new Intl.DateTimeFormat("en-CA", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: "America/Toronto",
+ }).format(new Date(value));
+}
diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts
index ae18373..b9ecf56 100644
--- a/apps/web/app/robots.ts
+++ b/apps/web/app/robots.ts
@@ -4,9 +4,9 @@ export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
- allow: "/"
+ allow: "/",
},
sitemap: "https://piphacklup.vercel.app/sitemap.xml",
- host: "https://piphacklup.vercel.app"
+ host: "https://piphacklup.vercel.app",
};
}
diff --git a/apps/web/app/setup/SetupEditor.tsx b/apps/web/app/setup/SetupEditor.tsx
new file mode 100644
index 0000000..804e7ea
--- /dev/null
+++ b/apps/web/app/setup/SetupEditor.tsx
@@ -0,0 +1,633 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import {
+ CalendarDays,
+ LockKeyhole,
+ MapPin,
+ RefreshCw,
+ Save,
+ Users,
+} from "lucide-react";
+import type { EventConfig } from "@piphacklup/core";
+import type { ManagedDiscordGuild } from "@/lib/discord-auth";
+import { useRouter } from "next/navigation";
+
+const roleFields = [
+ {
+ key: "newcomer",
+ label: "Newcomer role",
+ help: "People who joined but have not finished gated onboarding.",
+ },
+ {
+ key: "participant",
+ label: "Participant role",
+ help: "People with access to the active hackathon workspace.",
+ },
+ {
+ key: "mentor",
+ label: "Mentor role",
+ help: "Helpers who can work non-staff support queues.",
+ },
+ {
+ key: "judge",
+ label: "Judge role",
+ help: "Judges who can work judging requests.",
+ },
+ {
+ key: "organizer",
+ label: "Organizer role",
+ help: "Event leads with full PipHackLup staff access.",
+ },
+ {
+ key: "moderator",
+ label: "Moderator role",
+ help: "Safety staff who can use moderation and staff queues.",
+ },
+] as const;
+
+const channelFields = [
+ { key: "welcome", label: "Welcome", help: "First stop for new members." },
+ { key: "rules", label: "Rules", help: "Rules and acknowledgement panel." },
+ {
+ key: "announcements",
+ label: "Announcements",
+ help: "Official event updates.",
+ },
+ { key: "helpDesk", label: "Help desk", help: "Participant support." },
+ {
+ key: "teamCatalog",
+ label: "Team catalog",
+ help: "Team profiles and matching.",
+ },
+ {
+ key: "moderationLog",
+ label: "Moderation log",
+ help: "Verified staff-private moderation activity.",
+ },
+ {
+ key: "auditLog",
+ label: "Audit log",
+ help: "Verified staff-private organizer activity.",
+ },
+] as const;
+
+type RoleKey = (typeof roleFields)[number]["key"];
+type ChannelKey = (typeof channelFields)[number]["key"];
+type SelectionState = Record;
+type DiscordOption = { id: string; name: string };
+type DiscordOptions = {
+ roles: DiscordOption[];
+ channels: DiscordOption[];
+};
+type OptionsStatus = "loading" | "ready" | "error";
+type Notice = { kind: "status" | "warning" | "error"; message: string };
+
+interface SetupEditorProps {
+ guild: ManagedDiscordGuild;
+ initialConfig: EventConfig;
+ protectedResourceCount: number;
+}
+
+export function SetupEditor({
+ guild,
+ initialConfig,
+ protectedResourceCount,
+}: Readonly) {
+ const router = useRouter();
+ const eventNameRef = useRef(null);
+ const teamSizeMinRef = useRef(null);
+ const [eventName, setEventName] = useState(initialConfig.eventName);
+ const [onboardingMode, setOnboardingMode] = useState<
+ EventConfig["onboardingMode"]
+ >(initialConfig.onboardingMode);
+ const [teamSizeMin, setTeamSizeMin] = useState(
+ String(initialConfig.teamSizeMin),
+ );
+ const [teamSizeMax, setTeamSizeMax] = useState(
+ String(initialConfig.teamSizeMax),
+ );
+ const [roles, setRoles] = useState>(() =>
+ makeSelections(roleFields, initialConfig.roles),
+ );
+ const [channels, setChannels] = useState>(() =>
+ makeSelections(channelFields, initialConfig.channels),
+ );
+ const [discordOptions, setDiscordOptions] = useState(
+ null,
+ );
+ const [optionsStatus, setOptionsStatus] = useState("loading");
+ const [optionsAttempt, setOptionsAttempt] = useState(0);
+ const [busy, setBusy] = useState(false);
+ const [errors, setErrors] = useState<{
+ eventName?: string;
+ teamSize?: string;
+ }>({});
+ const [notice, setNotice] = useState({
+ kind: "status",
+ message: `Ready to configure ${guild.name}.`,
+ });
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setOptionsStatus("loading");
+ setDiscordOptions(null);
+
+ async function loadOptions() {
+ try {
+ const response = await fetch(
+ `/api/discord/guilds/${encodeURIComponent(guild.id)}/options`,
+ { signal: controller.signal },
+ );
+ const body = (await response.json().catch(() => null)) as unknown;
+ if (!response.ok || !isDiscordOptions(body)) {
+ throw new Error("options_unavailable");
+ }
+ if (!controller.signal.aborted) {
+ setDiscordOptions(body);
+ setOptionsStatus("ready");
+ }
+ } catch {
+ if (controller.signal.aborted) return;
+ console.error("PipHackLup could not load Discord setup choices.");
+ setOptionsStatus("error");
+ setNotice({
+ kind: "warning",
+ message:
+ "Live Discord roles and channels are unavailable. You can retry, or clear old selections before saving basic event details.",
+ });
+ }
+ }
+
+ void loadOptions();
+ return () => controller.abort();
+ }, [guild.id, optionsAttempt]);
+
+ const selectedCount =
+ Object.values(roles).filter(Boolean).length +
+ Object.values(channels).filter(Boolean).length;
+
+ function validateForm(): {
+ eventName: string;
+ teamSizeMin: number;
+ teamSizeMax: number;
+ } | null {
+ const nextErrors: { eventName?: string; teamSize?: string } = {};
+ const trimmedName = eventName.trim();
+ const minimum = Number(teamSizeMin);
+ const maximum = Number(teamSizeMax);
+
+ if (!trimmedName) nextErrors.eventName = "Enter the event name.";
+ else if (trimmedName.length > 80) {
+ nextErrors.eventName = "Keep the event name to 80 characters or fewer.";
+ }
+ if (
+ !Number.isInteger(minimum) ||
+ !Number.isInteger(maximum) ||
+ minimum < 1 ||
+ maximum > 20 ||
+ minimum > maximum
+ ) {
+ nextErrors.teamSize =
+ "Use whole numbers from 1 to 20, with the minimum no larger than the maximum.";
+ }
+
+ setErrors(nextErrors);
+ if (nextErrors.eventName) {
+ eventNameRef.current?.focus();
+ return null;
+ }
+ if (nextErrors.teamSize) {
+ teamSizeMinRef.current?.focus();
+ return null;
+ }
+ return {
+ eventName: trimmedName,
+ teamSizeMin: minimum,
+ teamSizeMax: maximum,
+ };
+ }
+
+ async function saveConfiguration(event: React.FormEvent) {
+ event.preventDefault();
+ const valid = validateForm();
+ if (!valid) {
+ setNotice({
+ kind: "error",
+ message: "Check the highlighted event settings, then save again.",
+ });
+ return;
+ }
+
+ setBusy(true);
+ setNotice({ kind: "status", message: `Saving ${guild.name}…` });
+ try {
+ const response = await fetch(
+ `/api/discord/guilds/${encodeURIComponent(guild.id)}/config`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ...valid,
+ onboardingMode,
+ roles: nullEmptySelections(roles),
+ channels: nullEmptySelections(channels),
+ }),
+ },
+ );
+ const body = (await response.json().catch(() => null)) as {
+ error?: string;
+ warning?: string | null;
+ } | null;
+ if (!response.ok) throw new Error(setupErrorMessage(body?.error));
+
+ setEventName(valid.eventName);
+ setNotice({
+ kind: body?.warning ? "warning" : "status",
+ message: body?.warning
+ ? `Settings were saved for ${guild.name}, but the activity-log entry could not be recorded.`
+ : `Settings saved for ${guild.name}. Run /setup in Discord to create or reconcile the workspace.`,
+ });
+ router.refresh();
+ } catch (error) {
+ setNotice({
+ kind: "error",
+ message:
+ error instanceof Error
+ ? error.message
+ : "The server settings could not be saved. Refresh and try again.",
+ });
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+function DiscordSelect({
+ id,
+ label,
+ help,
+ value,
+ options,
+ loading,
+ optionsLoaded,
+ onChange,
+}: Readonly<{
+ id: string;
+ label: string;
+ help: string;
+ value: string;
+ options: DiscordOption[];
+ loading: boolean;
+ optionsLoaded: boolean;
+ onChange: (value: string) => void;
+}>) {
+ const valueIsMissing = Boolean(
+ value && !options.some((option) => option.id === value),
+ );
+ return (
+
+ {label}
+ onChange(event.target.value)}
+ >
+ Create or choose during /setup
+ {valueIsMissing ? (
+
+ {optionsLoaded
+ ? "Saved choice is no longer available"
+ : "Saved choice (not verified)"}
+
+ ) : null}
+ {options.map((option) => (
+
+ {option.name}
+
+ ))}
+
+
+ {help}
+
+
+ );
+}
+
+function makeSelections(
+ fields: readonly { key: K }[],
+ source: Partial>,
+): SelectionState {
+ return Object.fromEntries(
+ fields.map(({ key }) => [key, source[key] ?? ""]),
+ ) as SelectionState;
+}
+
+function nullEmptySelections(
+ selections: SelectionState,
+): Record {
+ return Object.fromEntries(
+ Object.entries(selections).map(([key, value]) => [key, value || null]),
+ ) as Record;
+}
+
+function setupErrorMessage(code: string | undefined): string {
+ switch (code) {
+ case "missing_manage_server":
+ return "Your Discord account no longer has Manage Server permission.";
+ case "untrusted_request_origin":
+ return "The save request could not be verified. Refresh and try again.";
+ case "rate_limited":
+ return "Too many saves were attempted. Wait a moment and try again.";
+ case "invalid_event_name":
+ return "Enter an event name no longer than 80 characters.";
+ case "invalid_team_size":
+ return "Choose a valid team-size range from 1 to 20.";
+ case "discord_option_no_longer_available":
+ return "A selected Discord role or channel no longer exists. Reload the live choices and try again.";
+ case "discord_options_unavailable":
+ return "Discord could not verify the selected roles and channels. Check that the bot is installed, then try again.";
+ case "server_config_unavailable":
+ case "server_config_save_failed":
+ return "The saved server configuration is temporarily unavailable. Nothing was changed.";
+ default:
+ return "The server settings could not be saved. Refresh and try again.";
+ }
+}
+
+function isDiscordOptions(value: unknown): value is DiscordOptions {
+ if (
+ !isRecord(value) ||
+ !Array.isArray(value.roles) ||
+ !Array.isArray(value.channels)
+ ) {
+ return false;
+ }
+ return [...value.roles, ...value.channels].every(
+ (option) =>
+ isRecord(option) &&
+ typeof option.id === "string" &&
+ typeof option.name === "string",
+ );
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/apps/web/app/setup/page.tsx b/apps/web/app/setup/page.tsx
index 6836dce..25d5651 100644
--- a/apps/web/app/setup/page.tsx
+++ b/apps/web/app/setup/page.tsx
@@ -1,53 +1,172 @@
-import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
+import { CheckCircle2, Circle, ServerCog } from "lucide-react";
+import type { EventConfig } from "@piphacklup/core";
+import { getGuildConfigFromDb } from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
import { PageHeader } from "@/components/PageHeader";
-import { setupSteps } from "@/lib/demo-data";
+import { WorkspaceState } from "@/components/WorkspaceState";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
+import { SetupEditor } from "./SetupEditor";
+
+interface SetupPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function SetupPage({ searchParams }: SetupPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let config = null;
+ let dataUnavailable = false;
+
+ if (workspace.guild) {
+ try {
+ config = await getGuildConfigFromDb(workspace.guild.id);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load server setup.");
+ }
+ }
+
+ const roleCount = config ? Object.keys(config.roles).length : 0;
+ const channelCount = config ? Object.keys(config.channels).length : 0;
+ const resourceCount = config?.resources
+ ? Object.values(config.resources).filter(Boolean).length
+ : 0;
+ const setupStarted = Boolean(roleCount || channelCount || resourceCount);
+ const editorConfig: EventConfig | null = workspace.guild
+ ? (config ?? {
+ guildId: workspace.guild.id,
+ eventName: workspace.guild.name,
+ onboardingMode: "guided",
+ teamSizeMin: 2,
+ teamSizeMax: 4,
+ queueKinds: ["mentor", "tech", "judging", "staff"],
+ roles: {},
+ channels: {},
+ resources: {},
+ })
+ : null;
+ const steps = [
+ {
+ label: "Server configuration saved",
+ detail:
+ setupStarted && config
+ ? `${config.eventName} is connected to PipHackLup.`
+ : "Run /setup in Discord to create this server's event workspace.",
+ done: setupStarted,
+ },
+ {
+ label: "Event roles ready",
+ detail: roleCount
+ ? `${roleCount} role${roleCount === 1 ? "" : "s"} recorded for onboarding and staff access.`
+ : "No PipHackLup event roles have been recorded yet.",
+ done: roleCount > 0,
+ },
+ {
+ label: "Event channels ready",
+ detail: channelCount
+ ? `${channelCount} channel${channelCount === 1 ? "" : "s"} recorded for help, teams, and logs.`
+ : "No PipHackLup event channels have been recorded yet.",
+ done: channelCount > 0,
+ },
+ {
+ label: "Onboarding mode chosen",
+ detail:
+ setupStarted && config
+ ? `${config.onboardingMode === "gated" ? "Gated" : "Guided"} onboarding is active.`
+ : "Choose guided or gated onboarding when you run /setup.",
+ done: setupStarted,
+ },
+ ];
-export default function SetupPage() {
return (
-
+
-
- Developer Portal
-
+ workspace.guild ? (
+
+ Back to servers
+
+ ) : undefined
}
/>
-
- Launch checklist
-
- {setupSteps.map((step) => {
- const done = step.status === "done";
- const Icon = done ? CheckCircle2 : Circle;
- return (
-
-
-
-
-
-
{step.label}
-
{step.detail}
-
-
{done ? "Done" : "Todo"}
+ {workspace.requestedGuildUnavailable ? (
+
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
Then run this in {workspace.guild.name}
+
/setup
+
+ Only someone with Manage Server can run it. PipHackLup will
+ reuse anything it already created, so setup is safe to run again
+ after an interrupted attempt.
+
+
+
+
+
+
+
+
Setup checklist
+
+ These checks come from this server's saved configuration—not
+ sample data.
+
- );
- })}
-
-
-
-
- Invite permissions
-
- Use scopes bot and applications.commands . Grant View Channels, Send Messages, Embed Links,
- Attach Files, Read Message History, Manage Roles, Manage Nicknames, Manage Channels, Manage Threads, Moderate Members,
- Manage Guild, and optional Kick/Ban.
-
-
+
+ {setupStarted ? "Started" : "Not started"}
+
+
+
+ {steps.map((step) => {
+ const Icon = step.done ? CheckCircle2 : Circle;
+ return (
+
+
+
+
+
+
{step.label}
+
{step.detail}
+
+
+ {step.done ? "Ready" : "Needed"}
+
+
+ );
+ })}
+
+
+ >
+ )}
);
}
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
index 94c1976..96702e4 100644
--- a/apps/web/app/sitemap.ts
+++ b/apps/web/app/sitemap.ts
@@ -6,12 +6,47 @@ export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
return [
{ url: baseUrl, lastModified: now, changeFrequency: "weekly", priority: 1 },
- { url: `${baseUrl}/dashboard`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
- { url: `${baseUrl}/setup`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
- { url: `${baseUrl}/queues`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
- { url: `${baseUrl}/teams`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
- { url: `${baseUrl}/moderation`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
- { url: `${baseUrl}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
- { url: `${baseUrl}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }
+ {
+ url: `${baseUrl}/dashboard`,
+ lastModified: now,
+ changeFrequency: "weekly",
+ priority: 0.8,
+ },
+ {
+ url: `${baseUrl}/setup`,
+ lastModified: now,
+ changeFrequency: "monthly",
+ priority: 0.7,
+ },
+ {
+ url: `${baseUrl}/queues`,
+ lastModified: now,
+ changeFrequency: "monthly",
+ priority: 0.7,
+ },
+ {
+ url: `${baseUrl}/teams`,
+ lastModified: now,
+ changeFrequency: "monthly",
+ priority: 0.7,
+ },
+ {
+ url: `${baseUrl}/moderation`,
+ lastModified: now,
+ changeFrequency: "monthly",
+ priority: 0.7,
+ },
+ {
+ url: `${baseUrl}/terms`,
+ lastModified: now,
+ changeFrequency: "yearly",
+ priority: 0.3,
+ },
+ {
+ url: `${baseUrl}/privacy`,
+ lastModified: now,
+ changeFrequency: "yearly",
+ priority: 0.3,
+ },
];
}
diff --git a/apps/web/app/teams/page.tsx b/apps/web/app/teams/page.tsx
index 1fa386c..03c4a47 100644
--- a/apps/web/app/teams/page.tsx
+++ b/apps/web/app/teams/page.tsx
@@ -1,63 +1,153 @@
-import { suggestTeamMatches } from "@piphacklup/core";
+import { Sparkles, Users } from "lucide-react";
+import {
+ suggestTeamMatches,
+ type MatchResult,
+ type MemberProfile,
+ type TeamProfile,
+} from "@piphacklup/core";
+import { listMemberProfilesFromDb, listTeamsFromDb } from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
import { PageHeader } from "@/components/PageHeader";
-import { demoMembers, demoTeams } from "@/lib/demo-data";
+import { WorkspaceState } from "@/components/WorkspaceState";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
-export default function TeamsPage() {
- const matches = suggestTeamMatches(demoMembers, demoTeams);
+interface TeamsPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function TeamsPage({ searchParams }: TeamsPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let profiles: MemberProfile[] = [];
+ let teams: TeamProfile[] = [];
+ let matches: MatchResult[] = [];
+ let dataUnavailable = false;
+
+ if (workspace.guild) {
+ try {
+ [profiles, teams] = await Promise.all([
+ listMemberProfilesFromDb(workspace.guild.id),
+ listTeamsFromDb(workspace.guild.id),
+ ]);
+ matches = suggestTeamMatches(profiles, teams);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load team formation data.");
+ }
+ }
return (
-
+
-
-
- Recruiting teams
-
-
-
- Team
- Members
- Needs
-
-
-
- {demoTeams.map((team) => (
-
-
- {team.name}
- {team.projectIdea}
-
-
- {team.memberIds.length}/{team.maxSize}
-
- {team.desiredSkills.join(", ")}
-
- ))}
-
-
-
+ {workspace.requestedGuildUnavailable ? (
+
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
+ ) : teams.length || profiles.length ? (
+
+
+
+
+
Recruiting teams
+
{teams.length} saved in this server
+
+
+ {teams.length ? (
+
+ {teams.map((team) => (
+
+
+
{team.name}
+
{team.projectIdea ?? "No project idea shared yet."}
+
+
+
+
Members
+
+ {team.memberIds.length}/{team.maxSize}
+
+
+
+
Looking for
+
+ {team.desiredSkills.join(", ") || "Open to ideas"}
+
+
+
+
+ ))}
+
+ ) : (
+ No teams are recruiting yet.
+ )}
+
-
- Match suggestions
-
- {matches.map((match) => (
-
-
{match.score}
-
-
{match.teamId}
-
Add users {match.addedMemberIds.join(", ")}
-
-
Suggested
+
+
+
+
Match suggestions
+
+ Suggestions only—people decide where they join.
+
+
+
+
+ {matches.length ? (
+
+ {matches.map((match) => {
+ const team = teams.find((item) => item.id === match.teamId);
+ return (
+
+
{match.score}
+
+
{team?.name ?? match.teamId}
+
+ {match.addedMemberIds.length} possible teammate
+ {match.addedMemberIds.length === 1 ? "" : "s"}
+
+
+
Review
+
+ );
+ })}
- ))}
+ ) : (
+
+ No match is ready yet. Participants can use{" "}
+ /team profile to join the pool, then an
+ organizer can run /team match .
+
+ )}
+
+
+ ) : (
+
+
+
+
No team activity yet
+
+ Participants can create a profile or recruiting team in
+ {` ${workspace.guild.name}`} with the /team {" "}
+ command.
+
-
+ )}
);
}
diff --git a/apps/web/app/terms/page.tsx b/apps/web/app/terms/page.tsx
index 1a9a835..c071bce 100644
--- a/apps/web/app/terms/page.tsx
+++ b/apps/web/app/terms/page.tsx
@@ -3,7 +3,7 @@ import { PageHeader } from "@/components/PageHeader";
export default function TermsPage() {
return (
-
+
Use of PipHackLup
- PipHackLup is provided to help Discord communities run hackathons with onboarding, role setup, queues, team formation,
- moderation cases, and organizer dashboards. You are responsible for using the bot in a way that follows Discord's
- Terms of Service, Discord's Developer Policy, and the rules of your event.
+ PipHackLup is provided to help Discord communities run hackathons with
+ onboarding, role setup, queues, team formation, moderation cases, and
+ organizer dashboards. You are responsible for using the bot in a way
+ that follows Discord's Terms of Service, Discord's Developer Policy,
+ and the rules of your event.
Organizer Responsibilities
- Server owners and organizers control how PipHackLup is installed, configured, and used in their Discord servers.
- Organizers are responsible for notifying participants about event rules, moderation expectations, and any exports
- or records they choose to keep.
+ Server owners and organizers control how PipHackLup is installed,
+ configured, and used in their Discord servers. Organizers are
+ responsible for notifying participants about event rules, moderation
+ expectations, and any exports or records they choose to keep.
Availability
- PipHackLup is an early-stage open-source project and is provided as-is. We try to keep the service reliable, but
- do not guarantee uninterrupted operation, especially on free hosting tiers or during active development.
+ PipHackLup is an open-source service provided as-is. We work to keep
+ it reliable, but do not guarantee uninterrupted operation during
+ maintenance, provider outages, or active development.
Contact
- For issues, feature requests, or removal requests, use the public GitHub repository at
- {" "}
- github.com/rupayon123/PipHackLup .
+ For bugs and feature requests that contain no private data, use the
+ public GitHub repository at{" "}
+
+ github.com/rupayon123/PipHackLup
+
+ . For data deletion or privacy concerns, use the repository's{" "}
+
+ private reporting page
+
+ . Never put Discord IDs, moderation-case details, or personal data in
+ a public issue.
diff --git a/apps/web/app/training/TrainingConsole.tsx b/apps/web/app/training/TrainingConsole.tsx
index 4f3b1c1..0e9ea00 100644
--- a/apps/web/app/training/TrainingConsole.tsx
+++ b/apps/web/app/training/TrainingConsole.tsx
@@ -1,259 +1,285 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Save, Sparkles, Trash2, Upload } from "lucide-react";
import {
answerHackathonQuestion,
- createKnowledgeEntry,
- defaultKnowledgeSettings,
- parseKnowledgeImportText,
type HackathonKnowledgeEntry,
type KnowledgeAssistantSettings,
type KnowledgeEscalationTarget,
} from "@piphacklup/core";
-import type { DiscordSession, ManagedDiscordGuild } from "@/lib/discord-auth";
+import type { ManagedDiscordGuild } from "@/lib/discord-auth";
interface TrainingConsoleProps {
- session: DiscordSession | null;
- databaseReady: boolean;
- installUrl: string;
+ guild: ManagedDiscordGuild;
+ initialEntries: HackathonKnowledgeEntry[];
+ initialSettings: KnowledgeAssistantSettings;
+ botInstallation: boolean | null;
}
-const previewGuild: ManagedDiscordGuild = {
- id: "preview-guild",
- name: "Preview Hackathon Server",
- isOwner: true,
- permissions: "32",
- canManage: true,
-};
+interface DiscordOption {
+ id: string;
+ name: string;
+}
+
+interface DiscordGuildOptions {
+ roles: DiscordOption[];
+ channels: DiscordOption[];
+}
-const previewEntries: HackathonKnowledgeEntry[] = [
- createKnowledgeEntry({
- guildId: previewGuild.id,
- title: "Check-in location",
- answer:
- "Check-in is at the main registration desk. Staff can update this from the website trainer or `/train add`.",
- tags: ["check-in", "registration", "badge"],
- createdBy: "preview",
- }),
- createKnowledgeEntry({
- guildId: previewGuild.id,
- title: "Submission deadline",
- answer:
- "Project submissions close at 10:00 AM on demo day. Ask staff to change this for the real event.",
- tags: ["deadline", "submit", "demo"],
- escalationTarget: "staff",
- createdBy: "preview",
- }),
-];
+type BusyAction = "add" | "import" | "settings" | `delete:${string}`;
+type Notice = { kind: "status" | "error"; message: string };
+type DiscordOptionsStatus = "not-needed" | "loading" | "ready" | "error";
export function TrainingConsole({
- session,
- databaseReady,
- installUrl,
-}: TrainingConsoleProps) {
- const guilds = session?.guilds.length ? session.guilds : [previewGuild];
- const [selectedGuildId, setSelectedGuildId] = useState(
- guilds[0]?.id ?? previewGuild.id,
- );
- const selectedGuild =
- guilds.find((guild) => guild.id === selectedGuildId) ??
- guilds[0] ??
- previewGuild;
- const liveMode = Boolean(session && databaseReady);
+ guild,
+ initialEntries,
+ initialSettings,
+ botInstallation,
+}: Readonly
) {
const [entries, setEntries] =
- useState(previewEntries);
- const [settings, setSettings] = useState({
- ...defaultKnowledgeSettings,
- });
+ useState(initialEntries);
+ const [settings, setSettings] =
+ useState(initialSettings);
+ const [discordOptions, setDiscordOptions] =
+ useState(null);
+ const [discordOptionsStatus, setDiscordOptionsStatus] =
+ useState(
+ botInstallation === true ? "loading" : "not-needed",
+ );
const [title, setTitle] = useState("");
const [answer, setAnswer] = useState("");
+ const titleRef = useRef(null);
+ const answerRef = useRef(null);
+ const [entryErrors, setEntryErrors] = useState<{
+ title?: string;
+ answer?: string;
+ }>({});
const [tags, setTags] = useState("");
const [escalationTarget, setEscalationTarget] =
useState("none");
const [importText, setImportText] = useState("");
const [question, setQuestion] = useState("Where do I check in?");
- const [status, setStatus] = useState(
- liveMode
- ? "Connected to Discord and database."
- : "Preview mode: connect Discord and DATABASE_URL for live training.",
- );
-
- useEffect(() => {
- setSelectedGuildId(guilds[0]?.id ?? previewGuild.id);
- }, [session?.user.id]);
+ const [busyAction, setBusyAction] = useState(null);
+ const [removeConfirmationId, setRemoveConfirmationId] = useState<
+ string | null
+ >(null);
+ const deleteConfirmRef = useRef(null);
+ const returnDeleteFocusIdRef = useRef(null);
+ const [notice, setNotice] = useState({
+ kind: "status",
+ message: `Ready to edit answers for ${guild.name}.`,
+ });
useEffect(() => {
- if (!liveMode) {
- setEntries(
- previewEntries.map((entry) => ({
- ...entry,
- guildId: selectedGuild.id,
- })),
- );
- setSettings({ ...defaultKnowledgeSettings });
+ if (botInstallation !== true) {
+ setDiscordOptions(null);
+ setDiscordOptionsStatus("not-needed");
return;
}
- let canceled = false;
- async function loadTraining() {
- setStatus("Loading server training...");
- const query = `guildId=${encodeURIComponent(selectedGuild.id)}`;
- const [entriesResponse, settingsResponse] = await Promise.all([
- fetch(`/api/training/entries?${query}`),
- fetch(`/api/training/settings?${query}`),
- ]);
- if (canceled) return;
- if (!entriesResponse.ok || !settingsResponse.ok) {
- setStatus("Could not load live training for this server.");
- return;
+ const controller = new AbortController();
+ setDiscordOptions(null);
+ setDiscordOptionsStatus("loading");
+ async function loadDiscordOptions() {
+ try {
+ const response = await fetch(
+ `/api/discord/guilds/${encodeURIComponent(guild.id)}/options`,
+ { signal: controller.signal },
+ );
+ if (!response.ok) throw new Error("options_unavailable");
+ const body = (await response.json()) as DiscordGuildOptions;
+ if (!controller.signal.aborted) {
+ setDiscordOptions(body);
+ setDiscordOptionsStatus("ready");
+ }
+ } catch {
+ if (controller.signal.aborted) return;
+ console.error("PipHackLup could not load Discord roles and channels.");
+ setDiscordOptions(null);
+ setDiscordOptionsStatus("error");
+ setNotice({
+ kind: "error",
+ message:
+ "Discord roles and channels could not be loaded. Your saved answers are still available.",
+ });
}
- const entriesJson = (await entriesResponse.json()) as {
- entries: HackathonKnowledgeEntry[];
- };
- const settingsJson = (await settingsResponse.json()) as {
- settings: KnowledgeAssistantSettings;
- };
- setEntries(entriesJson.entries);
- setSettings(settingsJson.settings);
- setStatus(`Live training loaded for ${selectedGuild.name}.`);
}
+ void loadDiscordOptions();
+ return () => controller.abort();
+ }, [botInstallation, guild.id]);
- void loadTraining();
- return () => {
- canceled = true;
- };
- }, [liveMode, selectedGuild.id, selectedGuild.name]);
+ useEffect(() => {
+ if (removeConfirmationId !== null) {
+ if (busyAction !== null) return;
+ const frame = window.requestAnimationFrame(() => {
+ deleteConfirmRef.current?.focus();
+ });
+ return () => window.cancelAnimationFrame(frame);
+ }
+
+ if (!returnDeleteFocusIdRef.current) return;
+ const entryId = returnDeleteFocusIdRef.current;
+ returnDeleteFocusIdRef.current = null;
+ const frame = window.requestAnimationFrame(() => {
+ document
+ .querySelector(
+ `[data-remove-entry-id="${CSS.escape(entryId)}"]`,
+ )
+ ?.focus();
+ });
+ return () => window.cancelAnimationFrame(frame);
+ }, [busyAction, removeConfirmationId]);
const previewAnswer = useMemo(
() => answerHackathonQuestion(question, entries, settings),
[entries, question, settings],
);
+ const busy = busyAction !== null;
async function addEntry() {
- if (!title.trim() || !answer.trim()) {
- setStatus("Add a title and answer first.");
+ const nextErrors: { title?: string; answer?: string } = {};
+ if (!title.trim()) nextErrors.title = "Enter the participant question.";
+ if (!answer.trim())
+ nextErrors.answer = "Enter the answer PipHackLup should give.";
+ setEntryErrors(nextErrors);
+ if (nextErrors.title || nextErrors.answer) {
+ setNotice({
+ kind: "error",
+ message:
+ "Add both a participant question and the answer they should receive.",
+ });
+ if (nextErrors.title) titleRef.current?.focus();
+ else answerRef.current?.focus();
return;
}
- if (liveMode) {
- const guildQuery = encodeURIComponent(selectedGuild.id);
- const response = await fetch(
- `/api/training/entries?guildId=${guildQuery}`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ title, answer, tags, escalationTarget }),
- },
- );
- if (!response.ok) {
- setStatus("Live training save failed.");
- return;
- }
- const json = (await response.json()) as {
+ setBusyAction("add");
+ setNotice({ kind: "status", message: "Saving this answer…" });
+ try {
+ const response = await trainingFetch("/api/training/entries", guild.id, {
+ method: "POST",
+ body: JSON.stringify({ title, answer, tags, escalationTarget }),
+ });
+ const body = (await response.json()) as {
entry: HackathonKnowledgeEntry;
+ warning?: string | null;
};
- setEntries((current) => [json.entry, ...current]);
- setStatus(`Saved live training entry: ${json.entry.title}.`);
- } else {
- const entry = createKnowledgeEntry({
- guildId: selectedGuild.id,
- title,
- answer,
- tags: tags.split(","),
- escalationTarget,
- createdBy: session?.user.id ?? "preview",
+ setEntries((current) => [body.entry, ...current]);
+ setTitle("");
+ setAnswer("");
+ setEntryErrors({});
+ setTags("");
+ setEscalationTarget("none");
+ setNotice({
+ kind: body.warning ? "error" : "status",
+ message: body.warning
+ ? `Saved “${body.entry.title}” for ${guild.name}, but its activity-log entry could not be recorded. The answer itself is safe.`
+ : `Saved “${body.entry.title}” for ${guild.name}.`,
});
- setEntries((current) => [entry, ...current]);
- setStatus(`Preview entry added: ${entry.title}.`);
+ } catch (error) {
+ setNotice({ kind: "error", message: trainingErrorMessage(error) });
+ } finally {
+ setBusyAction(null);
}
-
- setTitle("");
- setAnswer("");
- setTags("");
- setEscalationTarget("none");
}
async function importEntries() {
if (!importText.trim()) {
- setStatus("Paste training lines first.");
+ setNotice({
+ kind: "error",
+ message: "Paste at least one event detail before importing.",
+ });
return;
}
- if (liveMode) {
- const guildQuery = encodeURIComponent(selectedGuild.id);
- const response = await fetch(
- `/api/training/entries?guildId=${guildQuery}`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ importText, escalationTarget }),
- },
- );
- if (!response.ok) {
- setStatus("Live import failed.");
- return;
- }
- const json = (await response.json()) as {
+ setBusyAction("import");
+ setNotice({ kind: "status", message: "Checking and importing details…" });
+ try {
+ const response = await trainingFetch("/api/training/entries", guild.id, {
+ method: "POST",
+ body: JSON.stringify({ importText, escalationTarget }),
+ });
+ const body = (await response.json()) as {
entries: HackathonKnowledgeEntry[];
+ warning?: string | null;
};
- setEntries((current) => [...json.entries, ...current]);
- setStatus(`Imported ${json.entries.length} live training entries.`);
- } else {
- const imported = parseKnowledgeImportText(
- importText,
- escalationTarget,
- ).map((entry) =>
- createKnowledgeEntry({
- guildId: selectedGuild.id,
- title: entry.title,
- answer: entry.answer,
- tags: entry.tags,
- escalationTarget: entry.escalationTarget,
- createdBy: session?.user.id ?? "preview",
- }),
- );
- setEntries((current) => [...imported, ...current]);
- setStatus(`Preview imported ${imported.length} entries.`);
+ setEntries((current) => [...body.entries, ...current]);
+ setImportText("");
+ setNotice({
+ kind: body.warning ? "error" : "status",
+ message: body.warning
+ ? `Imported ${body.entries.length} answer${body.entries.length === 1 ? "" : "s"}, but the activity-log entry could not be recorded. The answers themselves are safe.`
+ : `Imported ${body.entries.length} answer${body.entries.length === 1 ? "" : "s"}.`,
+ });
+ } catch (error) {
+ setNotice({ kind: "error", message: trainingErrorMessage(error) });
+ } finally {
+ setBusyAction(null);
}
- setImportText("");
}
async function saveSettings() {
- if (liveMode) {
- const guildQuery = encodeURIComponent(selectedGuild.id);
- const response = await fetch(
- `/api/training/settings?guildId=${guildQuery}`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(settings),
- },
- );
- if (!response.ok) {
- setStatus("Live settings save failed.");
- return;
- }
- setStatus("Live escalation settings saved.");
- return;
+ setBusyAction("settings");
+ setNotice({ kind: "status", message: "Saving answer settings…" });
+ try {
+ const response = await trainingFetch("/api/training/settings", guild.id, {
+ method: "POST",
+ body: JSON.stringify({
+ ...settings,
+ staffRoleId: settings.staffRoleId ?? null,
+ mentorRoleId: settings.mentorRoleId ?? null,
+ helpChannelId: settings.helpChannelId ?? null,
+ }),
+ });
+ const body = (await response.json()) as { warning?: string | null };
+ setNotice({
+ kind: body.warning ? "error" : "status",
+ message: body.warning
+ ? `Settings were saved for ${guild.name}, but the activity-log entry could not be recorded.`
+ : `Answer and follow-up settings saved for ${guild.name}.`,
+ });
+ } catch (error) {
+ setNotice({ kind: "error", message: trainingErrorMessage(error) });
+ } finally {
+ setBusyAction(null);
}
- setStatus("Preview settings updated.");
}
- async function removeEntry(entryId: string) {
- if (liveMode) {
- const guildQuery = encodeURIComponent(selectedGuild.id);
- const entryQuery = encodeURIComponent(entryId);
- const response = await fetch(
- `/api/training/entries?guildId=${guildQuery}&entryId=${entryQuery}`,
+ async function removeEntry(entry: HackathonKnowledgeEntry) {
+ setBusyAction(`delete:${entry.id}`);
+ setNotice({ kind: "status", message: `Removing “${entry.title}”…` });
+ try {
+ const response = await trainingFetch(
+ "/api/training/entries",
+ guild.id,
{ method: "DELETE" },
+ { entryId: entry.id },
);
- if (!response.ok) {
- setStatus("Could not delete live training entry.");
- return;
- }
+ const body = (await response.json()) as { warning?: string | null };
+ setEntries((current) => current.filter((item) => item.id !== entry.id));
+ setRemoveConfirmationId(null);
+ returnDeleteFocusIdRef.current = null;
+ setNotice({
+ kind: body.warning ? "error" : "status",
+ message: body.warning
+ ? `Removed “${entry.title}” from ${guild.name}, but the activity-log entry could not be recorded.`
+ : `Removed “${entry.title}” from ${guild.name}.`,
+ });
+ } catch (error) {
+ setNotice({ kind: "error", message: trainingErrorMessage(error) });
+ } finally {
+ setBusyAction(null);
}
- setEntries((current) => current.filter((entry) => entry.id !== entryId));
- setStatus("Training entry removed.");
+ }
+
+ function askToRemoveEntry(entryId: string): void {
+ setRemoveConfirmationId(entryId);
+ }
+
+ function cancelEntryRemoval(): void {
+ returnDeleteFocusIdRef.current = removeConfirmationId;
+ setRemoveConfirmationId(null);
}
function updateOptionalSetting(
@@ -262,110 +288,100 @@ export function TrainingConsole({
) {
setSettings((current) => {
const next = { ...current };
- if (value) {
- next[key] = value;
- } else {
- delete next[key];
- }
+ if (value) next[key] = value;
+ else delete next[key];
+ return next;
+ });
+ }
+
+ function clearEntryError(key: "title" | "answer") {
+ setEntryErrors((current) => {
+ if (!current[key]) return current;
+ const next = { ...current };
+ delete next[key];
return next;
});
}
return (
-
+
+
+ {notice.message}
+
+
- Linked Discord Account
- {session ? (
-
- {session.user.avatarUrl ? (
-
- ) : (
-
- {session.user.username.slice(0, 1).toUpperCase()}
-
- )}
-
-
- {session.user.globalName ?? session.user.username}
-
-
Discord ID {session.user.id}
-
-
- Sign out
-
-
- ) : (
-
-
-
1
-
-
Connect Discord
-
- The dashboard will show servers where your account has Manage
- Server.
-
-
-
- Connect
-
-
+
+
+
Add an answer
+
+ Use the words participants are likely to use.
+
- )}
-
-
- Server deployment
- setSelectedGuildId(event.target.value)}
- >
- {guilds.map((guild) => (
-
- {guild.name}
-
- ))}
-
-
-
-
- {liveMode ? "Live" : "Preview"}
-
- {status}
+
-
- Add PipHackLup to this Discord server
-
-
-
-
- Bulk Import
+ Import several details
+
+ Put one detail on each line: question | answer | comma-separated
+ keywords | optional mentor or staff follow-up.
+
- One line per detail
+ Event details
-
- Import details
+
+
+ {busyAction === "import" ? "Importing…" : "Import details"}
-
- Ask Preview
+
+ Try a participant question
- Participant question
+ Question
setQuestion(event.target.value)}
@@ -479,8 +537,8 @@ export function TrainingConsole({
{previewAnswer.shouldEscalate
- ? "Answer + human follow-up"
- : "Answer from training"}
+ ? "Answer with human follow-up"
+ : "Answer from saved details"}
{previewAnswer.answer}
{previewAnswer.escalationReason}
@@ -489,49 +547,173 @@ export function TrainingConsole({
- Training Library
-
-
-
- Topic
- Keywords
- Follow-up
-
-
-
-
+
+
+
Saved answers
+
+ {entries.length} in {guild.name}
+
+
+
+ {entries.length ? (
+
{entries.map((entry) => (
-
-
- {entry.title}
- {entry.answer}
-
- {entry.tags.join(", ") || "none"}
-
-
+
+
{entry.title}
+
{entry.answer}
+
+ {entry.tags.map((tag) => (
+ {tag}
+ ))}
+
+ {entry.escalationTarget === "none"
+ ? "No automatic follow-up"
+ : `${entry.escalationTarget} follow-up`}
+
+
+
+ {removeConfirmationId === entry.id ? (
+ {
+ if (event.key !== "Escape" || busy) return;
+ event.preventDefault();
+ cancelEntryRemoval();
+ }}
>
- {entry.escalationTarget}
-
-
-
+
+ Remove “{entry.title}” ? Participants will
+ no longer receive this saved answer.
+
+
+ removeEntry(entry)}
+ ref={deleteConfirmRef}
+ >
+
+ {busyAction === `delete:${entry.id}`
+ ? "Removing…"
+ : "Confirm remove"}
+
+
+ Keep answer
+
+
+
+ ) : (
removeEntry(entry.id)}
+ disabled={busy}
+ onClick={() => askToRemoveEntry(entry.id)}
+ aria-label={`Remove ${entry.title}`}
+ data-remove-entry-id={entry.id}
>
+
Remove
-
-
+ )}
+
))}
-
-
+
+ ) : (
+
+ No answers have been saved for this server yet. Add the event basics
+ above, then try the participant question preview.
+
+ )}
);
}
+
+function DiscordSelect({
+ label,
+ value,
+ options,
+ disabled,
+ onChange,
+}: Readonly<{
+ label: string;
+ value: string;
+ options: DiscordOption[];
+ disabled: boolean;
+ onChange: (value: string) => void;
+}>) {
+ const currentOptionExists = options.some((option) => option.id === value);
+ return (
+
+ {label}
+ onChange(event.target.value)}
+ disabled={disabled}
+ >
+ Not selected
+ {value && !currentOptionExists ? (
+ Previously selected
+ ) : null}
+ {options.map((option) => (
+
+ {option.name}
+
+ ))}
+
+
+ );
+}
+
+async function trainingFetch(
+ path: string,
+ guildId: string,
+ init: RequestInit,
+ extraQuery: Record
= {},
+): Promise {
+ const query = new URLSearchParams({ guildId, ...extraQuery });
+ const response = await fetch(`${path}?${query.toString()}`, {
+ ...init,
+ headers: {
+ "content-type": "application/json",
+ ...init.headers,
+ },
+ });
+ if (response.ok) return response;
+ const body = (await response.json().catch(() => null)) as {
+ error?: string;
+ } | null;
+ throw new Error(body?.error ?? `request_failed_${response.status}`);
+}
+
+function trainingErrorMessage(error: unknown): string {
+ const code = error instanceof Error ? error.message : "unknown";
+ switch (code) {
+ case "training_content_rejected":
+ return "That text looks like an instruction-override attempt, so it was not saved. Rewrite it as plain event information or ask staff to review it.";
+ case "training_import_empty":
+ return "No usable event details were found. Add one detail per line, then import again.";
+ case "training_import_too_many_entries":
+ return "This import has more than 50 event details. Split it into smaller groups so every answer can be reviewed and saved.";
+ case "missing_manage_server":
+ return "Your Discord account no longer has permission to manage this server.";
+ case "untrusted_request_origin":
+ return "This save could not be verified. Refresh the page and try again.";
+ case "rate_limited":
+ return "Too many changes were sent at once. Wait a moment, then try again.";
+ case "database_not_configured":
+ case "database_unavailable":
+ return "Your saved answers are temporarily unavailable. Nothing was changed; try again shortly.";
+ default:
+ return "That change could not be saved. Nothing was lost; try again.";
+ }
+}
diff --git a/apps/web/app/training/page.tsx b/apps/web/app/training/page.tsx
index c79992b..1ec83cd 100644
--- a/apps/web/app/training/page.tsx
+++ b/apps/web/app/training/page.tsx
@@ -1,104 +1,124 @@
-import { Bot, Database, LogIn, ServerCog } from "lucide-react";
-import { isDatabaseConfigured } from "@piphacklup/db";
+import { Bot, BookOpenCheck, ExternalLink } from "lucide-react";
+import {
+ defaultKnowledgeSettings,
+ type HackathonKnowledgeEntry,
+ type KnowledgeAssistantSettings,
+} from "@piphacklup/core";
+import {
+ getKnowledgeSettingsFromDb,
+ listKnowledgeEntriesFromDb,
+} from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
-import { MetricCard } from "@/components/MetricCard";
import { PageHeader } from "@/components/PageHeader";
+import { WorkspaceState } from "@/components/WorkspaceState";
import {
- isDiscordAuthConfigured,
- readDiscordSession,
-} from "@/lib/discord-auth";
+ getDiscordInstallUrl,
+ isDiscordBotApiConfigured,
+ listDiscordBotGuildIds,
+} from "@/lib/discord-installation";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
import { TrainingConsole } from "./TrainingConsole";
-const installUrl =
- "https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands&permissions=1117094267958";
+interface TrainingPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function TrainingPage({
+ searchParams,
+}: TrainingPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let entries: HackathonKnowledgeEntry[] = [];
+ let settings: KnowledgeAssistantSettings = { ...defaultKnowledgeSettings };
+ let dataUnavailable = false;
+ let installation: boolean | null = null;
+
+ if (workspace.guild) {
+ try {
+ [entries, settings] = await Promise.all([
+ listKnowledgeEntriesFromDb(workspace.guild.id),
+ getKnowledgeSettingsFromDb(workspace.guild.id),
+ ]);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load Q&A training.");
+ }
-export default async function TrainingPage() {
- const session = await readDiscordSession();
- const authReady = isDiscordAuthConfigured();
- const databaseReady = isDatabaseConfigured();
+ if (isDiscordBotApiConfigured()) {
+ try {
+ installation = (await listDiscordBotGuildIds()).has(workspace.guild.id);
+ } catch {
+ console.error("PipHackLup could not check the bot installation.");
+ }
+ }
+ }
return (
-
+
- {session ? (
-
-
- Sign out
-
- ) : (
-
-
- Connect Discord
-
- )}
-
+ workspace.guild && installation === false ? (
+
- Add bot
+ Add PipHackLup
+
- >
+ ) : undefined
}
/>
-
-
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
-
-
-
-
-
-
-
-
-
- Server training is scoped to Discord guilds the signed-in organizer
- can manage.
-
-
-
-
-
- With Postgres attached, website training and slash-command training
- use the same source.
-
-
-
-
-
+ ) : (
+ <>
+
+
+
+
+ {entries.length} saved answer{entries.length === 1 ? "" : "s"}
+
+
+ Changes apply only to {workspace.guild.name}. PipHackLup filters
+ instruction-override attempts before saving them.
+
+
+
+ {installation === true
+ ? "Bot installed"
+ : installation === false
+ ? "Bot not installed"
+ : "Install status unavailable"}
+
+
+
+ >
+ )}
);
}
diff --git a/apps/web/components/AppShell.tsx b/apps/web/components/AppShell.tsx
index 97949e8..d0b9d40 100644
--- a/apps/web/components/AppShell.tsx
+++ b/apps/web/components/AppShell.tsx
@@ -1,96 +1,67 @@
-"use client";
-
import {
- BarChart3,
- ClipboardList,
- Download,
- FileText,
- MessageCircleQuestion,
- Shield,
- Users,
- Wrench,
-} from "lucide-react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { useEffect, useState } from "react";
-import { ThemeToggle } from "@/components/ThemeToggle";
-
-type DashboardTheme = "light" | "dark";
-
-const themeStorageKey = "piphacklup-dashboard-theme";
+ isDiscordAuthConfigured,
+ readDiscordSession,
+ type DiscordSession,
+} from "@/lib/discord-auth";
+import { Suspense } from "react";
+import { AppShellClient } from "./AppShellClient";
-const navItems = [
- { href: "/dashboard", label: "Overview", icon: BarChart3 },
- { href: "/setup", label: "Setup", icon: Wrench },
- { href: "/training", label: "Q&A Training", icon: MessageCircleQuestion },
- { href: "/queues", label: "Queues", icon: ClipboardList },
- { href: "/teams", label: "Teams", icon: Users },
- { href: "/moderation", label: "Moderation", icon: Shield },
- { href: "/privacy", label: "Privacy", icon: FileText },
-];
+interface AppShellProps {
+ children: React.ReactNode;
+ session?: DiscordSession | null;
+ authRequired?: boolean;
+ sessionUnavailable?: boolean;
+}
-export function AppShell({
+export async function AppShell({
children,
-}: Readonly<{ children: React.ReactNode }>) {
- const [theme, setTheme] = useState("light");
- const pathname = usePathname();
-
- useEffect(() => {
- const saved = window.localStorage.getItem(themeStorageKey);
- const initial =
- saved === "dark" || saved === "light"
- ? saved
- : window.matchMedia("(prefers-color-scheme: dark)").matches
- ? "dark"
- : "light";
- setTheme(initial);
- document.documentElement.dataset.dashboardTheme = initial;
- }, []);
+ session: providedSession,
+ authRequired = true,
+ sessionUnavailable: providedSessionUnavailable = false,
+}: Readonly) {
+ let session = providedSession ?? null;
+ let sessionUnavailable = providedSessionUnavailable;
- function toggleTheme() {
- const next = theme === "dark" ? "light" : "dark";
- setTheme(next);
- window.localStorage.setItem(themeStorageKey, next);
- document.documentElement.dataset.dashboardTheme = next;
+ if (providedSession === undefined && isDiscordAuthConfigured()) {
+ try {
+ session = await readDiscordSession();
+ } catch {
+ sessionUnavailable = true;
+ console.error("PipHackLup could not read the organizer session.");
+ }
}
return (
-
+
}>
+
+ {children}
+
+
+ );
+}
+
+function AppShellFallback() {
+ return (
+
-
-
- {children}
+
+
+
Organizer control room
+
Opening your server workspace…
diff --git a/apps/web/components/AppShellClient.tsx b/apps/web/components/AppShellClient.tsx
new file mode 100644
index 0000000..8eb6692
--- /dev/null
+++ b/apps/web/components/AppShellClient.tsx
@@ -0,0 +1,525 @@
+"use client";
+
+import {
+ BarChart3,
+ Bot,
+ ClipboardList,
+ Download,
+ FileText,
+ LogIn,
+ LogOut,
+ MessageCircleQuestion,
+ MoreHorizontal,
+ Server,
+ Shield,
+ Users,
+ Wrench,
+} from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useMemo, useState } from "react";
+import type { DiscordSession } from "@/lib/discord-auth";
+import { ThemeToggle } from "@/components/ThemeToggle";
+
+type DashboardTheme = "light" | "dark";
+
+interface AppShellClientProps {
+ children: React.ReactNode;
+ session: DiscordSession | null;
+ authReady: boolean;
+ authRequired: boolean;
+ sessionUnavailable: boolean;
+}
+
+const themeStorageKey = "piphacklup-dashboard-theme";
+const guildWorkspacePaths = new Set([
+ "/dashboard",
+ "/dev-fixtures/control-room",
+ "/moderation",
+ "/queues",
+ "/setup",
+ "/teams",
+ "/training",
+]);
+
+const primaryNavItems = [
+ { href: "/dashboard", label: "Overview", icon: BarChart3 },
+ { href: "/setup", label: "Setup", icon: Wrench },
+ { href: "/training", label: "Q&A Training", icon: MessageCircleQuestion },
+ {
+ href: "/queues",
+ label: "Queues",
+ icon: ClipboardList,
+ mobileOverflow: true,
+ },
+ { href: "/teams", label: "Teams", icon: Users, mobileOverflow: true },
+];
+
+const mobileOverflowNavItems = primaryNavItems.filter(
+ (item) => item.mobileOverflow,
+);
+
+const secondaryNavItems = [
+ { href: "/moderation", label: "Moderation", icon: Shield },
+ { href: "/privacy", label: "Privacy", icon: FileText },
+];
+
+export function AppShellClient({
+ children,
+ session,
+ authReady,
+ authRequired,
+ sessionUnavailable,
+}: Readonly) {
+ const [theme, setTheme] = useState("light");
+ const pathname = usePathname();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const requestedGuildId = searchParams.get("guildId");
+ const shouldRestoreGuild = Boolean(
+ session?.guilds.length &&
+ !requestedGuildId &&
+ guildWorkspacePaths.has(pathname),
+ );
+ const [restoringGuild, setRestoringGuild] = useState(shouldRestoreGuild);
+ const requestedGuild = session?.guilds.find(
+ (guild) => guild.id === requestedGuildId,
+ );
+ const requestedGuildUnavailable = Boolean(
+ session && requestedGuildId && !requestedGuild,
+ );
+ const selectedGuildId = requestedGuildUnavailable
+ ? ""
+ : restoringGuild
+ ? ""
+ : (requestedGuild?.id ?? session?.guilds[0]?.id ?? "");
+ const selectedGuild = session?.guilds.find(
+ (guild) => guild.id === selectedGuildId,
+ );
+ const authMessage = getAuthMessage(searchParams.get("auth"));
+
+ useEffect(() => {
+ const initial = readPreparedTheme();
+ setTheme(initial);
+ document.documentElement.dataset.dashboardTheme = initial;
+ }, []);
+
+ useEffect(() => {
+ if (!session?.guilds.length || !guildWorkspacePaths.has(pathname)) {
+ setRestoringGuild(false);
+ return;
+ }
+
+ const storageKey = lastGuildStorageKey(session.user.id);
+ if (requestedGuildId) {
+ if (requestedGuild) {
+ storeLastGuild(storageKey, requestedGuild.id);
+ }
+ setRestoringGuild(false);
+ return;
+ }
+
+ const fallbackGuildId = session.guilds[0]!.id;
+ const savedGuildId = readLastGuild(storageKey);
+ const savedGuild = session.guilds.find(
+ (guild) => guild.id === savedGuildId,
+ );
+ if (savedGuild && savedGuild.id !== fallbackGuildId) {
+ setRestoringGuild(true);
+ router.replace(withGuild(pathname, savedGuild.id), { scroll: false });
+ return;
+ }
+
+ storeLastGuild(storageKey, fallbackGuildId);
+ setRestoringGuild(false);
+ }, [pathname, requestedGuild, requestedGuildId, router, session]);
+
+ useEffect(() => {
+ let canceled = false;
+ let retryTimer: ReturnType | undefined;
+ let attempt = 0;
+
+ async function retryPendingRevocations() {
+ let completed = false;
+ try {
+ const response = await fetch("/api/auth/session/revoke-pending", {
+ method: "POST",
+ });
+ completed = response.status === 204;
+ } catch {
+ completed = false;
+ }
+
+ if (canceled) return;
+ if (completed) {
+ if (searchParams.get("auth") === "logout_incomplete") {
+ const next = new URLSearchParams(searchParams.toString());
+ next.delete("auth");
+ router.replace(`${pathname}${next.size ? `?${next}` : ""}`, {
+ scroll: false,
+ });
+ }
+ return;
+ }
+
+ attempt += 1;
+ retryTimer = setTimeout(
+ () => void retryPendingRevocations(),
+ Math.min(60_000, 2_000 * 2 ** Math.min(attempt, 5)),
+ );
+ }
+
+ void retryPendingRevocations();
+ return () => {
+ canceled = true;
+ if (retryTimer) clearTimeout(retryTimer);
+ };
+ }, [pathname, router, searchParams]);
+
+ const navigation = useMemo(
+ () =>
+ primaryNavItems.map((item) => ({
+ ...item,
+ href: withGuild(item.href, selectedGuildId),
+ })),
+ [selectedGuildId],
+ );
+ const secondaryNavigation = useMemo(
+ () =>
+ secondaryNavItems.map((item) => ({
+ ...item,
+ href: withGuild(item.href, selectedGuildId),
+ })),
+ [selectedGuildId],
+ );
+
+ function toggleTheme() {
+ const next = theme === "dark" ? "light" : "dark";
+ setTheme(next);
+ try {
+ window.localStorage.setItem(themeStorageKey, next);
+ } catch {
+ // The theme still changes for this page when browser storage is blocked.
+ }
+ document.documentElement.dataset.dashboardTheme = next;
+ }
+
+ function selectGuild(guildId: string) {
+ if (!guildId) return;
+ router.push(withGuild(pathname, guildId));
+ }
+
+ const showAuthGate = authRequired && !session;
+
+ return (
+
+
+ Skip to main content
+
+
+
+
+
+ P
+
+ PipHackLup
+
+
+
+
+ {session ? (
+
+
+
+ Server workspace
+
+ selectGuild(event.target.value)}
+ >
+ {restoringGuild ? (
+
+ Opening last server…
+
+ ) : requestedGuildUnavailable ? (
+
+ Choose a server
+
+ ) : null}
+ {session.guilds.length ? (
+ session.guilds.map((guild) => (
+
+ {guild.name}
+
+ ))
+ ) : (
+ No manageable servers
+ )}
+
+
+ ) : null}
+
+ {session && !restoringGuild ? (
+
+ {navigation.map((item) => {
+ const Icon = item.icon;
+ const active =
+ pathname === item.href.split("?")[0] ||
+ pathname.startsWith(`${item.href.split("?")[0]}/`);
+
+ return (
+
+
+ {item.label}
+
+ );
+ })}
+ {
+ if (event.key !== "Escape") return;
+ event.currentTarget.open = false;
+ event.currentTarget.querySelector("summary")?.focus();
+ }}
+ >
+
+
+ More
+
+
+ {mobileOverflowNavItems.map((item) => {
+ const Icon = item.icon;
+ const href = withGuild(item.href, selectedGuildId);
+ const active =
+ pathname === item.href ||
+ pathname.startsWith(`${item.href}/`);
+ return (
+
+
+
{item.label}
+
+ );
+ })}
+ {secondaryNavigation.map((item) => {
+ const Icon = item.icon;
+ const active =
+ pathname === item.href.split("?")[0] ||
+ pathname.startsWith(`${item.href.split("?")[0]}/`);
+ return (
+
+
+
{item.label}
+
+ );
+ })}
+
+
+ Export
+
+
+
+
+ ) : null}
+
+
+ {session ? (
+ <>
+
+ {session.user.avatarUrl ? (
+
+ ) : (
+
+ {session.user.username.slice(0, 1).toUpperCase()}
+
+ )}
+
+
+ {session.user.globalName ?? session.user.username}
+
+
+ {selectedGuild ? selectedGuild.name : "Discord connected"}
+
+
+
+
+ >
+ ) : (
+
+
+
+ Organizer account
+ Discord is your sign-in
+
+
+ )}
+
+
+
+
+ {restoringGuild ? (
+
+
+
+
Opening your last server workspace…
+
PipHackLup is checking that you can still manage it.
+
+
+ ) : showAuthGate ? (
+
+ ) : (
+ children
+ )}
+
+
+
+ );
+}
+
+function AuthGate({
+ authMessage,
+ authReady,
+ sessionUnavailable,
+}: {
+ authMessage: string | undefined;
+ authReady: boolean;
+ sessionUnavailable: boolean;
+}) {
+ const unavailable = sessionUnavailable || !authReady;
+ return (
+
+
+
+
+ Organizer control room
+ Sign in with Discord to manage your servers
+
+ PipHackLup uses your Discord identity to show only servers you own or
+ where you currently have Manage Server permission.
+
+ {authMessage ? (
+
+ {authMessage}
+
+ ) : null}
+ {unavailable ? (
+
+ Discord account login is temporarily unavailable.
+
+ Nothing is wrong with your Discord account. Please try again a
+ little later.
+
+
+ ) : (
+
+
+ Continue with Discord
+
+ )}
+
+ PipHackLup never asks for your Discord password and does not request
+ access to read your messages.
+
+
+ );
+}
+
+function withGuild(href: string, guildId: string): string {
+ if (!guildId) return href;
+ const separator = href.includes("?") ? "&" : "?";
+ return `${href}${separator}guildId=${encodeURIComponent(guildId)}`;
+}
+
+function lastGuildStorageKey(discordUserId: string): string {
+ return `piphacklup:last-guild:${discordUserId}`;
+}
+
+function readPreparedTheme(): DashboardTheme {
+ const prepared = document.documentElement.dataset.dashboardTheme;
+ if (prepared === "dark" || prepared === "light") return prepared;
+
+ try {
+ const saved = window.localStorage.getItem(themeStorageKey);
+ if (saved === "dark" || saved === "light") return saved;
+ } catch {
+ // Fall through to the operating-system preference.
+ }
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+}
+
+function readLastGuild(storageKey: string): string | null {
+ try {
+ return window.localStorage.getItem(storageKey);
+ } catch {
+ return null;
+ }
+}
+
+function storeLastGuild(storageKey: string, guildId: string): void {
+ try {
+ window.localStorage.setItem(storageKey, guildId);
+ } catch {
+ // The workspace remains usable when browser storage is unavailable.
+ }
+}
+
+function getAuthMessage(status: string | null): string | undefined {
+ switch (status) {
+ case "denied":
+ return "Discord sign-in was canceled. Nothing was changed; try again when you are ready.";
+ case "expired":
+ return "Your sign-in request expired. Start again to reconnect securely.";
+ case "failed":
+ return "Discord sign-in could not be completed. Try again, then contact support if it keeps failing.";
+ case "missing":
+ return "Discord sign-in is not configured on this deployment yet.";
+ case "logout_incomplete":
+ return "You are signed out on this browser. PipHackLup could not immediately end the previous server session. It will retry while this page is open and when this browser returns; that session expires automatically within 12 hours.";
+ case "logout_requires_post":
+ return "For your safety, sign-out only works from the button inside PipHackLup. Your session was not changed.";
+ default:
+ return undefined;
+ }
+}
diff --git a/apps/web/components/MetricCard.tsx b/apps/web/components/MetricCard.tsx
deleted file mode 100644
index 63d166a..0000000
--- a/apps/web/components/MetricCard.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-export function MetricCard({
- label,
- value,
- detail
-}: Readonly<{
- label: string;
- value: string;
- detail: string;
-}>) {
- return (
-
- {value}
- {label}
- {detail}
-
- );
-}
diff --git a/apps/web/components/PageHeader.tsx b/apps/web/components/PageHeader.tsx
index 8b7c3f9..087d54e 100644
--- a/apps/web/components/PageHeader.tsx
+++ b/apps/web/components/PageHeader.tsx
@@ -2,7 +2,7 @@ export function PageHeader({
eyebrow,
title,
subtitle,
- actions
+ actions,
}: Readonly<{
eyebrow: string;
title: string;
diff --git a/apps/web/components/ServerManager.tsx b/apps/web/components/ServerManager.tsx
new file mode 100644
index 0000000..1cbdbc9
--- /dev/null
+++ b/apps/web/components/ServerManager.tsx
@@ -0,0 +1,471 @@
+"use client";
+
+import {
+ BookOpen,
+ Bot,
+ CheckCircle2,
+ ExternalLink,
+ RefreshCw,
+ Search,
+ ServerOff,
+ Settings,
+ ShieldCheck,
+ Trash2,
+ X,
+} from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useEffect, useRef, useState } from "react";
+
+export interface ManagedServerView {
+ id: string;
+ name: string;
+ iconUrl?: string;
+ isOwner: boolean;
+ installed: boolean | null;
+ installUrl: string;
+}
+
+interface ServerManagerProps {
+ servers: ManagedServerView[];
+ installationStatusError?: string;
+}
+
+type ServerFilter = "all" | "installed" | "needs_setup" | "unknown";
+
+export function ServerManager({
+ servers,
+ installationStatusError,
+}: Readonly) {
+ const router = useRouter();
+ const dialogRef = useRef(null);
+ const dialogErrorRef = useRef(null);
+ const [installedState, setInstalledState] = useState<
+ Record
+ >(Object.fromEntries(servers.map((server) => [server.id, server.installed])));
+ const [removeTarget, setRemoveTarget] = useState(
+ null,
+ );
+ const [confirmation, setConfirmation] = useState("");
+ const [dialogError, setDialogError] = useState(null);
+ const [query, setQuery] = useState("");
+ const [filter, setFilter] = useState("all");
+ const [busyGuildId, setBusyGuildId] = useState(null);
+ const [pendingInstallGuildId, setPendingInstallGuildId] = useState<
+ string | null
+ >(null);
+ const [status, setStatus] = useState(
+ installationStatusError ?? "Server installation status is up to date.",
+ );
+ const normalizedQuery = query.trim().toLocaleLowerCase();
+ const visibleServers = servers.filter((server) => {
+ const installed = Object.hasOwn(installedState, server.id)
+ ? installedState[server.id]!
+ : server.installed;
+ const matchesQuery =
+ !normalizedQuery ||
+ server.name.toLocaleLowerCase().includes(normalizedQuery);
+ const matchesFilter =
+ filter === "all" ||
+ (filter === "installed" && installed === true) ||
+ (filter === "needs_setup" && installed === false) ||
+ (filter === "unknown" && installed === null);
+ return matchesQuery && matchesFilter;
+ });
+
+ useEffect(() => {
+ if (!pendingInstallGuildId) return;
+ const refreshWhenOrganizerReturns = () => {
+ setStatus("Checking whether Discord finished adding PipHackLup...");
+ window.location.reload();
+ };
+ window.addEventListener("focus", refreshWhenOrganizerReturns, {
+ once: true,
+ });
+ return () =>
+ window.removeEventListener("focus", refreshWhenOrganizerReturns);
+ }, [pendingInstallGuildId]);
+
+ function openRemoval(server: ManagedServerView) {
+ setRemoveTarget(server);
+ setConfirmation("");
+ setDialogError(null);
+ dialogRef.current?.showModal();
+ }
+
+ function closeRemoval() {
+ if (busyGuildId) return;
+ dialogRef.current?.close();
+ setRemoveTarget(null);
+ setConfirmation("");
+ setDialogError(null);
+ }
+
+ async function removeBot() {
+ if (!removeTarget || confirmation !== removeTarget.name) return;
+ setBusyGuildId(removeTarget.id);
+ setDialogError(null);
+ setStatus(`Removing PipHackLup from ${removeTarget.name}...`);
+
+ try {
+ const response = await fetch(
+ `/api/discord/guilds/${encodeURIComponent(removeTarget.id)}/bot`,
+ {
+ method: "DELETE",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ confirmGuildName: confirmation }),
+ },
+ );
+ const body = (await response.json().catch(() => null)) as {
+ error?: string;
+ warning?: string;
+ } | null;
+ if (!response.ok) {
+ throw new Error(removalErrorMessage(body?.error));
+ }
+
+ setInstalledState((current) => ({
+ ...current,
+ [removeTarget.id]: false,
+ }));
+ setStatus(
+ body?.warning === "record_update_failed"
+ ? `Discord removed PipHackLup from ${removeTarget.name}, and its saved event data was retained. PipHackLup could not update its activity record, so refresh the status before relying on the dashboard record.`
+ : `PipHackLup was removed from ${removeTarget.name}. Its saved event data was retained for a future reinstall.`,
+ );
+ dialogRef.current?.close();
+ setRemoveTarget(null);
+ setConfirmation("");
+ router.refresh();
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : "PipHackLup could not be removed. Try again.";
+ setStatus(message);
+ setDialogError(message);
+ window.requestAnimationFrame(() => dialogErrorRef.current?.focus());
+ } finally {
+ setBusyGuildId(null);
+ }
+ }
+
+ if (!servers.length) {
+ return (
+
+
+ No manageable Discord servers found
+
+ Discord only returns servers you own or where your account currently
+ has Manage Server. Create a test server or ask its owner for that
+ permission, then reconnect Discord.
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
Server workspaces
+
+ Add, open, or remove PipHackLup without losing track of which server
+ you are changing.
+
+
+
{
+ setStatus("Refreshing installation status...");
+ window.location.reload();
+ }}
+ >
+
+ Refresh status
+
+
+
+
+ {status}
+
+
+
+
+ Search servers by name
+
+ setQuery(event.target.value)}
+ />
+
+
+ Status
+ setFilter(event.target.value as ServerFilter)}
+ >
+ All servers
+ Installed
+ Needs setup
+ Status unavailable
+
+
+
+
+
+ {visibleServers.map((server) => {
+ const installed = Object.hasOwn(installedState, server.id)
+ ? installedState[server.id]!
+ : server.installed;
+ return (
+
+
+ {server.iconUrl ? (
+
+ ) : (
+
+ {server.name.slice(0, 1).toUpperCase()}
+
+ )}
+
+
{server.name}
+
{server.isOwner ? "Server owner" : "Manage Server"}
+
+
+
+
+
+
+ Your organizer access was checked with Discord
+
+
+
+
+ );
+ })}
+
+
+ {!visibleServers.length ? (
+
+ No servers match those filters.
+ {
+ setQuery("");
+ setFilter("all");
+ }}
+ >
+ Clear filters
+
+
+ ) : null}
+
+ {
+ if (busyGuildId) event.preventDefault();
+ else closeRemoval();
+ }}
+ onClose={() => {
+ if (!busyGuildId) {
+ setRemoveTarget(null);
+ setConfirmation("");
+ setDialogError(null);
+ }
+ }}
+ >
+ {removeTarget ? (
+
+
+
+
+
+
+
+
+ Remove PipHackLup from {removeTarget.name}?
+
+
+ The bot will immediately lose access to this Discord server and
+ its commands will stop working. Saved hackathon data will be kept
+ so an organizer can reinstall later.
+
+ {dialogError ? (
+
+ {dialogError}
+
+ ) : null}
+
+
+ Type {removeTarget.name} to confirm
+
+ setConfirmation(event.target.value)}
+ />
+
+
+
+ Cancel
+
+
+
+ {busyGuildId ? "Removing..." : "Remove bot"}
+
+
+
+ ) : null}
+
+ >
+ );
+}
+
+function InstallationBadge({ installed }: { installed: boolean | null }) {
+ if (installed === true) {
+ return (
+
+
+ Installed
+
+ );
+ }
+ if (installed === false) {
+ return Not installed ;
+ }
+ return Unavailable ;
+}
+
+function removalErrorMessage(code: string | undefined): string {
+ switch (code) {
+ case "missing_manage_server":
+ return "Your Discord account no longer has Manage Server permission.";
+ case "guild_name_confirmation_required":
+ return "The server name confirmation did not match.";
+ case "untrusted_request_origin":
+ return "The removal request could not be verified. Refresh and try again.";
+ case "rate_limited":
+ return "Too many removal attempts. Wait a moment and try again.";
+ case "discord_bot_api_failed":
+ return "Discord did not complete the removal. Try again in a moment.";
+ default:
+ return "PipHackLup could not be removed. Refresh the page and try again.";
+ }
+}
diff --git a/apps/web/components/WorkspaceState.tsx b/apps/web/components/WorkspaceState.tsx
new file mode 100644
index 0000000..9a146b3
--- /dev/null
+++ b/apps/web/components/WorkspaceState.tsx
@@ -0,0 +1,53 @@
+import { AlertTriangle, ServerOff } from "lucide-react";
+
+export function WorkspaceState({
+ kind,
+ serverName,
+}: Readonly<{
+ kind: "no-server" | "server-unavailable" | "data-unavailable";
+ serverName?: string;
+}>) {
+ const unavailable = kind === "data-unavailable";
+ const serverUnavailable = kind === "server-unavailable";
+ const Icon = unavailable || serverUnavailable ? AlertTriangle : ServerOff;
+ return (
+
+
+
+
+ {unavailable
+ ? `We could not load ${serverName ?? "this server"}`
+ : serverUnavailable
+ ? "That server is no longer available"
+ : "No manageable server is available"}
+
+
+ {unavailable
+ ? "Your saved data was not changed. Refresh the page, and try again in a moment."
+ : serverUnavailable
+ ? "Discord no longer lists it as a server you can manage. Nothing from another server was loaded; choose a different workspace or reconnect Discord."
+ : "Create a Discord server or ask its owner for Manage Server permission, then reconnect your account."}
+
+
+
+ {unavailable
+ ? "Try again"
+ : serverUnavailable
+ ? "Choose another server"
+ : "Reconnect Discord"}
+
+
+ );
+}
diff --git a/apps/web/lib/audit-log.ts b/apps/web/lib/audit-log.ts
new file mode 100644
index 0000000..40764e3
--- /dev/null
+++ b/apps/web/lib/audit-log.ts
@@ -0,0 +1,25 @@
+import { createAuditEventInDb } from "@piphacklup/db";
+import type { AuditEvent } from "@piphacklup/core";
+
+export const activityLogUnavailableWarning = "activity_log_unavailable";
+
+type AuditInput = Omit & {
+ createdAt?: string;
+};
+
+type AuditWriter = (input: AuditInput) => Promise;
+
+export async function recordAuditAfterCommit(
+ input: AuditInput,
+ writeAudit: AuditWriter = createAuditEventInDb,
+): Promise {
+ try {
+ await writeAudit(input);
+ return null;
+ } catch {
+ console.error(
+ "PipHackLup saved a dashboard change but could not record its audit event.",
+ );
+ return activityLogUnavailableWarning;
+ }
+}
diff --git a/apps/web/lib/dashboard-security.ts b/apps/web/lib/dashboard-security.ts
index 9740af1..757694a 100644
--- a/apps/web/lib/dashboard-security.ts
+++ b/apps/web/lib/dashboard-security.ts
@@ -1,15 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { isDatabaseConfigured } from "@piphacklup/db";
import {
+ DiscordAuthUnavailableError,
findManagedGuild,
readDiscordSession,
type DiscordSession,
type ManagedDiscordGuild,
} from "@/lib/discord-auth";
import {
- buildRateLimitKey,
+ buildPreAuthRateLimitKey,
enforceRateLimit,
- getClientIp,
type RateLimitPolicy,
} from "@/lib/rate-limit";
@@ -27,21 +27,32 @@ export async function requireOrganizerGuildAccess(
action: string;
rateLimit: RateLimitPolicy;
requireDatabase?: boolean;
+ guildId?: string;
},
): Promise {
- const guildId = request.nextUrl.searchParams.get("guildId");
- const session = await readDiscordSession();
- const rateLimitResponse = enforceRateLimit(request, {
- key: buildRateLimitKey([
- "web",
- options.action,
- session?.user.id ?? `ip-${getClientIp(request)}`,
- guildId ?? "no-guild",
- ]),
+ const guildId =
+ options.guildId ?? request.nextUrl.searchParams.get("guildId") ?? undefined;
+ const rateLimitResponse = await enforceRateLimit(request, {
+ // This bucket runs before authentication, so its cardinality must depend
+ // only on trusted request context. Never let an attacker create one
+ // persistent database row per arbitrary guildId.
+ key: buildPreAuthRateLimitKey(request, options.action),
policy: options.rateLimit,
});
if (rateLimitResponse) return rateLimitResponse;
+ let session: DiscordSession | null;
+ try {
+ session = await readDiscordSession();
+ } catch (error) {
+ if (error instanceof DiscordAuthUnavailableError) {
+ return NextResponse.json(
+ { error: "discord_session_unavailable" },
+ { status: 503 },
+ );
+ }
+ throw error;
+ }
if (options.requireDatabase !== false && !isDatabaseConfigured()) {
return NextResponse.json(
{ error: "database_not_configured" },
diff --git a/apps/web/lib/demo-data.ts b/apps/web/lib/demo-data.ts
deleted file mode 100644
index b11041e..0000000
--- a/apps/web/lib/demo-data.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import {
- createModerationCase,
- createQueueTicket,
- createTeam,
- type MemberProfile,
- type QueueTicket,
- type TeamProfile
-} from "@piphacklup/core";
-
-const now = "2026-06-06T15:00:00.000Z";
-const guildId = "demo-guild";
-
-export const demoMembers: MemberProfile[] = [
- {
- userId: "1001",
- displayName: "Ava",
- skills: ["frontend", "design"],
- interests: ["climate", "education"],
- timezone: "ET",
- beginnerFriendly: true,
- lookingForTeam: true,
- updatedAt: now
- },
- {
- userId: "1002",
- displayName: "Noah",
- skills: ["backend", "python"],
- interests: ["ai", "health"],
- timezone: "PT",
- beginnerFriendly: true,
- lookingForTeam: true,
- updatedAt: now
- },
- {
- userId: "1003",
- displayName: "Mina",
- skills: ["pitch", "product"],
- interests: ["accessibility"],
- timezone: "ET",
- beginnerFriendly: false,
- lookingForTeam: false,
- updatedAt: now
- }
-];
-
-export const demoTeams: TeamProfile[] = [
- createTeam({
- guildId,
- owner: demoMembers[2]!,
- name: "Iceberg Labs",
- desiredSkills: ["frontend", "ai"],
- projectIdea: "AI helper for first-time hackers",
- now
- })
-];
-
-export const demoTickets: QueueTicket[] = [
- createQueueTicket({
- guildId,
- kind: "mentor",
- requesterId: "1001",
- topic: "Scope check",
- description: "Need help cutting the project down for demo time.",
- priority: 2,
- now
- }),
- createQueueTicket({
- guildId,
- kind: "tech",
- requesterId: "1002",
- topic: "Vercel deploy",
- description: "Build passes locally but fails in CI.",
- priority: 3,
- now: "2026-06-06T15:03:00.000Z"
- }),
- createQueueTicket({
- guildId,
- kind: "judging",
- requesterId: "1003",
- topic: "Demo room",
- description: "Ready for practice judging.",
- priority: 1,
- now: "2026-06-06T15:07:00.000Z"
- })
-];
-
-export const demoCases = [
- createModerationCase({
- guildId,
- targetUserId: "2001",
- action: "report",
- reason: "Suspicious invite link in general chat",
- reporterId: "1002",
- evidenceMessageUrl: "https://discord.com/channels/demo/1/2",
- now
- })
-];
-
-export const setupSteps = [
- { label: "Create Discord application", status: "done", detail: "Bot app owned by your Discord account." },
- { label: "Enable Guild Members intent", status: "todo", detail: "Required for joins, roles, and onboarding." },
- { label: "Invite bot to test server", status: "todo", detail: "Use bot + applications.commands scopes." },
- { label: "Run /setup", status: "todo", detail: "Creates event defaults and previews AutoMod rules." },
- { label: "Deploy dashboard", status: "todo", detail: "Vercel Hobby can host the organizer surface." }
-] as const;
diff --git a/apps/web/lib/discord-auth.ts b/apps/web/lib/discord-auth.ts
index 4c7a8c5..74eb484 100644
--- a/apps/web/lib/discord-auth.ts
+++ b/apps/web/lib/discord-auth.ts
@@ -1,11 +1,55 @@
-import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
+import {
+ createCipheriv,
+ createDecipheriv,
+ createHash,
+ hkdfSync,
+ randomBytes,
+ timingSafeEqual,
+} from "node:crypto";
+import {
+ acquireDiscordAccountTokenRefreshLease,
+ completeDiscordAccountTokenRefresh,
+ createDiscordAuthSession,
+ getActiveDiscordAuthSession,
+ getDiscordAccount,
+ releaseDiscordAccountTokenRefreshLease,
+ revokeDiscordAuthSession,
+ revokeDiscordAuthSessionsForAccountIfTokenVersion,
+ touchDiscordAuthSession,
+ updateDiscordAccountIdentity,
+ upsertDiscordAccount,
+ type DiscordAccountRecord,
+ type DiscordAuthSessionWithAccount,
+} from "@piphacklup/db";
import { cookies } from "next/headers";
const SESSION_COOKIE = "piphacklup_discord_session";
+const PENDING_REVOCATIONS_COOKIE = "piphacklup_pending_session_revocations";
const STATE_COOKIE = "piphacklup_oauth_state";
const DISCORD_API = "https://discord.com/api/v10";
+const DISCORD_TOKEN_URL = `${DISCORD_API}/oauth2/token`;
const ADMINISTRATOR = 1n << 3n;
const MANAGE_GUILD = 1n << 5n;
+const SESSION_DURATION_MS = 12 * 60 * 60 * 1_000;
+const MAX_PENDING_SESSION_REVOCATIONS = 48;
+const TOKEN_REFRESH_SKEW_MS = 60_000;
+const TOKEN_REFRESH_LEASE_MS = 60_000;
+const TOKEN_REFRESH_WAIT_TIMEOUT_MS = 12_000;
+const TOKEN_REFRESH_POLL_MS = 100;
+const OAUTH_STATE_TTL_SECONDS = 10 * 60;
+const ENCRYPTION_VERSION = "v1";
+const ENCRYPTION_ALGORITHM = "aes-256-gcm";
+const ENCRYPTION_IV_BYTES = 12;
+const ENCRYPTION_TAG_BYTES = 16;
+const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
+const OAUTH_STATE_PATTERN = /^[A-Za-z0-9_-]{32}$/;
+const SESSION_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
+const SESSION_HASH_PATTERN = /^[a-f0-9]{64}$/;
+const SNOWFLAKE_PATTERN = /^[1-9]\d{16,19}$/;
+const PERMISSIONS_PATTERN = /^\d{1,32}$/;
+const REFRESH_LEASE_PATTERN = /^[A-Za-z0-9_-]{24}$/;
+
+type DiscordTokenKind = "access" | "refresh";
export interface ManagedDiscordGuild {
id: string;
@@ -27,64 +71,243 @@ export interface DiscordSession {
issuedAt: number;
}
-interface DiscordUserResponse {
+export interface CreatedDiscordSession {
+ expiresAt: Date;
+ sessionToken: string;
+}
+
+export interface DiscordSessionCookieStore {
+ get(name: string): { value: string } | undefined;
+ set(
+ name: string,
+ value: string,
+ options: {
+ httpOnly: boolean;
+ sameSite: "lax" | "strict";
+ secure: boolean;
+ expires: Date;
+ path: string;
+ },
+ ): void;
+ delete(name: string): void;
+}
+
+export interface DiscordSessionCookieDependencies {
+ getCookieStore: () => Promise;
+ revokeSession: (tokenHash: string) => Promise;
+ hasSessionStore: () => boolean;
+ appUrl: () => string;
+ now: () => number;
+}
+
+export interface DiscordLogoutResult {
+ revocationPending: boolean;
+}
+
+interface ParsedDiscordUser {
id: string;
username: string;
- global_name?: string | null;
- avatar?: string | null;
+ globalName: string | null;
+ avatarHash: string | null;
}
-interface DiscordGuildResponse {
- id: string;
- name: string;
- icon?: string | null;
- owner?: boolean;
- permissions: string;
+export interface ParsedDiscordToken {
+ accessToken: string;
+ expiresAt: Date;
+ refreshToken: string;
}
-export function isDiscordAuthConfigured(): boolean {
+export interface DiscordTokenRefreshDependencies {
+ acquireLease: typeof acquireDiscordAccountTokenRefreshLease;
+ completeRefresh: typeof completeDiscordAccountTokenRefresh;
+ createLeaseId: () => string;
+ exchangeRefreshToken: (refreshToken: string) => Promise;
+ getAccount: typeof getDiscordAccount;
+ now: () => number;
+ releaseLease: typeof releaseDiscordAccountTokenRefreshLease;
+ wait: (milliseconds: number) => Promise;
+}
+
+class DiscordAuthorizationError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "DiscordAuthorizationError";
+ }
+}
+
+export class DiscordAuthUnavailableError extends Error {
+ constructor(message = "Discord authentication is temporarily unavailable.") {
+ super(message);
+ this.name = "DiscordAuthUnavailableError";
+ }
+}
+
+class DiscordApiError extends Error {
+ constructor(
+ readonly status: number,
+ operation: string,
+ ) {
+ super(`Discord API request failed during ${operation}.`);
+ this.name = "DiscordApiError";
+ }
+}
+
+export function hasDiscordAuthConfiguration(
+ env: Readonly>,
+): boolean {
return Boolean(
- process.env.DISCORD_CLIENT_ID &&
- process.env.DISCORD_CLIENT_SECRET &&
- process.env.NEXTAUTH_SECRET,
+ env.DISCORD_CLIENT_ID &&
+ SNOWFLAKE_PATTERN.test(env.DISCORD_CLIENT_ID) &&
+ env.DISCORD_CLIENT_SECRET &&
+ env.NEXTAUTH_SECRET &&
+ Buffer.byteLength(env.NEXTAUTH_SECRET, "utf8") >= 32 &&
+ hasDiscordSessionStoreConfiguration(env) &&
+ hasValidAppUrlConfiguration(env),
);
}
-export function getDiscordAuthorizeUrl(state: string): string {
- const clientId = process.env.DISCORD_CLIENT_ID;
- if (!clientId) throw new Error("DISCORD_CLIENT_ID is required.");
+export function hasDiscordSessionStoreConfiguration(
+ env: Readonly>,
+): boolean {
+ const databaseUrl = env.DATABASE_URL;
+ return Boolean(
+ databaseUrl &&
+ !databaseUrl.includes("user:password@host") &&
+ !databaseUrl.includes("example.com"),
+ );
+}
+export function isDiscordAuthConfigured(): boolean {
+ return hasDiscordAuthConfiguration(process.env);
+}
+
+export function buildDiscordAuthorizeUrl(input: {
+ clientId: string;
+ redirectUri: string;
+ state: string;
+}): string {
const params = new URLSearchParams({
- client_id: clientId,
- redirect_uri: getDiscordRedirectUri(),
+ client_id: input.clientId,
+ redirect_uri: input.redirectUri,
response_type: "code",
scope: "identify guilds",
- state,
+ state: input.state,
});
return `https://discord.com/oauth2/authorize?${params.toString()}`;
}
+export function buildDiscordTokenRequest(
+ input:
+ | { code: string; grantType: "authorization_code"; redirectUri: string }
+ | { grantType: "refresh_token"; refreshToken: string },
+ client: { clientId: string; clientSecret: string },
+): URLSearchParams {
+ const body = new URLSearchParams({
+ client_id: client.clientId,
+ client_secret: client.clientSecret,
+ grant_type: input.grantType,
+ });
+ if (input.grantType === "authorization_code") {
+ body.set("code", input.code);
+ body.set("redirect_uri", input.redirectUri);
+ } else {
+ body.set("refresh_token", input.refreshToken);
+ }
+ return body;
+}
+
+export function getDiscordAuthorizeUrl(state: string): string {
+ const clientId = process.env.DISCORD_CLIENT_ID;
+ if (!clientId || !SNOWFLAKE_PATTERN.test(clientId)) {
+ throw new Error(
+ "DISCORD_CLIENT_ID must be a valid Discord application ID.",
+ );
+ }
+ return buildDiscordAuthorizeUrl({
+ clientId,
+ redirectUri: getDiscordRedirectUri(),
+ state,
+ });
+}
+
export function getDiscordRedirectUri(): string {
return `${getAppUrl()}/api/auth/discord/callback`;
}
-export function getAppUrl(): string {
- if (process.env.NEXTAUTH_URL) return process.env.NEXTAUTH_URL;
- if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
+export function getAppUrl(
+ env: Readonly> = process.env,
+): string {
+ const configured = env.NEXTAUTH_URL;
+ if (configured) return normalizeAppOrigin(configured, "NEXTAUTH_URL");
+ if (env.VERCEL_URL) {
+ return normalizeAppOrigin(`https://${env.VERCEL_URL}`, "VERCEL_URL");
+ }
return "http://localhost:3000";
}
+function hasValidAppUrlConfiguration(
+ env: Readonly>,
+): boolean {
+ try {
+ getAppUrl(env);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function normalizeAppOrigin(value: string, name: string): string {
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ throw new Error(`${name} must be an absolute HTTP(S) origin.`);
+ }
+
+ const localHttpHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
+ if (
+ (url.protocol !== "https:" &&
+ !(url.protocol === "http:" && localHttpHosts.has(url.hostname))) ||
+ url.username ||
+ url.password ||
+ url.pathname !== "/" ||
+ url.search ||
+ url.hash
+ ) {
+ throw new Error(
+ `${name} must be an HTTPS origin without credentials, a path, a query, or a fragment; HTTP is allowed only for localhost.`,
+ );
+ }
+ return url.origin;
+}
+
export function createOauthState(): string {
return randomBytes(24).toString("base64url");
}
+export function oauthStatesMatch(
+ stored: string | null | undefined,
+ received: string | null | undefined,
+): boolean {
+ if (
+ !stored ||
+ !received ||
+ !OAUTH_STATE_PATTERN.test(stored) ||
+ !OAUTH_STATE_PATTERN.test(received)
+ )
+ return false;
+ const left = Buffer.from(stored, "utf8");
+ const right = Buffer.from(received, "utf8");
+ return timingSafeEqual(left, right);
+}
+
export async function setOauthStateCookie(state: string): Promise {
const cookieStore = await cookies();
cookieStore.set(STATE_COOKIE, state, {
httpOnly: true,
sameSite: "lax",
secure: getAppUrl().startsWith("https://"),
- maxAge: 60 * 10,
+ maxAge: OAUTH_STATE_TTL_SECONDS,
path: "/",
});
}
@@ -95,97 +318,536 @@ export async function consumeOauthStateCookie(
const cookieStore = await cookies();
const stored = cookieStore.get(STATE_COOKIE)?.value;
cookieStore.delete(STATE_COOKIE);
- return Boolean(stored && state && stored === state);
+ return oauthStatesMatch(stored, state);
}
-export async function createDiscordSessionFromCode(
- code: string,
-): Promise {
- const clientId = process.env.DISCORD_CLIENT_ID;
- const clientSecret = process.env.DISCORD_CLIENT_SECRET;
- if (!clientId || !clientSecret) {
- throw new Error("Discord OAuth is not configured.");
+export function hashSessionToken(sessionToken: string): string {
+ return createHash("sha256").update(sessionToken, "utf8").digest("hex");
+}
+
+export function createSessionToken(): string {
+ return randomBytes(32).toString("base64url");
+}
+
+export function shouldRefreshDiscordToken(
+ expiresAt: Date,
+ nowMs: number = Date.now(),
+): boolean {
+ return (
+ !isValidDate(expiresAt) ||
+ expiresAt.getTime() <= nowMs + TOKEN_REFRESH_SKEW_MS
+ );
+}
+
+export function encryptDiscordToken(
+ plaintext: string,
+ secret: string,
+ context: string,
+): string {
+ if (!plaintext) throw new Error("Cannot encrypt an empty Discord token.");
+ const iv = randomBytes(ENCRYPTION_IV_BYTES);
+ const cipher = createCipheriv(
+ ENCRYPTION_ALGORITHM,
+ deriveEncryptionKey(secret),
+ iv,
+ { authTagLength: ENCRYPTION_TAG_BYTES },
+ );
+ cipher.setAAD(Buffer.from(context, "utf8"));
+ const ciphertext = Buffer.concat([
+ cipher.update(plaintext, "utf8"),
+ cipher.final(),
+ ]);
+ const tag = cipher.getAuthTag();
+ return [
+ ENCRYPTION_VERSION,
+ iv.toString("base64url"),
+ ciphertext.toString("base64url"),
+ tag.toString("base64url"),
+ ].join(".");
+}
+
+export function decryptDiscordToken(
+ encrypted: string,
+ secret: string,
+ context: string,
+): string {
+ const [version, ivEncoded, ciphertextEncoded, tagEncoded, extra] =
+ encrypted.split(".");
+ if (
+ version !== ENCRYPTION_VERSION ||
+ !ivEncoded ||
+ !ciphertextEncoded ||
+ !tagEncoded ||
+ !BASE64URL_PATTERN.test(ivEncoded) ||
+ !BASE64URL_PATTERN.test(ciphertextEncoded) ||
+ !BASE64URL_PATTERN.test(tagEncoded) ||
+ extra
+ ) {
+ throw new Error("Encrypted Discord token has an invalid format.");
}
- const tokenResponse = await fetch(`${DISCORD_API}/oauth2/token`, {
- method: "POST",
- headers: { "content-type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- client_id: clientId,
- client_secret: clientSecret,
- grant_type: "authorization_code",
- code,
- redirect_uri: getDiscordRedirectUri(),
- }),
- });
+ const iv = Buffer.from(ivEncoded, "base64url");
+ const ciphertext = Buffer.from(ciphertextEncoded, "base64url");
+ const tag = Buffer.from(tagEncoded, "base64url");
+ if (
+ iv.length !== ENCRYPTION_IV_BYTES ||
+ tag.length !== ENCRYPTION_TAG_BYTES ||
+ ciphertext.length === 0
+ ) {
+ throw new Error("Encrypted Discord token has invalid components.");
+ }
- if (!tokenResponse.ok) {
- throw new Error("Discord rejected the OAuth code.");
+ try {
+ const decipher = createDecipheriv(
+ ENCRYPTION_ALGORITHM,
+ deriveEncryptionKey(secret),
+ iv,
+ { authTagLength: ENCRYPTION_TAG_BYTES },
+ );
+ decipher.setAAD(Buffer.from(context, "utf8"));
+ decipher.setAuthTag(tag);
+ return Buffer.concat([
+ decipher.update(ciphertext),
+ decipher.final(),
+ ]).toString("utf8");
+ } catch {
+ throw new Error("Encrypted Discord token authentication failed.");
}
+}
- const token = (await tokenResponse.json()) as { access_token: string };
- const [user, guilds] = await Promise.all([
- fetchDiscord("/users/@me", token.access_token),
- fetchDiscord(
- "/users/@me/guilds",
- token.access_token,
- ),
- ]);
+export function canManageGuild(permissions: string, isOwner: boolean): boolean {
+ if (isOwner) return true;
+ if (!PERMISSIONS_PATTERN.test(permissions)) return false;
+ try {
+ const granted = BigInt(permissions);
+ return (
+ (granted & ADMINISTRATOR) === ADMINISTRATOR ||
+ (granted & MANAGE_GUILD) === MANAGE_GUILD
+ );
+ } catch {
+ return false;
+ }
+}
- return {
- user: {
- id: user.id,
- username: user.username,
- ...(user.global_name ? { globalName: user.global_name } : {}),
- ...(user.avatar
+export function parseManagedDiscordGuilds(
+ value: unknown,
+): ManagedDiscordGuild[] | null {
+ if (!Array.isArray(value) || value.length > 200) return null;
+
+ const guilds: ManagedDiscordGuild[] = [];
+ for (const item of value) {
+ if (!isRecord(item)) return null;
+ const id = parseSnowflake(item.id);
+ const name = parseBoundedString(item.name, 1, 100);
+ const permissions = parseDecimalString(item.permissions);
+ const owner = item.owner === undefined ? false : item.owner;
+ const icon = parseOptionalNullableString(item.icon, 256);
+ if (
+ !id ||
+ !name ||
+ !permissions ||
+ typeof owner !== "boolean" ||
+ icon === undefined
+ ) {
+ return null;
+ }
+
+ const manageable = canManageGuild(permissions, owner);
+ if (!manageable) continue;
+ guilds.push({
+ id,
+ name,
+ ...(icon
? {
- avatarUrl: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`,
+ iconUrl: `https://cdn.discordapp.com/icons/${id}/${encodeURIComponent(icon)}.png?size=128`,
}
: {}),
- },
- guilds: guilds
- .map((guild) => ({
- id: guild.id,
- name: guild.name,
- ...(guild.icon
- ? {
- iconUrl: `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128`,
- }
- : {}),
- isOwner: guild.owner === true,
- permissions: guild.permissions,
- canManage: canManageGuild(guild.permissions, guild.owner === true),
- }))
- .filter((guild) => guild.canManage)
- .slice(0, 25),
- issuedAt: Date.now(),
+ isOwner: owner,
+ permissions,
+ canManage: true,
+ });
+ }
+ return guilds;
+}
+
+export function parseStoredDiscordAuthSession(
+ value: unknown,
+ now: Date = new Date(),
+ expectedTokenHash?: string,
+): DiscordAuthSessionWithAccount | null {
+ if (!isRecord(value) || !isRecord(value.account) || !isRecord(value.session))
+ return null;
+ const account = parseStoredDiscordAccount(value.account);
+ const session = value.session;
+ if (
+ !account ||
+ !("revokedAt" in session) ||
+ !("lastSeenAt" in session) ||
+ typeof session.tokenHash !== "string" ||
+ !SESSION_HASH_PATTERN.test(session.tokenHash) ||
+ (expectedTokenHash !== undefined &&
+ session.tokenHash !== expectedTokenHash) ||
+ session.discordUserId !== account.discordUserId ||
+ !isValidDate(session.expiresAt) ||
+ session.expiresAt <= now ||
+ session.revokedAt !== null ||
+ (session.lastSeenAt !== null && !isValidDate(session.lastSeenAt)) ||
+ !isValidDate(session.createdAt) ||
+ !isValidDate(session.updatedAt)
+ ) {
+ return null;
+ }
+ return {
+ account,
+ session: session as unknown as DiscordAuthSessionWithAccount["session"],
};
}
+export function isPostOriginAllowed(
+ origin: string | null,
+ appUrl: string,
+): boolean {
+ if (!origin) return false;
+ try {
+ return new URL(origin).origin === new URL(appUrl).origin;
+ } catch {
+ return false;
+ }
+}
+
+export async function createDiscordSessionFromCode(
+ code: string,
+): Promise {
+ if (!isDiscordAuthConfigured()) {
+ throw new Error("Discord OAuth is not configured.");
+ }
+ if (!code || code.length > 1_024) {
+ throw new Error("Discord OAuth code is invalid.");
+ }
+
+ const token = await exchangeDiscordToken({
+ code,
+ grantType: "authorization_code",
+ });
+ const { user } = await fetchDiscordIdentity(token.accessToken);
+ const secret = getSessionSecret();
+ const avatarUrl = buildDiscordAvatarUrl(user);
+ await runSessionStoreOperation(() =>
+ upsertDiscordAccount({
+ discordUserId: user.id,
+ username: user.username,
+ globalName: user.globalName,
+ avatarUrl,
+ accessTokenEncrypted: encryptDiscordToken(
+ token.accessToken,
+ secret,
+ tokenEncryptionContext(user.id, "access"),
+ ),
+ refreshTokenEncrypted: encryptDiscordToken(
+ token.refreshToken,
+ secret,
+ tokenEncryptionContext(user.id, "refresh"),
+ ),
+ tokenExpiresAt: token.expiresAt,
+ }),
+ );
+
+ const sessionToken = createSessionToken();
+ const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
+ await runSessionStoreOperation(() =>
+ createDiscordAuthSession({
+ tokenHash: hashSessionToken(sessionToken),
+ discordUserId: user.id,
+ expiresAt,
+ }),
+ );
+ return { expiresAt, sessionToken };
+}
+
export async function setDiscordSessionCookie(
- session: DiscordSession,
+ created: CreatedDiscordSession,
+ dependencies: DiscordSessionCookieDependencies = sessionCookieDependencies(),
): Promise {
- const cookieStore = await cookies();
- cookieStore.set(SESSION_COOKIE, signSession(session), {
+ const cookieStore = await dependencies.getCookieStore();
+ const displacedSessionToken = cookieStore.get(SESSION_COOKIE)?.value;
+ if (
+ displacedSessionToken &&
+ SESSION_TOKEN_PATTERN.test(displacedSessionToken) &&
+ displacedSessionToken !== created.sessionToken
+ ) {
+ if (!dependencies.hasSessionStore()) {
+ queuePendingSessionRevocation(
+ cookieStore,
+ hashSessionToken(created.sessionToken),
+ dependencies,
+ );
+ throw new DiscordAuthUnavailableError(
+ "Discord session storage is not configured.",
+ );
+ }
+ try {
+ await runSessionStoreOperation(() =>
+ dependencies.revokeSession(hashSessionToken(displacedSessionToken)),
+ );
+ } catch (error) {
+ queuePendingSessionRevocation(
+ cookieStore,
+ hashSessionToken(created.sessionToken),
+ dependencies,
+ );
+ throw error;
+ }
+ }
+ cookieStore.set(SESSION_COOKIE, created.sessionToken, {
httpOnly: true,
sameSite: "lax",
- secure: getAppUrl().startsWith("https://"),
- maxAge: 60 * 60 * 24 * 14,
+ secure: dependencies.appUrl().startsWith("https://"),
+ expires: created.expiresAt,
path: "/",
});
}
-export async function clearDiscordSessionCookie(): Promise {
- const cookieStore = await cookies();
+export async function clearDiscordSessionCookie(
+ dependencies: DiscordSessionCookieDependencies = sessionCookieDependencies(),
+): Promise {
+ const cookieStore = await dependencies.getCookieStore();
+ const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
cookieStore.delete(SESSION_COOKIE);
+ if (!sessionToken || !SESSION_TOKEN_PATTERN.test(sessionToken)) {
+ return {
+ revocationPending:
+ parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ ).length > 0,
+ };
+ }
+ const tokenHash = hashSessionToken(sessionToken);
+ if (!dependencies.hasSessionStore()) {
+ queuePendingSessionRevocation(cookieStore, tokenHash, dependencies);
+ console.error(
+ "PipHackLup cleared the browser session but could not revoke it in storage.",
+ );
+ return { revocationPending: true };
+ }
+ try {
+ await runSessionStoreOperation(() => dependencies.revokeSession(tokenHash));
+ const remaining = removePendingSessionRevocation(
+ cookieStore,
+ tokenHash,
+ dependencies,
+ );
+ return { revocationPending: remaining > 0 };
+ } catch {
+ queuePendingSessionRevocation(cookieStore, tokenHash, dependencies);
+ console.error(
+ "PipHackLup cleared the browser session but could not revoke it in storage.",
+ );
+ return { revocationPending: true };
+ }
+}
+
+export async function retryPendingDiscordSessionRevocations(
+ dependencies: DiscordSessionCookieDependencies = sessionCookieDependencies(),
+): Promise {
+ const cookieStore = await dependencies.getCookieStore();
+ const pending = parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ );
+ if (!pending.length) return 0;
+ if (!dependencies.hasSessionStore()) return pending.length;
+
+ const remaining: string[] = [];
+ for (const tokenHash of pending) {
+ try {
+ await runSessionStoreOperation(() =>
+ dependencies.revokeSession(tokenHash),
+ );
+ } catch {
+ remaining.push(tokenHash);
+ }
+ }
+ writePendingSessionRevocations(cookieStore, remaining, dependencies);
+ if (remaining.length) {
+ console.error(
+ "PipHackLup could not finish one or more pending session revocations.",
+ );
+ }
+ return remaining.length;
+}
+
+export async function hasPendingDiscordSessionRevocations(
+ dependencies: DiscordSessionCookieDependencies = sessionCookieDependencies(),
+): Promise {
+ const cookieStore = await dependencies.getCookieStore();
+ return (
+ parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ ).length > 0
+ );
+}
+
+function sessionCookieDependencies(): DiscordSessionCookieDependencies {
+ return {
+ getCookieStore: async () =>
+ (await cookies()) as unknown as DiscordSessionCookieStore,
+ revokeSession: revokeDiscordAuthSession,
+ hasSessionStore: () => hasDiscordSessionStoreConfiguration(process.env),
+ appUrl: getAppUrl,
+ now: Date.now,
+ };
+}
+
+function queuePendingSessionRevocation(
+ cookieStore: DiscordSessionCookieStore,
+ tokenHash: string,
+ dependencies: DiscordSessionCookieDependencies,
+): void {
+ const pending = parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ );
+ const combined = [...new Set([...pending, tokenHash])];
+ if (combined.length > MAX_PENDING_SESSION_REVOCATIONS) {
+ console.error(
+ "PipHackLup reached the browser limit for pending session revocations.",
+ );
+ }
+ writePendingSessionRevocations(
+ cookieStore,
+ combined.slice(-MAX_PENDING_SESSION_REVOCATIONS),
+ dependencies,
+ );
+}
+
+function removePendingSessionRevocation(
+ cookieStore: DiscordSessionCookieStore,
+ tokenHash: string,
+ dependencies: DiscordSessionCookieDependencies,
+): number {
+ const pending = parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ ).filter((candidate) => candidate !== tokenHash);
+ writePendingSessionRevocations(cookieStore, pending, dependencies);
+ return pending.length;
+}
+
+function writePendingSessionRevocations(
+ cookieStore: DiscordSessionCookieStore,
+ tokenHashes: string[],
+ dependencies: DiscordSessionCookieDependencies,
+): void {
+ if (!tokenHashes.length) {
+ cookieStore.delete(PENDING_REVOCATIONS_COOKIE);
+ return;
+ }
+ cookieStore.set(PENDING_REVOCATIONS_COOKIE, tokenHashes.join("."), {
+ httpOnly: true,
+ sameSite: "strict",
+ secure: dependencies.appUrl().startsWith("https://"),
+ expires: new Date(dependencies.now() + SESSION_DURATION_MS),
+ path: "/",
+ });
+}
+
+function parsePendingSessionRevocations(value: string | undefined): string[] {
+ if (!value || value.length > MAX_PENDING_SESSION_REVOCATIONS * (64 + 1))
+ return [];
+ const hashes = value.split(".");
+ if (!hashes.length || hashes.length > MAX_PENDING_SESSION_REVOCATIONS)
+ return [];
+ return hashes.every((hash) => SESSION_HASH_PATTERN.test(hash)) ? hashes : [];
}
export async function readDiscordSession(): Promise {
- if (!process.env.NEXTAUTH_SECRET) return null;
+ if (!isDiscordAuthConfigured()) return null;
const cookieStore = await cookies();
- const signed = cookieStore.get(SESSION_COOKIE)?.value;
- if (!signed) return null;
- return verifySession(signed);
+ const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
+ if (!sessionToken || !SESSION_TOKEN_PATTERN.test(sessionToken)) return null;
+ const tokenHash = hashSessionToken(sessionToken);
+ const now = new Date();
+
+ const stored = await runSessionStoreOperation(() =>
+ getActiveDiscordAuthSession(tokenHash, now),
+ );
+ if (!stored) return null;
+ const loaded = parseStoredDiscordAuthSession(stored, now, tokenHash);
+ if (!loaded) {
+ throw new DiscordAuthUnavailableError(
+ "Stored Discord authentication data is invalid.",
+ );
+ }
+
+ let authorizationTokenVersion = loaded.account.tokenVersion;
+ try {
+ let authorized = await getAuthorizedAccessToken(loaded.account, false);
+ authorizationTokenVersion = authorized.account.tokenVersion;
+ let identity;
+ try {
+ identity = await fetchDiscordIdentity(authorized.accessToken);
+ } catch (error) {
+ if (
+ !(error instanceof DiscordApiError) ||
+ (error.status !== 401 && error.status !== 403)
+ )
+ throw error;
+ authorized = await getAuthorizedAccessToken(authorized.account, true);
+ authorizationTokenVersion = authorized.account.tokenVersion;
+ identity = await fetchDiscordIdentity(authorized.accessToken);
+ }
+
+ if (identity.user.id !== loaded.account.discordUserId) {
+ throw new DiscordAuthorizationError("Discord account identity changed.");
+ }
+
+ const avatarUrl = buildDiscordAvatarUrl(identity.user);
+ const [, sessionStillActive] = await Promise.all([
+ runSessionStoreOperation(() =>
+ updateDiscordAccountIdentity({
+ discordUserId: identity.user.id,
+ username: identity.user.username,
+ globalName: identity.user.globalName,
+ avatarUrl,
+ }),
+ ),
+ runSessionStoreOperation(() => touchDiscordAuthSession(tokenHash)),
+ ]);
+ if (!sessionStillActive) return null;
+
+ return {
+ user: {
+ id: identity.user.id,
+ username: identity.user.username,
+ ...(identity.user.globalName
+ ? { globalName: identity.user.globalName }
+ : {}),
+ ...(avatarUrl ? { avatarUrl } : {}),
+ },
+ guilds: identity.guilds,
+ issuedAt: loaded.session.createdAt.getTime(),
+ };
+ } catch (error) {
+ if (
+ error instanceof DiscordAuthorizationError ||
+ (error instanceof DiscordApiError &&
+ (error.status === 401 || error.status === 403))
+ ) {
+ const revoked = await runSessionStoreOperation(() =>
+ revokeDiscordAuthSessionsForAccountIfTokenVersion(
+ loaded.account.discordUserId,
+ authorizationTokenVersion,
+ ),
+ );
+ if (revoked) return null;
+
+ const stillActive = await runSessionStoreOperation(() =>
+ getActiveDiscordAuthSession(tokenHash),
+ );
+ if (!stillActive) return null;
+ throw new DiscordAuthUnavailableError(
+ "Discord credentials changed while authorization was checked.",
+ );
+ }
+ throw error;
+ }
}
export function findManagedGuild(
@@ -195,55 +857,509 @@ export function findManagedGuild(
return session.guilds.find((guild) => guild.id === guildId) ?? null;
}
-async function fetchDiscord