Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions app/admin/(protected)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center justify-between border-b border-gray-200 pb-4 dark:border-gray-800">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Admin</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">HackRegina event management</p>
</div>
<div className="flex items-center gap-3">
{image && <Image src={image} alt="" width={32} height={32} className="rounded-full" />}
<span className="text-sm text-gray-700 dark:text-gray-300">{name}</span>
<form action={signOutAction}>
<Button type="submit" variant="outline" size="sm">
Sign out
</Button>
</form>
</div>
</div>
{children}
</div>
);
}
14 changes: 14 additions & 0 deletions app/admin/(protected)/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-gray-700 dark:text-gray-300">
Signed in as {session.user.name ?? 'unknown user'}. Event management is coming in the next
update.
</p>
);
}
63 changes: 63 additions & 0 deletions app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 (
<div className="mx-auto flex max-w-7xl items-center justify-center px-4 py-24 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Admin Dashboard</CardTitle>
<CardDescription>
Sign in with Slack to manage HackRegina events. Access is limited to workspace
administrators.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{error && (
<p className="rounded-md border border-red-300 bg-red-100 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
{ERROR_MESSAGES[error] ?? 'Sign-in failed. Please try again.'}
</p>
)}
<form action={signInWithSlack}>
<Button type="submit" className="w-full gap-2">
<Slack className="h-5 w-5" />
Sign in with Slack
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
3 changes: 3 additions & 0 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { handlers } from '@/auth';

export const { GET, POST } = handlers;
43 changes: 43 additions & 0 deletions auth.ts
Original file line number Diff line number Diff line change
@@ -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;
},
},
});
15 changes: 15 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions lib/adminAuth.ts
Original file line number Diff line number Diff line change
@@ -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<Session | null> => {
const session = await auth();
return session?.user?.isAdmin ? session : null;
};

export const requireAdminOrRedirect = async (): Promise<Session> => {
const session = await requireAdmin();
if (!session) redirect(routes.admin.login());
return session;
};
5 changes: 5 additions & 0 deletions lib/route.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
23 changes: 23 additions & 0 deletions lib/slackAdmin.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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;
}
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions proxy.ts
Original file line number Diff line number Diff line change
@@ -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*'],
};
17 changes: 17 additions & 0 deletions types/next-auth.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}