From b1f01208fa36c39f4caaa07c304b5724895c9a60 Mon Sep 17 00:00:00 2001 From: echo Date: Wed, 18 Mar 2026 13:56:51 +0100 Subject: [PATCH] admin role enhancment basic invitation flow --- apps/admin/.env.example | 4 ++ apps/admin/data/fitai.db | Bin 172032 -> 172032 bytes apps/admin/src/app/api/users/create/route.ts | 44 ++++++++++++++++-- apps/admin/src/app/api/users/route.ts | 7 +-- .../src/components/users/UserManagement.tsx | 8 +++- apps/admin/src/lib/validation/schemas.ts | 9 ++-- 6 files changed, 59 insertions(+), 13 deletions(-) diff --git a/apps/admin/.env.example b/apps/admin/.env.example index a852574..41eae41 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -14,6 +14,10 @@ RESEND_API_KEY=re_your_resend_api_key_here EMAIL_FROM=FitAI EMAIL_REPLY_TO=support@yourdomain.com +# Admin App URL (for invitation redirects) +# Set to your production URL in production +NEXT_PUBLIC_APP_URL=http://localhost:3000 + # Database (optional - defaults to ./fitai.db) DATABASE_PATH=./fitai.db diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index f5a3cd05ea60bc1387e048ba07244f018e4a3ca4..ed0cb0cd8471ac1184905e7116c0a42b24eae249 100644 GIT binary patch delta 352 zcmZoTz}0YoYl1Z6jEOSNj59VSEaVsE-NeAolgh?d%iqTLgx8Yi9-k^t>Sjd&e;)pF z4qjhpX?b2=aTZox28PLn`U;cp$tr0Sq?Q!rmlbDcCZ^K~7=wHvNAHcYwV)_9H?uT1F}WnOEEVYHYqv|_B9}OQF1O$_DFCdfb9U>)AWpr|Hc5`cKFgGwVIW#jk zHe+mQWo~q7J5|qVJ8Y8=K>!6Evn3wL0R!EX__OdIp$`QFBm+$WlkgrOv$ik(4+R7m z12qAY5FZqi%|A2^000034yFJPp${nzrn4a+m<|Vl00RtnO&gQ3PF)9Za%F94b#0Ro zPZbBc@D3~z%iPlqi}0ka ASpWb4 diff --git a/apps/admin/src/app/api/users/create/route.ts b/apps/admin/src/app/api/users/create/route.ts index fe2152d..1ab24bb 100644 --- a/apps/admin/src/app/api/users/create/route.ts +++ b/apps/admin/src/app/api/users/create/route.ts @@ -84,12 +84,30 @@ export async function POST(request: NextRequest) { if (data.sendInvitation && data.email) { try { const client = await clerkClient(); + + // Build publicMetadata - only include gymId if it exists + const publicMetadata: Record = { + role: data.role, + }; + if (assignedGymId) { + publicMetadata.gymId = assignedGymId; + } + + // Determine redirect URL based on role + // Staff (admin, trainer, superAdmin) → Admin web app + // Clients → Mobile app (handled by Clerk's default) + const isStaffRole = ["admin", "trainer", "superAdmin"].includes( + data.role, + ); + const redirectUrl = isStaffRole + ? `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/sign-up` + : undefined; // Clients use default Clerk redirect + const invitation = await client.invitations.createInvitation({ emailAddress: data.email, - publicMetadata: { - role: data.role, - gymId: assignedGymId, - }, + publicMetadata, + redirectUrl, + ignoreExisting: true, // Don't fail if invitation already exists }); log.info("Clerk invitation sent", { @@ -97,6 +115,7 @@ export async function POST(request: NextRequest) { role: data.role, gymId: assignedGymId, invitationId: invitation.id, + redirectUrl: redirectUrl || "default (mobile app)", }); // Send custom invitation email (in addition to Clerk's) @@ -117,7 +136,22 @@ export async function POST(request: NextRequest) { } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - log.error("Failed to send Clerk invitation", error); + + // Extract Clerk-specific error details + const clerkError = error as any; + + log.error("Failed to send Clerk invitation", error, { + email: data.email, + role: data.role, + gymId: assignedGymId, + publicMetadata: assignedGymId + ? { role: data.role, gymId: assignedGymId } + : { role: data.role }, + clerkStatus: clerkError?.status, + clerkErrors: clerkError?.errors, + clerkTraceId: clerkError?.clerkTraceId, + }); + return errorResponse(`Failed to send invitation: ${errorMessage}`, { status: 500, }); diff --git a/apps/admin/src/app/api/users/route.ts b/apps/admin/src/app/api/users/route.ts index a4a2662..298189f 100644 --- a/apps/admin/src/app/api/users/route.ts +++ b/apps/admin/src/app/api/users/route.ts @@ -414,14 +414,15 @@ export async function PUT(request: NextRequest) { return forbiddenResponse(`Not authorized to assign role '${role}'`); } - // Authorization: trainers cannot reassign users to different gyms + // Authorization: trainers and admins cannot reassign users to different gyms + // Only superAdmins can reassign gyms if ( - requesterRole === "trainer" && + requesterRole !== "superAdmin" && gymId !== undefined && gymId !== existingUser.gymId ) { return forbiddenResponse( - "Trainers cannot reassign users to different gyms", + "Only superAdmins can reassign users to different gyms", ); } diff --git a/apps/admin/src/components/users/UserManagement.tsx b/apps/admin/src/components/users/UserManagement.tsx index a9c2a32..cd1b983 100644 --- a/apps/admin/src/components/users/UserManagement.tsx +++ b/apps/admin/src/components/users/UserManagement.tsx @@ -362,8 +362,12 @@ export function UserManagement({ gymId }: UserManagementProps) { /> - {/* Only superAdmins and admins can reassign gyms */} - {user?.publicMetadata?.role !== "trainer" && ( + {/* Only superAdmins can reassign gyms */} + {(() => { + const currentRole = user?.publicMetadata?.role; + console.log("Current user role for gym selector:", currentRole); + return currentRole === "superAdmin"; + })() && (