rb fine tune

This commit is contained in:
echo 2026-03-18 13:14:47 +01:00
parent 7043487fc2
commit d112dbb122
9 changed files with 335 additions and 43 deletions

Binary file not shown.

View File

@ -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<string, string> = {
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,
});

View File

@ -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<string, string[]> = {
@ -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,

View File

@ -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({
<div className="max-w-7xl mx-auto space-y-8">{children}</div>
</main>
</div>
<Toaster richColors position="top-right" />
<ToasterProvider />
</body>
</html>
</QueryProvider>

View File

@ -0,0 +1,7 @@
"use client";
import { Toaster } from "sonner";
export function ToasterProvider() {
return <Toaster richColors position="top-right" />;
}

View File

@ -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<ClientInfoData | null>(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<RoleSelectionData>({
@ -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({
<div className="space-y-2">
<label className="text-sm font-medium">Role *</label>
<div className="grid grid-cols-3 gap-3">
{(["admin", "trainer", "client"] as const).map((role) => (
{invitableRoles.map((role) => (
<label
key={role}
className={`flex items-center justify-center p-4 border-2 rounded-lg cursor-pointer transition-colors ${
@ -463,6 +482,39 @@ export function CreateUserModal({
</p>
</div>
)}
{/* Gym Selector - Only for SuperAdmins */}
{currentUserRole === "superAdmin" && (
<div className="space-y-2">
<label className="text-sm font-medium">
Assign to Gym (Optional)
</label>
<select
{...invitationForm.register("gymId")}
className="w-full p-2 border border-gray-300 rounded-md"
>
<option value="">Select a gym...</option>
{gyms.map((gym) => (
<option key={gym.id} value={gym.id}>
{gym.name}
</option>
))}
</select>
<p className="text-xs text-gray-500">
If not selected, user will not be assigned to any gym
</p>
</div>
)}
{/* Info message for non-superAdmins */}
{currentUserRole !== "superAdmin" && currentUserGymId && (
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md">
<p className="text-sm text-gray-700">
User will be automatically assigned to your gym
</p>
</div>
)}
<DialogFooter className="flex justify-between">
<Button type="button" variant="outline" onClick={handleBack}>
<ChevronLeft className="mr-2 h-4 w-4" /> Back

View File

@ -34,6 +34,8 @@ export function UserManagement({ gymId }: UserManagementProps) {
role: string;
phone: string;
gymId: string;
membershipType?: string;
membershipStatus?: string;
} | null>(null);
const {
@ -62,6 +64,8 @@ export function UserManagement({ gymId }: UserManagementProps) {
role: user.role,
phone: user.phone || "",
gymId: user.gymId || "",
membershipType: user.client?.membershipType || "basic",
membershipStatus: user.client?.membershipStatus || "active",
});
setIsEditing(true);
};
@ -133,7 +137,7 @@ export function UserManagement({ gymId }: UserManagementProps) {
try {
if (selectedUser) {
const payload = {
const payload: any = {
id: selectedUser.id,
email: editForm.email,
firstName: editForm.firstName,
@ -143,6 +147,12 @@ export function UserManagement({ gymId }: UserManagementProps) {
gymId: editForm.gymId === "" ? null : editForm.gymId,
};
// Include membership fields if user is a client
if (editForm.role === "client") {
payload.membershipType = editForm.membershipType;
payload.membershipStatus = editForm.membershipStatus;
}
await updateUser.mutateAsync(payload);
setIsEditing(false);
setEditForm(null);
@ -351,6 +361,9 @@ export function UserManagement({ gymId }: UserManagementProps) {
className="w-full border border-gray-300 rounded px-3 py-2"
/>
</div>
{/* Only superAdmins and admins can reassign gyms */}
{user?.publicMetadata?.role !== "trainer" && (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Gym</label>
<select
@ -371,6 +384,51 @@ export function UserManagement({ gymId }: UserManagementProps) {
Select an active gym or proceed without a gym.
</p>
</div>
)}
{/* Client-specific fields - only show when role is client */}
{editForm.role === "client" && (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Membership Type
</label>
<select
value={editForm.membershipType || "basic"}
onChange={(e) =>
setEditForm({
...editForm,
membershipType: e.target.value,
})
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="basic">Basic</option>
<option value="premium">Premium</option>
<option value="vip">VIP</option>
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Membership Status
</label>
<select
value={editForm.membershipStatus || "active"}
onChange={(e) =>
setEditForm({
...editForm,
membershipStatus: e.target.value,
})
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
</select>
</div>
</>
)}
<div className="flex justify-end gap-2">
<button
type="button"

View File

@ -30,20 +30,62 @@ export async function getAuthContext(): Promise<AuthContext> {
throw new Error("Unauthorized: No authenticated user");
}
// Get role from Clerk metadata
const role = (sessionClaims?.metadata as { role?: UserRole })?.role;
// 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)) {
throw new Error(`Forbidden: Invalid or missing role - ${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 metadata (faster path)
const clerkGymId = (sessionClaims?.metadata as { gymId?: string })?.gymId;
// 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 {
@ -55,6 +97,7 @@ export async function getAuthContext(): Promise<AuthContext> {
gymId = null;
}
}
}
return { userId, role, gymId };
}

View File

@ -33,6 +33,14 @@ export const userRoleSchema = z.enum([
"superAdmin",
]);
export const membershipTypeSchema = z.enum(["basic", "premium", "vip"]);
export const membershipStatusSchema = z.enum([
"active",
"inactive",
"suspended",
]);
export const userSchema = z.object({
email: emailSchema,
password: passwordSchema,
@ -65,6 +73,8 @@ export const userUpdateWithIdSchema = z.object({
role: userRoleSchema.optional(),
phone: phoneSchema,
gymId: z.string().nullable().optional(),
membershipType: membershipTypeSchema.optional(),
membershipStatus: membershipStatusSchema.optional(),
});
// ============================================================================
@ -335,6 +345,8 @@ export type User = z.infer<typeof userSchema>;
export type UserUpdate = z.infer<typeof userUpdateSchema>;
export type UserLogin = z.infer<typeof userLoginSchema>;
export type UserRole = z.infer<typeof userRoleSchema>;
export type MembershipType = z.infer<typeof membershipTypeSchema>;
export type MembershipStatus = z.infer<typeof membershipStatusSchema>;
export type FitnessGoal = z.infer<typeof fitnessGoalSchema>;
export type FitnessGoalUpdate = z.infer<typeof fitnessGoalUpdateSchema>;