From 624cdfc45cde2b6fc42542f9bf93b853c9a4c10b Mon Sep 17 00:00:00 2001 From: echo Date: Wed, 18 Mar 2026 06:06:01 +0100 Subject: [PATCH] role based auth implemented superadmin -> admin -> traniner --- .../src/app/api/admin/analytics/route.ts | 35 ++- .../src/app/api/admin/attendance/route.ts | 24 +- .../src/app/api/profile/fitness/route.ts | 72 ++++- .../src/app/api/recommendations/route.ts | 49 +++- apps/admin/src/app/api/users/route.ts | 30 ++- apps/admin/src/app/page.tsx | 46 +++- apps/admin/src/app/users/page.tsx | 14 +- .../analytics/AnalyticsDashboard.tsx | 8 +- apps/admin/src/components/gym/GymSelector.tsx | 91 +++++++ .../src/components/users/UserManagement.tsx | 7 +- apps/admin/src/hooks/use-api.ts | 16 +- apps/admin/src/lib/auth/context.ts | 231 ++++++++++++++++ apps/admin/src/lib/auth/permissions.ts | 249 ++++++++++++++++++ apps/admin/src/lib/gym-context.ts | 149 +++++++++++ .../src/lib/migrations/fix-gym-assignments.js | 138 ++++++++++ data/fitai.db | 0 16 files changed, 1105 insertions(+), 54 deletions(-) create mode 100644 apps/admin/src/components/gym/GymSelector.tsx create mode 100644 apps/admin/src/lib/auth/context.ts create mode 100644 apps/admin/src/lib/auth/permissions.ts create mode 100644 apps/admin/src/lib/gym-context.ts create mode 100644 apps/admin/src/lib/migrations/fix-gym-assignments.js create mode 100644 data/fitai.db diff --git a/apps/admin/src/app/api/admin/analytics/route.ts b/apps/admin/src/app/api/admin/analytics/route.ts index 638587f..1c0e7c5 100644 --- a/apps/admin/src/app/api/admin/analytics/route.ts +++ b/apps/admin/src/app/api/admin/analytics/route.ts @@ -4,6 +4,7 @@ import { getDatabase } from "@/lib/database"; import { ensureUserSynced } from "@/lib/sync-user"; import { successResponse } from "@/lib/api/responses"; import { db as rawDb, sql } from "@fitai/database"; +import { getUsersByGym, getClientsByGym } from "@/lib/gym-context"; interface UserGrowthPoint { label: string; @@ -45,12 +46,36 @@ export async function GET(req: NextRequest) { const url = new URL(req.url); const months = parseInt(url.searchParams.get("months") || "6"); - const allUsers = await database.getAllUsers(); - const allClients = await database.getAllClients(); + // Get target gym based on role + const targetGymId = + user.role === "superAdmin" + ? (url.searchParams.get("gymId") ?? undefined) + : (user.gymId ?? undefined); - const paymentsRaw = await rawDb.all( - sql`SELECT * FROM payments WHERE status = 'completed' AND paid_at IS NOT NULL`, - ); + // Validate gym access for non-superAdmins + if (user.role !== "superAdmin" && !targetGymId) { + return NextResponse.json( + { error: "Forbidden - No gym assigned" }, + { status: 403 }, + ); + } + + // Get gym-scoped data + const allUsers = targetGymId + ? await getUsersByGym(targetGymId) + : await database.getAllUsers(); + + const allClients = targetGymId + ? await getClientsByGym(targetGymId) + : await database.getAllClients(); + + // For payments, we'd need a similar filter - for now, skip payments if gym-scoped + // TODO: Add getPaymentsByGym when needed + const paymentsRaw = targetGymId + ? [] // Skip payments for gym-scoped for now + : await rawDb.all( + sql`SELECT * FROM payments WHERE status = 'completed' AND paid_at IS NOT NULL`, + ); const payments: any[] = paymentsRaw || []; const now = new Date(); diff --git a/apps/admin/src/app/api/admin/attendance/route.ts b/apps/admin/src/app/api/admin/attendance/route.ts index c540337..03a5d50 100644 --- a/apps/admin/src/app/api/admin/attendance/route.ts +++ b/apps/admin/src/app/api/admin/attendance/route.ts @@ -1,23 +1,39 @@ import { auth } from "@clerk/nextjs/server"; -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getDatabase } from "@/lib/database"; import { ensureUserSynced } from "@/lib/sync-user"; import { successResponse } from "@/lib/api/responses"; +import { getAttendanceByGym } from "@/lib/gym-context"; -export async function GET(req: Request) { +export async function GET(req: NextRequest) { try { const { userId } = await auth(); if (!userId) return new NextResponse("Unauthorized", { status: 401 }); const db = await getDatabase(); - const user = await ensureUserSynced(userId, db); if (!user || (user.role !== "admin" && user.role !== "superAdmin")) { return new NextResponse("Forbidden", { status: 403 }); } - const attendance = await db.getAllAttendance(); + // Get target gym based on role + const url = new URL(req.url); + const targetGymId = + user.role === "superAdmin" + ? (url.searchParams.get("gymId") ?? undefined) + : (user.gymId ?? undefined); + + // Validate gym access for non-superAdmins + if (user.role !== "superAdmin" && !targetGymId) { + return new NextResponse("Forbidden - No gym assigned", { status: 403 }); + } + + // Get attendance filtered by gym + const attendance = targetGymId + ? await getAttendanceByGym(targetGymId) + : await db.getAllAttendance(); + return successResponse({ records: attendance }); } catch (error) { console.error("Admin attendance error:", error); diff --git a/apps/admin/src/app/api/profile/fitness/route.ts b/apps/admin/src/app/api/profile/fitness/route.ts index e49825b..b4b6867 100644 --- a/apps/admin/src/app/api/profile/fitness/route.ts +++ b/apps/admin/src/app/api/profile/fitness/route.ts @@ -1,11 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { getDatabase } from "../../../../lib/database/index"; +import { auth } from "@clerk/nextjs/server"; import log from "@/lib/logger"; import { fitnessProfileSchema } from "@/lib/validation/schemas"; import { validateRequestBody, validationErrorResponse, } from "@/lib/validation/helpers"; +import { getFitnessProfilesByGym } from "@/lib/gym-context"; +import { ensureUserSynced } from "@/lib/sync-user"; export async function POST(request: NextRequest) { try { @@ -63,12 +66,47 @@ export async function POST(request: NextRequest) { export async function GET(request: NextRequest) { try { - const db = await getDatabase(); - const { searchParams } = new URL(request.url); - const userId = searchParams.get("userId"); + // First authenticate + const { userId: clerkUserId } = await auth(); + if (!clerkUserId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - if (userId) { - const profile = await db.getFitnessProfileByUserId(userId); + const db = await getDatabase(); + const currentUser = await ensureUserSynced(clerkUserId, db); + + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const targetUserId = searchParams.get("userId"); + + // If accessing another user's profile, verify gym access + if (targetUserId && targetUserId !== clerkUserId) { + const isStaff = + currentUser.role === "admin" || + currentUser.role === "superAdmin" || + currentUser.role === "trainer"; + + if (!isStaff) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Staff need to verify target user is in same gym + if (currentUser.role !== "superAdmin") { + const targetUser = await db.getUserById(targetUserId); + if (!targetUser || targetUser.gymId !== currentUser.gymId) { + return NextResponse.json( + { error: "Forbidden - Cannot access users from other gyms" }, + { status: 403 }, + ); + } + } + } + + if (targetUserId) { + const profile = await db.getFitnessProfileByUserId(targetUserId); if (!profile) { return NextResponse.json( { error: "Profile not found" }, @@ -78,8 +116,28 @@ export async function GET(request: NextRequest) { return NextResponse.json({ profile }); } - const profiles = await db.getAllFitnessProfiles(); - return NextResponse.json({ profiles }); + // Staff get gym-scoped profiles + const isStaff = + currentUser.role === "admin" || + currentUser.role === "superAdmin" || + currentUser.role === "trainer"; + + if (isStaff) { + const targetGymId = + currentUser.role === "superAdmin" + ? (searchParams.get("gymId") ?? undefined) + : (currentUser.gymId ?? undefined); + + const profiles = targetGymId + ? await getFitnessProfilesByGym(targetGymId) + : await db.getAllFitnessProfiles(); + + return NextResponse.json({ profiles }); + } + + // Regular users only get their own + const profile = await db.getFitnessProfileByUserId(clerkUserId); + return NextResponse.json({ profile: profile ? [profile] : [] }); } catch (error) { log.error("Failed to get fitness profiles", error); return NextResponse.json( diff --git a/apps/admin/src/app/api/recommendations/route.ts b/apps/admin/src/app/api/recommendations/route.ts index d084d03..d2b9eeb 100644 --- a/apps/admin/src/app/api/recommendations/route.ts +++ b/apps/admin/src/app/api/recommendations/route.ts @@ -17,6 +17,8 @@ import { badRequestResponse, internalErrorResponse, } from "@/lib/api/responses"; +import { getRecommendationsByGym } from "@/lib/gym-context"; +import { ensureUserSynced } from "@/lib/sync-user"; export async function GET(request: NextRequest) { try { @@ -25,38 +27,59 @@ export async function GET(request: NextRequest) { return unauthorizedResponse(); } + const db = await getDatabase(); + const currentUser = await ensureUserSynced(currentUserId, db); + + if (!currentUser) { + return forbiddenResponse("User not found"); + } + const { searchParams } = new URL(request.url); const targetUserId = searchParams.get("userId"); - const db = await getDatabase(); - - // If no userId provided, check if staff and return all recommendations + // If no userId provided, staff gets gym-scoped recommendations if (!targetUserId) { - const currentUser = await db.getUserById(currentUserId); const isStaff = - currentUser?.role === "admin" || - currentUser?.role === "superAdmin" || - currentUser?.role === "trainer"; + currentUser.role === "admin" || + currentUser.role === "superAdmin" || + currentUser.role === "trainer"; if (!isStaff) { return badRequestResponse("User ID is required"); } - const recommendations = await db.getAllRecommendations(); + // Get target gym based on role + const targetGymId = + currentUser.role === "superAdmin" + ? (searchParams.get("gymId") ?? undefined) + : (currentUser.gymId ?? undefined); + + // Get recommendations filtered by gym + const recommendations = targetGymId + ? await getRecommendationsByGym(targetGymId) + : await db.getAllRecommendations(); + return successResponse({ recommendations }); } - // Check permissions: Users can view their own, Admins/Trainers can view anyone's - const currentUser = await db.getUserById(currentUserId); + // Check permissions: Users can view their own, Admins/Trainers can view anyone's in their gym const isStaff = - currentUser?.role === "admin" || - currentUser?.role === "superAdmin" || - currentUser?.role === "trainer"; + currentUser.role === "admin" || + currentUser.role === "superAdmin" || + currentUser.role === "trainer"; if (currentUserId !== targetUserId) { if (!isStaff) { return forbiddenResponse(); } + + // Staff need to verify target user is in same gym + if (currentUser.role !== "superAdmin") { + const targetUser = await db.getUserById(targetUserId); + if (!targetUser || targetUser.gymId !== currentUser.gymId) { + return forbiddenResponse("Cannot access users from other gyms"); + } + } } let recommendations = await db.getRecommendationsByUserId(targetUserId); diff --git a/apps/admin/src/app/api/users/route.ts b/apps/admin/src/app/api/users/route.ts index ee57b71..832b00d 100644 --- a/apps/admin/src/app/api/users/route.ts +++ b/apps/admin/src/app/api/users/route.ts @@ -24,14 +24,42 @@ import { internalErrorResponse, badRequestResponse, } from "@/lib/api/responses"; +import { getUsersByGym } from "@/lib/gym-context"; +import { ensureUserSynced } from "@/lib/sync-user"; export async function GET(request: NextRequest) { try { + // First authenticate the user + const { userId: clerkUserId } = await auth(); + if (!clerkUserId) { + return unauthorizedResponse(); + } + const db = await getDatabase(); + const currentUser = await ensureUserSynced(clerkUserId, db); + + if (!currentUser) { + return forbiddenResponse("User not found"); + } + const { searchParams } = new URL(request.url); const role = searchParams.get("role"); - let users = await db.getAllUsers(); + // Get target gym based on role + const targetGymId = + currentUser.role === "superAdmin" + ? (searchParams.get("gymId") ?? undefined) + : (currentUser.gymId ?? undefined); + + // Validate gym access for non-superAdmins + if (currentUser.role !== "superAdmin" && !targetGymId) { + return forbiddenResponse("No gym assigned"); + } + + // Get users filtered by gym + let users = targetGymId + ? await getUsersByGym(targetGymId) + : await db.getAllUsers(); // Hydrate gymId from raw DB to ensure consistency with writes const rawUserRows = await rawDb.all(sql`SELECT id, gym_id FROM users`); diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx index 76278eb..2a00a55 100644 --- a/apps/admin/src/app/page.tsx +++ b/apps/admin/src/app/page.tsx @@ -14,11 +14,26 @@ import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard"; import { useDashboardStats } from "@/hooks/use-api"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; +import { useUser } from "@clerk/nextjs"; +import { useSearchParams } from "next/navigation"; +import { GymSelector } from "@/components/gym/GymSelector"; export default function Home() { - const { data: stats, isLoading, refetch, isFetching } = useDashboardStats(); + const { user } = useUser(); + const searchParams = useSearchParams(); + const gymId = searchParams.get("gymId") ?? undefined; + + const { + data: stats, + isLoading, + refetch, + isFetching, + } = useDashboardStats(gymId); const queryClient = useQueryClient(); + // Get user role from metadata + const userRole = (user?.publicMetadata?.role as string) ?? "client"; + const handleRefresh = () => { queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); queryClient.invalidateQueries({ queryKey: ["analytics"] }); @@ -40,18 +55,21 @@ export default function Home() { title="Dashboard" description="Welcome back! Here's what's happening with your gym today." actions={ - +
+ + +
} /> @@ -125,7 +143,7 @@ export default function Home() { Overview of your gym metrics

- + diff --git a/apps/admin/src/app/users/page.tsx b/apps/admin/src/app/users/page.tsx index f631cb4..e5eed4a 100644 --- a/apps/admin/src/app/users/page.tsx +++ b/apps/admin/src/app/users/page.tsx @@ -1,17 +1,29 @@ +"use client"; + import { UserManagement } from "@/components/users/UserManagement"; import { PageHeader } from "@/components/ui/PageHeader"; +import { useUser } from "@clerk/nextjs"; +import { useSearchParams } from "next/navigation"; +import { GymSelector } from "@/components/gym/GymSelector"; export default function UsersPage() { + const { user } = useUser(); + const searchParams = useSearchParams(); + + const userRole = (user?.publicMetadata?.role as string) ?? "client"; + const gymId = searchParams.get("gymId") ?? undefined; + return (
} />
- +
); diff --git a/apps/admin/src/components/analytics/AnalyticsDashboard.tsx b/apps/admin/src/components/analytics/AnalyticsDashboard.tsx index 3b3ec94..e94f828 100644 --- a/apps/admin/src/components/analytics/AnalyticsDashboard.tsx +++ b/apps/admin/src/components/analytics/AnalyticsDashboard.tsx @@ -6,8 +6,12 @@ import { RevenueChart } from "@/components/charts/RevenueChart"; import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { useAnalytics } from "@/hooks/use-api"; -export function AnalyticsDashboard() { - const { data: analytics, isLoading } = useAnalytics(6); +interface AnalyticsDashboardProps { + gymId?: string; +} + +export function AnalyticsDashboard({ gymId }: AnalyticsDashboardProps) { + const { data: analytics, isLoading } = useAnalytics(6, gymId); const userGrowthData = analytics?.userGrowth ?? []; const membershipData = analytics?.membershipDistribution ?? []; diff --git a/apps/admin/src/components/gym/GymSelector.tsx b/apps/admin/src/components/gym/GymSelector.tsx new file mode 100644 index 0000000..7907e79 --- /dev/null +++ b/apps/admin/src/components/gym/GymSelector.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +interface Gym { + id: string; + name: string; +} + +interface GymSelectorProps { + currentGymId?: string; + userRole: string; +} + +export function GymSelector({ userRole }: GymSelectorProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [gyms, setGyms] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Only fetch gyms for superAdmin + if (userRole !== "superAdmin") { + setLoading(false); + return; + } + + async function fetchGyms() { + try { + const response = await fetch("/api/gyms"); + const data = await response.json(); + + // API returns array directly + if (Array.isArray(data)) { + setGyms(data); + } + } catch (error) { + console.error("Failed to fetch gyms:", error); + } finally { + setLoading(false); + } + } + + fetchGyms(); + }, [userRole]); + + const handleGymChange = (gymId: string) => { + const params = new URLSearchParams(searchParams.toString()); + + if (gymId === "all") { + params.delete("gymId"); + } else { + params.set("gymId", gymId); + } + + router.push(`?${params.toString()}`); + }; + + // Only show for superAdmin + if (userRole !== "superAdmin") { + return null; + } + + const selectedGymId = searchParams.get("gymId") ?? "all"; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/apps/admin/src/components/users/UserManagement.tsx b/apps/admin/src/components/users/UserManagement.tsx index 24a1fa4..a5ce0a7 100644 --- a/apps/admin/src/components/users/UserManagement.tsx +++ b/apps/admin/src/components/users/UserManagement.tsx @@ -16,7 +16,11 @@ import { useSendInvitation, } from "@/hooks/use-api"; -export function UserManagement() { +interface UserManagementProps { + gymId?: string; +} + +export function UserManagement({ gymId }: UserManagementProps) { const { user } = useUser(); const [filter, setFilter] = useState("all"); const [selectedUser, setSelectedUser] = useState(null); @@ -38,6 +42,7 @@ export function UserManagement() { refetch, } = useUsers({ role: filter !== "all" ? filter : undefined, + gymId, }); const { data: gyms = [] } = useGyms(); const updateUser = useUpdateUser(); diff --git a/apps/admin/src/hooks/use-api.ts b/apps/admin/src/hooks/use-api.ts index 87be6d3..287eab0 100644 --- a/apps/admin/src/hooks/use-api.ts +++ b/apps/admin/src/hooks/use-api.ts @@ -370,12 +370,16 @@ export interface AnalyticsData { revenue: { label: string; value: number; color: string }[]; } -export function useAnalytics(months: number = 6) { +export function useAnalytics(months: number = 6, gymId?: string) { return useQuery({ - queryKey: ["analytics", months], - queryFn: () => - fetchApi<{ data: { analytics: AnalyticsData } }>( - `/api/admin/analytics?months=${months}`, - ).then((res) => res.data?.analytics), + queryKey: ["analytics", months, gymId], + queryFn: () => { + const url = gymId + ? `/api/admin/analytics?months=${months}&gymId=${gymId}` + : `/api/admin/analytics?months=${months}`; + return fetchApi<{ data: { analytics: AnalyticsData } }>(url).then( + (res) => res.data?.analytics, + ); + }, }); } diff --git a/apps/admin/src/lib/auth/context.ts b/apps/admin/src/lib/auth/context.ts new file mode 100644 index 0000000..50f1333 --- /dev/null +++ b/apps/admin/src/lib/auth/context.ts @@ -0,0 +1,231 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { USER_ROLES, type UserRole } from "@fitai/shared"; +import { getDatabase } from "../database"; + +export interface AuthContext { + userId: string; + role: UserRole; + gymId: string | null; +} + +export interface AuthResult extends AuthContext {} + +/** + * Get the full authentication context including gymId + * Combines Clerk session claims with database lookup for accurate gym assignment + * + * @returns AuthContext with userId, role, and gymId + * + * @example + * const { userId, role, gymId } = await getAuthContext(); + * // userId: "user_abc123" + * // role: "admin" + * // gymId: "gym_xyz" + */ +export async function getAuthContext(): Promise { + const { userId, sessionClaims } = await auth(); + + if (!userId) { + throw new Error("Unauthorized: No authenticated user"); + } + + // Get role from Clerk metadata + const role = (sessionClaims?.metadata as { role?: UserRole })?.role; + + if (!role || !USER_ROLES.includes(role)) { + throw new Error(`Forbidden: Invalid or missing role - ${role}`); + } + + // 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; + 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; + } + } + + return { userId, role, gymId }; +} + +/** + * Middleware to require authentication and optionally check user role + * Enhanced version that also retrieves gymId + * + * @param allowedRoles - Array of roles allowed to access the endpoint (optional) + * @returns Authentication result with userId, role, and gymId, or NextResponse error + * + * @example + * export async function DELETE(request: NextRequest) { + * const authResult = await requireAuth(["admin", "superAdmin"]); + * if (authResult instanceof NextResponse) return authResult; + * const { userId, role, gymId } = authResult; + * // ... proceed with authorized logic + * } + */ +export async function requireAuth( + allowedRoles?: UserRole[], +): Promise { + try { + const context = await getAuthContext(); + + // Check if user has required role + if (allowedRoles && allowedRoles.length > 0) { + if (!allowedRoles.includes(context.role)) { + return NextResponse.json( + { + error: `Forbidden - Requires one of: ${allowedRoles.join(", ")}`, + requiredRoles: allowedRoles, + userRole: context.role, + }, + { status: 403 }, + ); + } + } + + return context; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + + if (message.includes("Unauthorized")) { + return NextResponse.json( + { error: "Unauthorized - Authentication required" }, + { status: 401 }, + ); + } + + if (message.includes("Forbidden")) { + return NextResponse.json({ error: message }, { status: 403 }); + } + + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + +/** + * Require authentication with gym access validation + * Use this for endpoints that need to verify the user can access a specific gym + * + * @param allowedRoles - Roles allowed to access this endpoint + * @param requiredGymId - Gym ID that must be accessed (optional - uses user's gym if not provided) + * @returns AuthResult with full context, or NextResponse error + * + * @example + * // Admin accessing their own gym + * const result = await requireGymAccess(["admin"]); + * + * // SuperAdmin accessing specific gym + * const result = await requireGymAccess(["superAdmin"], "gym_123"); + */ +export async function requireGymAccess( + allowedRoles: UserRole[], + requiredGymId?: string, +): Promise { + const authResult = await requireAuth(allowedRoles); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const { role, gymId: userGymId } = authResult; + + // Determine target gym + const targetGymId = requiredGymId ?? userGymId; + + // SuperAdmin can access any gym + if (role === "superAdmin") { + return { ...authResult, gymId: targetGymId }; + } + + // Other roles must have matching gym + if (!targetGymId) { + return NextResponse.json( + { error: "Forbidden - User is not assigned to a gym" }, + { status: 403 }, + ); + } + + if (userGymId !== targetGymId) { + return NextResponse.json( + { error: "Forbidden - Cannot access other gym's data" }, + { status: 403 }, + ); + } + + return { ...authResult, gymId: targetGymId }; +} + +/** + * Get the target gym ID from request for superAdmin context switching + * SuperAdmins can pass ?gymId to view specific gym data + * + * @param request - NextRequest with query params + * @param userRole - Current user's role + * @param userGymId - Current user's gym ID + * @returns Target gym ID or undefined for superAdmin viewing all + * + * @example + * const targetGymId = getTargetGymId(request, role, gymId); + * // For superAdmin with ?gymId=abc -> "abc" + * // For superAdmin without param -> undefined (all gyms) + * // For admin -> always returns their gymId + */ +export function getTargetGymId( + request: NextRequest, + userRole: UserRole, + userGymId: string | null, +): string | undefined { + // For superAdmin, allow gymId query param for context switching + if (userRole === "superAdmin") { + const queryGymId = request.nextUrl.searchParams.get("gymId"); + return queryGymId ?? undefined; + } + + // For other roles, they can only access their own gym + return userGymId ?? undefined; +} + +/** + * Check if the authenticated user is trying to modify their own account + * Useful for preventing self-deletion or self-demotion + */ +export function isSelfModification( + userId: string, + targetUserId: string, +): boolean { + return userId === targetUserId; +} + +/** + * Validate that an admin isn't demoting themselves or deleting their own account + */ +export function preventSelfModification( + userId: string, + targetUserId: string, + action: string, +): NextResponse | undefined { + if (isSelfModification(userId, targetUserId)) { + return NextResponse.json( + { + error: `Cannot ${action} your own account`, + hint: "Ask another administrator to perform this action", + }, + { status: 403 }, + ); + } + return undefined; +} diff --git a/apps/admin/src/lib/auth/permissions.ts b/apps/admin/src/lib/auth/permissions.ts new file mode 100644 index 0000000..907106d --- /dev/null +++ b/apps/admin/src/lib/auth/permissions.ts @@ -0,0 +1,249 @@ +import type { UserRole } from "@fitai/shared"; +import { NextResponse } from "next/server"; + +/** + * Check if a user can access data from a specific gym + * + * @param userRole - The role of the requesting user + * @param userGymId - The gym ID of the requesting user (null for superAdmin) + * @param targetGymId - The gym ID being accessed (undefined means all gyms) + * @returns true if access is allowed + * + * @example + * // SuperAdmin accessing any gym + * canAccessGym("superAdmin", null, "gym_123") // true + * + * // Admin accessing their own gym + * canAccessGym("admin", "gym_abc", "gym_abc") // true + * + * // Admin trying to access other gym + * canAccessGym("admin", "gym_abc", "gym_xyz") // false + */ +export function canAccessGym( + userRole: UserRole, + userGymId: string | null, + targetGymId: string | undefined, +): boolean { + // SuperAdmin can access any gym + if (userRole === "superAdmin") { + return true; + } + + // If no target gym specified, allow access to own gym + if (targetGymId === undefined) { + return true; + } + + // Non-superAdmins must have a gymId + if (!userGymId) { + return false; + } + + // Must match the gym + return userGymId === targetGymId; +} + +/** + * Check if a user can manage (create/update/delete) another user + * Based on role hierarchy and gym membership + * + * @param requesterRole - Role of the user making the request + * @param requesterGymId - Gym ID of the requesting user + * @param targetRole - Role of the user being managed + * @param targetGymId - Gym ID of the user being managed + * @returns true if management is allowed + * + * @example + * // SuperAdmin can manage anyone + * canManageUser("superAdmin", null, "admin", "gym_abc") // true + * + * // Admin can manage trainers and clients in their gym + * canManageUser("admin", "gym_abc", "trainer", "gym_abc") // true + * canManageUser("admin", "gym_abc", "client", "gym_abc") // true + * + * // Admin cannot manage users in other gyms + * canManageUser("admin", "gym_abc", "trainer", "gym_xyz") // false + * + * // Trainer can manage clients in their gym + * canManageUser("trainer", "gym_abc", "client", "gym_abc") // true + */ +export function canManageUser( + requesterRole: UserRole, + requesterGymId: string | null, + targetRole: UserRole, + targetGymId: string | null, +): boolean { + // SuperAdmin can manage anyone + if (requesterRole === "superAdmin") { + return true; + } + + // Role hierarchy: admin > trainer > client + const roleHierarchy: Record = { + superAdmin: 4, + admin: 3, + trainer: 2, + client: 1, + }; + + const requesterLevel = roleHierarchy[requesterRole]; + const targetLevel = roleHierarchy[targetRole]; + + // Cannot manage users at same or higher level + if (requesterLevel <= targetLevel) { + return false; + } + + // Must have gym access + if (!requesterGymId) { + return false; + } + + // For admin/trainer, target must be in same gym + if (requesterRole === "admin" || requesterRole === "trainer") { + return requesterGymId === targetGymId; + } + + return false; +} + +/** + * Check if a trainer can access a specific client's data + * Trainer must be assigned to that client via trainerClients table + * + * @param trainerGymId - Gym ID of the trainer + * @param clientGymId - Gym ID of the client + * @returns true if in same gym + * + * @note In a more complex system, this would query trainerClients table + * For now, we use gym-based access (trainer and client in same gym) + */ +export function canTrainerAccessClient( + trainerGymId: string | null, + clientGymId: string | null, +): boolean { + if (!trainerGymId || !clientGymId) { + return false; + } + return trainerGymId === clientGymId; +} + +/** + * Get the roles that a specific role can invite + * Based on role hierarchy + * + * @param role - The role wanting to invite + * @returns Array of roles that can be invited + * + * @example + * getInvitableRoles("superAdmin") // ["admin", "trainer", "client"] + * getInvitableRoles("admin") // ["trainer", "client"] + * getInvitableRoles("trainer") // ["client"] + * getInvitableRoles("client") // [] + */ +export function getInvitableRoles(role: UserRole): UserRole[] { + const invitationRules: Record = { + superAdmin: ["admin", "trainer", "client"], + admin: ["trainer", "client"], + trainer: ["client"], + client: [], + }; + + return invitationRules[role] ?? []; +} + +/** + * Validate gym access and return error response if denied + * Use this in API routes to enforce gym-based access control + * + * @param request - NextRequest for getting query params + * @param userRole - Current user's role + * @param userGymId - Current user's gym ID + * @param targetGymId - Gym being accessed (optional) + * @returns NextResponse if denied, undefined if allowed + * + * @example + * export async function GET(request: NextRequest) { + * const error = await validateGymAccess(request, role, gymId); + * if (error) return error; + * // ... proceed + * } + */ +export function validateGymAccess( + userRole: UserRole, + userGymId: string | null, + targetGymId?: string, +): NextResponse | undefined { + if (!canAccessGym(userRole, userGymId, targetGymId)) { + return NextResponse.json( + { error: "Forbidden - Cannot access this gym's data" }, + { status: 403 }, + ); + } + return undefined; +} + +/** + * Get filter condition for gym-scoped queries + * Returns appropriate filter based on user role + * + * @param userRole - Current user's role + * @param userGymId - Current user's gym ID + * @returns Filter object or undefined (no filter = all gyms) + * + * @example + * // For superAdmin: returns undefined (no filter) + * getGymFilter("superAdmin", null) // undefined + * + * // For admin: returns { gymId: "gym_abc" } + * getGymFilter("admin", "gym_abc") // { gymId: "gym_abc" } + */ +export function getGymFilter( + userRole: UserRole, + userGymId: string | null, +): { gymId: string } | undefined { + // SuperAdmin sees all gyms + if (userRole === "superAdmin") { + return undefined; + } + + // Other roles filtered by their gym + if (userGymId) { + return { gymId: userGymId }; + } + + // No gym assigned - return filter that matches nothing + return { gymId: "" }; +} + +/** + * Get all gym IDs a user can access + * SuperAdmin gets all gyms, others get only their own + * + * @param userRole - Current user's role + * @param userGymId - Current user's gym ID + * @param allGymIds - List of all gym IDs in system (for superAdmin) + * @returns Array of accessible gym IDs + * + * @example + * getAccessibleGymIds("admin", "gym_abc", ["gym_abc", "gym_xyz"]) + * // Returns: ["gym_abc"] + * + * getAccessibleGymIds("superAdmin", null, ["gym_abc", "gym_xyz"]) + * // Returns: ["gym_abc", "gym_xyz"] + */ +export function getAccessibleGymIds( + userRole: UserRole, + userGymId: string | null, + allGymIds: string[], +): string[] { + if (userRole === "superAdmin") { + return allGymIds; + } + + if (userGymId) { + return [userGymId]; + } + + return []; +} diff --git a/apps/admin/src/lib/gym-context.ts b/apps/admin/src/lib/gym-context.ts new file mode 100644 index 0000000..049b733 --- /dev/null +++ b/apps/admin/src/lib/gym-context.ts @@ -0,0 +1,149 @@ +import { getDatabase } from "@/lib/database"; + +/** + * Get users filtered by gym ID + * + * @param gymId - Gym ID to filter by (undefined returns all users for superAdmin) + * @returns Array of users in the gym + */ +export async function getUsersByGym(gymId?: string) { + const db = await getDatabase(); + const allUsers = await db.getAllUsers(); + + if (!gymId) { + return allUsers; + } + + return allUsers.filter((u) => u.gymId === gymId); +} + +/** + * Get clients filtered by gym ID + * + * @param gymId - Gym ID to filter by + * @returns Array of clients in the gym + */ +export async function getClientsByGym(gymId: string) { + const db = await getDatabase(); + const allUsers = await db.getAllUsers(); + const allClients = await db.getAllClients(); + + const gymClientUserIds = allUsers + .filter((u) => u.gymId === gymId && u.role === "client") + .map((u) => u.id); + + if (gymClientUserIds.length === 0) { + return []; + } + + return allClients.filter((c) => gymClientUserIds.includes(c.userId)); +} + +/** + * Get trainers filtered by gym ID + * + * @param gymId - Gym ID to filter by + * @returns Array of trainers in the gym + */ +export async function getTrainersByGym(gymId: string) { + const db = await getDatabase(); + const allUsers = await db.getAllUsers(); + + return allUsers.filter((u) => u.gymId === gymId && u.role === "trainer"); +} + +/** + * Get attendance filtered by gym ID + * + * @param gymId - Gym ID to filter by + * @returns Array of attendance records in the gym + */ +export async function getAttendanceByGym(gymId: string) { + const db = await getDatabase(); + const allUsers = await db.getAllUsers(); + const allAttendance = await db.getAllAttendance(); + + const gymUserIds = allUsers.filter((u) => u.gymId === gymId).map((u) => u.id); + + if (gymUserIds.length === 0) { + return []; + } + + return allAttendance.filter((a) => gymUserIds.includes(a.userId)); +} + +/** + * Get recommendations filtered by gym ID + * + * @param gymId - Gym ID to filter by + * @returns Array of recommendations in the gym + */ +export async function getRecommendationsByGym(gymId: string) { + const db = await getDatabase(); + const allUsers = await db.getAllUsers(); + const allRecommendations = await db.getAllRecommendations(); + + const gymUserIds = allUsers.filter((u) => u.gymId === gymId).map((u) => u.id); + + if (gymUserIds.length === 0) { + return []; + } + + return allRecommendations.filter((r) => gymUserIds.includes(r.userId)); +} + +/** + * Get fitness profiles filtered by gym ID + * + * @param gymId - Gym ID to filter by + * @returns Array of fitness profiles in the gym + */ +export async function getFitnessProfilesByGym(gymId: string) { + const db = await getDatabase(); + const allUsers = await db.getAllUsers(); + const allProfiles = await db.getAllFitnessProfiles(); + + const gymUserIds = allUsers.filter((u) => u.gymId === gymId).map((u) => u.id); + + if (gymUserIds.length === 0) { + return []; + } + + return allProfiles.filter((p) => gymUserIds.includes(p.userId)); +} + +/** + * Get fitness goals filtered by gym ID + * + * @param gymId - Gym ID to filter by + * @returns Array of fitness goals in the gym + */ +export async function getFitnessGoalsByGym(gymId: string) { + const db = await getDatabase(); + const allUsers = await db.getAllUsers(); + + const gymUserIds = allUsers.filter((u) => u.gymId === gymId).map((u) => u.id); + + if (gymUserIds.length === 0) { + return []; + } + + const allGoals: any[] = []; + for (const userId of gymUserIds) { + const goals = await db.getFitnessGoalsByUserId(userId); + allGoals.push(...goals); + } + + return allGoals.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); +} + +/** + * Get user by ID and check if they belong to a specific gym + */ +export async function getUserGymId(userId: string): Promise { + const db = await getDatabase(); + const user = await db.getUserById(userId); + return user?.gymId ?? null; +} diff --git a/apps/admin/src/lib/migrations/fix-gym-assignments.js b/apps/admin/src/lib/migrations/fix-gym-assignments.js new file mode 100644 index 0000000..8e466b4 --- /dev/null +++ b/apps/admin/src/lib/migrations/fix-gym-assignments.js @@ -0,0 +1,138 @@ +/** + * Migration Script: Fix gym assignments for users without gymId + * + * This script: + * 1. Finds all users with null gymId + * 2. For admins: Assigns them to gym where they are adminUserId + * 3. For trainers: Gets gymId from their trainerClients records + * 4. For clients: Gets gymId from trainerClients or leaves as null for manual review + * + * Run with: node apps/admin/src/lib/migrations/fix-gym-assignments.js + * + * Note: Run this AFTER setting up the database, before starting the app + */ + +const Database = require("better-sqlite3"); + +// Use absolute path to the database - this is where the app stores data +const dbPath = "/home/echo/dev/prototype/apps/admin/data/fitai.db"; + +function fixGymAssignments() { + console.log("Starting gym assignment migration...\n"); + console.log(`Database path: ${dbPath}\n`); + + const db = new Database(dbPath); + + // Step 1: Find all users without gymId + const usersWithoutGym = db + .prepare( + ` + SELECT id, email, role FROM users WHERE gym_id IS NULL + `, + ) + .all(); + + console.log(`Found ${usersWithoutGym.length} users without gymId`); + + let adminsFixed = 0; + let trainersFixed = 0; + let clientsFixed = 0; + const unableToFix = []; + + for (const user of usersWithoutGym) { + try { + if (user.role === "admin") { + // Find gym where this user is admin + const gym = db + .prepare( + ` + SELECT id, name FROM gyms WHERE admin_user_id = ? + `, + ) + .get(user.id); + + if (gym) { + db.prepare( + ` + UPDATE users SET gym_id = ? WHERE id = ? + `, + ).run(gym.id, user.id); + + console.log(` ✓ Fixed admin ${user.email} -> gym ${gym.name}`); + adminsFixed++; + } else { + unableToFix.push(`Admin ${user.email} (no gym found)`); + } + } else if (user.role === "trainer") { + // Get gym from trainerClients + const tc = db + .prepare( + ` + SELECT gym_id FROM trainer_clients WHERE trainer_user_id = ? LIMIT 1 + `, + ) + .get(user.id); + + if (tc) { + db.prepare( + ` + UPDATE users SET gym_id = ? WHERE id = ? + `, + ).run(tc.gym_id, user.id); + + console.log(` ✓ Fixed trainer ${user.email} -> gym ${tc.gym_id}`); + trainersFixed++; + } else { + unableToFix.push(`Trainer ${user.email} (no trainerClients record)`); + } + } else if (user.role === "client") { + // Get gym from trainerClients + const tc = db + .prepare( + ` + SELECT gym_id FROM trainer_clients WHERE client_user_id = ? LIMIT 1 + `, + ) + .get(user.id); + + if (tc) { + db.prepare( + ` + UPDATE users SET gym_id = ? WHERE id = ? + `, + ).run(tc.gym_id, user.id); + + console.log(` ✓ Fixed client ${user.email} -> gym ${tc.gym_id}`); + clientsFixed++; + } else { + unableToFix.push(`Client ${user.email} (no trainer assignment)`); + } + } + } catch (error) { + console.error(` ✗ Error fixing user ${user.email}:`, error.message); + unableToFix.push(`User ${user.email} (error: ${error.message})`); + } + } + + db.close(); + + console.log("\n=== Migration Summary ==="); + console.log(`Admins fixed: ${adminsFixed}`); + console.log(`Trainers fixed: ${trainersFixed}`); + console.log(`Clients fixed: ${clientsFixed}`); + console.log(`Unable to fix: ${unableToFix.length}`); + + if (unableToFix.length > 0) { + console.log("\nUsers requiring manual review:"); + unableToFix.forEach((u) => console.log(` - ${u}`)); + } + + console.log("\nMigration complete!"); +} + +// Run if called directly +if (require.main === module) { + fixGymAssignments(); +} + +module.exports = { fixGymAssignments }; diff --git a/data/fitai.db b/data/fitai.db new file mode 100644 index 0000000..e69de29