diff --git a/app/api/companies/[slug]/members/[userId]/route.ts b/app/api/companies/[slug]/members/[userId]/route.ts index 030b58f9..32163f5b 100644 --- a/app/api/companies/[slug]/members/[userId]/route.ts +++ b/app/api/companies/[slug]/members/[userId]/route.ts @@ -122,7 +122,7 @@ export async function PUT( .eq('id', user.id) .single() - const changedByName = requestingUserProfile?.first_name + const changedByName = requestingUserProfile?.first_name ? `${requestingUserProfile.first_name} ${requestingUserProfile.last_name || ''}`.trim() : 'a team administrator' @@ -130,7 +130,7 @@ export async function PUT( if (memberProfile?.email && oldRole !== role) { const memberName = memberProfile.first_name || memberProfile.email.split('@')[0] const dashboardUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://codeunia.com'}/dashboard/company/${company.slug}` - + const emailContent = getRoleChangeEmail({ memberName, companyName: company.name, @@ -226,14 +226,14 @@ export async function DELETE( ) } - // Check if user is owner (only owners can remove members) + // Check if user is owner or admin (both can remove members) const requestingMember = await companyMemberService.checkMembership(user.id, company.id) - if (!requestingMember || requestingMember.role !== 'owner') { + if (!requestingMember || !['owner', 'admin'].includes(requestingMember.role)) { return NextResponse.json( { success: false, - error: 'Insufficient permissions: Owner role required to remove members', + error: 'Insufficient permissions: Owner or Admin role required to remove members', }, { status: 403 } ) @@ -252,6 +252,17 @@ export async function DELETE( ) } + // Prevent admins from removing owners (only owners can remove owners) + if (requestingMember.role === 'admin' && targetMember.role === 'owner') { + return NextResponse.json( + { + success: false, + error: 'Insufficient permissions: Only owners can remove other owners', + }, + { status: 403 } + ) + } + // Prevent owner from removing themselves if they're the last owner if (userId === user.id && targetMember.role === 'owner') { return NextResponse.json( @@ -277,7 +288,7 @@ export async function DELETE( .eq('id', user.id) .single() - const removedByName = requestingUserProfile?.first_name + const removedByName = requestingUserProfile?.first_name ? `${requestingUserProfile.first_name} ${requestingUserProfile.last_name || ''}`.trim() : 'a team administrator' @@ -287,7 +298,7 @@ export async function DELETE( // Send removal notification email if (memberProfile?.email) { const memberName = memberProfile.first_name || memberProfile.email.split('@')[0] - + const emailContent = getMemberRemovedEmail({ memberName, companyName: company.name, diff --git a/app/api/hackathons/[id]/route.ts b/app/api/hackathons/[id]/route.ts index 48e5112d..2081a2c5 100644 --- a/app/api/hackathons/[id]/route.ts +++ b/app/api/hackathons/[id]/route.ts @@ -16,14 +16,14 @@ export async function GET(request: NextRequest, { params }: RouteContext) { try { const { id } = await params const hackathon = await hackathonsService.getHackathonBySlug(id) - + if (!hackathon) { return NextResponse.json( { error: 'Hackathon not found' }, { status: 404 } ) } - + return NextResponse.json(hackathon) } catch (error) { console.error('Error in GET /api/hackathons/[id]:', error) @@ -39,7 +39,7 @@ export async function PUT(request: NextRequest, { params }: RouteContext) { try { const { id } = await params const hackathonData = await request.json() - + // Check authentication const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -52,8 +52,8 @@ export async function PUT(request: NextRequest, { params }: RouteContext) { } // Get the existing hackathon to check company_id - const existingHackathon = await hackathonsService.getHackathonBySlug(id) - + const existingHackathon = await hackathonsService.getHackathonByIdOrSlug(id) + if (!existingHackathon) { return NextResponse.json( { error: 'Hackathon not found' }, @@ -69,7 +69,7 @@ export async function PUT(request: NextRequest, { params }: RouteContext) { .select('is_admin') .eq('id', user.id) .single() - + if (profile?.is_admin) { isAuthorized = true } @@ -83,7 +83,7 @@ export async function PUT(request: NextRequest, { params }: RouteContext) { .eq('user_id', user.id) .eq('status', 'active') .single() - + if (membership) { isAuthorized = true } @@ -95,9 +95,9 @@ export async function PUT(request: NextRequest, { params }: RouteContext) { { status: 401 } ) } - + const hackathon = await hackathonsService.updateHackathon(id, hackathonData, user.id) - + return NextResponse.json({ hackathon }) } catch (error) { console.error('Error in PUT /api/hackathons/[id]:', error) @@ -112,9 +112,9 @@ export async function PUT(request: NextRequest, { params }: RouteContext) { export async function DELETE(_request: NextRequest, { params }: RouteContext) { try { const { id } = await params - + console.log('🗑️ DELETE request for hackathon:', id) - + // Check authentication const supabase = await createClient() const { data: { user }, error: authError } = await supabase.auth.getUser() @@ -130,8 +130,8 @@ export async function DELETE(_request: NextRequest, { params }: RouteContext) { console.log('✅ User authenticated:', user.id) // Get the existing hackathon to check company_id - const existingHackathon = await hackathonsService.getHackathonBySlug(id) - + const existingHackathon = await hackathonsService.getHackathonByIdOrSlug(id) + if (!existingHackathon) { console.error('❌ Hackathon not found:', id) return NextResponse.json( @@ -150,13 +150,13 @@ export async function DELETE(_request: NextRequest, { params }: RouteContext) { .select('is_admin') .eq('id', user.id) .single() - + if (profile?.is_admin) { isAuthorized = true console.log('✅ User is admin') } - // If not admin, check if user is a member of the company + // If not admin, check if user is a company owner or admin (not editor/viewer) if (!isAuthorized && existingHackathon.company_id) { const { data: membership } = await supabase .from('company_members') @@ -165,28 +165,30 @@ export async function DELETE(_request: NextRequest, { params }: RouteContext) { .eq('user_id', user.id) .eq('status', 'active') .single() - - if (membership) { + + if (membership && ['owner', 'admin'].includes(membership.role)) { isAuthorized = true - console.log('✅ User is company member with role:', membership.role) + console.log('✅ User is company owner/admin with role:', membership.role) + } else if (membership) { + console.log('❌ User has insufficient role:', membership.role) } } if (!isAuthorized) { console.error('❌ User not authorized to delete hackathon') return NextResponse.json( - { error: 'Unauthorized: You must be a company member or admin to delete this hackathon' }, + { error: 'Insufficient permissions: Owner or Admin role required to delete hackathons' }, { status: 403 } ) } - + console.log('🗑️ Attempting to delete hackathon...') await hackathonsService.deleteHackathon(id) console.log('✅ Hackathon deleted successfully') - - return NextResponse.json({ + + return NextResponse.json({ success: true, - message: 'Hackathon deleted successfully' + message: 'Hackathon deleted successfully' }) } catch (error) { console.error('❌ Error in DELETE /api/hackathons/[id]:', error) diff --git a/app/dashboard/company/layout.tsx b/app/dashboard/company/layout.tsx index 8295d6fb..f530bfae 100644 --- a/app/dashboard/company/layout.tsx +++ b/app/dashboard/company/layout.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react' import Link from 'next/link' -import { useParams } from 'next/navigation' +import { useParams, usePathname } from 'next/navigation' import { Button } from '@/components/ui/button' import { CompanySidebar } from '@/components/dashboard/CompanySidebar' import { CompanyHeader } from '@/components/dashboard/CompanyHeader' @@ -35,10 +35,20 @@ export default function CompanyDashboardLayout({ children: React.ReactNode }) { const { user, loading, error } = useAuth() - const { isChecking, isAuthorized } = useRoleProtection('company_member') const params = useParams() + const pathname = usePathname() const companySlug = params?.slug as string | undefined + // Check if this is the accept-invitation page + // Users with pending invitations should be able to access this page + const isAcceptInvitationPage = pathname?.includes('/accept-invitation') ?? false + + // Only apply role protection if NOT on the accept-invitation page + // Skip redirects on accept-invitation page to allow pending users to access it + const { isChecking, isAuthorized } = useRoleProtection('company_member', { + skipRedirect: isAcceptInvitationPage + }) + // Prevent hydration mismatch by using a consistent initial state const [mounted, setMounted] = useState(false) @@ -54,7 +64,7 @@ export default function CompanyDashboardLayout({ ) } - if (loading || isChecking) { + if (loading || (!isAcceptInvitationPage && isChecking)) { return (
@@ -78,7 +88,8 @@ export default function CompanyDashboardLayout({ ) } - if (!user || !isAuthorized) { + // Skip authorization check for accept-invitation page + if (!user || (!isAcceptInvitationPage && !isAuthorized)) { return (
@@ -130,7 +141,7 @@ function CompanyDashboardContent({ useEffect(() => { async function fetchProfile() { if (!user?.id) return - + try { const supabase = createClient() const { data, error } = await supabase @@ -138,7 +149,7 @@ function CompanyDashboardContent({ .select('id, email, first_name, last_name, avatar_url') .eq('id', user.id) .single() - + if (!error && data) { setUserProfile(data) } @@ -146,7 +157,7 @@ function CompanyDashboardContent({ console.error('Error fetching user profile:', error) } } - + fetchProfile() }, [user?.id]) @@ -187,7 +198,7 @@ function CompanyDashboardContent({ // Generate sidebar items with dynamic company slug // Use currentCompany.slug as fallback when companySlug from params is undefined const effectiveSlug = companySlug || currentCompany.slug - + const sidebarItems: SidebarGroupType[] = [ { title: 'Dashboard', diff --git a/components/dashboard/TeamManagement.tsx b/components/dashboard/TeamManagement.tsx index 9c19f493..ac1becc2 100644 --- a/components/dashboard/TeamManagement.tsx +++ b/components/dashboard/TeamManagement.tsx @@ -90,8 +90,9 @@ export function TeamManagement({ // Check if current user can manage team const canManageTeam = ['owner', 'admin'].includes(currentUserRole) const canUpdateRoles = currentUserRole === 'owner' - const canRemoveMembers = currentUserRole === 'owner' - + // Admins can remove members (except owner), owners can remove anyone + const canRemoveMembers = ['owner', 'admin'].includes(currentUserRole) + // Debug logging console.log('TeamManagement - currentUserRole:', currentUserRole) console.log('TeamManagement - canManageTeam:', canManageTeam) @@ -141,7 +142,7 @@ export function TeamManagement({ if (!response.ok) { console.error('Invite error response:', data) - + // Check if it's a team limit error if (data.upgrade_required) { toast.error('Team Member Limit Reached', { @@ -299,8 +300,8 @@ export function TeamManagement({
Team Members - {canManageTeam - ? 'Manage your team members and their roles' + {canManageTeam + ? 'Manage your team members and their roles' : 'View your team members and their roles'}
@@ -411,7 +412,8 @@ export function TeamManagement({ Update Role )} - {canRemoveMembers && ( + {/* Admins can remove members except owner, owners can remove anyone */} + {canRemoveMembers && (currentUserRole === 'owner' || member.role !== 'owner') && ( openRemoveDialog(member)} className="text-red-500 hover:bg-zinc-800 hover:text-red-400" diff --git a/lib/hooks/useRoleProtection.ts b/lib/hooks/useRoleProtection.ts index aa333fe2..9bdaedda 100644 --- a/lib/hooks/useRoleProtection.ts +++ b/lib/hooks/useRoleProtection.ts @@ -4,7 +4,10 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' -export function useRoleProtection(requiredRole: 'student' | 'company_member') { +export function useRoleProtection( + requiredRole: 'student' | 'company_member', + options?: { skipRedirect?: boolean } +) { const router = useRouter() const [isChecking, setIsChecking] = useState(true) const [isAuthorized, setIsAuthorized] = useState(false) @@ -16,7 +19,9 @@ export function useRoleProtection(requiredRole: 'student' | 'company_member') { const { data: { user } } = await supabase.auth.getUser() if (!user) { - router.push('/auth/signin') + if (!options?.skipRedirect) { + router.push('/auth/signin') + } return } @@ -37,20 +42,24 @@ export function useRoleProtection(requiredRole: 'student' | 'company_member') { if (requiredRole === 'company_member') { if (!isCompanyMember) { // User is a student trying to access company routes - router.push('/protected') + if (!options?.skipRedirect) { + router.push('/protected') + } return } } else if (requiredRole === 'student') { if (isCompanyMember) { // User is a company member trying to access student routes // Redirect to their company dashboard with the correct slug - const company = companyMembership.company as unknown as { slug: string } | null - const companySlug = company?.slug - if (companySlug) { - router.push(`/dashboard/company/${companySlug}`) - } else { - // Fallback if slug is not available - router.push('/dashboard/company') + if (!options?.skipRedirect) { + const company = companyMembership.company as unknown as { slug: string } | null + const companySlug = company?.slug + if (companySlug) { + router.push(`/dashboard/company/${companySlug}`) + } else { + // Fallback if slug is not available + router.push('/dashboard/company') + } } return } @@ -59,14 +68,16 @@ export function useRoleProtection(requiredRole: 'student' | 'company_member') { setIsAuthorized(true) } catch (error) { console.error('Error checking role:', error) - router.push('/auth/signin') + if (!options?.skipRedirect) { + router.push('/auth/signin') + } } finally { setIsChecking(false) } } checkRole() - }, [requiredRole, router]) + }, [requiredRole, router, options?.skipRedirect]) return { isChecking, isAuthorized } } \ No newline at end of file diff --git a/lib/services/hackathons.ts b/lib/services/hackathons.ts index 10bbd78c..9c6d43f5 100644 --- a/lib/services/hackathons.ts +++ b/lib/services/hackathons.ts @@ -141,6 +141,39 @@ class HackathonsService { return hackathon } + async getHackathonByIdOrSlug(idOrSlug: string): Promise { + const cacheKey = `hackathon:${idOrSlug}` + const cached = getCachedData(cacheKey) + if (cached) { + return cached as Hackathon + } + + const supabase = await createClient() + + // Check if idOrSlug is a number (ID) or string (slug) + const isNumeric = /^\d+$/.test(idOrSlug) + + const { data: hackathon, error } = await supabase + .from('hackathons') + .select(` + *, + company:companies(*) + `) + .eq(isNumeric ? 'id' : 'slug', isNumeric ? parseInt(idOrSlug) : idOrSlug) + .single() + + if (error) { + if (error.code === 'PGRST116') { + return null // Hackathon not found + } + console.error('Error fetching hackathon:', error) + throw new Error('Failed to fetch hackathon') + } + + setCachedData(cacheKey, hackathon) + return hackathon + } + async getFeaturedHackathons(limit: number = 5) { const cacheKey = `featured_hackathons:${limit}` const cached = getCachedData(cacheKey) @@ -213,14 +246,14 @@ class HackathonsService { } async updateHackathon( - slug: string, + idOrSlug: string, hackathonData: Partial>, userId?: string ): Promise { const supabase = await createClient() - // Get existing hackathon first - const existingHackathon = await this.getHackathonBySlug(slug) + // Get existing hackathon first (handles both ID and slug) + const existingHackathon = await this.getHackathonByIdOrSlug(idOrSlug) if (!existingHackathon) { throw new Error('Hackathon not found') } @@ -245,7 +278,7 @@ class HackathonsService { const { data: hackathon, error } = await supabase .from('hackathons') .update(updatePayload) - .eq('slug', slug) + .eq('id', existingHackathon.id) .select(` *, company:companies(*) @@ -322,15 +355,21 @@ class HackathonsService { return hackathon } - async deleteHackathon(slug: string) { + async deleteHackathon(idOrSlug: string) { const supabase = await createClient() - console.log('🗑️ Deleting hackathon with slug:', slug) + console.log('🗑️ Deleting hackathon with ID or slug:', idOrSlug) + + // Get existing hackathon first to get the ID + const existingHackathon = await this.getHackathonByIdOrSlug(idOrSlug) + if (!existingHackathon) { + throw new Error('Hackathon not found') + } const { data, error } = await supabase .from('hackathons') .delete() - .eq('slug', slug) + .eq('id', existingHackathon.id) .select() if (error) {