diff --git a/.gitignore b/.gitignore
index 3a97a90..10cd775 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,6 @@ node_modules
/src/generated/prisma
# Snyk Security Extension - AI Rules (auto-generated)
.github/instructions/snyk_rules.instructions.md
+
+# clerk configuration (can include secrets)
+/.clerk/
diff --git a/package-lock.json b/package-lock.json
index 0d82137..a0a1478 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
+ "@clerk/nextjs": "^6.31.0",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.3.2",
"@mantine/notifications": "^8.3.2",
@@ -27,9 +28,9 @@
"postcss-preset-mantine": "^1.18.0",
"prettier": "^3.6.2",
"prettier-eslint": "^16.4.2",
- "react": "19.1.0",
+ "react": "19.1.4",
"react-bootstrap": "^2.10.10",
- "react-dom": "19.1.0",
+ "react-dom": "19.1.4",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0"
},
@@ -224,6 +225,102 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@clerk/backend": {
+ "version": "2.29.3",
+ "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.29.3.tgz",
+ "integrity": "sha512-BLepnFJRsnkqqXu2a79pgbzZz+veecB2bqMrqcmzLl+nBdUPPdeCTRazcmIifKB/424nyT8eX9ADqOz5iySoug==",
+ "license": "MIT",
+ "dependencies": {
+ "@clerk/shared": "^3.43.0",
+ "@clerk/types": "^4.101.11",
+ "standardwebhooks": "^1.0.0",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ }
+ },
+ "node_modules/@clerk/clerk-react": {
+ "version": "5.59.4",
+ "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.59.4.tgz",
+ "integrity": "sha512-CNr9n7uJT4cRx+cc3fzWr4l4x47+3S5j32HPOP5oUGeIF8O0QHHaoIQ8BHc3lnr4zJJpZxAyrLfwYPv3krtYIw==",
+ "license": "MIT",
+ "dependencies": {
+ "@clerk/shared": "^3.43.0",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
+ "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
+ }
+ },
+ "node_modules/@clerk/nextjs": {
+ "version": "6.36.8",
+ "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.36.8.tgz",
+ "integrity": "sha512-Hipw/B/AqdkkcrLPVfVOW47YT+Nt8PwYzpxQv0iMWezdP9u4RWkQ0OfrhluvC7eSOLk/YCCljjaP+S4+VPfHig==",
+ "license": "MIT",
+ "dependencies": {
+ "@clerk/backend": "^2.29.3",
+ "@clerk/clerk-react": "^5.59.4",
+ "@clerk/shared": "^3.43.0",
+ "@clerk/types": "^4.101.11",
+ "server-only": "0.0.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "peerDependencies": {
+ "next": "^13.5.7 || ^14.2.25 || ^15.2.3 || ^16",
+ "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
+ "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
+ }
+ },
+ "node_modules/@clerk/shared": {
+ "version": "3.43.0",
+ "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.43.0.tgz",
+ "integrity": "sha512-pj8jgV5TX7l0ClHMvDLG7Ensp1BwA63LNvOE2uLwRV4bx3j9s4oGHy5bZlLBoOxdvRPCMpQksHi/O0x1Y+obdw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "3.1.3",
+ "dequal": "2.0.3",
+ "glob-to-regexp": "0.4.1",
+ "js-cookie": "3.0.5",
+ "std-env": "^3.9.0",
+ "swr": "2.3.4"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
+ "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@clerk/types": {
+ "version": "4.101.11",
+ "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.101.11.tgz",
+ "integrity": "sha512-6m1FQSLFqb4L+ovMDxNIRSrw6I0ByVX5hs6slcevOaaD5UXNzSANWqVtKaU80AZwcm391lZqVS5fRisHt9tmXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@clerk/shared": "^3.43.0"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ }
+ },
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -2151,6 +2248,12 @@
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
"license": "MIT"
},
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -5624,6 +5727,12 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT"
},
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -5958,6 +6067,12 @@
"node": ">=10.13.0"
}
},
+ "node_modules/glob-to-regexp": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -6717,6 +6832,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/js-cookie": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
+ "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -10853,9 +10977,9 @@
}
},
"node_modules/react": {
- "version": "19.1.0",
- "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
- "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
+ "version": "19.1.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz",
+ "integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10893,15 +11017,15 @@
}
},
"node_modules/react-dom": {
- "version": "19.1.0",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
- "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
+ "version": "19.1.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
+ "integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
- "react": "^19.1.0"
+ "react": "^19.1.4"
}
},
"node_modules/react-icons": {
@@ -11294,6 +11418,12 @@
"node": ">=10"
}
},
+ "node_modules/server-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
+ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
+ "license": "MIT"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -11527,6 +11657,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/standardwebhooks": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "license": "MIT"
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -11764,6 +11910,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/swr": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz",
+ "integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/synckit": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.4.tgz",
@@ -12289,6 +12448,15 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/package.json b/package.json
index e0b385c..a205468 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@mantine/form": "^8.3.2",
"@mantine/notifications": "^8.3.2",
"@mantine/styles": "^6.0.22",
+ "@clerk/nextjs": "^6.31.0",
"@prisma/adapter-pg": "^6.19.0",
"@prisma/client": "^6.19.0",
"@prisma/extension-accelerate": "^2.0.2",
@@ -31,9 +32,9 @@
"postcss-preset-mantine": "^1.18.0",
"prettier": "^3.6.2",
"prettier-eslint": "^16.4.2",
- "react": "19.1.0",
+ "react": "19.1.4",
"react-bootstrap": "^2.10.10",
- "react-dom": "19.1.0",
+ "react-dom": "19.1.4",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0"
},
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index adb12e7..6bd4f8f 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -1,3 +1,5 @@
+import Profile from "@/components/admin/Profile";
+
import {
AppShell,
AppShellHeader,
@@ -14,9 +16,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
Beantown Baby Admin
-
- Rachel
-
+
{children}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index c0251ed..bc4c586 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -33,7 +33,6 @@ export default function Page() {
- Hello, Rachel 👋
Last data uploaded: Monday, 30 Aug, 2025
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index cfcbcae..2cd07cd 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { MantineProvider } from "@mantine/core";
+import { ClerkProvider } from "@clerk/nextjs";
import "@mantine/core/styles.css";
import "./globals.css";
import "leaflet/dist/leaflet.css";
@@ -15,10 +16,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
}
diff --git a/src/app/sign-in/[[...sign-in]]/page.tsx b/src/app/sign-in/[[...sign-in]]/page.tsx
new file mode 100644
index 0000000..bb3ce20
--- /dev/null
+++ b/src/app/sign-in/[[...sign-in]]/page.tsx
@@ -0,0 +1,10 @@
+import { SignIn } from "@clerk/nextjs";
+import { Center } from "@mantine/core";
+
+export default function Page() {
+ return
+
+ ;
+
+}
diff --git a/src/app/unauthorized/page.tsx b/src/app/unauthorized/page.tsx
new file mode 100644
index 0000000..4679c14
--- /dev/null
+++ b/src/app/unauthorized/page.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import {
+ Button,
+ Card,
+ Divider,
+ Group,
+ Stack,
+ Text,
+ ThemeIcon,
+ Title,
+} from "@mantine/core";
+import { useClerk, useUser } from "@clerk/nextjs";
+import Link from "next/link";
+
+const adminContactEmail = process.env.NEXT_PUBLIC_ADMIN_CONTACT_EMAIL;
+
+export default function Page() {
+ const { user, isLoaded } = useUser();
+ const { signOut } = useClerk();
+
+ const primaryEmail =
+ user?.primaryEmailAddress?.emailAddress ??
+ user?.emailAddresses?.[0]?.emailAddress ??
+ "No email on file";
+
+ const role =
+ (typeof user?.publicMetadata?.role === "string" &&
+ user.publicMetadata.role) ||
+ "member";
+ const requestAccessSubject = "Request admin access";
+ const requestAccessBody = `Hello,
+
+I would like to request access to the admin page.
+
+Account email: ${primaryEmail}
+Current role: ${role}
+
+Thanks,`;
+ const requestAccessHref = adminContactEmail
+ ? `mailto:${adminContactEmail}?subject=${encodeURIComponent(
+ requestAccessSubject,
+ )}&body=${encodeURIComponent(requestAccessBody)}`
+ : null;
+
+ return (
+
+
+
+
+
+ !
+
+
+ Access denied
+
+ You are signed in, but your account does not have admin access.
+
+
+
+
+ {isLoaded && (
+
+
+ Signed in as
+
+
+ {primaryEmail} ({role})
+
+
+ )}
+
+
+
+
+
+ {requestAccessHref && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/Profile.tsx b/src/components/admin/Profile.tsx
new file mode 100644
index 0000000..a487e59
--- /dev/null
+++ b/src/components/admin/Profile.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { Group, Text } from "@mantine/core";
+import { UserButton, useUser } from "@clerk/nextjs";
+
+export default function Profile() {
+ const { user, isLoaded } = useUser();
+
+ if (!isLoaded) {
+ return null;
+ }
+
+ const role =
+ (typeof user?.publicMetadata?.role === "string" &&
+ user.publicMetadata.role) ||
+ "member";
+
+ return (
+
+
+
+ {role}
+
+
+ );
+}
diff --git a/src/lib/util.ts b/src/lib/util.ts
index cfc7f48..19e3893 100644
--- a/src/lib/util.ts
+++ b/src/lib/util.ts
@@ -1,3 +1,5 @@
+import { Roles } from '@/types/global'
+import { auth } from '@clerk/nextjs/server'
/**
* Stringifies a value to JSON, converting any BigInt values to strings to avoid serialization errors. Use this function whenever you need to serialize data that may contain BigInt fields, such as Prisma query results.
* @param value - The value to stringify, which can be of any type. This can be an object, array, primitive, etc. Most commonly used for objects containing BigInt fields, like the Prisma query results.
@@ -8,3 +10,9 @@ export function stringifyWithBigInt(value: unknown) {
typeof jsonValue === "bigint" ? jsonValue.toString() : jsonValue,
);
}
+
+
+export const checkRole = async (role: Roles) => {
+ const { sessionClaims } = await auth()
+ return sessionClaims?.metadata.role === role
+}
\ No newline at end of file
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..de4a402
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,42 @@
+import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
+import { NextResponse } from "next/server";
+
+const isAdminRoute = createRouteMatcher(["/admin(.*)"]);
+const isAdminApiRoute = createRouteMatcher([
+ "/api/partners",
+ "/api/partners/percentages(.*)",
+ "/api/distributions(.*)",
+]);
+
+export default clerkMiddleware(async (auth, req) => {
+ if (!(isAdminRoute(req) || isAdminApiRoute(req))) {
+ return;
+ }
+
+ const authState = await auth();
+ if (!authState.userId) {
+ if (isAdminApiRoute(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const signInUrl = new URL("/sign-in", req.url);
+ signInUrl.searchParams.set("redirect_url", req.url);
+ return NextResponse.redirect(signInUrl);
+ }
+
+ if (authState.sessionClaims?.metadata?.role !== "admin") {
+ if (isAdminApiRoute(req)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const url = new URL("/unauthorized", req.url);
+ return NextResponse.redirect(url);
+ }
+});
+
+export const config = {
+ matcher: [
+ "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
+ "/(api|trpc)(.*)",
+ ],
+};
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
index be89378..41e9bbb 100644
--- a/src/types/global.d.ts
+++ b/src/types/global.d.ts
@@ -1,3 +1,16 @@
declare module "*.css";
declare module "@mantine/core/styles.css";
declare module "leaflet/dist/leaflet.css";
+
+export {}
+
+// Create a type for the Roles
+export type Roles = 'admin' | 'user';
+
+declare global {
+ interface CustomJwtSessionClaims {
+ metadata: {
+ role?: Roles
+ }
+ }
+}
\ No newline at end of file