harden admin api authorization and add authz regression tests
This commit is contained in:
parent
272c9b36dd
commit
10b58245f5
@ -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 },
|
||||
|
||||
103
apps/admin/src/app/api/admin/set-role/__tests__/route.test.ts
Normal file
103
apps/admin/src/app/api/admin/set-role/__tests__/route.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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<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
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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
|
||||
|
||||
88
apps/admin/src/app/api/gyms/__tests__/route-authz.test.ts
Normal file
88
apps/admin/src/app/api/gyms/__tests__/route-authz.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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 ||
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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<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 });
|
||||
@ -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(
|
||||
|
||||
109
apps/admin/src/app/api/users/__tests__/delete-authz.test.ts
Normal file
109
apps/admin/src/app/api/users/__tests__/delete-authz.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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<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 counts: Record<UserRole, number> = {
|
||||
superAdmin: 0,
|
||||
admin: 0,
|
||||
trainer: 0,
|
||||
client: 0,
|
||||
|
||||
@ -154,6 +154,7 @@ export interface IDatabase {
|
||||
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
|
||||
getRecommendationsByUserIds(userIds: string[]): Promise<Recommendation[]>;
|
||||
getAllRecommendations(): Promise<Recommendation[]>;
|
||||
getRecommendationById(id: string): Promise<Recommendation | null>;
|
||||
updateRecommendation(
|
||||
id: string,
|
||||
updates: Partial<Recommendation>,
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user