diff --git a/.env.local.example b/.env.local.example
index bac5aef..8af19b6 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -3,8 +3,20 @@ NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_token_here
# Slack API (for community members)
SLACK_TOKEN=your_slack_user_token_here
+# Bot token also needs the `users:read` scope for the admin dashboard's admin check
SLACK_BOT_TOKEN=your_slack_bot_token_here
+# Admin dashboard auth (Slack OIDC via next-auth)
+# Generate AUTH_SECRET with: openssl rand -base64 32
+AUTH_SECRET=your_auth_secret_here
+# Client ID/Secret from the Slack app's Basic Information page
+AUTH_SLACK_ID=your_slack_client_id_here
+AUTH_SLACK_SECRET=your_slack_client_secret_here
+# Slack workspace ID (T...) — only admins of this workspace may sign in
+SLACK_TEAM_ID=your_slack_team_id_here
+# Local HTTPS dev only (bun run dev:https); unset in production
+# AUTH_URL=https://localhost:3000
+
# Eventbrite API (for events)
EVENTBRITE_TOKEN=your_eventbrite_token_here
EVENTBRITE_WEBHOOK_SECRET=your_eventbrite_webhook_secret_here
diff --git a/app/admin/(protected)/layout.tsx b/app/admin/(protected)/layout.tsx
new file mode 100644
index 0000000..2f5a8d3
--- /dev/null
+++ b/app/admin/(protected)/layout.tsx
@@ -0,0 +1,42 @@
+import type { Metadata } from 'next';
+import Image from 'next/image';
+import type { ReactNode } from 'react';
+import { signOut } from '@/auth';
+import { Button } from '@/components/ui/button';
+import { requireAdminOrRedirect } from '@/lib/adminAuth';
+import { routes } from '@/lib/route';
+
+export const metadata: Metadata = {
+ robots: { index: false, follow: false },
+};
+
+export default async function AdminLayout({ children }: { children: ReactNode }) {
+ const session = await requireAdminOrRedirect();
+ const { name, image } = session.user;
+
+ const signOutAction = async () => {
+ 'use server';
+ await signOut({ redirectTo: routes.admin.login() });
+ };
+
+ return (
+
+
+
+
Admin
+
HackRegina event management
+
+
+ {image && }
+ {name}
+
+
+
+ {children}
+
+ );
+}
diff --git a/app/admin/(protected)/page.tsx b/app/admin/(protected)/page.tsx
new file mode 100644
index 0000000..fcba953
--- /dev/null
+++ b/app/admin/(protected)/page.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from 'next';
+import { requireAdminOrRedirect } from '@/lib/adminAuth';
+
+export const metadata: Metadata = { title: 'Admin - HackRegina' };
+
+export default async function AdminPage() {
+ const session = await requireAdminOrRedirect();
+ return (
+
+ Signed in as {session.user.name ?? 'unknown user'}. Event management is coming in the next
+ update.
+
+ );
+}
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx
new file mode 100644
index 0000000..bb7e6be
--- /dev/null
+++ b/app/admin/login/page.tsx
@@ -0,0 +1,63 @@
+import type { Metadata } from 'next';
+import { redirect } from 'next/navigation';
+import { auth, signIn } from '@/auth';
+import { Slack } from '@/components/icons/BrandIcons';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { routes } from '@/lib/route';
+
+export const metadata: Metadata = {
+ title: 'Admin Sign In - HackRegina',
+ robots: { index: false, follow: false },
+};
+
+const ERROR_MESSAGES: Record = {
+ AccessDenied: 'You must be an administrator of the HackRegina Slack workspace to sign in.',
+};
+
+interface AdminLoginPageProps {
+ searchParams: Promise<{ error?: string; callbackUrl?: string }>;
+}
+
+export default async function AdminLoginPage({ searchParams }: AdminLoginPageProps) {
+ const { error, callbackUrl } = await searchParams;
+ const session = await auth();
+ if (session?.user?.isAdmin) redirect(routes.admin.dashboard());
+
+ const redirectTo =
+ callbackUrl?.startsWith('/') && !callbackUrl.startsWith('//')
+ ? callbackUrl
+ : routes.admin.dashboard();
+
+ const signInWithSlack = async () => {
+ 'use server';
+ await signIn('slack', { redirectTo });
+ };
+
+ return (
+
+
+
+ Admin Dashboard
+
+ Sign in with Slack to manage HackRegina events. Access is limited to workspace
+ administrators.
+
+
+
+ {error && (
+
+ {ERROR_MESSAGES[error] ?? 'Sign-in failed. Please try again.'}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..0a98352
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from '@/auth';
+
+export const { GET, POST } = handlers;
diff --git a/auth.ts b/auth.ts
new file mode 100644
index 0000000..74fdcde
--- /dev/null
+++ b/auth.ts
@@ -0,0 +1,43 @@
+import NextAuth from 'next-auth';
+import Slack from 'next-auth/providers/slack';
+import { isWorkspaceAdmin } from '@/lib/slackAdmin';
+
+const teamId = process.env.SLACK_TEAM_ID;
+
+// Slack's OIDC id_token carries team/user ids as namespaced claims, not top-level fields.
+const TEAM_CLAIM = 'https://slack.com/team_id';
+const USER_CLAIM = 'https://slack.com/user_id';
+
+export const { handlers, auth, signIn, signOut } = NextAuth({
+ providers: [Slack],
+ session: { strategy: 'jwt', maxAge: 60 * 60 * 12 },
+ trustHost: true,
+ pages: { signIn: '/admin/login', error: '/admin/login' },
+ callbacks: {
+ async signIn({ profile }) {
+ const slackTeamId = profile?.[TEAM_CLAIM];
+ const slackUserId = profile?.[USER_CLAIM] ?? profile?.sub;
+ if (!teamId || slackTeamId !== teamId) return false;
+ if (typeof slackUserId !== 'string' || !slackUserId) return false;
+ return isWorkspaceAdmin(slackUserId);
+ },
+ async jwt({ token, account, profile }) {
+ if (account && profile) {
+ const slackUserId = profile[USER_CLAIM] ?? profile.sub;
+ if (typeof slackUserId === 'string' && slackUserId) {
+ token.slackUserId = slackUserId;
+ token.isAdmin =
+ !!teamId && profile[TEAM_CLAIM] === teamId && (await isWorkspaceAdmin(slackUserId));
+ } else {
+ token.isAdmin = false;
+ }
+ }
+ return token;
+ },
+ async session({ session, token }) {
+ session.user.isAdmin = !!token.isAdmin;
+ session.user.slackUserId = typeof token.slackUserId === 'string' ? token.slackUserId : '';
+ return session;
+ },
+ },
+});
diff --git a/bun.lock b/bun.lock
index fdb2771..6447407 100644
--- a/bun.lock
+++ b/bun.lock
@@ -17,6 +17,7 @@
"luxon": "^3.7.2",
"mapbox-gl": "^3.25.0",
"next": "^16.2.9",
+ "next-auth": "5.0.0-beta.32",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-map-gl": "^8.1.1",
@@ -41,6 +42,8 @@
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+ "@auth/core": ["@auth/core@0.41.3", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7 || ^8.0.5" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw=="],
+
"@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="],
@@ -155,6 +158,8 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="],
+ "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
+
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
@@ -387,6 +392,8 @@
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
+ "jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="],
+
"json-stringify-pretty-compact": ["json-stringify-pretty-compact@3.0.0", "", {}, ""],
"kdbush": ["kdbush@4.0.2", "", {}, ""],
@@ -441,8 +448,12 @@
"next": ["next@16.2.9", "", { "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.9", "@next/swc-darwin-x64": "16.2.9", "@next/swc-linux-arm64-gnu": "16.2.9", "@next/swc-linux-arm64-musl": "16.2.9", "@next/swc-linux-x64-gnu": "16.2.9", "@next/swc-linux-x64-musl": "16.2.9", "@next/swc-win32-arm64-msvc": "16.2.9", "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww=="],
+ "next-auth": ["next-auth@5.0.0-beta.32", "", { "dependencies": { "@auth/core": "0.41.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7 || ^8.0.5", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q=="],
+
"node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="],
+ "oauth4webapi": ["oauth4webapi@3.8.7", "", {}, "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw=="],
+
"p-finally": ["p-finally@1.0.0", "", {}, ""],
"p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, ""],
@@ -461,6 +472,10 @@
"potpack": ["potpack@2.0.0", "", {}, ""],
+ "preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
+
+ "preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
+
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, ""],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
diff --git a/lib/adminAuth.ts b/lib/adminAuth.ts
new file mode 100644
index 0000000..52724c8
--- /dev/null
+++ b/lib/adminAuth.ts
@@ -0,0 +1,15 @@
+import type { Session } from 'next-auth';
+import { redirect } from 'next/navigation';
+import { auth } from '@/auth';
+import { routes } from '@/lib/route';
+
+export const requireAdmin = async (): Promise => {
+ const session = await auth();
+ return session?.user?.isAdmin ? session : null;
+};
+
+export const requireAdminOrRedirect = async (): Promise => {
+ const session = await requireAdmin();
+ if (!session) redirect(routes.admin.login());
+ return session;
+};
diff --git a/lib/route.ts b/lib/route.ts
index f17ce02..83940a7 100644
--- a/lib/route.ts
+++ b/lib/route.ts
@@ -1,6 +1,11 @@
export const routes = {
home: () => '/',
events: () => '/events',
+ admin: {
+ dashboard: () => '/admin',
+ event: (id: string) => `/admin/events/${id}`,
+ login: () => '/admin/login',
+ },
techmap: {
list: () => '/techmap',
technologies: () => '/techmap?view=technologies',
diff --git a/lib/slackAdmin.ts b/lib/slackAdmin.ts
new file mode 100644
index 0000000..227260d
--- /dev/null
+++ b/lib/slackAdmin.ts
@@ -0,0 +1,23 @@
+import { WebClient } from '@slack/web-api';
+
+const token = process.env.SLACK_BOT_TOKEN;
+
+/**
+ * Checks whether a Slack user is an admin, owner, or primary owner of the
+ * workspace. Requires the bot token to have the `users:read` scope. Fails
+ * closed: a missing token or any API error results in `false`.
+ */
+export const isWorkspaceAdmin = async (slackUserId: string): Promise => {
+ if (!token) {
+ console.error('SLACK_BOT_TOKEN is not set; denying admin access');
+ return false;
+ }
+ try {
+ const web = new WebClient(token);
+ const { user } = await web.users.info({ user: slackUserId });
+ return !!(user?.is_admin || user?.is_owner || user?.is_primary_owner);
+ } catch (error) {
+ console.error('Slack users.info failed during admin check', error);
+ return false;
+ }
+};
diff --git a/package.json b/package.json
index 74330da..69c0561 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
+ "dev:https": "next dev --experimental-https",
"build": "next build",
"start": "next start",
"lint": "biome lint .",
@@ -37,6 +38,7 @@
"luxon": "^3.7.2",
"mapbox-gl": "^3.25.0",
"next": "^16.2.9",
+ "next-auth": "5.0.0-beta.32",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-map-gl": "^8.1.1",
diff --git a/proxy.ts b/proxy.ts
new file mode 100644
index 0000000..3785e86
--- /dev/null
+++ b/proxy.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from 'next/server';
+import { auth } from '@/auth';
+import { routes } from '@/lib/route';
+
+export default auth((req) => {
+ const { nextUrl } = req;
+ const isAdmin = !!req.auth?.user?.isAdmin;
+
+ if (nextUrl.pathname === routes.admin.login()) {
+ if (isAdmin) return NextResponse.redirect(new URL(routes.admin.dashboard(), nextUrl));
+ return NextResponse.next();
+ }
+ if (isAdmin) return NextResponse.next();
+ if (nextUrl.pathname.startsWith('/api/admin')) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ const login = new URL(routes.admin.login(), nextUrl);
+ login.searchParams.set('callbackUrl', nextUrl.pathname);
+ return NextResponse.redirect(login);
+});
+
+export const config = {
+ matcher: ['/admin/:path*', '/api/admin/:path*'],
+};
diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts
new file mode 100644
index 0000000..89a98a1
--- /dev/null
+++ b/types/next-auth.d.ts
@@ -0,0 +1,17 @@
+import type { DefaultSession } from 'next-auth';
+
+declare module 'next-auth' {
+ interface Session {
+ user: {
+ isAdmin: boolean;
+ slackUserId: string;
+ } & DefaultSession['user'];
+ }
+}
+
+declare module 'next-auth/jwt' {
+ interface JWT {
+ isAdmin?: boolean;
+ slackUserId?: string;
+ }
+}