275 lines
8.0 KiB
TypeScript
275 lines
8.0 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { auth } from "@clerk/nextjs/server";
|
|
import { USER_ROLES, type UserRole } from "@fitai/shared";
|
|
import { getDatabase } from "../database";
|
|
|
|
export interface AuthContext {
|
|
userId: string;
|
|
role: UserRole;
|
|
gymId: string | null;
|
|
}
|
|
|
|
export interface AuthResult extends AuthContext {}
|
|
|
|
/**
|
|
* Get the full authentication context including gymId
|
|
* Combines Clerk session claims with database lookup for accurate gym assignment
|
|
*
|
|
* @returns AuthContext with userId, role, and gymId
|
|
*
|
|
* @example
|
|
* const { userId, role, gymId } = await getAuthContext();
|
|
* // userId: "user_abc123"
|
|
* // role: "admin"
|
|
* // gymId: "gym_xyz"
|
|
*/
|
|
export async function getAuthContext(): Promise<AuthContext> {
|
|
const { userId, sessionClaims } = await auth();
|
|
|
|
if (!userId) {
|
|
throw new Error("Unauthorized: No authenticated user");
|
|
}
|
|
|
|
// Try to get role from Clerk metadata (could be in publicMetadata or metadata)
|
|
let role = (sessionClaims?.publicMetadata as { role?: UserRole })?.role;
|
|
|
|
// Fallback: Try the metadata field if publicMetadata doesn't have it
|
|
if (!role) {
|
|
role = (sessionClaims?.metadata as { role?: UserRole })?.role;
|
|
}
|
|
|
|
// If still no role, try database lookup as last resort
|
|
if (!role || !USER_ROLES.includes(role)) {
|
|
console.log("Role not found in session claims, fetching from database", {
|
|
userId,
|
|
roleFromPublicMetadata: (
|
|
sessionClaims?.publicMetadata as { role?: UserRole }
|
|
)?.role,
|
|
roleFromMetadata: (sessionClaims?.metadata as { role?: UserRole })?.role,
|
|
});
|
|
|
|
try {
|
|
const db = await getDatabase();
|
|
const user = await db.getUserById(userId);
|
|
|
|
if (!user) {
|
|
console.error("User not found in database", { userId });
|
|
throw new Error(`Forbidden: User not found in database - ${userId}`);
|
|
}
|
|
|
|
if (user.role && USER_ROLES.includes(user.role as UserRole)) {
|
|
console.log("Role found in database", { userId, role: user.role });
|
|
role = user.role as UserRole;
|
|
} else {
|
|
console.error("Invalid role in database", { userId, role: user.role });
|
|
throw new Error(`Forbidden: Invalid role in database - ${user.role}`);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to get role from database:", error);
|
|
throw new Error(
|
|
`Forbidden: Could not determine user role - ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Get gymId from Clerk metadata or database
|
|
let gymId: string | null = null;
|
|
|
|
// First try Clerk publicMetadata (faster path)
|
|
const clerkGymId = (sessionClaims?.publicMetadata as { gymId?: string })
|
|
?.gymId;
|
|
if (clerkGymId) {
|
|
gymId = clerkGymId;
|
|
} else {
|
|
// Try metadata field
|
|
const metadataGymId = (sessionClaims?.metadata as { gymId?: string })
|
|
?.gymId;
|
|
if (metadataGymId) {
|
|
gymId = metadataGymId;
|
|
} else {
|
|
// Fallback to database lookup
|
|
try {
|
|
const db = await getDatabase();
|
|
const user = await db.getUserById(userId);
|
|
gymId = user?.gymId ?? null;
|
|
} catch (error) {
|
|
console.error("Failed to get gymId from database:", error);
|
|
gymId = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { userId, role, gymId };
|
|
}
|
|
|
|
/**
|
|
* Middleware to require authentication and optionally check user role
|
|
* Enhanced version that also retrieves gymId
|
|
*
|
|
* @param allowedRoles - Array of roles allowed to access the endpoint (optional)
|
|
* @returns Authentication result with userId, role, and gymId, or NextResponse error
|
|
*
|
|
* @example
|
|
* export async function DELETE(request: NextRequest) {
|
|
* const authResult = await requireAuth(["admin", "superAdmin"]);
|
|
* if (authResult instanceof NextResponse) return authResult;
|
|
* const { userId, role, gymId } = authResult;
|
|
* // ... proceed with authorized logic
|
|
* }
|
|
*/
|
|
export async function requireAuth(
|
|
allowedRoles?: UserRole[],
|
|
): Promise<AuthResult | NextResponse> {
|
|
try {
|
|
const context = await getAuthContext();
|
|
|
|
// Check if user has required role
|
|
if (allowedRoles && allowedRoles.length > 0) {
|
|
if (!allowedRoles.includes(context.role)) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `Forbidden - Requires one of: ${allowedRoles.join(", ")}`,
|
|
requiredRoles: allowedRoles,
|
|
userRole: context.role,
|
|
},
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
}
|
|
|
|
return context;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
|
|
if (message.includes("Unauthorized")) {
|
|
return NextResponse.json(
|
|
{ error: "Unauthorized - Authentication required" },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
if (message.includes("Forbidden")) {
|
|
return NextResponse.json({ error: message }, { status: 403 });
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: "Internal server error" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require authentication with gym access validation
|
|
* Use this for endpoints that need to verify the user can access a specific gym
|
|
*
|
|
* @param allowedRoles - Roles allowed to access this endpoint
|
|
* @param requiredGymId - Gym ID that must be accessed (optional - uses user's gym if not provided)
|
|
* @returns AuthResult with full context, or NextResponse error
|
|
*
|
|
* @example
|
|
* // Admin accessing their own gym
|
|
* const result = await requireGymAccess(["admin"]);
|
|
*
|
|
* // SuperAdmin accessing specific gym
|
|
* const result = await requireGymAccess(["superAdmin"], "gym_123");
|
|
*/
|
|
export async function requireGymAccess(
|
|
allowedRoles: UserRole[],
|
|
requiredGymId?: string,
|
|
): Promise<AuthResult | NextResponse> {
|
|
const authResult = await requireAuth(allowedRoles);
|
|
|
|
if (authResult instanceof NextResponse) {
|
|
return authResult;
|
|
}
|
|
|
|
const { role, gymId: userGymId } = authResult;
|
|
|
|
// Determine target gym
|
|
const targetGymId = requiredGymId ?? userGymId;
|
|
|
|
// SuperAdmin can access any gym
|
|
if (role === "superAdmin") {
|
|
return { ...authResult, gymId: targetGymId };
|
|
}
|
|
|
|
// Other roles must have matching gym
|
|
if (!targetGymId) {
|
|
return NextResponse.json(
|
|
{ error: "Forbidden - User is not assigned to a gym" },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
if (userGymId !== targetGymId) {
|
|
return NextResponse.json(
|
|
{ error: "Forbidden - Cannot access other gym's data" },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
return { ...authResult, gymId: targetGymId };
|
|
}
|
|
|
|
/**
|
|
* Get the target gym ID from request for superAdmin context switching
|
|
* SuperAdmins can pass ?gymId to view specific gym data
|
|
*
|
|
* @param request - NextRequest with query params
|
|
* @param userRole - Current user's role
|
|
* @param userGymId - Current user's gym ID
|
|
* @returns Target gym ID or undefined for superAdmin viewing all
|
|
*
|
|
* @example
|
|
* const targetGymId = getTargetGymId(request, role, gymId);
|
|
* // For superAdmin with ?gymId=abc -> "abc"
|
|
* // For superAdmin without param -> undefined (all gyms)
|
|
* // For admin -> always returns their gymId
|
|
*/
|
|
export function getTargetGymId(
|
|
request: NextRequest,
|
|
userRole: UserRole,
|
|
userGymId: string | null,
|
|
): string | undefined {
|
|
// For superAdmin, allow gymId query param for context switching
|
|
if (userRole === "superAdmin") {
|
|
const queryGymId = request.nextUrl.searchParams.get("gymId");
|
|
return queryGymId ?? undefined;
|
|
}
|
|
|
|
// For other roles, they can only access their own gym
|
|
return userGymId ?? undefined;
|
|
}
|
|
|
|
/**
|
|
* Check if the authenticated user is trying to modify their own account
|
|
* Useful for preventing self-deletion or self-demotion
|
|
*/
|
|
export function isSelfModification(
|
|
userId: string,
|
|
targetUserId: string,
|
|
): boolean {
|
|
return userId === targetUserId;
|
|
}
|
|
|
|
/**
|
|
* Validate that an admin isn't demoting themselves or deleting their own account
|
|
*/
|
|
export function preventSelfModification(
|
|
userId: string,
|
|
targetUserId: string,
|
|
action: string,
|
|
): NextResponse | undefined {
|
|
if (isSelfModification(userId, targetUserId)) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `Cannot ${action} your own account`,
|
|
hint: "Ask another administrator to perform this action",
|
|
},
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
return undefined;
|
|
}
|