invitation flow basis

This commit is contained in:
dimitar committed 2026-03-18 23:08:55 +01:00
1 parent b1f01208fa
commit 0817e8e72b
8 files changed
+507 -26

No files matched your search

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
import log from "@/lib/logger";
/**
* POST /api/invitations/[id]/resend
*
* Resend an invitation with same parameters
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id: invitationId } = await params;
// Fetch pending invitations to find the one being resent
const client = await clerkClient();
const invitationList = await client.invitations.getInvitationList({
status: "pending",
});
// Find the invitation
const invitation = invitationList.data.find(
(inv) => inv.id === invitationId,
);
if (!invitation) {
return NextResponse.json(
{ error: "Invitation not found or already processed" },
{ status: 404 },
);
}
const metadata = invitation.publicMetadata as any;
// Check if current user created this invitation
if (metadata?.createdBy !== userId) {
return NextResponse.json(
{ error: "Forbidden - You can only resend invitations you created" },
{ status: 403 },
);
}
// Create new invitation with same parameters
const role = metadata?.role;
// Determine redirect URL based on role
const isStaffRole = ["admin", "trainer", "superAdmin"].includes(role);
const redirectUrl = isStaffRole
? `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/sign-up`
: undefined;
const newInvitation = await client.invitations.createInvitation({
emailAddress: invitation.emailAddress,
publicMetadata: invitation.publicMetadata || undefined,
redirectUrl,
ignoreExisting: true,
});
log.info("Invitation resent", {
originalId: invitationId,
newId: newInvitation.id,
email: invitation.emailAddress,
resentBy: userId,
});
return NextResponse.json({
data: {
invitation: {
id: newInvitation.id,
email: newInvitation.emailAddress,
status: newInvitation.status,
},
},
});
} catch (error) {
log.error("POST /api/invitations/[id]/resend error:", error);
return NextResponse.json(
{ error: "Failed to resend invitation" },
{ status: 500 },
);
}
}
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
import log from "@/lib/logger";
/**
* DELETE /api/invitations/[id]
*
* Revoke a pending invitation (creator-only permission)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id: invitationId } = await params;
// Fetch pending invitations to find and verify the one being revoked
const client = await clerkClient();
const invitationList = await client.invitations.getInvitationList({
status: "pending",
});
// Find the invitation
const invitation = invitationList.data.find(
(inv) => inv.id === invitationId,
);
if (!invitation) {
return NextResponse.json(
{ error: "Invitation not found or already processed" },
{ status: 404 },
);
}
const metadata = invitation.publicMetadata as any;
// Check if current user created this invitation
if (metadata?.createdBy !== userId) {
return NextResponse.json(
{ error: "Forbidden - You can only cancel invitations you created" },
{ status: 403 },
);
}
// Revoke the invitation
await client.invitations.revokeInvitation(invitationId);
log.info("Invitation revoked", {
invitationId,
revokedBy: userId,
email: invitation.emailAddress,
});
return NextResponse.json({
data: { success: true },
});
} catch (error) {
log.error("DELETE /api/invitations/[id] error:", error);
return NextResponse.json(
{ error: "Failed to revoke invitation" },
{ status: 500 },
);
}
}
+86 -1
View File
@@ -1,5 +1,90 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
import { getAuthContext } from "@/lib/auth/context";
import { validateGymAccess } from "@/lib/auth/permissions";
import log from "@/lib/logger";
/**
* GET /api/invitations
*
* Fetch pending invitations with gym and creator filtering.
* Users can only see invitations they created, scoped to their gym access.
*
* Query params:
* - gymId: Optional gym filter (superAdmin only)
*/
export async function GET(request: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get auth context
const authContext = await getAuthContext();
const { role, gymId: userGymId } = authContext;
// Get query params
const { searchParams } = new URL(request.url);
const gymIdParam = searchParams.get("gymId");
// Validate gym access
const targetGymId = gymIdParam || undefined;
const accessError = validateGymAccess(role, userGymId, targetGymId);
if (accessError) return accessError;
// Fetch all pending invitations from Clerk
const client = await clerkClient();
const invitationList = await client.invitations.getInvitationList({
status: "pending",
});
// Filter invitations based on:
// 1. Creator (only show invitations created by current user)
// 2. Gym (apply gym-scoping rules)
const filteredInvitations = invitationList.data
.filter((inv) => {
const metadata = inv.publicMetadata as any;
// Creator filter: only show if user created it
if (metadata?.createdBy !== userId) {
return false;
}
// Gym filter: SuperAdmin can see all, others only their gym
if (role === "superAdmin") {
// If gymId param provided, filter by it
if (targetGymId && metadata?.gymId !== targetGymId) {
return false;
}
return true;
} else {
// Non-superAdmins: must match their gym
return metadata?.gymId === userGymId;
}
})
.map((inv) => ({
id: inv.id,
emailAddress: inv.emailAddress,
publicMetadata: inv.publicMetadata,
status: inv.status,
url: inv.url,
createdAt: inv.createdAt,
updatedAt: inv.updatedAt,
revoked: inv.revoked,
}));
return NextResponse.json({
data: { invitations: filteredInvitations },
});
} catch (error) {
log.error("GET /api/invitations error:", error);
return NextResponse.json(
{ error: "Failed to fetch invitations" },
{ status: 500 },
);
}
}
/**
* POST /api/invitations
@@ -88,6 +88,7 @@ export async function POST(request: NextRequest) {
// Build publicMetadata - only include gymId if it exists
const publicMetadata: Record<string, any> = {
role: data.role,
createdBy: userId,
};
if (assignedGymId) {
publicMetadata.gymId = assignedGymId;