admin role enhancment

basic invitation flow
This commit is contained in:
echo 2026-03-18 13:56:51 +01:00
parent d112dbb122
commit b1f01208fa
6 changed files with 59 additions and 13 deletions

View File

@ -14,6 +14,10 @@ RESEND_API_KEY=re_your_resend_api_key_here
EMAIL_FROM=FitAI <noreply@yourdomain.com>
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

Binary file not shown.

View File

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

View File

@ -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",
);
}

View File

@ -362,8 +362,12 @@ export function UserManagement({ gymId }: UserManagementProps) {
/>
</div>
{/* 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";
})() && (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Gym</label>
<select

View File

@ -16,9 +16,12 @@ export const passwordSchema = z
export const phoneSchema = z
.string()
.optional()
.refine((val) => !val || /^\+?[1-9]\d{1,14}$/.test(val), {
message: "Invalid phone number format",
});
.refine(
(val) => !val || val.trim() === "" || /^\+?[1-9]\d{1,14}$/.test(val),
{
message: "Invalid phone number format",
},
);
export const dateTimeSchema = z.string().datetime("Invalid datetime format");