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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
60 changes: 60 additions & 0 deletions backend/src/api/admin/admin.handlers.ts
Original file line number Diff line number Diff line change
@@ -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<typeof UpdateOrganizationAuthorizedBody>,
{}
>,
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 });
};
47 changes: 47 additions & 0 deletions backend/src/api/admin/admin.router.ts
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +39 to +41

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reorder the middleware to authenticate, authorizeAdmin, validate to match the other routes and prevent unauthenticated callers from inspecting the body schema.

asyncHandler(updateOrganizationAuthorizedHandler),
);

adminRouter.use(handlePrismaErrors);

export default adminRouter;
29 changes: 29 additions & 0 deletions backend/src/api/admin/admin.services.ts
Original file line number Diff line number Diff line change
@@ -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;
};
1 change: 1 addition & 0 deletions backend/src/api/admin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./admin.router";
31 changes: 31 additions & 0 deletions backend/src/middleware/authorizeAdmin.ts
Original file line number Diff line number Diff line change
@@ -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 <ParamsT, BodyT, QueryT>(
_req: Request<ParamsT, any, BodyT, QueryT>,
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();
};
2 changes: 2 additions & 0 deletions backend/src/router.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions common/schemas/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
159 changes: 159 additions & 0 deletions frontend/src/app/admin/components/AdminOrganizationsTable.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AdminOrganizationSchema>;

interface AdminOrganizationsTableProps {
organizations: Organization[];
}

export default function AdminOrganizationsTable({
organizations,
}: AdminOrganizationsTableProps) {
const router = useRouter();
const supabase = createClient();
const [loadingId, setLoadingId] = useState<string | null>(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 (
<p className="text-muted-foreground text-sm py-8 text-center">
No organizations found.
</p>
);
}

return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Admins</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orgs.map((org) => (
<TableRow key={org.id}>
<TableCell className="font-medium">{org.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{org.admins.map((a) => a.email).join(", ")}
</TableCell>
<TableCell>
{org.authorized ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">
Approved
</Badge>
) : (
<Badge variant="secondary">Pending</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(org.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
{org.authorized ? (
<Button
variant="outline"
size="sm"
disabled={loadingId === org.id}
onClick={() => handleAuthorize(org.id, false)}
className="gap-1.5"
>
<ShieldX className="h-4 w-4" />
Revoke
</Button>
) : (
<Button
size="sm"
disabled={loadingId === org.id}
onClick={() => handleAuthorize(org.id, true)}
className="gap-1.5"
>
<ShieldCheck className="h-4 w-4" />
Approve
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

return (
<Tabs defaultValue="pending">
<TabsList>
<TabsTrigger value="pending">
Pending ({pending.length})
</TabsTrigger>
<TabsTrigger value="approved">
Approved ({approved.length})
</TabsTrigger>
<TabsTrigger value="all">
All ({organizations.length})
</TabsTrigger>
</TabsList>
<TabsContent value="pending">{renderTable(pending)}</TabsContent>
<TabsContent value="approved">{renderTable(approved)}</TabsContent>
<TabsContent value="all">{renderTable(organizations)}</TabsContent>
</Tabs>
);
}
Loading