From ece2d36892568f4a10de98c87d511d9b309032fa Mon Sep 17 00:00:00 2001 From: Cooper Golemme <116388624+coopergolemme@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:21:19 -0500 Subject: [PATCH] feat: add clerk auth for admin routes --- .gitignore | 3 + package-lock.json | 186 ++++++++++++++++++++++-- package.json | 5 +- src/app/admin/layout.tsx | 6 +- src/app/admin/page.tsx | 1 - src/app/layout.tsx | 13 +- src/app/sign-in/[[...sign-in]]/page.tsx | 10 ++ src/app/unauthorized/page.tsx | 108 ++++++++++++++ src/components/admin/Profile.tsx | 26 ++++ src/lib/util.ts | 8 + src/middleware.ts | 42 ++++++ src/types/global.d.ts | 13 ++ 12 files changed, 401 insertions(+), 20 deletions(-) create mode 100644 src/app/sign-in/[[...sign-in]]/page.tsx create mode 100644 src/app/unauthorized/page.tsx create mode 100644 src/components/admin/Profile.tsx create mode 100644 src/middleware.ts 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