diff --git a/apps/admin/src/app/api/admin/analytics/route.ts b/apps/admin/src/app/api/admin/analytics/route.ts index 1c0e7c5..b884ec6 100644 --- a/apps/admin/src/app/api/admin/analytics/route.ts +++ b/apps/admin/src/app/api/admin/analytics/route.ts @@ -5,6 +5,7 @@ import { ensureUserSynced } from "@/lib/sync-user"; import { successResponse } from "@/lib/api/responses"; import { db as rawDb, sql } from "@fitai/database"; import { getUsersByGym, getClientsByGym } from "@/lib/gym-context"; +import log from "@/lib/logger"; interface UserGrowthPoint { label: string; @@ -158,7 +159,7 @@ export async function GET(req: NextRequest) { return successResponse({ analytics: analyticsData }); } catch (error) { - console.error("Analytics error:", error); + log.error("Analytics error", error); return NextResponse.json( { error: "Internal server error" }, { status: 500 }, diff --git a/apps/admin/src/app/api/admin/set-role/__tests__/route.test.ts b/apps/admin/src/app/api/admin/set-role/__tests__/route.test.ts new file mode 100644 index 0000000..487df07 --- /dev/null +++ b/apps/admin/src/app/api/admin/set-role/__tests__/route.test.ts @@ -0,0 +1,103 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/clerk-helpers", () => ({ + setUserRole: jest.fn(), +})); + +describe("POST /api/admin/set-role", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + const mockSetUserRole = require("@/lib/clerk-helpers") + .setUserRole as jest.Mock; + + const mockDb = { + getUserById: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue(mockDb); + }); + + it("returns 403 when admin tries to assign role across gyms", async () => { + mockAuth.mockResolvedValue({ userId: "admin_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "admin_1", + role: "admin", + gymId: "gym_a", + }); + mockDb.getUserById.mockResolvedValue({ + id: "user_2", + role: "client", + gymId: "gym_b", + }); + + const request = new NextRequest("http://localhost/api/admin/set-role", { + method: "POST", + body: JSON.stringify({ + targetUserId: "user_2", + role: "trainer", + }), + }); + + const response = await POST(request); + + expect(response.status).toBe(403); + expect(mockSetUserRole).not.toHaveBeenCalled(); + }); + + it("allows superAdmin to assign roles across gyms", async () => { + mockAuth.mockResolvedValue({ userId: "super_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "super_1", + role: "superAdmin", + gymId: null, + }); + mockDb.getUserById.mockResolvedValue({ + id: "user_2", + role: "client", + gymId: "gym_b", + }); + mockSetUserRole.mockResolvedValue({ + id: "user_2", + emailAddresses: [{ emailAddress: "user2@example.com" }], + firstName: "User", + lastName: "Two", + publicMetadata: { role: "admin" }, + }); + + const request = new NextRequest("http://localhost/api/admin/set-role", { + method: "POST", + body: JSON.stringify({ + targetUserId: "user_2", + role: "admin", + }), + }); + + const response = await POST(request); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(mockSetUserRole).toHaveBeenCalledWith("user_2", "admin"); + }); +}); diff --git a/apps/admin/src/app/api/admin/set-role/route.ts b/apps/admin/src/app/api/admin/set-role/route.ts index 52a62e0..1e37837 100644 --- a/apps/admin/src/app/api/admin/set-role/route.ts +++ b/apps/admin/src/app/api/admin/set-role/route.ts @@ -1,6 +1,9 @@ -import { auth } from '@clerk/nextjs/server'; -import { NextResponse } from 'next/server'; -import { setUserRole, isAdmin, type UserRole } from '@/lib/clerk-helpers'; +import { auth } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; +import { USER_ROLES, type UserRole } from "@fitai/shared"; +import { setUserRole } from "@/lib/clerk-helpers"; +import { getDatabase } from "@/lib/database"; +import { ensureUserSynced } from "@/lib/sync-user"; export async function POST(req: Request) { try { @@ -8,16 +11,27 @@ export async function POST(req: Request) { const { userId } = await auth(); if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + + if (!currentUser) { + return NextResponse.json( + { error: "Forbidden: user not found" }, + { status: 403 }, + ); } // Check if the requesting user is an admin - const requestingUserIsAdmin = await isAdmin(userId); + const requestingUserIsAdmin = + currentUser.role === "admin" || currentUser.role === "superAdmin"; if (!requestingUserIsAdmin) { return NextResponse.json( - { error: 'Forbidden: Admin access required' }, - { status: 403 } + { error: "Forbidden: Admin access required" }, + { status: 403 }, ); } @@ -26,25 +40,57 @@ export async function POST(req: Request) { const { targetUserId, role } = body; // Validate inputs - if (!targetUserId || typeof targetUserId !== 'string') { + if (!targetUserId || typeof targetUserId !== "string") { return NextResponse.json( - { error: 'Invalid or missing targetUserId' }, - { status: 400 } + { error: "Invalid or missing targetUserId" }, + { status: 400 }, ); } - if (!role || !['admin', 'trainer', 'client'].includes(role)) { + if (!role || !USER_ROLES.includes(role as UserRole)) { return NextResponse.json( - { error: 'Invalid role. Must be admin, trainer, or client' }, - { status: 400 } + { + error: `Invalid role. Must be one of: ${USER_ROLES.join(", ")}`, + }, + { status: 400 }, + ); + } + + const allowedRolesByRequester: Record = { + superAdmin: ["superAdmin", "admin", "trainer", "client"], + admin: ["admin", "trainer", "client"], + trainer: [], + client: [], + }; + + const allowedTargetRoles = allowedRolesByRequester[currentUser.role]; + if (!allowedTargetRoles.includes(role as UserRole)) { + return NextResponse.json( + { error: `Forbidden: cannot assign role '${role}'` }, + { status: 403 }, ); } // Prevent admin from changing their own role if (userId === targetUserId) { return NextResponse.json( - { error: 'Cannot change your own role' }, - { status: 400 } + { error: "Cannot change your own role" }, + { status: 400 }, + ); + } + + const targetUser = await db.getUserById(targetUserId); + if (!targetUser) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + if ( + currentUser.role !== "superAdmin" && + (!currentUser.gymId || targetUser.gymId !== currentUser.gymId) + ) { + return NextResponse.json( + { error: "Cannot change roles for users from other gyms" }, + { status: 403 }, ); } @@ -63,15 +109,15 @@ export async function POST(req: Request) { }, }); } catch (error) { - console.error('Error setting user role:', error); + console.error("Error setting user role:", error); - if (error instanceof Error && error.message.includes('not found')) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }); + if (error instanceof Error && error.message.includes("not found")) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); } return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } + { error: "Internal server error" }, + { status: 500 }, ); } } diff --git a/apps/admin/src/app/api/admin/stats/route.ts b/apps/admin/src/app/api/admin/stats/route.ts index bd96d1d..36bcafd 100644 --- a/apps/admin/src/app/api/admin/stats/route.ts +++ b/apps/admin/src/app/api/admin/stats/route.ts @@ -3,20 +3,26 @@ import { NextResponse } from "next/server"; import { getDatabase } from "@/lib/database"; import { ensureUserSynced } from "@/lib/sync-user"; import { successResponse } from "@/lib/api/responses"; +import log from "@/lib/logger"; export async function GET(req: Request) { try { const { userId } = await auth(); - if (!userId) return new NextResponse("Unauthorized", { status: 401 }); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } const db = await getDatabase(); const user = await ensureUserSynced(userId, db); if (!user || (user.role !== "admin" && user.role !== "superAdmin")) { - return new NextResponse("Forbidden", { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } if (user.role === "admin" && !user.gymId) { - return new NextResponse("Admin gymId not set", { status: 400 }); + return NextResponse.json( + { error: "Admin gymId not set" }, + { status: 400 }, + ); } const url = new URL(req.url); @@ -54,7 +60,10 @@ export async function GET(req: Request) { return successResponse({ stats }); } catch (error) { - console.error("Dashboard stats error:", error); - return new NextResponse("Internal Server Error", { status: 500 }); + log.error("Dashboard stats error", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } diff --git a/apps/admin/src/app/api/auth/register/route.ts b/apps/admin/src/app/api/auth/register/route.ts index c79069c..f251699 100644 --- a/apps/admin/src/app/api/auth/register/route.ts +++ b/apps/admin/src/app/api/auth/register/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import bcrypt from "bcryptjs"; +import { auth } from "@clerk/nextjs/server"; import { getDatabase } from "../../../../lib/database/index"; import log from "@/lib/logger"; import { userSchema } from "@/lib/validation/schemas"; @@ -7,6 +8,8 @@ import { validateRequestBody, validationErrorResponse, } from "@/lib/validation/helpers"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { getUsersByGym } from "@/lib/gym-context"; export async function POST(request: NextRequest) { try { @@ -68,8 +71,31 @@ export async function POST(request: NextRequest) { export async function GET() { try { + const { userId: clerkUserId } = await auth(); + if (!clerkUserId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const db = await getDatabase(); - const allUsers = await db.getAllUsers(); + const currentUser = await ensureUserSynced(clerkUserId, db); + + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const canViewUsers = + currentUser.role === "superAdmin" || currentUser.role === "admin"; + if (!canViewUsers) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const allUsers = + currentUser.role === "superAdmin" + ? await db.getAllUsers() + : currentUser.gymId + ? await getUsersByGym(currentUser.gymId) + : []; + const usersWithoutPassword = allUsers.map( ({ password: _, ...user }) => user, ); diff --git a/apps/admin/src/app/api/gyms/[id]/route.ts b/apps/admin/src/app/api/gyms/[id]/route.ts index 095772e..815c733 100644 --- a/apps/admin/src/app/api/gyms/[id]/route.ts +++ b/apps/admin/src/app/api/gyms/[id]/route.ts @@ -3,6 +3,7 @@ import { auth } from "@clerk/nextjs/server"; import { eq, sql } from "@fitai/database"; import { db, users as usersTable, gyms as gymsTable } from "@fitai/database"; import { ensureUserSynced } from "@/lib/sync-user"; +import { getDatabase } from "@/lib/database"; import log from "@/lib/logger"; async function ensureGymsTable() { @@ -33,30 +34,8 @@ export async function DELETE( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Ensure user is synced - const currentUser = await ensureUserSynced(userId, { - getUserById: async (id: string) => { - const row = await db - .select() - .from(usersTable) - .where(eq(usersTable.id, id)) - .get(); - return row - ? { - id: row.id, - email: row.email, - firstName: row.firstName, - lastName: row.lastName, - password: row.password ?? "", - phone: row.phone ?? undefined, - role: row.role, - imageUrl: undefined, - createdAt: new Date(row.createdAt), - updatedAt: new Date(row.updatedAt), - } - : null; - }, - } as any); + const appDb = await getDatabase(); + const currentUser = await ensureUserSynced(userId, appDb); // Only superAdmin can delete gyms if (!currentUser || currentUser.role !== "superAdmin") { diff --git a/apps/admin/src/app/api/gyms/[id]/stats/route.ts b/apps/admin/src/app/api/gyms/[id]/stats/route.ts index 9da109a..c0f4d39 100644 --- a/apps/admin/src/app/api/gyms/[id]/stats/route.ts +++ b/apps/admin/src/app/api/gyms/[id]/stats/route.ts @@ -1,7 +1,10 @@ import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; import { eq, sql } from "@fitai/database"; import { db, gyms as gymsTable } from "@fitai/database"; import log from "@/lib/logger"; +import { getDatabase } from "@/lib/database"; +import { ensureUserSynced } from "@/lib/sync-user"; async function ensureGymsTable() { await db.run(sql` @@ -24,7 +27,33 @@ export async function GET( { params }: { params: Promise<{ id: string }> }, ) { try { + const { userId: clerkUserId } = await auth(); + if (!clerkUserId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const appDb = await getDatabase(); + const currentUser = await ensureUserSynced(clerkUserId, appDb); + + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { id: gymId } = await params; + + const canViewGymStats = + currentUser.role === "superAdmin" || currentUser.role === "admin"; + if (!canViewGymStats) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (currentUser.role !== "superAdmin" && currentUser.gymId !== gymId) { + return NextResponse.json( + { error: "Forbidden - Cannot access other gym's data" }, + { status: 403 }, + ); + } + await ensureGymsTable(); // Get gym info using Drizzle ORM diff --git a/apps/admin/src/app/api/gyms/__tests__/route-authz.test.ts b/apps/admin/src/app/api/gyms/__tests__/route-authz.test.ts new file mode 100644 index 0000000..83a0cc8 --- /dev/null +++ b/apps/admin/src/app/api/gyms/__tests__/route-authz.test.ts @@ -0,0 +1,88 @@ +/** + * @jest-environment node + */ + +import { GET } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@fitai/database", () => ({ + eq: jest.fn(() => ({})), + sql: jest.fn((strings: TemplateStringsArray) => strings.join("")), + db: { + run: jest.fn(), + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + orderBy: jest.fn(() => ({ + all: jest.fn().mockResolvedValue([ + { id: "gym_a", status: "active", name: "Gym A" }, + { id: "gym_b", status: "active", name: "Gym B" }, + ]), + })), + })), + })), + })), + gyms: { + status: "active", + }, + users: {}, + }, + gyms: { + status: "active", + }, + users: {}, +})); + +describe("GET /api/gyms authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue({}); + }); + + it("returns only own gym for admin", async () => { + mockAuth.mockResolvedValue({ userId: "admin_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "admin_1", + role: "admin", + gymId: "gym_a", + }); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveLength(1); + expect(data[0].id).toBe("gym_a"); + }); + + it("returns all gyms for superAdmin", async () => { + mockAuth.mockResolvedValue({ userId: "super_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "super_1", + role: "superAdmin", + gymId: null, + }); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveLength(2); + }); +}); diff --git a/apps/admin/src/app/api/gyms/route.ts b/apps/admin/src/app/api/gyms/route.ts index 728e340..bcfa6d9 100644 --- a/apps/admin/src/app/api/gyms/route.ts +++ b/apps/admin/src/app/api/gyms/route.ts @@ -3,6 +3,7 @@ import { auth } from "@clerk/nextjs/server"; import { eq, sql } from "@fitai/database"; import { db, users as usersTable, gyms as gymsTable } from "@fitai/database"; import { ensureUserSynced } from "@/lib/sync-user"; +import { getDatabase } from "@/lib/database"; import log from "@/lib/logger"; async function ensureGymsTable() { @@ -23,14 +24,37 @@ async function ensureGymsTable() { // Lists active gyms for selection (grid) export async function GET() { try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const appDb = await getDatabase(); + const currentUser = await ensureUserSynced(userId, appDb); + + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (currentUser.role !== "admin" && currentUser.role !== "superAdmin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + await ensureGymsTable(); - const rows = await db + let rows = await db .select() .from(gymsTable) .where(eq(gymsTable.status, "active")) .orderBy(sql`created_at DESC`) .all(); + if (currentUser.role !== "superAdmin") { + if (!currentUser.gymId) { + return NextResponse.json({ error: "No gym assigned" }, { status: 403 }); + } + rows = rows.filter((row) => row.id === currentUser.gymId); + } + return NextResponse.json(rows); } catch (error) { log.error("Failed to get gyms", error); @@ -48,60 +72,8 @@ export async function POST(req: Request) { const { userId } = await auth(); if (!userId) return new NextResponse("Unauthorized", { status: 401 }); - // Ensure our local DB has the user synced (role, etc.) - const currentUser = await ensureUserSynced(userId, { - // minimal facade for ensureUserSynced to work: it expects an object implementing part of IDatabase - getUserById: async (id: string) => { - const row = await db - .select() - .from(usersTable) - .where(eq(usersTable.id, id)) - .get(); - return row - ? { - id: row.id, - email: row.email, - firstName: row.firstName, - lastName: row.lastName, - password: row.password ?? "", - phone: row.phone ?? undefined, - role: row.role, - imageUrl: undefined, - createdAt: new Date(row.createdAt), - updatedAt: new Date(row.updatedAt), - } - : null; - }, - updateUser: async (id: string, updates: any) => { - await db - .update(usersTable) - .set({ - ...updates, - updatedAt: new Date(), - }) - .where(eq(usersTable.id, id)) - .run(); - const row = await db - .select() - .from(usersTable) - .where(eq(usersTable.id, id)) - .get(); - return row - ? { - id: row.id, - email: row.email, - firstName: row.firstName, - lastName: row.lastName, - password: row.password ?? "", - phone: row.phone ?? undefined, - role: row.role, - imageUrl: undefined, - createdAt: new Date(row.createdAt), - updatedAt: new Date(row.updatedAt), - } - : null; - }, - } as any); + const appDb = await getDatabase(); + const currentUser = await ensureUserSynced(userId, appDb); if ( !currentUser || diff --git a/apps/admin/src/app/api/invitations/[id]/resend/route.ts b/apps/admin/src/app/api/invitations/[id]/resend/route.ts index 0f42875..bb391fd 100644 --- a/apps/admin/src/app/api/invitations/[id]/resend/route.ts +++ b/apps/admin/src/app/api/invitations/[id]/resend/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { auth, clerkClient } from "@clerk/nextjs/server"; +import { getAuthContext } from "@/lib/auth/context"; import log from "@/lib/logger"; /** @@ -18,6 +19,7 @@ export async function POST( } const { id: invitationId } = await params; + const authContext = await getAuthContext(); // Fetch pending invitations to find the one being resent const client = await clerkClient(); @@ -38,11 +40,23 @@ export async function POST( } const metadata = invitation.publicMetadata as any; + const invitationGymId = + (metadata?.gymId as string | null | undefined) ?? null; + const createdBy = (metadata?.createdBy as string | undefined) ?? undefined; + + const canManageByRole = + authContext.role === "superAdmin" || + (authContext.role === "admin" && + authContext.gymId !== null && + invitationGymId === authContext.gymId); // Check if current user created this invitation - if (metadata?.createdBy !== userId) { + if (createdBy !== userId && !canManageByRole) { return NextResponse.json( - { error: "Forbidden - You can only resend invitations you created" }, + { + error: + "Forbidden - You can only resend invitations you created or manage within your scope", + }, { status: 403 }, ); } diff --git a/apps/admin/src/app/api/invitations/[id]/route.ts b/apps/admin/src/app/api/invitations/[id]/route.ts index 73bad4b..8eac8fb 100644 --- a/apps/admin/src/app/api/invitations/[id]/route.ts +++ b/apps/admin/src/app/api/invitations/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { auth, clerkClient } from "@clerk/nextjs/server"; +import { getAuthContext } from "@/lib/auth/context"; import log from "@/lib/logger"; /** @@ -18,6 +19,7 @@ export async function DELETE( } const { id: invitationId } = await params; + const authContext = await getAuthContext(); // Fetch pending invitations to find and verify the one being revoked const client = await clerkClient(); @@ -38,11 +40,23 @@ export async function DELETE( } const metadata = invitation.publicMetadata as any; + const invitationGymId = + (metadata?.gymId as string | null | undefined) ?? null; + const createdBy = (metadata?.createdBy as string | undefined) ?? undefined; + + const canManageByRole = + authContext.role === "superAdmin" || + (authContext.role === "admin" && + authContext.gymId !== null && + invitationGymId === authContext.gymId); // Check if current user created this invitation - if (metadata?.createdBy !== userId) { + if (createdBy !== userId && !canManageByRole) { return NextResponse.json( - { error: "Forbidden - You can only cancel invitations you created" }, + { + error: + "Forbidden - You can only cancel invitations you created or manage within your scope", + }, { status: 403 }, ); } diff --git a/apps/admin/src/app/api/invitations/__tests__/route-authz.test.ts b/apps/admin/src/app/api/invitations/__tests__/route-authz.test.ts new file mode 100644 index 0000000..6d50d67 --- /dev/null +++ b/apps/admin/src/app/api/invitations/__tests__/route-authz.test.ts @@ -0,0 +1,92 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), + clerkClient: jest.fn(), +})); + +jest.mock("@/lib/auth/context", () => ({ + getAuthContext: jest.fn(), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("POST /api/invitations authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockClerkClient = require("@clerk/nextjs/server") + .clerkClient as jest.Mock; + const mockGetAuthContext = require("@/lib/auth/context") + .getAuthContext as jest.Mock; + + const createInvitation = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockClerkClient.mockResolvedValue({ + invitations: { + createInvitation, + }, + }); + }); + + it("blocks admin from inviting into another gym", async () => { + mockAuth.mockResolvedValue({ userId: "admin_1" }); + mockGetAuthContext.mockResolvedValue({ + userId: "admin_1", + role: "admin", + gymId: "gym_a", + }); + + const request = new NextRequest("http://localhost/api/invitations", { + method: "POST", + body: JSON.stringify({ + inviteeEmail: "test@example.com", + roleAssigned: "trainer", + gymId: "gym_b", + }), + }); + + const response = await POST(request); + + expect(response.status).toBe(403); + expect(createInvitation).not.toHaveBeenCalled(); + }); + + it("allows superAdmin to invite with explicit gym", async () => { + mockAuth.mockResolvedValue({ userId: "super_1" }); + mockGetAuthContext.mockResolvedValue({ + userId: "super_1", + role: "superAdmin", + gymId: null, + }); + createInvitation.mockResolvedValue({ id: "inv_1" }); + + const request = new NextRequest("http://localhost/api/invitations", { + method: "POST", + body: JSON.stringify({ + inviteeEmail: "test@example.com", + roleAssigned: "admin", + gymId: "gym_b", + }), + }); + + const response = await POST(request); + + expect(response.status).toBe(201); + expect(createInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + emailAddress: "test@example.com", + }), + ); + }); +}); diff --git a/apps/admin/src/app/api/invitations/route.ts b/apps/admin/src/app/api/invitations/route.ts index f475aca..dbc4adc 100644 --- a/apps/admin/src/app/api/invitations/route.ts +++ b/apps/admin/src/app/api/invitations/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth, clerkClient } from "@clerk/nextjs/server"; import { getAuthContext } from "@/lib/auth/context"; -import { validateGymAccess } from "@/lib/auth/permissions"; +import { getInvitableRoles, validateGymAccess } from "@/lib/auth/permissions"; import log from "@/lib/logger"; /** @@ -132,91 +132,51 @@ export async function POST(req: Request) { ); } - // Fetch inviter user from Clerk - const client = await clerkClient(); - const inviter = await client.users.getUser(userId); - const inviterRole = - (inviter.publicMetadata?.role as - | "superAdmin" - | "admin" - | "trainer" - | "client" - | "generalUser") ?? "client"; - const inviterGymId = - (inviter.publicMetadata?.gymId as string | undefined) ?? undefined; + const authContext = await getAuthContext(); + const { role: inviterRole, gymId: inviterGymId } = authContext; + + const allowedRoles = getInvitableRoles(inviterRole); + if (!allowedRoles.includes(roleAssigned)) { + return NextResponse.json( + { error: `Forbidden - Cannot invite role '${roleAssigned}'` }, + { status: 403 }, + ); + } // Enforce role-based rules and resolve target gymId for the invitation let gymIdForInvite: string | null = null; - switch (inviterRole) { - case "admin": { - if (roleAssigned !== "trainer" && roleAssigned !== "client") { - return NextResponse.json( - { error: "Admin can only invite trainer or client" }, - { status: 403 }, - ); - } - if (!inviterGymId) { - return NextResponse.json( - { error: "Inviter admin must be assigned to a gym" }, - { status: 400 }, - ); - } - gymIdForInvite = inviterGymId; - break; - } - case "trainer": { - if (roleAssigned !== "client") { - return NextResponse.json( - { error: "Trainer can only invite client" }, - { status: 403 }, - ); - } - if (!inviterGymId) { - return NextResponse.json( - { error: "Inviter trainer must be assigned to a gym" }, - { status: 400 }, - ); - } - gymIdForInvite = inviterGymId; - break; - } - case "superAdmin": { - if ( - roleAssigned !== "admin" && - roleAssigned !== "trainer" && - roleAssigned !== "client" - ) { - return NextResponse.json( - { error: "Invalid roleAssigned for SuperAdmin" }, - { status: 400 }, - ); - } - // Prefer explicitly provided gymId, otherwise fall back to inviter's gymId if present - gymIdForInvite = requestedGymId || inviterGymId || null; - if (!gymIdForInvite) { - return NextResponse.json( - { error: "gymId is required for SuperAdmin when inviting" }, - { status: 400 }, - ); - } - break; - } - default: { + if (inviterRole === "superAdmin") { + gymIdForInvite = requestedGymId || inviterGymId || null; + if (!gymIdForInvite) { return NextResponse.json( - { error: "Inviter role not permitted to create invitations" }, + { error: "gymId is required for superAdmin invitations" }, + { status: 400 }, + ); + } + } else { + if (!inviterGymId) { + return NextResponse.json( + { error: "Inviter must be assigned to a gym" }, + { status: 400 }, + ); + } + if (requestedGymId && requestedGymId !== inviterGymId) { + return NextResponse.json( + { error: "Cannot invite users into another gym" }, { status: 403 }, ); } + gymIdForInvite = inviterGymId; } // Create Clerk invitation with metadata needed by webhook to assign role & gym - // reuse existing Clerk client instance + const client = await clerkClient(); const invitation = await client.invitations.createInvitation({ emailAddress: inviteeEmail, publicMetadata: { role: roleAssigned, gymId: gymIdForInvite, - createdBy: inviter.id, + createdBy: userId, }, }); diff --git a/apps/admin/src/app/api/notifications/__tests__/route-authz.test.ts b/apps/admin/src/app/api/notifications/__tests__/route-authz.test.ts new file mode 100644 index 0000000..4be137d --- /dev/null +++ b/apps/admin/src/app/api/notifications/__tests__/route-authz.test.ts @@ -0,0 +1,87 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("POST /api/notifications authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + + const mockDb = { + getUserById: jest.fn(), + createNotification: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue(mockDb); + }); + + it("returns 403 for non-staff user", async () => { + mockAuth.mockResolvedValue({ userId: "client_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "client_1", + role: "client", + gymId: "gym_a", + }); + + const request = new NextRequest("http://localhost/api/notifications", { + method: "POST", + body: JSON.stringify({ + targetUserId: "client_2", + title: "Hello", + message: "Test", + type: "system", + }), + }); + + const response = await POST(request); + expect(response.status).toBe(403); + }); + + it("returns 403 for cross-gym notify by admin", async () => { + mockAuth.mockResolvedValue({ userId: "admin_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "admin_1", + role: "admin", + gymId: "gym_a", + }); + mockDb.getUserById.mockResolvedValue({ id: "client_2", gymId: "gym_b" }); + + const request = new NextRequest("http://localhost/api/notifications", { + method: "POST", + body: JSON.stringify({ + targetUserId: "client_2", + title: "Hello", + message: "Test", + type: "system", + }), + }); + + const response = await POST(request); + expect(response.status).toBe(403); + }); +}); diff --git a/apps/admin/src/app/api/notifications/route.ts b/apps/admin/src/app/api/notifications/route.ts index e297d72..3383643 100644 --- a/apps/admin/src/app/api/notifications/route.ts +++ b/apps/admin/src/app/api/notifications/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { getDatabase } from "@/lib/database"; import log from "@/lib/logger"; +import { ensureUserSynced } from "@/lib/sync-user"; /** * GET /api/notifications @@ -84,6 +85,39 @@ export async function POST(req: NextRequest) { } const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const canCreateNotifications = + currentUser.role === "superAdmin" || + currentUser.role === "admin" || + currentUser.role === "trainer"; + + if (!canCreateNotifications) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const targetUser = await db.getUserById(targetUserId); + if (!targetUser) { + return NextResponse.json( + { error: "Target user not found" }, + { status: 404 }, + ); + } + + if ( + currentUser.role !== "superAdmin" && + (!currentUser.gymId || targetUser.gymId !== currentUser.gymId) + ) { + return NextResponse.json( + { error: "Forbidden - Cannot notify users from other gyms" }, + { status: 403 }, + ); + } + const notification = await db.createNotification({ id: crypto.randomUUID(), userId: targetUserId, diff --git a/apps/admin/src/app/api/recommendations/approve/__tests__/route-authz.test.ts b/apps/admin/src/app/api/recommendations/approve/__tests__/route-authz.test.ts new file mode 100644 index 0000000..9016f08 --- /dev/null +++ b/apps/admin/src/app/api/recommendations/approve/__tests__/route-authz.test.ts @@ -0,0 +1,83 @@ +/** + * @jest-environment node + */ + +import { POST } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("POST /api/recommendations/approve authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + + const mockDb = { + getAllRecommendations: jest.fn(), + getUserById: jest.fn(), + updateRecommendation: jest.fn(), + createNotification: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue(mockDb); + }); + + it("returns 403 for non-staff role", async () => { + mockAuth.mockResolvedValue({ userId: "client_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "client_1", + role: "client", + gymId: "gym_a", + }); + + const req = new Request("http://localhost/api/recommendations/approve", { + method: "POST", + body: JSON.stringify({ recommendationId: "rec_1", status: "approved" }), + headers: { "Content-Type": "application/json" }, + }); + + const res = await POST(req); + expect(res.status).toBe(403); + }); + + it("returns 403 for cross-gym approval by admin", async () => { + mockAuth.mockResolvedValue({ userId: "admin_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "admin_1", + role: "admin", + gymId: "gym_a", + }); + mockDb.getAllRecommendations.mockResolvedValue([ + { id: "rec_1", userId: "client_1" }, + ]); + mockDb.getUserById.mockResolvedValue({ id: "client_1", gymId: "gym_b" }); + + const req = new Request("http://localhost/api/recommendations/approve", { + method: "POST", + body: JSON.stringify({ recommendationId: "rec_1", status: "approved" }), + headers: { "Content-Type": "application/json" }, + }); + + const res = await POST(req); + expect(res.status).toBe(403); + }); +}); diff --git a/apps/admin/src/app/api/recommendations/approve/route.ts b/apps/admin/src/app/api/recommendations/approve/route.ts index 3f46f2c..730fb4a 100644 --- a/apps/admin/src/app/api/recommendations/approve/route.ts +++ b/apps/admin/src/app/api/recommendations/approve/route.ts @@ -1,13 +1,20 @@ import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; import { getDatabase } from "@/lib/database"; import log from "@/lib/logger"; +import { ensureUserSynced } from "@/lib/sync-user"; export async function POST(req: Request) { try { + const { userId: clerkUserId } = await auth(); + if (!clerkUserId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const body = await req.json(); log.debug("Approve recommendation request body", { body }); - const { recommendationId, status, approvedBy } = body; + const { recommendationId, status } = body; if (!recommendationId || !status) { log.error("Missing required fields", { @@ -22,12 +29,52 @@ export async function POST(req: Request) { } const db = await getDatabase(); + const currentUser = await ensureUserSynced(clerkUserId, db); + + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const canApproveRecommendations = + currentUser.role === "superAdmin" || + currentUser.role === "admin" || + currentUser.role === "trainer"; + + if (!canApproveRecommendations) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const existingRecommendation = (await db.getAllRecommendations()).find( + (recommendation) => recommendation.id === recommendationId, + ); + + if (!existingRecommendation) { + return NextResponse.json( + { error: "Recommendation not found" }, + { status: 404 }, + ); + } + + if (currentUser.role !== "superAdmin") { + const targetUser = await db.getUserById(existingRecommendation.userId); + + if ( + !currentUser.gymId || + !targetUser || + targetUser.gymId !== currentUser.gymId + ) { + return NextResponse.json( + { error: "Forbidden - Cannot access users from other gyms" }, + { status: 403 }, + ); + } + } // Update recommendation status const updates: any = { status, approvedAt: status === "approved" ? new Date() : undefined, - approvedBy: status === "approved" ? approvedBy : undefined, + approvedBy: status === "approved" ? clerkUserId : undefined, }; // Remove undefined keys diff --git a/apps/admin/src/app/api/recommendations/generate/__tests__/route-authz.test.ts b/apps/admin/src/app/api/recommendations/generate/__tests__/route-authz.test.ts new file mode 100644 index 0000000..ca20433 --- /dev/null +++ b/apps/admin/src/app/api/recommendations/generate/__tests__/route-authz.test.ts @@ -0,0 +1,85 @@ +/** + * @jest-environment node + */ + +import { POST } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/ai/ai-context", () => ({ + buildAIContext: jest.fn(), +})); + +jest.mock("@/lib/ai/prompt-builder", () => ({ + buildEnhancedPrompt: jest.fn(), + buildBasicPrompt: jest.fn(), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("POST /api/recommendations/generate authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + + const mockDb = { + getUserById: jest.fn(), + getFitnessProfileByUserId: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue(mockDb); + }); + + it("returns 401 when unauthenticated", async () => { + mockAuth.mockResolvedValue({ userId: null }); + + const req = new Request("http://localhost/api/recommendations/generate", { + method: "POST", + body: JSON.stringify({ userId: "client_1" }), + headers: { "Content-Type": "application/json" }, + }); + + const res = await POST(req); + expect(res.status).toBe(401); + }); + + it("returns 403 when staff accesses user from another gym", async () => { + mockAuth.mockResolvedValue({ userId: "admin_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "admin_1", + role: "admin", + gymId: "gym_a", + }); + mockDb.getUserById.mockResolvedValue({ + id: "client_1", + gymId: "gym_b", + }); + + const req = new Request("http://localhost/api/recommendations/generate", { + method: "POST", + body: JSON.stringify({ userId: "client_1" }), + headers: { "Content-Type": "application/json" }, + }); + + const res = await POST(req); + expect(res.status).toBe(403); + }); +}); diff --git a/apps/admin/src/app/api/recommendations/generate/route.ts b/apps/admin/src/app/api/recommendations/generate/route.ts index e46ab19..a3d5d80 100644 --- a/apps/admin/src/app/api/recommendations/generate/route.ts +++ b/apps/admin/src/app/api/recommendations/generate/route.ts @@ -1,11 +1,18 @@ import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; import { getDatabase } from "@/lib/database"; import { buildAIContext } from "@/lib/ai/ai-context"; import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder"; import log from "@/lib/logger"; +import { ensureUserSynced } from "@/lib/sync-user"; export async function POST(req: Request) { try { + const { userId: clerkUserId } = await auth(); + if (!clerkUserId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { userId, useExternalModel, modelProvider } = await req.json(); if (!userId) { @@ -22,6 +29,34 @@ export async function POST(req: Request) { }); const db = await getDatabase(); + const currentUser = await ensureUserSynced(clerkUserId, db); + + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const canGenerateRecommendations = + currentUser.role === "superAdmin" || + currentUser.role === "admin" || + currentUser.role === "trainer"; + + if (!canGenerateRecommendations) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const targetUser = await db.getUserById(userId); + if (!targetUser) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + if (currentUser.role !== "superAdmin") { + if (!currentUser.gymId || targetUser.gymId !== currentUser.gymId) { + return NextResponse.json( + { error: "Forbidden - Cannot access users from other gyms" }, + { status: 403 }, + ); + } + } // Fetch fitness profile const profile = await db.getFitnessProfileByUserId(userId); diff --git a/apps/admin/src/app/api/recommendations/route.ts b/apps/admin/src/app/api/recommendations/route.ts index d2b9eeb..46670b7 100644 --- a/apps/admin/src/app/api/recommendations/route.ts +++ b/apps/admin/src/app/api/recommendations/route.ts @@ -106,7 +106,7 @@ export async function POST(request: NextRequest) { } const db = await getDatabase(); - const currentUser = await db.getUserById(currentUserId); + const currentUser = await ensureUserSynced(currentUserId, db); const isStaff = currentUser?.role === "admin" || currentUser?.role === "superAdmin" || @@ -140,6 +140,18 @@ export async function POST(request: NextRequest) { content, } = validation.data; + const targetUser = await db.getUserById(userId); + if (!targetUser) { + return badRequestResponse("Target user not found"); + } + + if ( + currentUser?.role !== "superAdmin" && + (!currentUser?.gymId || targetUser.gymId !== currentUser.gymId) + ) { + return forbiddenResponse("Cannot create recommendations for other gyms"); + } + // Handle AI Plan (Legacy/Specific) if (recommendationText && activityPlan && dietPlan && fitnessProfileId) { const recommendation = await db.createRecommendation({ @@ -198,6 +210,41 @@ export async function PUT(request: NextRequest) { validation.data; const db = await getDatabase(); + const currentUser = await ensureUserSynced(currentUserId, db); + + if (!currentUser) { + return forbiddenResponse("User not found"); + } + + const isStaff = + currentUser.role === "admin" || + currentUser.role === "superAdmin" || + currentUser.role === "trainer"; + + if (!isStaff) { + return forbiddenResponse(); + } + + const existingRecommendation = (await db.getAllRecommendations()).find( + (recommendation) => recommendation.id === id, + ); + + if (!existingRecommendation) { + return badRequestResponse("Recommendation not found"); + } + + if (currentUser.role !== "superAdmin") { + const targetUser = await db.getUserById(existingRecommendation.userId); + if ( + !currentUser.gymId || + !targetUser || + targetUser.gymId !== currentUser.gymId + ) { + return forbiddenResponse( + "Cannot modify recommendations for other gyms", + ); + } + } const updated = await db.updateRecommendation(id, { ...(status && { status }), diff --git a/apps/admin/src/app/api/trainer-client/[id]/route.ts b/apps/admin/src/app/api/trainer-client/[id]/route.ts index 1c365ae..293912a 100644 --- a/apps/admin/src/app/api/trainer-client/[id]/route.ts +++ b/apps/admin/src/app/api/trainer-client/[id]/route.ts @@ -35,36 +35,37 @@ export async function DELETE( const { id } = await params; - // Try to find the assignment by checking trainer's assignments first - const trainerAssignments = await db.getTrainerClientAssignments( - currentUser.id, - ); - let assignment = trainerAssignments.find((a) => a.id === id); + const allAssignments = await db.getAllTrainerClientAssignments(); + const assignment = allAssignments.find((a) => a.id === id); if (!assignment) { - // Check all assignments to find the one with this ID - const allAssignments = await db.getAllTrainerClientAssignments(); - assignment = allAssignments.find((a) => a.id === id); + return NextResponse.json( + { error: "Assignment not found" }, + { status: 404 }, + ); + } - if (!assignment) { - return NextResponse.json( - { error: "Assignment not found" }, - { status: 404 }, - ); + if (currentUser.role !== "superAdmin") { + if (!currentUser.gymId) { + return NextResponse.json({ error: "No gym assigned" }, { status: 403 }); } - // Deactivate the assignment - await db.deactivateTrainerClientAssignment(id); + const [trainer, client] = await Promise.all([ + db.getUserById(assignment.trainerId), + db.getUserById(assignment.clientId), + ]); - log.info("Trainer-client assignment deactivated", { - assignmentId: id, - deactivatedBy: currentUser.id, - }); - - return NextResponse.json({ - success: true, - message: "Assignment deactivated successfully", - }); + if ( + !trainer || + !client || + trainer.gymId !== currentUser.gymId || + client.gymId !== currentUser.gymId + ) { + return NextResponse.json( + { error: "Cannot modify assignments from other gyms" }, + { status: 403 }, + ); + } } // Deactivate the assignment diff --git a/apps/admin/src/app/api/trainer-client/__tests__/route-authz.test.ts b/apps/admin/src/app/api/trainer-client/__tests__/route-authz.test.ts new file mode 100644 index 0000000..ad4d3a6 --- /dev/null +++ b/apps/admin/src/app/api/trainer-client/__tests__/route-authz.test.ts @@ -0,0 +1,89 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { GET, POST } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("/api/trainer-client authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + + const mockDb = { + getUserById: jest.fn(), + getTrainerClientAssignments: jest.fn(), + getAllTrainerClientAssignments: jest.fn(), + createTrainerClientAssignment: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue(mockDb); + }); + + it("blocks non-admin users from listing assignments", async () => { + mockAuth.mockResolvedValue({ userId: "trainer_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "trainer_1", + role: "trainer", + gymId: "gym_a", + }); + + const request = new NextRequest("http://localhost/api/trainer-client"); + const response = await GET(request); + + expect(response.status).toBe(403); + }); + + it("blocks admins from creating assignments across gyms", async () => { + mockAuth.mockResolvedValue({ userId: "admin_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "admin_1", + role: "admin", + gymId: "gym_a", + }); + + mockDb.getUserById + .mockResolvedValueOnce({ + id: "trainer_2", + role: "trainer", + gymId: "gym_b", + }) + .mockResolvedValueOnce({ + id: "client_2", + role: "client", + gymId: "gym_b", + }); + + const request = new NextRequest("http://localhost/api/trainer-client", { + method: "POST", + body: JSON.stringify({ trainerId: "trainer_2", clientId: "client_2" }), + }); + + const response = await POST(request); + + expect(response.status).toBe(403); + expect(mockDb.createTrainerClientAssignment).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/app/api/trainer-client/route.ts b/apps/admin/src/app/api/trainer-client/route.ts index 7a1a688..cc62e69 100644 --- a/apps/admin/src/app/api/trainer-client/route.ts +++ b/apps/admin/src/app/api/trainer-client/route.ts @@ -38,17 +38,110 @@ export async function GET(request: NextRequest) { const trainerId = searchParams.get("trainerId"); const clientId = searchParams.get("clientId"); - let assignments; + if (trainerId && clientId) { + const [trainer, client] = await Promise.all([ + db.getUserById(trainerId), + db.getUserById(clientId), + ]); - if (trainerId) { - assignments = await db.getTrainerClientAssignments(trainerId); - // Filter by clientId if provided - if (clientId) { - assignments = assignments.filter((a) => a.clientId === clientId); + if (!trainer || !client) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); } - } else { - // Get all assignments (for admins, filtered by gym) - assignments = await db.getAllTrainerClientAssignments(); + + if ( + currentUser.role !== "superAdmin" && + (!currentUser.gymId || + trainer.gymId !== currentUser.gymId || + client.gymId !== currentUser.gymId) + ) { + return NextResponse.json( + { error: "Cannot access assignments from other gyms" }, + { status: 403 }, + ); + } + } + + if (trainerId && !clientId) { + const trainer = await db.getUserById(trainerId); + if (!trainer) { + return NextResponse.json( + { error: "Trainer not found" }, + { status: 404 }, + ); + } + + if ( + currentUser.role !== "superAdmin" && + (!currentUser.gymId || trainer.gymId !== currentUser.gymId) + ) { + return NextResponse.json( + { error: "Cannot access assignments from other gyms" }, + { status: 403 }, + ); + } + } + + if (!trainerId && clientId) { + const client = await db.getUserById(clientId); + if (!client) { + return NextResponse.json( + { error: "Client not found" }, + { status: 404 }, + ); + } + + if ( + currentUser.role !== "superAdmin" && + (!currentUser.gymId || client.gymId !== currentUser.gymId) + ) { + return NextResponse.json( + { error: "Cannot access assignments from other gyms" }, + { status: 403 }, + ); + } + } + + let assignments = trainerId + ? await db.getTrainerClientAssignments(trainerId) + : await db.getAllTrainerClientAssignments(); + + if (clientId) { + assignments = assignments.filter((a) => a.clientId === clientId); + } + + if (currentUser.role !== "superAdmin") { + if (!currentUser.gymId) { + return NextResponse.json({ error: "No gym assigned" }, { status: 403 }); + } + + const involvedUserIds = Array.from( + new Set( + assignments.flatMap((assignment) => [ + assignment.trainerId, + assignment.clientId, + ]), + ), + ); + + const users = await Promise.all( + involvedUserIds.map((userId) => db.getUserById(userId)), + ); + + const gymByUserId = new Map( + users + .filter((user): user is NonNullable => !!user) + .map((user) => [user.id, user.gymId]), + ); + + assignments = assignments.filter((assignment) => { + const trainerGymId = gymByUserId.get(assignment.trainerId); + const clientGymId = gymByUserId.get(assignment.clientId); + + return ( + trainerGymId === currentUser.gymId && + clientGymId === currentUser.gymId + ); + }); } return NextResponse.json({ assignments }); @@ -121,6 +214,18 @@ export async function POST(request: NextRequest) { ); } + if ( + currentUser.role !== "superAdmin" && + (!currentUser.gymId || + trainer.gymId !== currentUser.gymId || + client.gymId !== currentUser.gymId) + ) { + return NextResponse.json( + { error: "Cannot assign users from other gyms" }, + { status: 403 }, + ); + } + // Check if assignment already exists const existingAssignments = await db.getTrainerClientAssignments(trainerId); const existingAssignment = existingAssignments.find( diff --git a/apps/admin/src/app/api/users/__tests__/delete-authz.test.ts b/apps/admin/src/app/api/users/__tests__/delete-authz.test.ts new file mode 100644 index 0000000..fb2eadf --- /dev/null +++ b/apps/admin/src/app/api/users/__tests__/delete-authz.test.ts @@ -0,0 +1,109 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { DELETE } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), + clerkClient: jest.fn(), +})); + +jest.mock("@/lib/database/index", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@fitai/database", () => ({ + db: { + all: jest.fn(), + get: jest.fn(), + run: jest.fn(), + }, + sql: jest.fn((strings: TemplateStringsArray) => strings.join("")), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("DELETE /api/users authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database/index") + .getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + + const mockDb = { + getUserById: jest.fn(), + deleteUser: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue(mockDb); + }); + + it("blocks self deletion", async () => { + mockAuth.mockResolvedValue({ userId: "admin_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "admin_1", + role: "admin", + gymId: "gym_a", + }); + + const request = new NextRequest("http://localhost/api/users?id=admin_1", { + method: "DELETE", + }); + + const response = await DELETE(request); + expect(response.status).toBe(403); + expect(mockDb.deleteUser).not.toHaveBeenCalled(); + }); + + it("blocks cross-gym deletion for admin", async () => { + mockAuth.mockResolvedValue({ userId: "admin_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "admin_1", + role: "admin", + gymId: "gym_a", + }); + mockDb.getUserById.mockResolvedValue({ id: "user_2", gymId: "gym_b" }); + + const request = new NextRequest("http://localhost/api/users?id=user_2", { + method: "DELETE", + }); + + const response = await DELETE(request); + expect(response.status).toBe(403); + expect(mockDb.deleteUser).not.toHaveBeenCalled(); + }); + + it("allows superAdmin cross-gym deletion", async () => { + mockAuth.mockResolvedValue({ userId: "super_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "super_1", + role: "superAdmin", + gymId: null, + }); + mockDb.getUserById.mockResolvedValue({ id: "user_2", gymId: "gym_b" }); + + const request = new NextRequest("http://localhost/api/users?id=user_2", { + method: "DELETE", + }); + + const response = await DELETE(request); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(mockDb.deleteUser).toHaveBeenCalledWith("user_2"); + }); +}); diff --git a/apps/admin/src/app/api/users/route.ts b/apps/admin/src/app/api/users/route.ts index 4630514..0acaf6b 100644 --- a/apps/admin/src/app/api/users/route.ts +++ b/apps/admin/src/app/api/users/route.ts @@ -580,26 +580,76 @@ export async function PUT(request: NextRequest) { export async function DELETE(request: NextRequest) { try { + const { userId: clerkUserId } = await auth(); + if (!clerkUserId) { + return unauthorizedResponse(); + } + const db = await getDatabase(); + const currentUser = await ensureUserSynced(clerkUserId, db); + + if (!currentUser) { + return forbiddenResponse("Current user not found"); + } + + const canDeleteUsers = + currentUser.role === "admin" || currentUser.role === "superAdmin"; + + if (!canDeleteUsers) { + return forbiddenResponse("Only admins can delete users"); + } + const { searchParams } = new URL(request.url); const id = searchParams.get("id"); const body = await request.json().catch(() => ({})); const { ids } = body; + const targetIds: string[] = Array.isArray(ids) + ? ids.filter( + (userId: unknown): userId is string => typeof userId === "string", + ) + : id + ? [id] + : []; + + if (targetIds.length === 0) { + return badRequestResponse("User ID or IDs array required"); + } + + if (targetIds.includes(clerkUserId)) { + return forbiddenResponse("Cannot delete your own account"); + } + + const targetUsers = await Promise.all( + targetIds.map((targetId) => db.getUserById(targetId)), + ); + + const missingTargets = targetUsers.filter((user) => !user).length; + if (missingTargets > 0) { + return notFoundResponse("One or more users were not found"); + } + + if (currentUser.role !== "superAdmin") { + if (!currentUser.gymId) { + return forbiddenResponse("No gym assigned to current user"); + } + + const hasCrossGymTarget = targetUsers.some( + (targetUser) => targetUser && targetUser.gymId !== currentUser.gymId, + ); + + if (hasCrossGymTarget) { + return forbiddenResponse("Cannot delete users from other gyms"); + } + } + if (ids && Array.isArray(ids)) { // Bulk delete await Promise.all(ids.map((userId: string) => db.deleteUser(userId))); return successResponse({ deleted: ids.length }); - } else if (id) { - // Single delete - const user = await db.getUserById(id); - if (!user) { - return notFoundResponse("User not found"); - } - await db.deleteUser(id); - return successResponse({ deleted: 1 }); } else { - return badRequestResponse("User ID or IDs array required"); + await db.deleteUser(id as string); + return successResponse({ deleted: 1 }); } } catch (error) { log.error("Failed to delete user(s)", error); diff --git a/apps/admin/src/lib/clerk-helpers.ts b/apps/admin/src/lib/clerk-helpers.ts index 6f038d8..71627ac 100644 --- a/apps/admin/src/lib/clerk-helpers.ts +++ b/apps/admin/src/lib/clerk-helpers.ts @@ -1,11 +1,7 @@ import { clerkClient } from "@clerk/nextjs/server"; +import { type UserRole } from "@fitai/shared"; import log from "./logger"; -/** - * User roles available in the application - */ -export type UserRole = "admin" | "trainer" | "client"; - /** * Set a user's role in Clerk public metadata * This will trigger a webhook that syncs the role to the database @@ -71,7 +67,8 @@ export async function hasRole( * const isAdmin = await isAdmin('user_abc123'); */ export async function isAdmin(userId: string): Promise { - return hasRole(userId, "admin"); + const role = await getUserRole(userId); + return role === "admin" || role === "superAdmin"; } /** @@ -161,6 +158,7 @@ export async function getUserCountByRole(): Promise> { const { data: users } = await client.users.getUserList(); const counts: Record = { + superAdmin: 0, admin: 0, trainer: 0, client: 0, diff --git a/apps/admin/src/lib/database/types.ts b/apps/admin/src/lib/database/types.ts index e37519a..ae0d53d 100644 --- a/apps/admin/src/lib/database/types.ts +++ b/apps/admin/src/lib/database/types.ts @@ -154,6 +154,7 @@ export interface IDatabase { getRecommendationsByUserId(userId: string): Promise; getRecommendationsByUserIds(userIds: string[]): Promise; getAllRecommendations(): Promise; + getRecommendationById(id: string): Promise; updateRecommendation( id: string, updates: Partial, diff --git a/apps/admin/src/middleware.ts b/apps/admin/src/middleware.ts index ac50f3a..ef32070 100644 --- a/apps/admin/src/middleware.ts +++ b/apps/admin/src/middleware.ts @@ -25,14 +25,6 @@ const isPublicRoute = createRouteMatcher([ const isApiRoute = createRouteMatcher(["/api/(.*)"]); export default clerkMiddleware(async (auth, req) => { - // Log for debugging - const authHeader = req.headers.get("authorization"); - if (authHeader) { - log.debug("Authorization header present", { - preview: authHeader.substring(0, 20) + "...", - }); - } - // Don't protect public routes if (isPublicRoute(req)) { log.debug("Public route, skipping auth");