diff --git a/CLAUDE.md b/CLAUDE.md index c6ccbe49..bd821f46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ The backend follows a **modular router pattern** with consistent file organizati └── index.ts # Module exports ``` -**Current modules**: email, fundraiser, order, organization, referral, user +**Current modules**: admin, email, fundraiser, order, organization, referral, user **Request flow**: 1. Route matched in router @@ -145,6 +145,7 @@ All API contracts defined in `/common/schemas/` using Zod: ``` /app/ + ├── /admin # Platform admin (org approval, gated by ADMIN_EMAILS) ├── /auth # Login/logout ├── /buyer # Customer-facing routes │ ├── /browse # Browse fundraisers @@ -158,7 +159,7 @@ All API contracts defined in `/common/schemas/` using Zod: └── /page.tsx # Landing page ``` -**Protected routes**: `/buyer/**` and `/seller/**` require authentication (enforced by middleware) +**Protected routes**: `/buyer/**`, `/seller/**`, and `/admin/**` require authentication (enforced by middleware). `/admin/**` additionally requires the user's email to be in the `ADMIN_EMAILS` backend env var. ### State Management @@ -215,6 +216,7 @@ Each package has environment files: - `SUPABASE_URL`, `SUPABASE_ANON_KEY`: Supabase project config - `SUPABASE_SERVICE_ROLE_KEY`: Backend-only (for JWT validation) - `MAILGUN_API_KEY`, `MAILGUN_DOMAIN`: Email service +- `ADMIN_EMAILS`: Comma-separated list of admin email addresses (backend-only) ## Testing diff --git a/backend/src/api/admin/admin.handlers.ts b/backend/src/api/admin/admin.handlers.ts new file mode 100644 index 00000000..bc4182db --- /dev/null +++ b/backend/src/api/admin/admin.handlers.ts @@ -0,0 +1,60 @@ +import { Request, Response } from "express-serve-static-core"; +import { + getAllOrganizations, + updateOrganizationAuthorized, +} from "./admin.services"; +import { + AdminOrganizationSchema, + UpdateOrganizationAuthorizedBody, +} from "common"; +import { z } from "zod"; + +interface OrganizationParams { + id: string; +} + +export const verifyAdminHandler = async (_req: Request, res: Response) => { + res.status(200).json({ message: "Admin verified", data: { isAdmin: true } }); +}; + +export const getAllOrganizationsHandler = async ( + _req: Request, + res: Response, +) => { + const organizations = await getAllOrganizations(); + + const parsed = AdminOrganizationSchema.array().safeParse(organizations); + if (!parsed.success) { + res.status(500).json({ message: "Internal server error" }); + return; + } + + res + .status(200) + .json({ message: "Organizations retrieved", data: parsed.data }); +}; + +export const updateOrganizationAuthorizedHandler = async ( + req: Request< + OrganizationParams, + any, + z.infer, + {} + >, + res: Response, +) => { + const organization = await updateOrganizationAuthorized( + req.params.id, + req.body.authorized, + ); + + const parsed = AdminOrganizationSchema.safeParse(organization); + if (!parsed.success) { + res.status(500).json({ message: "Internal server error" }); + return; + } + + res + .status(200) + .json({ message: "Organization authorization updated", data: parsed.data }); +}; diff --git a/backend/src/api/admin/admin.router.ts b/backend/src/api/admin/admin.router.ts new file mode 100644 index 00000000..1b712efc --- /dev/null +++ b/backend/src/api/admin/admin.router.ts @@ -0,0 +1,47 @@ +import { Router } from "express"; +import { authenticate } from "../../middleware/authenticate"; +import { authorizeAdmin } from "../../middleware/authorizeAdmin"; +import validate from "../../middleware/validate"; +import { UpdateOrganizationAuthorizedBody } from "common"; +import { + verifyAdminHandler, + getAllOrganizationsHandler, + updateOrganizationAuthorizedHandler, +} from "./admin.handlers"; +import { + asyncHandler, + handlePrismaErrors, +} from "../../middleware/handlePrismaErrors"; +import { z } from "zod"; + +const adminRouter = Router(); + +const OrganizationParams = z.object({ + id: z.string().uuid(), +}); + +adminRouter.get( + "/verify", + authenticate, + authorizeAdmin, + asyncHandler(verifyAdminHandler), +); + +adminRouter.get( + "/organizations", + authenticate, + authorizeAdmin, + asyncHandler(getAllOrganizationsHandler), +); + +adminRouter.post( + "/organizations/:id/authorize", + validate({ params: OrganizationParams, body: UpdateOrganizationAuthorizedBody }), + authenticate, + authorizeAdmin, + asyncHandler(updateOrganizationAuthorizedHandler), +); + +adminRouter.use(handlePrismaErrors); + +export default adminRouter; diff --git a/backend/src/api/admin/admin.services.ts b/backend/src/api/admin/admin.services.ts new file mode 100644 index 00000000..a7619473 --- /dev/null +++ b/backend/src/api/admin/admin.services.ts @@ -0,0 +1,29 @@ +import { prisma } from "../../utils/prisma"; + +export const getAllOrganizations = async () => { + const organizations = await prisma.organization.findMany({ + include: { + admins: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return organizations; +}; + +export const updateOrganizationAuthorized = async ( + organizationId: string, + authorized: boolean, +) => { + const organization = await prisma.organization.update({ + where: { id: organizationId }, + data: { authorized }, + include: { + admins: true, + }, + }); + + return organization; +}; diff --git a/backend/src/api/admin/index.ts b/backend/src/api/admin/index.ts new file mode 100644 index 00000000..5c22c192 --- /dev/null +++ b/backend/src/api/admin/index.ts @@ -0,0 +1 @@ +export { default } from "./admin.router"; diff --git a/backend/src/middleware/authorizeAdmin.ts b/backend/src/middleware/authorizeAdmin.ts new file mode 100644 index 00000000..ca000a94 --- /dev/null +++ b/backend/src/middleware/authorizeAdmin.ts @@ -0,0 +1,31 @@ +import { NextFunction, Request, Response } from "express-serve-static-core"; + +const getAdminEmails = (): string[] => { + const raw = process.env.ADMIN_EMAILS ?? ""; + return raw + .split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); +}; + +// Checks if the authenticated user's email is in the ADMIN_EMAILS env var. +// Must be used after the `authenticate` middleware. +export const authorizeAdmin = async ( + _req: Request, + res: Response, + next: NextFunction, +) => { + const user = res.locals.user; + if (!user?.email) { + res.status(403).json({ message: "Forbidden: admin access required" }); + return; + } + + const adminEmails = getAdminEmails(); + if (!adminEmails.includes(user.email.toLowerCase())) { + res.status(403).json({ message: "Forbidden: admin access required" }); + return; + } + + next(); +}; diff --git a/backend/src/router.ts b/backend/src/router.ts index 3f7ce479..11da33b6 100644 --- a/backend/src/router.ts +++ b/backend/src/router.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import adminRouter from "./api/admin"; import emailRouter from "./api/email"; import fundraiserRouter from "./api/fundraiser"; import orderRouter from "./api/order"; @@ -11,6 +12,7 @@ router.get("/api/", (_, res) => { res.status(200).json({ message: "Hello, World!" }); }); +router.use("/api/admin", adminRouter); router.use("/api/email", emailRouter); router.use("/api/fundraiser", fundraiserRouter); router.use("/api/order", orderRouter); diff --git a/common/schemas/organization.ts b/common/schemas/organization.ts index 164ea2fb..ca4813da 100644 --- a/common/schemas/organization.ts +++ b/common/schemas/organization.ts @@ -16,6 +16,17 @@ export const CompleteOrganizationSchema = BasicOrganizationSchema.extend({ admins: z.array(UserSchema), }); +export const AdminOrganizationSchema = BasicOrganizationSchema.extend({ + createdAt: z.coerce.date(), + websiteUrl: z.string().url().nullish(), + instagramUsername: z.string().min(1).max(255).nullish(), + admins: z.array(UserSchema), +}); + +export const UpdateOrganizationAuthorizedBody = z.object({ + authorized: z.boolean(), +}); + // CRUD BODIES: export const CreateOrganizationBody = z.object({ diff --git a/frontend/src/app/admin/components/AdminOrganizationsTable.tsx b/frontend/src/app/admin/components/AdminOrganizationsTable.tsx new file mode 100644 index 00000000..450b7343 --- /dev/null +++ b/frontend/src/app/admin/components/AdminOrganizationsTable.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { z } from "zod"; +import { AdminOrganizationSchema } from "common"; +import { createClient } from "@/utils/supabase/client"; +import { mutationFetch } from "@/lib/fetcher"; +import { toast } from "sonner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ShieldCheck, ShieldX } from "lucide-react"; + +type Organization = z.infer; + +interface AdminOrganizationsTableProps { + organizations: Organization[]; +} + +export default function AdminOrganizationsTable({ + organizations, +}: AdminOrganizationsTableProps) { + const router = useRouter(); + const supabase = createClient(); + const [loadingId, setLoadingId] = useState(null); + + const handleAuthorize = async (orgId: string, authorized: boolean) => { + setLoadingId(orgId); + try { + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session?.access_token) { + toast.error("Session expired. Please sign in again."); + return; + } + + await mutationFetch(`/admin/organizations/${orgId}/authorize`, { + method: "POST", + token: session.access_token, + body: { authorized }, + }); + + toast.success( + authorized ? "Organization approved" : "Organization approval revoked", + ); + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to update organization", + ); + } finally { + setLoadingId(null); + } + }; + + const pending = organizations.filter((org) => !org.authorized); + const approved = organizations.filter((org) => org.authorized); + + const renderTable = (orgs: Organization[]) => { + if (orgs.length === 0) { + return ( +

+ No organizations found. +

+ ); + } + + return ( +
+ + + + Name + Admins + Status + Created + Actions + + + + {orgs.map((org) => ( + + {org.name} + + {org.admins.map((a) => a.email).join(", ")} + + + {org.authorized ? ( + + Approved + + ) : ( + Pending + )} + + + {new Date(org.createdAt).toLocaleDateString()} + + + {org.authorized ? ( + + ) : ( + + )} + + + ))} + +
+
+ ); + }; + + return ( + + + + Pending ({pending.length}) + + + Approved ({approved.length}) + + + All ({organizations.length}) + + + {renderTable(pending)} + {renderTable(approved)} + {renderTable(organizations)} + + ); +} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx new file mode 100644 index 00000000..a7d9c207 --- /dev/null +++ b/frontend/src/app/admin/layout.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { signOut } from "@/lib/auth-actions"; +import { LogOut } from "lucide-react"; + +export default function AdminLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+
+
+
+ + CURaise + + + Admin + +
+ +
+
+
{children}
+
+ ); +} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 00000000..ce0d3640 --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,47 @@ +import { redirect } from "next/navigation"; +import { createClient } from "@/utils/supabase/server"; +import { connection } from "next/server"; +import { AdminOrganizationSchema } from "common"; +import { serverFetch } from "@/lib/fetcher"; +import AdminOrganizationsTable from "./components/AdminOrganizationsTable"; + +export default async function AdminPage() { + await connection(); + + const supabase = await createClient(); + + const { + data: { user }, + error: error1, + } = await supabase.auth.getUser(); + if (error1 || !user) { + redirect("/"); + } + + const { + data: { session }, + error: error2, + } = await supabase.auth.getSession(); + if (error2 || !session?.access_token) { + redirect("/"); + } + + // Verify admin access — redirects to home if not an admin + try { + await serverFetch("/admin/verify", { token: session.access_token }); + } catch { + redirect("/"); + } + + const organizations = await serverFetch("/admin/organizations", { + token: session.access_token, + schema: AdminOrganizationSchema.array(), + }); + + return ( +
+

Organizations

+ +
+ ); +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 6fbc20e3..147d617b 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -14,5 +14,6 @@ export const config = { "/account-actions", "/account", "/seller/:path*", + "/admin/:path*", ], };