diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index ed0cb0c..0b7bdbb 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/invitations/[id]/resend/route.ts b/apps/admin/src/app/api/invitations/[id]/resend/route.ts new file mode 100644 index 0000000..0f42875 --- /dev/null +++ b/apps/admin/src/app/api/invitations/[id]/resend/route.ts @@ -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 }, + ); + } +} diff --git a/apps/admin/src/app/api/invitations/[id]/route.ts b/apps/admin/src/app/api/invitations/[id]/route.ts new file mode 100644 index 0000000..73bad4b --- /dev/null +++ b/apps/admin/src/app/api/invitations/[id]/route.ts @@ -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 }, + ); + } +} diff --git a/apps/admin/src/app/api/invitations/route.ts b/apps/admin/src/app/api/invitations/route.ts index 60f2449..5c25689 100644 --- a/apps/admin/src/app/api/invitations/route.ts +++ b/apps/admin/src/app/api/invitations/route.ts @@ -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 diff --git a/apps/admin/src/app/api/users/create/route.ts b/apps/admin/src/app/api/users/create/route.ts index 1ab24bb..4319126 100644 --- a/apps/admin/src/app/api/users/create/route.ts +++ b/apps/admin/src/app/api/users/create/route.ts @@ -88,6 +88,7 @@ export async function POST(request: NextRequest) { // Build publicMetadata - only include gymId if it exists const publicMetadata: Record = { role: data.role, + createdBy: userId, }; if (assignedGymId) { publicMetadata.gymId = assignedGymId; diff --git a/apps/admin/src/components/users/InvitationsGrid.tsx b/apps/admin/src/components/users/InvitationsGrid.tsx new file mode 100644 index 0000000..ebd2fe9 --- /dev/null +++ b/apps/admin/src/components/users/InvitationsGrid.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { toast } from "@/lib/toast"; +import { + useRevokeInvitation, + useResendInvitation, + type Invitation, +} from "@/hooks/use-api"; +import { Copy, RefreshCw, Trash2 } from "lucide-react"; + +interface InvitationsGridProps { + invitations: Invitation[]; + onRefetch: () => void; +} + +export function InvitationsGrid({ + invitations, + onRefetch, +}: InvitationsGridProps) { + const revokeInvitation = useRevokeInvitation(); + const resendInvitation = useResendInvitation(); + const [actioningId, setActioningId] = useState(null); + + const handleCopyUrl = async (url: string) => { + try { + await navigator.clipboard.writeText(url); + toast.success("Invitation link copied to clipboard"); + } catch (error) { + toast.error("Failed to copy link"); + } + }; + + const handleRevoke = async (invitation: Invitation) => { + if (!confirm(`Cancel invitation for ${invitation.emailAddress}?`)) { + return; + } + + setActioningId(invitation.id); + try { + await revokeInvitation.mutateAsync(invitation.id); + toast.success("Invitation cancelled"); + onRefetch(); + } catch (error) { + toast.error("Failed to cancel invitation"); + } finally { + setActioningId(null); + } + }; + + const handleResend = async (invitation: Invitation) => { + setActioningId(invitation.id); + try { + await resendInvitation.mutateAsync(invitation.id); + toast.success("Invitation resent"); + onRefetch(); + } catch (error) { + toast.error("Failed to resend invitation"); + } finally { + setActioningId(null); + } + }; + + if (invitations.length === 0) { + return ( + + No pending invitations + + ); + } + + return ( +
+ {invitations.map((invitation) => { + const role = invitation.publicMetadata?.role || "unknown"; + const createdDate = new Date(invitation.createdAt); + const isActioning = actioningId === invitation.id; + + return ( + +
+
+
+

{invitation.emailAddress}

+ + Pending + + + {role} + +
+

+ Sent {formatDistanceToNow(createdDate, { addSuffix: true })} +

+
+ +
+ {invitation.url && ( + + )} + + + + +
+
+
+ ); + })} +
+ ); +} diff --git a/apps/admin/src/components/users/UserManagement.tsx b/apps/admin/src/components/users/UserManagement.tsx index cd1b983..3babce7 100644 --- a/apps/admin/src/components/users/UserManagement.tsx +++ b/apps/admin/src/components/users/UserManagement.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { UserGrid, type User } from "@/components/users/UserGrid"; +import { InvitationsGrid } from "@/components/users/InvitationsGrid"; import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { useUser } from "@clerk/nextjs"; @@ -14,6 +15,7 @@ import { useUpdateUser, useDeleteUser, useSendInvitation, + useInvitations, } from "@/hooks/use-api"; interface UserManagementProps { @@ -23,6 +25,9 @@ interface UserManagementProps { export function UserManagement({ gymId }: UserManagementProps) { const { user } = useUser(); const [filter, setFilter] = useState("all"); + const [viewFilter, setViewFilter] = useState<"all" | "active" | "pending">( + "all", + ); const [selectedUser, setSelectedUser] = useState(null); const [isEditing, setIsEditing] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -40,17 +45,28 @@ export function UserManagement({ gymId }: UserManagementProps) { const { data: users = [], - isLoading, - refetch, + isLoading: usersLoading, + refetch: refetchUsers, } = useUsers({ role: filter !== "all" ? filter : undefined, gymId, }); + + const { + data: invitations = [], + isLoading: invitationsLoading, + refetch: refetchInvitations, + } = useInvitations(gymId); + const { data: gyms = [] } = useGyms(); const updateUser = useUpdateUser(); const deleteUser = useDeleteUser(); const sendInvitation = useSendInvitation(); + const isLoading = + viewFilter === "pending" ? invitationsLoading : usersLoading; + const refetch = viewFilter === "pending" ? refetchInvitations : refetchUsers; + const handleUserSelect = (user: User | null) => { setSelectedUser(user); }; @@ -193,6 +209,19 @@ export function UserManagement({ gymId }: UserManagementProps) {

User Management

+ {/* View Filter: All/Active/Pending */} + + - + {viewFilter !== "pending" && ( + + )}
- handleUserSelect(user)} - onEditUser={handleEditUser} - onDeleteUser={handleDeleteUser} - onBulkDelete={handleBulkDelete} - loading={isLoading} - /> + {viewFilter === "pending" ? ( + + ) : ( + handleUserSelect(user)} + onEditUser={handleEditUser} + onDeleteUser={handleDeleteUser} + onBulkDelete={handleBulkDelete} + loading={isLoading} + /> + )} @@ -580,7 +624,10 @@ export function UserManagement({ gymId }: UserManagementProps) { refetch()} + onSuccess={() => { + refetchUsers(); + refetchInvitations(); + }} /> ); diff --git a/apps/admin/src/hooks/use-api.ts b/apps/admin/src/hooks/use-api.ts index 3cead9c..39d1397 100644 --- a/apps/admin/src/hooks/use-api.ts +++ b/apps/admin/src/hooks/use-api.ts @@ -61,6 +61,21 @@ export interface AttendanceRecord { type?: string; } +export interface Invitation { + id: string; + emailAddress: string; + publicMetadata: { + role?: string; + gymId?: string; + createdBy?: string; + } | null; + status: "pending" | "accepted" | "revoked" | "expired"; + url?: string; + createdAt: number; + updatedAt: number; + revoked?: boolean; +} + async function fetchApi(url: string, options?: RequestInit): Promise { const response = await fetch(url, { ...options, @@ -339,13 +354,17 @@ export function useCheckOut() { }); } -export function useInvitations() { +export function useInvitations(gymId?: string) { return useQuery({ - queryKey: ["invitations"], - queryFn: () => - fetchApi<{ data: { invitations: unknown[] } }>("/api/invitations").then( + queryKey: ["invitations", gymId], + queryFn: () => { + const url = gymId + ? `/api/invitations?gymId=${gymId}` + : "/api/invitations"; + return fetchApi<{ data: { invitations: Invitation[] } }>(url).then( (res) => res.data?.invitations ?? [], - ), + ); + }, }); } @@ -364,6 +383,37 @@ export function useSendInvitation() { }); } +export function useRevokeInvitation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (invitationId: string) => + fetchApi<{ data: { success: boolean } }>( + `/api/invitations/${invitationId}`, + { method: "DELETE" }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["invitations"] }); + queryClient.invalidateQueries({ queryKey: ["users"] }); + }, + }); +} + +export function useResendInvitation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (invitationId: string) => + fetchApi<{ data: { invitation: any } }>( + `/api/invitations/${invitationId}/resend`, + { method: "POST" }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["invitations"] }); + }, + }); +} + export interface AnalyticsData { userGrowth: { label: string; value: number }[]; membershipDistribution: { label: string; value: number; color: string }[];