Compare commits

..
Author SHA1 Message Date
dimitar 0817e8e72b invitation flow basis 2026-03-18 23:08:55 +01:00
dimitar b1f01208fa admin role enhancment
basic invitation flow
2026-03-18 13:56:51 +01:00
dimitar d112dbb122 rb fine tune 2026-03-18 13:14:47 +01:00
dimitar 7043487fc2 edit local user fix 2026-03-18 12:37:21 +01:00
dimitar bb9c675421 setings page enhanced
gym managment added
2026-03-18 06:23:40 +01:00
18 changed files with 1395 additions and 171 deletions

No files matched your search

+4
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.
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database";
import { db, users as usersTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
async function ensureGymsTable() {
await db.run(sql`
CREATE TABLE IF NOT EXISTS gyms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
status TEXT NOT NULL CHECK (status IN ('active','inactive')) DEFAULT 'active',
admin_user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
// DELETE /api/gyms/[id]
// Delete a gym (soft delete - mark as inactive)
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id: gymId } = await params;
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
// Ensure user is synced
const currentUser = await ensureUserSynced(userId, {
getUserById: async (id: string) => {
const row = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.get();
return row
? {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password ?? "",
phone: row.phone ?? undefined,
role: row.role,
imageUrl: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
: null;
},
} as any);
// Only superAdmin can delete gyms
if (!currentUser || currentUser.role !== "superAdmin") {
return new NextResponse("Forbidden - Only superAdmin can delete gyms", {
status: 403,
});
}
await ensureGymsTable();
// Check if gym exists
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`);
if (gymRows.length === 0) {
return new NextResponse("Gym not found", { status: 404 });
}
// Soft delete - mark as inactive
await db.run(
sql`UPDATE gyms SET status = 'inactive', updated_at = ${Date.now()} WHERE id = ${gymId}`,
);
return NextResponse.json({
success: true,
message: "Gym deleted successfully",
});
} catch (error) {
log.error("Failed to delete gym", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { eq, sql } from "@fitai/database";
import { db } from "@fitai/database";
import log from "@/lib/logger";
async function ensureGymsTable() {
await db.run(sql`
CREATE TABLE IF NOT EXISTS gyms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
status TEXT NOT NULL CHECK (status IN ('active','inactive')) DEFAULT 'active',
admin_user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
// GET /api/gyms/[id]/stats
// Get stats for a specific gym
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id: gymId } = await params;
await ensureGymsTable();
// Get gym info
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`);
if (gymRows.length === 0) {
return new NextResponse("Gym not found", { status: 404 });
}
const gym = gymRows[0];
// Get user counts
const usersResult = await db.all(
sql`SELECT role, COUNT(*) as count FROM users WHERE gym_id = ${gymId} GROUP BY role`,
);
const userCounts: Record<string, number> = {
admin: 0,
trainer: 0,
client: 0,
};
for (const row of usersResult as any[]) {
if (row.role in userCounts) {
userCounts[row.role] = row.count;
}
}
// Get client stats
const clientsResult = (await db.all(
sql`SELECT
membership_type,
membership_status,
COUNT(*) as count
FROM clients
WHERE user_id IN (SELECT id FROM users WHERE gym_id = ${gymId})
GROUP BY membership_type, membership_status`,
)) as any[];
const membershipStats: Record<string, number> = {
basic: 0,
premium: 0,
vip: 0,
};
const activeClients = clientsResult.filter(
(c: any) => c.membership_status === "active",
);
for (const row of activeClients) {
if (row.membership_type in membershipStats) {
membershipStats[row.membership_type] = row.count;
}
}
// Get recent activity (attendance in last 30 days)
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
const attendanceResult = (await db.all(
sql`SELECT COUNT(*) as count FROM attendance
WHERE user_id IN (SELECT id FROM users WHERE gym_id = ${gymId})
AND check_in_time > ${thirtyDaysAgo}`,
)) as any[];
const attendanceCount = attendanceResult[0]?.count || 0;
const stats = {
totalUsers: userCounts.admin + userCounts.trainer + userCounts.client,
admins: userCounts.admin,
trainers: userCounts.trainer,
clients: userCounts.client,
membershipStats,
activeClients: activeClients.reduce(
(sum: number, c: any) => sum + c.count,
0,
),
attendanceLast30Days: attendanceCount,
};
return NextResponse.json({ gym, stats });
} catch (error) {
log.error("Failed to get gym stats", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -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
+76 -6
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) {
@@ -51,18 +84,39 @@ 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,
createdBy: userId,
};
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: data.gymId,
},
publicMetadata,
redirectUrl,
ignoreExisting: true, // Don't fail if invitation already exists
});
log.info("Clerk invitation sent", {
email: data.email,
role: data.role,
gymId: assignedGymId,
invitationId: invitation.id,
redirectUrl: redirectUrl || "default (mobile app)",
});
// Send custom invitation email (in addition to Clerk's)
@@ -83,7 +137,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,
});
@@ -106,13 +175,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,
});
+121 -26
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,27 @@ 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 and admins cannot reassign users to different gyms
// Only superAdmins can reassign gyms
if (
requesterRole !== "superAdmin" &&
gymId !== undefined &&
gymId !== existingUser.gymId
) {
return forbiddenResponse(
"Only superAdmins can 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);
@@ -387,24 +436,27 @@ export async function PUT(request: NextRequest) {
// Update Clerk publicMetadata (role/gymId) to propagate via webhook
// Note: Only update metadata when a change is requested
try {
const client = await clerkClient();
const publicMetadata: Record<string, unknown> = {};
log.debug("Preparing Clerk metadata update", {
targetUserId: id,
role,
gymId,
});
const client = await clerkClient();
const publicMetadata: Record<string, unknown> = {};
log.debug("Preparing Clerk metadata update", {
targetUserId: id,
role,
gymId,
});
if (role) {
publicMetadata.role = role;
}
if (gymId !== undefined) {
publicMetadata.gymId = gymId === null ? null : String(gymId);
}
if (role) {
publicMetadata.role = role;
}
if (gymId !== undefined) {
publicMetadata.gymId = gymId === null ? null : String(gymId);
}
if (Object.keys(publicMetadata).length > 0) {
log.debug("Updating Clerk user metadata", { publicMetadata });
try {
// Check if user exists in Clerk first
await client.users.getUser(id);
if (Object.keys(publicMetadata).length > 0) {
log.debug("Updating Clerk user metadata", { publicMetadata });
const clerkResult = await client.users.updateUser(id, {
publicMetadata,
});
@@ -413,14 +465,20 @@ export async function PUT(request: NextRequest) {
role: clerkResult.publicMetadata?.role,
gymId: clerkResult.publicMetadata?.gymId,
});
} else {
log.debug("No Clerk metadata changes requested");
} catch (clerkErr: any) {
// User might not exist in Clerk yet - that's OK for local users
if (
clerkErr?.status === 404 ||
clerkErr?.errors?.[0]?.code === "resource_not_found"
) {
log.debug(
"User not found in Clerk, skipping metadata update (local-only user)",
);
} else {
log.error("Clerk metadata update failed", clerkErr, { userId: id });
// Don't fail the whole request - local DB update can still proceed
}
}
} catch (clerkErr: any) {
log.error("Clerk metadata update failed", clerkErr, { userId: id });
return internalErrorResponse(
"Failed to update role/gym in identity provider",
);
}
// Update local DB for immediate UI feedback (webhook will also sync)
@@ -442,6 +500,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,
+2 -2
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>
+271 -71
View File
@@ -9,6 +9,10 @@ import {
AlertTriangle,
Check,
Loader2,
Trash2,
Users,
CalendarCheck,
TrendingUp,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
@@ -26,6 +30,21 @@ interface Gym {
location?: string | null;
status: "active" | "inactive";
adminUserId: string;
createdAt?: number;
}
interface GymStats {
totalUsers: number;
admins: number;
trainers: number;
clients: number;
membershipStats: {
basic: number;
premium: number;
vip: number;
};
activeClients: number;
attendanceLast30Days: number;
}
export default function SettingsPage() {
@@ -46,6 +65,13 @@ export default function SettingsPage() {
type: "success" | "error";
text: string;
} | null>(null);
// Selected gym for details
const [selectedGym, setSelectedGym] = useState<Gym | null>(null);
const [gymStats, setGymStats] = useState<GymStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [deletingGym, setDeletingGym] = useState(false);
// Create Gym modal state
const [showCreateGym, setShowCreateGym] = useState(false);
const [gymName, setGymName] = useState("");
@@ -77,11 +103,33 @@ export default function SettingsPage() {
}
};
const fetchGymStats = async (gymId: string) => {
setStatsLoading(true);
try {
const res = await axios.get(`/api/gyms/${gymId}/stats`);
if (res.data?.stats) {
setGymStats(res.data.stats);
}
} catch (error) {
log.error("Failed to fetch gym stats", error);
} finally {
setStatsLoading(false);
}
};
useEffect(() => {
fetchBackups();
fetchGyms();
}, []);
useEffect(() => {
if (selectedGym) {
fetchGymStats(selectedGym.id);
} else {
setGymStats(null);
}
}, [selectedGym]);
const handleCreateBackup = async () => {
setCreatingBackup(true);
setMessage(null);
@@ -111,7 +159,6 @@ export default function SettingsPage() {
try {
await axios.post("/api/admin/backups/restore", { filename });
setMessage({ type: "success", text: "Database restored successfully" });
// Optional: Refresh page or force re-login if session is invalidated
} catch (error) {
log.error("Failed to restore backup", error);
setMessage({ type: "error", text: "Failed to restore backup" });
@@ -135,21 +182,39 @@ export default function SettingsPage() {
return new Date(dateString).toLocaleString();
};
const handleSelectGym = async (gymId: string | null) => {
const handleSelectGym = async (gym: Gym | null) => {
setSelectedGym(gym);
setGymStats(null);
};
const handleDeleteGym = async (gymId: string) => {
if (
!window.confirm(
"Are you sure you want to delete this gym? This action cannot be undone.",
)
) {
return;
}
setDeletingGym(true);
setGymMessage(null);
try {
// Update current user's gym selection
await axios.patch("/api/users/gym", { gymId });
setGymMessage({
type: "success",
text: gymId ? "Gym selected successfully" : "Proceeding without gym",
});
await axios.delete(`/api/gyms/${gymId}`);
setGymMessage({ type: "success", text: "Gym deleted successfully" });
setSelectedGym(null);
setGymStats(null);
fetchGyms();
} catch (error) {
log.error("Failed to set gym", error);
setGymMessage({ type: "error", text: "Failed to set gym" });
log.error("Failed to delete gym", error);
setGymMessage({ type: "error", text: "Failed to delete gym" });
} finally {
setDeletingGym(false);
}
};
const userRole = (user?.publicMetadata?.role as string) ?? "client";
const isSuperAdmin = userRole === "superAdmin";
return (
<div className="space-y-8 p-8">
<div>
@@ -159,7 +224,7 @@ export default function SettingsPage() {
</p>
</div>
{/* Gym Picker */}
{/* Gym Management */}
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
@@ -168,10 +233,12 @@ export default function SettingsPage() {
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">
Gym Selection
Gym Management
</h3>
<p className="text-sm text-slate-500">
Select your gym or proceed without a gym
{isSuperAdmin
? "Select a gym to view details, create or delete gyms"
: "Select your gym or proceed without a gym"}
</p>
<p className="text-xs text-blue-600 mt-1">
{user ? (
@@ -180,11 +247,6 @@ export default function SettingsPage() {
<span className="font-medium">
{String(user.publicMetadata?.role ?? "unknown")}
</span>
{" • "}
Gym ID:{" "}
<span className="font-medium">
{String(user.publicMetadata?.gymId ?? "none")}
</span>
</>
) : (
"Loading user metadata..."
@@ -196,6 +258,8 @@ export default function SettingsPage() {
<Button
onClick={fetchGyms}
disabled={gymsLoading}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{gymsLoading ? (
@@ -203,14 +267,17 @@ export default function SettingsPage() {
) : (
<RefreshCw className="w-4 h-4" />
)}
Refresh Gyms
</Button>
<Button
onClick={() => setShowCreateGym(true)}
className="flex items-center gap-2"
>
Create Gym
Refresh
</Button>
{isSuperAdmin && (
<Button
onClick={() => setShowCreateGym(true)}
size="sm"
className="flex items-center gap-2"
>
Create Gym
</Button>
)}
</div>
</div>
@@ -226,6 +293,7 @@ export default function SettingsPage() {
{gymMessage.text}
</div>
)}
{showCreateGym && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
@@ -310,59 +378,191 @@ export default function SettingsPage() {
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="border rounded-lg p-4 flex flex-col justify-between">
<div>
<h4 className="font-semibold text-slate-900">
Proceed without gym
</h4>
<p className="text-sm text-slate-600 mt-1">
You can select a gym later.
</p>
</div>
<Button
variant="outline"
className="mt-4"
onClick={() => handleSelectGym(null)}
>
Proceed without gym
</Button>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Gym List */}
<div className="lg:col-span-1 space-y-4">
<h4 className="font-semibold text-slate-900">Gyms</h4>
{gymsLoading ? (
<div className="flex items-center justify-center p-8 text-slate-500">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading...
</div>
) : gyms.length === 0 ? (
<div className="p-4 text-center text-slate-500">
No active gyms found.
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{gyms.map((gym) => (
<div
key={gym.id}
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
selectedGym?.id === gym.id
? "border-blue-500 bg-blue-50"
: "hover:bg-slate-50"
}`}
onClick={() => handleSelectGym(gym)}
>
<div className="flex items-center justify-between">
<div>
<h5 className="font-medium text-slate-900">
{gym.name}
</h5>
<p className="text-xs text-slate-500">
{gym.location || "No location"}
</p>
</div>
<span
className={`text-xs px-2 py-1 rounded ${
gym.status === "active"
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}
>
{gym.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
{gymsLoading ? (
<div className="col-span-full flex items-center justify-center p-8 text-slate-500">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading gyms...
</div>
) : gyms.length === 0 ? (
<div className="col-span-full p-8 text-center text-slate-500">
No active gyms found.
</div>
) : (
gyms.map((gym) => (
<div
key={gym.id}
className="border rounded-lg p-4 flex flex-col justify-between"
>
<div>
<h4 className="font-semibold text-slate-900">{gym.name}</h4>
<p className="text-sm text-slate-600 mt-1">
{gym.location || "No location provided"}
</p>
{/* Gym Details / Stats */}
<div className="lg:col-span-2">
{selectedGym ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-slate-900">
{selectedGym.name} - Details
</h4>
{isSuperAdmin && (
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDeleteGym(selectedGym.id)}
disabled={deletingGym}
>
{deletingGym ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-1" />
)}
Delete Gym
</Button>
)}
</div>
<Button
variant="default"
className="mt-4"
onClick={() => handleSelectGym(gym.id)}
>
Select this gym
</Button>
{/* Gym Info */}
<div className="grid grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg">
<div>
<p className="text-xs text-slate-500">Location</p>
<p className="font-medium">
{selectedGym.location || "Not specified"}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Status</p>
<p className="font-medium capitalize">
{selectedGym.status}
</p>
</div>
</div>
{/* Stats */}
{statsLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
</div>
) : gymStats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-blue-600" />
<span className="text-xs text-blue-600">
Total Users
</span>
</div>
<p className="text-2xl font-bold text-blue-700">
{gymStats.totalUsers}
</p>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<CalendarCheck className="w-4 h-4 text-green-600" />
<span className="text-xs text-green-600">
Active Clients
</span>
</div>
<p className="text-2xl font-bold text-green-700">
{gymStats.activeClients}
</p>
</div>
<div className="p-4 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-purple-600" />
<span className="text-xs text-purple-600">
Check-ins (30d)
</span>
</div>
<p className="text-2xl font-bold text-purple-700">
{gymStats.attendanceLast30Days}
</p>
</div>
<div className="p-4 bg-orange-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-orange-600" />
<span className="text-xs text-orange-600">
Trainers
</span>
</div>
<p className="text-2xl font-bold text-orange-700">
{gymStats.trainers}
</p>
</div>
</div>
) : null}
{/* Membership Distribution */}
{gymStats && (
<div className="mt-4">
<h5 className="text-sm font-medium text-slate-700 mb-2">
Membership Distribution
</h5>
<div className="flex gap-4">
<div className="flex-1 p-3 bg-slate-100 rounded-lg text-center">
<p className="text-2xl font-bold text-slate-700">
{gymStats.membershipStats.basic}
</p>
<p className="text-xs text-slate-500">Basic</p>
</div>
<div className="flex-1 p-3 bg-blue-50 rounded-lg text-center">
<p className="text-2xl font-bold text-blue-700">
{gymStats.membershipStats.premium}
</p>
<p className="text-xs text-blue-500">Premium</p>
</div>
<div className="flex-1 p-3 bg-yellow-50 rounded-lg text-center">
<p className="text-2xl font-bold text-yellow-700">
{gymStats.membershipStats.vip}
</p>
<p className="text-xs text-yellow-600">VIP</p>
</div>
</div>
</div>
)}
</div>
))
)}
) : (
<div className="flex items-center justify-center h-64 bg-slate-50 rounded-lg">
<p className="text-slate-500">Select a gym to view details</p>
</div>
)}
</div>
</div>
</div>
{/* Database Management */}
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
@@ -0,0 +1,7 @@
"use client";
import { Toaster } from "sonner";
export function ToasterProvider() {
return <Toaster richColors position="top-right" />;
}
@@ -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,
@@ -26,6 +27,9 @@ import {
type InvitationOptionsData,
} 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;
@@ -45,6 +49,15 @@ export function CreateUserModal({
const [basicInfo, setBasicInfo] = useState<BasicInfoData | null>(null);
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>({
@@ -136,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) {
@@ -259,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 ${
@@ -461,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
@@ -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<string | null>(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 (
<Card className="p-8 text-center text-muted-foreground">
No pending invitations
</Card>
);
}
return (
<div className="space-y-4">
{invitations.map((invitation) => {
const role = invitation.publicMetadata?.role || "unknown";
const createdDate = new Date(invitation.createdAt);
const isActioning = actioningId === invitation.id;
return (
<Card key={invitation.id} className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium">{invitation.emailAddress}</h3>
<span className="text-xs px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
<span className="text-xs px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{role}
</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
Sent {formatDistanceToNow(createdDate, { addSuffix: true })}
</p>
</div>
<div className="flex items-center gap-2">
{invitation.url && (
<Button
variant="outline"
size="sm"
onClick={() => handleCopyUrl(invitation.url!)}
>
<Copy className="h-4 w-4 mr-1" />
Copy Link
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleResend(invitation)}
disabled={isActioning}
>
<RefreshCw
className={`h-4 w-4 mr-1 ${isActioning ? "animate-spin" : ""}`}
/>
Resend
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleRevoke(invitation)}
disabled={isActioning}
>
<Trash2 className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
</Card>
);
})}
</div>
);
}
@@ -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<string>("all");
const [viewFilter, setViewFilter] = useState<"all" | "active" | "pending">(
"all",
);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -34,21 +39,34 @@ export function UserManagement({ gymId }: UserManagementProps) {
role: string;
phone: string;
gymId: string;
membershipType?: string;
membershipStatus?: string;
} | null>(null);
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);
};
@@ -62,6 +80,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 +153,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 +163,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);
@@ -183,6 +209,19 @@ export function UserManagement({ gymId }: UserManagementProps) {
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">User Management</h2>
<div className="flex gap-2">
{/* View Filter: All/Active/Pending */}
<select
value={viewFilter}
onChange={(e) =>
setViewFilter(e.target.value as "all" | "active" | "pending")
}
className="px-3 py-2 border rounded-md bg-white dark:bg-gray-800"
>
<option value="all">All Users</option>
<option value="active">Active Users</option>
<option value="pending">Pending Invitations</option>
</select>
<Button
variant={filter === "all" ? "default" : "outline"}
onClick={() => setFilter("all")}
@@ -210,7 +249,7 @@ export function UserManagement({ gymId }: UserManagementProps) {
variant={filter === "client" ? "default" : "outline"}
onClick={() => setFilter("client")}
>
Clientsa
Clients
</Button>
<Button
variant={filter === "trainer" ? "default" : "outline"}
@@ -235,33 +274,48 @@ export function UserManagement({ gymId }: UserManagementProps) {
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
Showing {users.length} users
{selectedUser && (
<span className="ml-4 text-blue-600">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</span>
{viewFilter === "pending" ? (
<>Showing {invitations.length} pending invitations</>
) : (
<>
Showing {users.length} users
{selectedUser && (
<span className="ml-4 text-blue-600">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</span>
)}
</>
)}
</div>
<div className="flex gap-2">
<Button variant="default" onClick={handleRefresh}>
Refresh
</Button>
<Button variant="default" onClick={handleExport}>
Export CSV
</Button>
{viewFilter !== "pending" && (
<Button variant="default" onClick={handleExport}>
Export CSV
</Button>
)}
</div>
</div>
<Card>
<CardContent className="p-0">
<UserGrid
users={users}
onUserSelect={(user) => handleUserSelect(user)}
onEditUser={handleEditUser}
onDeleteUser={handleDeleteUser}
onBulkDelete={handleBulkDelete}
loading={isLoading}
/>
{viewFilter === "pending" ? (
<InvitationsGrid
invitations={invitations}
onRefetch={refetchInvitations}
/>
) : (
<UserGrid
users={users}
onUserSelect={(user) => handleUserSelect(user)}
onEditUser={handleEditUser}
onDeleteUser={handleDeleteUser}
onBulkDelete={handleBulkDelete}
loading={isLoading}
/>
)}
</CardContent>
</Card>
@@ -351,26 +405,78 @@ export function UserManagement({ gymId }: UserManagementProps) {
className="w-full border border-gray-300 rounded px-3 py-2"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Gym</label>
<select
value={editForm.gymId}
onChange={(e) =>
setEditForm({ ...editForm, gymId: e.target.value })
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="">Proceed without gym</option>
{gyms.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Select an active gym or proceed without a gym.
</p>
</div>
{/* 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
value={editForm.gymId}
onChange={(e) =>
setEditForm({ ...editForm, gymId: e.target.value })
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="">Proceed without gym</option>
{gyms.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
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"
@@ -518,7 +624,10 @@ export function UserManagement({ gymId }: UserManagementProps) {
<CreateUserModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onSuccess={() => refetch()}
onSuccess={() => {
refetchUsers();
refetchInvitations();
}}
/>
</div>
);
+56 -6
View File
@@ -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<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
@@ -170,7 +185,7 @@ export function useGyms() {
queryKey: ["gyms"],
queryFn: () =>
fetchApi<{ data: { gyms: Gym[] } }>("/api/gyms").then(
(res) => res.data?.gyms ?? [],
(res) => res.data?.gyms ?? (Array.isArray(res) ? res : []),
),
});
}
@@ -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 }[];
+56 -13
View File
@@ -30,29 +30,72 @@ 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 {
// Fallback to database lookup
try {
const db = await getDatabase();
const user = await db.getUserById(userId);
gymId = user?.gymId ?? null;
} catch (error) {
console.error("Failed to get gymId from database:", error);
gymId = null;
// Try metadata field
const metadataGymId = (sessionClaims?.metadata as { gymId?: string })
?.gymId;
if (metadataGymId) {
gymId = metadataGymId;
} else {
// Fallback to database lookup
try {
const db = await getDatabase();
const user = await db.getUserById(userId);
gymId = user?.gymId ?? null;
} catch (error) {
console.error("Failed to get gymId from database:", error);
gymId = null;
}
}
}
+18 -3
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");
@@ -33,6 +36,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 +76,8 @@ export const userUpdateWithIdSchema = z.object({
role: userRoleSchema.optional(),
phone: phoneSchema,
gymId: z.string().nullable().optional(),
membershipType: membershipTypeSchema.optional(),
membershipStatus: membershipStatusSchema.optional(),
});
// ============================================================================
@@ -335,6 +348,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>;