harden admin api authorization and add authz regression tests

This commit is contained in:
echo 2026-03-29 11:23:49 +02:00
parent 272c9b36dd
commit 10b58245f5
28 changed files with 1336 additions and 240 deletions

View File

@ -5,6 +5,7 @@ import { ensureUserSynced } from "@/lib/sync-user";
import { successResponse } from "@/lib/api/responses"; import { successResponse } from "@/lib/api/responses";
import { db as rawDb, sql } from "@fitai/database"; import { db as rawDb, sql } from "@fitai/database";
import { getUsersByGym, getClientsByGym } from "@/lib/gym-context"; import { getUsersByGym, getClientsByGym } from "@/lib/gym-context";
import log from "@/lib/logger";
interface UserGrowthPoint { interface UserGrowthPoint {
label: string; label: string;
@ -158,7 +159,7 @@ export async function GET(req: NextRequest) {
return successResponse({ analytics: analyticsData }); return successResponse({ analytics: analyticsData });
} catch (error) { } catch (error) {
console.error("Analytics error:", error); log.error("Analytics error", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 }, { status: 500 },

View File

@ -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");
});
});

View File

@ -1,6 +1,9 @@
import { auth } from '@clerk/nextjs/server'; import { auth } from "@clerk/nextjs/server";
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { setUserRole, isAdmin, type UserRole } from '@/lib/clerk-helpers'; 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) { export async function POST(req: Request) {
try { try {
@ -8,16 +11,27 @@ export async function POST(req: Request) {
const { userId } = await auth(); const { userId } = await auth();
if (!userId) { 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 // Check if the requesting user is an admin
const requestingUserIsAdmin = await isAdmin(userId); const requestingUserIsAdmin =
currentUser.role === "admin" || currentUser.role === "superAdmin";
if (!requestingUserIsAdmin) { if (!requestingUserIsAdmin) {
return NextResponse.json( return NextResponse.json(
{ error: 'Forbidden: Admin access required' }, { error: "Forbidden: Admin access required" },
{ status: 403 } { status: 403 },
); );
} }
@ -26,25 +40,57 @@ export async function POST(req: Request) {
const { targetUserId, role } = body; const { targetUserId, role } = body;
// Validate inputs // Validate inputs
if (!targetUserId || typeof targetUserId !== 'string') { if (!targetUserId || typeof targetUserId !== "string") {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid or missing targetUserId' }, { error: "Invalid or missing targetUserId" },
{ status: 400 } { status: 400 },
); );
} }
if (!role || !['admin', 'trainer', 'client'].includes(role)) { if (!role || !USER_ROLES.includes(role as UserRole)) {
return NextResponse.json( 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<UserRole, UserRole[]> = {
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 // Prevent admin from changing their own role
if (userId === targetUserId) { if (userId === targetUserId) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cannot change your own role' }, { error: "Cannot change your own role" },
{ status: 400 } { 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) { } 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')) { if (error instanceof Error && error.message.includes("not found")) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: "User not found" }, { status: 404 });
} }
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, { error: "Internal server error" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@ -3,20 +3,26 @@ import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database"; import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user"; import { ensureUserSynced } from "@/lib/sync-user";
import { successResponse } from "@/lib/api/responses"; import { successResponse } from "@/lib/api/responses";
import log from "@/lib/logger";
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const { userId } = await auth(); 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 db = await getDatabase();
const user = await ensureUserSynced(userId, db); const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) { 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) { 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); const url = new URL(req.url);
@ -54,7 +60,10 @@ export async function GET(req: Request) {
return successResponse({ stats }); return successResponse({ stats });
} catch (error) { } catch (error) {
console.error("Dashboard stats error:", error); log.error("Dashboard stats error", error);
return new NextResponse("Internal Server Error", { status: 500 }); return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
} }
} }

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "../../../../lib/database/index"; import { getDatabase } from "../../../../lib/database/index";
import log from "@/lib/logger"; import log from "@/lib/logger";
import { userSchema } from "@/lib/validation/schemas"; import { userSchema } from "@/lib/validation/schemas";
@ -7,6 +8,8 @@ import {
validateRequestBody, validateRequestBody,
validationErrorResponse, validationErrorResponse,
} from "@/lib/validation/helpers"; } from "@/lib/validation/helpers";
import { ensureUserSynced } from "@/lib/sync-user";
import { getUsersByGym } from "@/lib/gym-context";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@ -68,8 +71,31 @@ export async function POST(request: NextRequest) {
export async function GET() { export async function GET() {
try { try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase(); 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( const usersWithoutPassword = allUsers.map(
({ password: _, ...user }) => user, ({ password: _, ...user }) => user,
); );

View File

@ -3,6 +3,7 @@ import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database"; import { eq, sql } from "@fitai/database";
import { db, users as usersTable, gyms as gymsTable } from "@fitai/database"; import { db, users as usersTable, gyms as gymsTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user"; import { ensureUserSynced } from "@/lib/sync-user";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger"; import log from "@/lib/logger";
async function ensureGymsTable() { async function ensureGymsTable() {
@ -33,30 +34,8 @@ export async function DELETE(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
// Ensure user is synced const appDb = await getDatabase();
const currentUser = await ensureUserSynced(userId, { const currentUser = await ensureUserSynced(userId, appDb);
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);
// Only superAdmin can delete gyms // Only superAdmin can delete gyms
if (!currentUser || currentUser.role !== "superAdmin") { if (!currentUser || currentUser.role !== "superAdmin") {

View File

@ -1,7 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database"; import { eq, sql } from "@fitai/database";
import { db, gyms as gymsTable } from "@fitai/database"; import { db, gyms as gymsTable } from "@fitai/database";
import log from "@/lib/logger"; import log from "@/lib/logger";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
async function ensureGymsTable() { async function ensureGymsTable() {
await db.run(sql` await db.run(sql`
@ -24,7 +27,33 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
try { 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 { 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(); await ensureGymsTable();
// Get gym info using Drizzle ORM // Get gym info using Drizzle ORM

View File

@ -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);
});
});

View File

@ -3,6 +3,7 @@ import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database"; import { eq, sql } from "@fitai/database";
import { db, users as usersTable, gyms as gymsTable } from "@fitai/database"; import { db, users as usersTable, gyms as gymsTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user"; import { ensureUserSynced } from "@/lib/sync-user";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger"; import log from "@/lib/logger";
async function ensureGymsTable() { async function ensureGymsTable() {
@ -23,14 +24,37 @@ async function ensureGymsTable() {
// Lists active gyms for selection (grid) // Lists active gyms for selection (grid)
export async function GET() { export async function GET() {
try { 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(); await ensureGymsTable();
const rows = await db let rows = await db
.select() .select()
.from(gymsTable) .from(gymsTable)
.where(eq(gymsTable.status, "active")) .where(eq(gymsTable.status, "active"))
.orderBy(sql`created_at DESC`) .orderBy(sql`created_at DESC`)
.all(); .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); return NextResponse.json(rows);
} catch (error) { } catch (error) {
log.error("Failed to get gyms", error); log.error("Failed to get gyms", error);
@ -48,60 +72,8 @@ export async function POST(req: Request) {
const { userId } = await auth(); const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 }); if (!userId) return new NextResponse("Unauthorized", { status: 401 });
// Ensure our local DB has the user synced (role, etc.) const appDb = await getDatabase();
const currentUser = await ensureUserSynced(userId, { const currentUser = await ensureUserSynced(userId, appDb);
// 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);
if ( if (
!currentUser || !currentUser ||

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server"; import { auth, clerkClient } from "@clerk/nextjs/server";
import { getAuthContext } from "@/lib/auth/context";
import log from "@/lib/logger"; import log from "@/lib/logger";
/** /**
@ -18,6 +19,7 @@ export async function POST(
} }
const { id: invitationId } = await params; const { id: invitationId } = await params;
const authContext = await getAuthContext();
// Fetch pending invitations to find the one being resent // Fetch pending invitations to find the one being resent
const client = await clerkClient(); const client = await clerkClient();
@ -38,11 +40,23 @@ export async function POST(
} }
const metadata = invitation.publicMetadata as any; 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 // Check if current user created this invitation
if (metadata?.createdBy !== userId) { if (createdBy !== userId && !canManageByRole) {
return NextResponse.json( 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 }, { status: 403 },
); );
} }

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server"; import { auth, clerkClient } from "@clerk/nextjs/server";
import { getAuthContext } from "@/lib/auth/context";
import log from "@/lib/logger"; import log from "@/lib/logger";
/** /**
@ -18,6 +19,7 @@ export async function DELETE(
} }
const { id: invitationId } = await params; const { id: invitationId } = await params;
const authContext = await getAuthContext();
// Fetch pending invitations to find and verify the one being revoked // Fetch pending invitations to find and verify the one being revoked
const client = await clerkClient(); const client = await clerkClient();
@ -38,11 +40,23 @@ export async function DELETE(
} }
const metadata = invitation.publicMetadata as any; 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 // Check if current user created this invitation
if (metadata?.createdBy !== userId) { if (createdBy !== userId && !canManageByRole) {
return NextResponse.json( 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 }, { status: 403 },
); );
} }

View File

@ -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",
}),
);
});
});

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server"; import { auth, clerkClient } from "@clerk/nextjs/server";
import { getAuthContext } from "@/lib/auth/context"; import { getAuthContext } from "@/lib/auth/context";
import { validateGymAccess } from "@/lib/auth/permissions"; import { getInvitableRoles, validateGymAccess } from "@/lib/auth/permissions";
import log from "@/lib/logger"; import log from "@/lib/logger";
/** /**
@ -132,91 +132,51 @@ export async function POST(req: Request) {
); );
} }
// Fetch inviter user from Clerk const authContext = await getAuthContext();
const client = await clerkClient(); const { role: inviterRole, gymId: inviterGymId } = authContext;
const inviter = await client.users.getUser(userId);
const inviterRole = const allowedRoles = getInvitableRoles(inviterRole);
(inviter.publicMetadata?.role as if (!allowedRoles.includes(roleAssigned)) {
| "superAdmin" return NextResponse.json(
| "admin" { error: `Forbidden - Cannot invite role '${roleAssigned}'` },
| "trainer" { status: 403 },
| "client" );
| "generalUser") ?? "client"; }
const inviterGymId =
(inviter.publicMetadata?.gymId as string | undefined) ?? undefined;
// Enforce role-based rules and resolve target gymId for the invitation // Enforce role-based rules and resolve target gymId for the invitation
let gymIdForInvite: string | null = null; let gymIdForInvite: string | null = null;
switch (inviterRole) { if (inviterRole === "superAdmin") {
case "admin": { gymIdForInvite = requestedGymId || inviterGymId || null;
if (roleAssigned !== "trainer" && roleAssigned !== "client") { if (!gymIdForInvite) {
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: {
return NextResponse.json( 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 }, { status: 403 },
); );
} }
gymIdForInvite = inviterGymId;
} }
// Create Clerk invitation with metadata needed by webhook to assign role & gym // 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({ const invitation = await client.invitations.createInvitation({
emailAddress: inviteeEmail, emailAddress: inviteeEmail,
publicMetadata: { publicMetadata: {
role: roleAssigned, role: roleAssigned,
gymId: gymIdForInvite, gymId: gymIdForInvite,
createdBy: inviter.id, createdBy: userId,
}, },
}); });

View File

@ -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);
});
});

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database"; import { getDatabase } from "@/lib/database";
import log from "@/lib/logger"; import log from "@/lib/logger";
import { ensureUserSynced } from "@/lib/sync-user";
/** /**
* GET /api/notifications * GET /api/notifications
@ -84,6 +85,39 @@ export async function POST(req: NextRequest) {
} }
const db = await getDatabase(); 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({ const notification = await db.createNotification({
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId: targetUserId, userId: targetUserId,

View File

@ -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);
});
});

View File

@ -1,13 +1,20 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database"; import { getDatabase } from "@/lib/database";
import log from "@/lib/logger"; import log from "@/lib/logger";
import { ensureUserSynced } from "@/lib/sync-user";
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json(); const body = await req.json();
log.debug("Approve recommendation request body", { body }); log.debug("Approve recommendation request body", { body });
const { recommendationId, status, approvedBy } = body; const { recommendationId, status } = body;
if (!recommendationId || !status) { if (!recommendationId || !status) {
log.error("Missing required fields", { log.error("Missing required fields", {
@ -22,12 +29,52 @@ export async function POST(req: Request) {
} }
const db = await getDatabase(); 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 // Update recommendation status
const updates: any = { const updates: any = {
status, status,
approvedAt: status === "approved" ? new Date() : undefined, approvedAt: status === "approved" ? new Date() : undefined,
approvedBy: status === "approved" ? approvedBy : undefined, approvedBy: status === "approved" ? clerkUserId : undefined,
}; };
// Remove undefined keys // Remove undefined keys

View File

@ -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);
});
});

View File

@ -1,11 +1,18 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database"; import { getDatabase } from "@/lib/database";
import { buildAIContext } from "@/lib/ai/ai-context"; import { buildAIContext } from "@/lib/ai/ai-context";
import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder"; import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder";
import log from "@/lib/logger"; import log from "@/lib/logger";
import { ensureUserSynced } from "@/lib/sync-user";
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId, useExternalModel, modelProvider } = await req.json(); const { userId, useExternalModel, modelProvider } = await req.json();
if (!userId) { if (!userId) {
@ -22,6 +29,34 @@ export async function POST(req: Request) {
}); });
const db = await getDatabase(); 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 // Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId); const profile = await db.getFitnessProfileByUserId(userId);

View File

@ -106,7 +106,7 @@ export async function POST(request: NextRequest) {
} }
const db = await getDatabase(); const db = await getDatabase();
const currentUser = await db.getUserById(currentUserId); const currentUser = await ensureUserSynced(currentUserId, db);
const isStaff = const isStaff =
currentUser?.role === "admin" || currentUser?.role === "admin" ||
currentUser?.role === "superAdmin" || currentUser?.role === "superAdmin" ||
@ -140,6 +140,18 @@ export async function POST(request: NextRequest) {
content, content,
} = validation.data; } = 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) // Handle AI Plan (Legacy/Specific)
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) { if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
const recommendation = await db.createRecommendation({ const recommendation = await db.createRecommendation({
@ -198,6 +210,41 @@ export async function PUT(request: NextRequest) {
validation.data; validation.data;
const db = await getDatabase(); 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, { const updated = await db.updateRecommendation(id, {
...(status && { status }), ...(status && { status }),

View File

@ -35,36 +35,37 @@ export async function DELETE(
const { id } = await params; const { id } = await params;
// Try to find the assignment by checking trainer's assignments first const allAssignments = await db.getAllTrainerClientAssignments();
const trainerAssignments = await db.getTrainerClientAssignments( const assignment = allAssignments.find((a) => a.id === id);
currentUser.id,
);
let assignment = trainerAssignments.find((a) => a.id === id);
if (!assignment) { if (!assignment) {
// Check all assignments to find the one with this ID return NextResponse.json(
const allAssignments = await db.getAllTrainerClientAssignments(); { error: "Assignment not found" },
assignment = allAssignments.find((a) => a.id === id); { status: 404 },
);
}
if (!assignment) { if (currentUser.role !== "superAdmin") {
return NextResponse.json( if (!currentUser.gymId) {
{ error: "Assignment not found" }, return NextResponse.json({ error: "No gym assigned" }, { status: 403 });
{ status: 404 },
);
} }
// Deactivate the assignment const [trainer, client] = await Promise.all([
await db.deactivateTrainerClientAssignment(id); db.getUserById(assignment.trainerId),
db.getUserById(assignment.clientId),
]);
log.info("Trainer-client assignment deactivated", { if (
assignmentId: id, !trainer ||
deactivatedBy: currentUser.id, !client ||
}); trainer.gymId !== currentUser.gymId ||
client.gymId !== currentUser.gymId
return NextResponse.json({ ) {
success: true, return NextResponse.json(
message: "Assignment deactivated successfully", { error: "Cannot modify assignments from other gyms" },
}); { status: 403 },
);
}
} }
// Deactivate the assignment // Deactivate the assignment

View File

@ -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();
});
});

View File

@ -38,17 +38,110 @@ export async function GET(request: NextRequest) {
const trainerId = searchParams.get("trainerId"); const trainerId = searchParams.get("trainerId");
const clientId = searchParams.get("clientId"); const clientId = searchParams.get("clientId");
let assignments; if (trainerId && clientId) {
const [trainer, client] = await Promise.all([
db.getUserById(trainerId),
db.getUserById(clientId),
]);
if (trainerId) { if (!trainer || !client) {
assignments = await db.getTrainerClientAssignments(trainerId); return NextResponse.json({ error: "User not found" }, { status: 404 });
// Filter by clientId if provided
if (clientId) {
assignments = assignments.filter((a) => a.clientId === clientId);
} }
} else {
// Get all assignments (for admins, filtered by gym) if (
assignments = await db.getAllTrainerClientAssignments(); 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<typeof user> => !!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 }); 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 // Check if assignment already exists
const existingAssignments = await db.getTrainerClientAssignments(trainerId); const existingAssignments = await db.getTrainerClientAssignments(trainerId);
const existingAssignment = existingAssignments.find( const existingAssignment = existingAssignments.find(

View File

@ -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");
});
});

View File

@ -580,26 +580,76 @@ export async function PUT(request: NextRequest) {
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return unauthorizedResponse();
}
const db = await getDatabase(); 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 { searchParams } = new URL(request.url);
const id = searchParams.get("id"); const id = searchParams.get("id");
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const { ids } = body; 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)) { if (ids && Array.isArray(ids)) {
// Bulk delete // Bulk delete
await Promise.all(ids.map((userId: string) => db.deleteUser(userId))); await Promise.all(ids.map((userId: string) => db.deleteUser(userId)));
return successResponse({ deleted: ids.length }); 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 { } else {
return badRequestResponse("User ID or IDs array required"); await db.deleteUser(id as string);
return successResponse({ deleted: 1 });
} }
} catch (error) { } catch (error) {
log.error("Failed to delete user(s)", error); log.error("Failed to delete user(s)", error);

View File

@ -1,11 +1,7 @@
import { clerkClient } from "@clerk/nextjs/server"; import { clerkClient } from "@clerk/nextjs/server";
import { type UserRole } from "@fitai/shared";
import log from "./logger"; 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 * Set a user's role in Clerk public metadata
* This will trigger a webhook that syncs the role to the database * 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'); * const isAdmin = await isAdmin('user_abc123');
*/ */
export async function isAdmin(userId: string): Promise<boolean> { export async function isAdmin(userId: string): Promise<boolean> {
return hasRole(userId, "admin"); const role = await getUserRole(userId);
return role === "admin" || role === "superAdmin";
} }
/** /**
@ -161,6 +158,7 @@ export async function getUserCountByRole(): Promise<Record<UserRole, number>> {
const { data: users } = await client.users.getUserList(); const { data: users } = await client.users.getUserList();
const counts: Record<UserRole, number> = { const counts: Record<UserRole, number> = {
superAdmin: 0,
admin: 0, admin: 0,
trainer: 0, trainer: 0,
client: 0, client: 0,

View File

@ -154,6 +154,7 @@ export interface IDatabase {
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>; getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
getRecommendationsByUserIds(userIds: string[]): Promise<Recommendation[]>; getRecommendationsByUserIds(userIds: string[]): Promise<Recommendation[]>;
getAllRecommendations(): Promise<Recommendation[]>; getAllRecommendations(): Promise<Recommendation[]>;
getRecommendationById(id: string): Promise<Recommendation | null>;
updateRecommendation( updateRecommendation(
id: string, id: string,
updates: Partial<Recommendation>, updates: Partial<Recommendation>,

View File

@ -25,14 +25,6 @@ const isPublicRoute = createRouteMatcher([
const isApiRoute = createRouteMatcher(["/api/(.*)"]); const isApiRoute = createRouteMatcher(["/api/(.*)"]);
export default clerkMiddleware(async (auth, req) => { 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 // Don't protect public routes
if (isPublicRoute(req)) { if (isPublicRoute(req)) {
log.debug("Public route, skipping auth"); log.debug("Public route, skipping auth");