diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 38a0ab3..f5a3cd0 100644 Binary files a/apps/admin/data/fitai.db and b/apps/admin/data/fitai.db differ diff --git a/apps/admin/src/app/api/users/create/route.ts b/apps/admin/src/app/api/users/create/route.ts index b1d9a5e..fe2152d 100644 --- a/apps/admin/src/app/api/users/create/route.ts +++ b/apps/admin/src/app/api/users/create/route.ts @@ -8,6 +8,8 @@ import { errorResponse, unauthorizedResponse, } from "@/lib/api/responses"; +import { getAuthContext } from "@/lib/auth/context"; +import { getInvitableRoles } from "@/lib/auth/permissions"; import log from "@/lib/logger"; /** @@ -26,6 +28,10 @@ export async function POST(request: NextRequest) { return unauthorizedResponse("Unauthorized"); } + // Get full auth context (role and gymId) + const authContext = await getAuthContext(); + const { role: requesterRole, gymId: requesterGymId } = authContext; + const body = await request.json(); const validationResult = createUserSchema.safeParse(body); @@ -41,6 +47,33 @@ export async function POST(request: NextRequest) { const data = validationResult.data; + // Validate role creation permission + const allowedRoles = getInvitableRoles(requesterRole); + if (!allowedRoles.includes(data.role)) { + const roleMessages: Record = { + trainer: + "Trainers can only create clients. Contact an admin to create other roles.", + admin: + "Admins can create trainers and clients only. Contact a superAdmin to create other admins.", + client: "Clients cannot create users.", + }; + return errorResponse( + roleMessages[requesterRole] || + "You are not authorized to create this role", + { status: 403 }, + ); + } + + // Auto-assign gym for non-superAdmins + // SuperAdmins can choose any gym, others must use their own gym + const assignedGymId = + requesterRole === "superAdmin" ? data.gymId : requesterGymId; + + // Validate that non-superAdmins have a gym assigned + if (!assignedGymId && requesterRole !== "superAdmin") { + return errorResponse("You are not assigned to a gym", { status: 400 }); + } + // Note: Email is required in current schema // TODO: Add schema migration to make email optional for direct creation if (!data.email) { @@ -55,13 +88,14 @@ export async function POST(request: NextRequest) { emailAddress: data.email, publicMetadata: { role: data.role, - gymId: data.gymId, + gymId: assignedGymId, }, }); log.info("Clerk invitation sent", { email: data.email, role: data.role, + gymId: assignedGymId, invitationId: invitation.id, }); @@ -106,13 +140,14 @@ export async function POST(request: NextRequest) { lastName: data.lastName, role: data.role, phone: data.phone, - gymId: data.gymId, + gymId: assignedGymId, }) .returning(); log.info("User created in database", { userId: newUser.id, role: data.role, + gymId: assignedGymId, hasEmail: !!data.email, }); diff --git a/apps/admin/src/app/api/users/route.ts b/apps/admin/src/app/api/users/route.ts index f5e9f54..a4a2662 100644 --- a/apps/admin/src/app/api/users/route.ts +++ b/apps/admin/src/app/api/users/route.ts @@ -331,8 +331,17 @@ export async function PUT(request: NextRequest) { return validationErrorResponse(validation.errors); } - const { id, email, firstName, lastName, role, phone, gymId } = - validation.data; + const { + id, + email, + firstName, + lastName, + role, + phone, + gymId, + membershipType, + membershipStatus, + } = validation.data; log.debug("Updating user", { id, @@ -342,6 +351,8 @@ export async function PUT(request: NextRequest) { role, phone, gymId, + membershipType, + membershipStatus, }); const db = await getDatabase(); @@ -363,6 +374,27 @@ export async function PUT(request: NextRequest) { return notFoundResponse("User not found"); } + log.debug("Edit authorization check", { + requesterId, + requesterRole: requester.role, + requesterGymId: requester.gymId, + targetUserId: id, + targetRole: existingUser.role, + targetGymId: existingUser.gymId, + requestedRole: role, + }); + + // Authorization: check gym-based permissions + // Non-superAdmins can only edit users in their own gym + if (requester.role !== "superAdmin") { + if (!requester.gymId) { + return forbiddenResponse("No gym assigned to requester"); + } + if (existingUser.gymId !== requester.gymId) { + return forbiddenResponse("Cannot edit users from other gyms"); + } + } + // Authorization: determine allowed role changes const requesterRole = requester.role; const allowedByRole: Record = { @@ -373,10 +405,26 @@ export async function PUT(request: NextRequest) { generalUser: [], // general users cannot change roles }; - if (role && !allowedByRole[requesterRole]?.includes(role)) { + // Only check authorization if the role is actually being changed + if ( + role && + role !== existingUser.role && + !allowedByRole[requesterRole]?.includes(role) + ) { return forbiddenResponse(`Not authorized to assign role '${role}'`); } + // Authorization: trainers cannot reassign users to different gyms + if ( + requesterRole === "trainer" && + gymId !== undefined && + gymId !== existingUser.gymId + ) { + return forbiddenResponse( + "Trainers cannot reassign users to different gyms", + ); + } + // Check if email is being changed and if it's already taken if (email && email !== existingUser.email) { const userWithEmail = await db.getUserByEmail(email); @@ -451,6 +499,43 @@ export async function PUT(request: NextRequest) { ); log.debug("User updated in database", { updatedRow }); + // If the user is a client, update membership fields in clients table + const finalRole = role ?? existingUser.role; + if ( + finalRole === "client" && + (membershipType !== undefined || membershipStatus !== undefined) + ) { + log.debug("Updating client membership fields", { + userId: id, + membershipType, + membershipStatus, + }); + + // Check if client record exists + const existingClient = await rawDb.get( + sql`SELECT * FROM clients WHERE user_id = ${id}`, + ); + + if (existingClient) { + // Update existing client record + await rawDb.run( + sql`UPDATE clients + SET membership_type = ${membershipType ?? (existingClient as any).membership_type}, + membership_status = ${membershipStatus ?? (existingClient as any).membership_status}, + updated_at = ${Date.now()} + WHERE user_id = ${id}`, + ); + log.debug("Client membership updated"); + } else { + // Create client record if it doesn't exist + await rawDb.run( + sql`INSERT INTO clients (id, user_id, membership_type, membership_status, join_date, created_at, updated_at) + VALUES (${`client_${id}_${Date.now()}`}, ${id}, ${membershipType ?? "basic"}, ${membershipStatus ?? "active"}, ${Date.now()}, ${Date.now()}, ${Date.now()})`, + ); + log.debug("Client record created"); + } + } + const updatedUser = { ...existingUser, email: email ?? existingUser.email, diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 5b0e439..d4a4429 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -9,7 +9,7 @@ import { UserButton, } from "@clerk/nextjs"; import { Sidebar } from "@/components/ui/Sidebar"; -import { Toaster } from "sonner"; +import { ToasterProvider } from "@/components/providers/ToasterProvider"; import { QueryProvider } from "@/components/providers/QueryProvider"; const inter = Inter({ subsets: ["latin"] }); @@ -35,7 +35,7 @@ export default function RootLayout({
{children}
- + diff --git a/apps/admin/src/components/providers/ToasterProvider.tsx b/apps/admin/src/components/providers/ToasterProvider.tsx new file mode 100644 index 0000000..6988757 --- /dev/null +++ b/apps/admin/src/components/providers/ToasterProvider.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { Toaster } from "sonner"; + +export function ToasterProvider() { + return ; +} diff --git a/apps/admin/src/components/users/CreateUserModal.tsx b/apps/admin/src/components/users/CreateUserModal.tsx index 20eeba5..53f961c 100644 --- a/apps/admin/src/components/users/CreateUserModal.tsx +++ b/apps/admin/src/components/users/CreateUserModal.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useUser } from "@clerk/nextjs"; import { Dialog, DialogContent, @@ -27,6 +28,8 @@ import { } from "@/lib/validation/user-schemas"; import { ChevronLeft, ChevronRight, Check } from "lucide-react"; import { useGyms } from "@/hooks/use-api"; +import { getInvitableRoles } from "@/lib/auth/permissions"; +import type { UserRole } from "@fitai/shared"; interface CreateUserModalProps { open: boolean; @@ -47,6 +50,14 @@ export function CreateUserModal({ const [clientInfo, setClientInfo] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const { data: gyms = [] } = useGyms(); + const { user } = useUser(); + + // Get current user's role and gym from Clerk metadata + const currentUserRole = (user?.publicMetadata?.role as UserRole) || "client"; + const currentUserGymId = user?.publicMetadata?.gymId as string | null; + + // Determine which roles the current user can create + const invitableRoles = getInvitableRoles(currentUserRole); // Step 1: Role Selection Form const roleForm = useForm({ @@ -138,10 +149,18 @@ export function CreateUserModal({ invitationData, ); + // Auto-assign gym for non-superAdmins + // SuperAdmins can select gym manually, others use their own gym + const finalPayload = { + ...payload, + gymId: + currentUserRole === "superAdmin" ? payload.gymId : currentUserGymId, + }; + const response = await fetch("/api/users/create", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + body: JSON.stringify(finalPayload), }); if (!response.ok) { @@ -261,7 +280,7 @@ export function CreateUserModal({
- {(["admin", "trainer", "client"] as const).map((role) => ( + {invitableRoles.map((role) => (
)} + + {/* Gym Selector - Only for SuperAdmins */} + {currentUserRole === "superAdmin" && ( +
+ + +

+ If not selected, user will not be assigned to any gym +

+
+ )} + + {/* Info message for non-superAdmins */} + {currentUserRole !== "superAdmin" && currentUserGymId && ( +
+

+ User will be automatically assigned to your gym +

+
+ )} +
-
- - -

- Select an active gym or proceed without a gym. -

-
+ + {/* Only superAdmins and admins can reassign gyms */} + {user?.publicMetadata?.role !== "trainer" && ( +
+ + +

+ Select an active gym or proceed without a gym. +

+
+ )} + + {/* Client-specific fields - only show when role is client */} + {editForm.role === "client" && ( + <> +
+ + +
+
+ + +
+ + )}