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 { 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 },
|
||||||
|
|||||||
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 { 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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 { 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 ||
|
||||||
|
|||||||
@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { 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": {
|
|
||||||
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;
|
gymIdForInvite = requestedGymId || inviterGymId || null;
|
||||||
if (!gymIdForInvite) {
|
if (!gymIdForInvite) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "gymId is required for SuperAdmin when inviting" },
|
{ error: "gymId is required for superAdmin invitations" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
} else {
|
||||||
}
|
if (!inviterGymId) {
|
||||||
default: {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Inviter role not permitted to create invitations" },
|
{ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 { 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,
|
||||||
|
|||||||
@ -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 { 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
|
||||||
|
|||||||
@ -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 { 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);
|
||||||
|
|||||||
@ -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 }),
|
||||||
|
|||||||
@ -35,16 +35,8 @@ export async function DELETE(
|
|||||||
|
|
||||||
const { id } = await params;
|
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);
|
|
||||||
|
|
||||||
if (!assignment) {
|
|
||||||
// Check all assignments to find the one with this ID
|
|
||||||
const allAssignments = await db.getAllTrainerClientAssignments();
|
const allAssignments = await db.getAllTrainerClientAssignments();
|
||||||
assignment = allAssignments.find((a) => a.id === id);
|
const assignment = allAssignments.find((a) => a.id === id);
|
||||||
|
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -53,18 +45,27 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deactivate the assignment
|
if (currentUser.role !== "superAdmin") {
|
||||||
await db.deactivateTrainerClientAssignment(id);
|
if (!currentUser.gymId) {
|
||||||
|
return NextResponse.json({ error: "No gym assigned" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
log.info("Trainer-client assignment deactivated", {
|
const [trainer, client] = await Promise.all([
|
||||||
assignmentId: id,
|
db.getUserById(assignment.trainerId),
|
||||||
deactivatedBy: currentUser.id,
|
db.getUserById(assignment.clientId),
|
||||||
});
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
if (
|
||||||
success: true,
|
!trainer ||
|
||||||
message: "Assignment deactivated successfully",
|
!client ||
|
||||||
});
|
trainer.gymId !== currentUser.gymId ||
|
||||||
|
client.gymId !== currentUser.gymId
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Cannot modify assignments from other gyms" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deactivate the assignment
|
// 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 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 (!trainer || !client) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (trainerId) {
|
|
||||||
assignments = await db.getTrainerClientAssignments(trainerId);
|
|
||||||
// Filter by clientId if provided
|
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
assignments = assignments.filter((a) => a.clientId === clientId);
|
assignments = assignments.filter((a) => a.clientId === clientId);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Get all assignments (for admins, filtered by gym)
|
if (currentUser.role !== "superAdmin") {
|
||||||
assignments = await db.getAllTrainerClientAssignments();
|
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(
|
||||||
|
|||||||
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) {
|
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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>,
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user