From ff12f086cb0b56eaa4ec706efa9529c4c5119aea Mon Sep 17 00:00:00 2001 From: Akshay Date: Sat, 15 Nov 2025 15:49:44 +0530 Subject: [PATCH] feat(company): Implement team member role system and pending invitation handling - Replace 'member' role with 'viewer' role across member management APIs - Add usePendingInvitationRedirect hook for handling pending team invitations - Integrate pending invitation redirect logic into analytics, events, and hackathons pages - Add loading states while checking invitation status on company dashboard pages - Update team invitation email template with improved formatting - Enhance TeamManagement component with role-based access controls - Update company member service to support new viewer role permissions - Fix brand name capitalization from "CodeUnia" to "Codeunia" in invitation messaging - Add comprehensive role documentation in company FAQ - Improve company layout with better context management and loading states --- .../[slug]/members/[userId]/route.ts | 4 +-- .../companies/[slug]/members/invite/route.ts | 4 +-- .../company/[slug]/accept-invitation/page.tsx | 2 +- .../company/[slug]/analytics/page.tsx | 10 +++++++ app/dashboard/company/[slug]/events/page.tsx | 10 +++++++ .../company/[slug]/hackathons/page.tsx | 10 +++++++ app/dashboard/company/[slug]/page.tsx | 4 ++- .../company/[slug]/settings/page.tsx | 10 +++++++ .../company/[slug]/subscription/page.tsx | 12 +++++++- app/dashboard/company/[slug]/team/page.tsx | 4 ++- app/dashboard/company/layout.tsx | 13 +++++++- components/dashboard/TeamManagement.tsx | 18 +++++------ components/help/CompanyFAQ.tsx | 2 +- lib/email/templates/team-invitation.tsx | 2 +- lib/hooks/usePendingInvitationRedirect.ts | 30 +++++++++++++++++++ lib/services/company-member-service.ts | 20 ++++++------- types/company.ts | 4 +-- 17 files changed, 127 insertions(+), 32 deletions(-) create mode 100644 lib/hooks/usePendingInvitationRedirect.ts diff --git a/app/api/companies/[slug]/members/[userId]/route.ts b/app/api/companies/[slug]/members/[userId]/route.ts index d9957c0d..145b47a1 100644 --- a/app/api/companies/[slug]/members/[userId]/route.ts +++ b/app/api/companies/[slug]/members/[userId]/route.ts @@ -11,8 +11,8 @@ export const runtime = 'nodejs' // Validation schema for role update const updateRoleSchema = z.object({ - role: z.enum(['owner', 'admin', 'editor', 'member'], { - errorMap: () => ({ message: 'Role must be owner, admin, editor, or member' }), + role: z.enum(['owner', 'admin', 'editor', 'viewer'], { + errorMap: () => ({ message: 'Role must be owner, admin, editor, or viewer' }), }), }) diff --git a/app/api/companies/[slug]/members/invite/route.ts b/app/api/companies/[slug]/members/invite/route.ts index 1a532189..eca82817 100644 --- a/app/api/companies/[slug]/members/invite/route.ts +++ b/app/api/companies/[slug]/members/invite/route.ts @@ -13,8 +13,8 @@ export const runtime = 'nodejs' // Validation schema for invite request const inviteSchema = z.object({ email: z.string().email('Invalid email address'), - role: z.enum(['admin', 'editor', 'member'], { - errorMap: () => ({ message: 'Role must be admin, editor, or member' }), + role: z.enum(['admin', 'editor', 'viewer'], { + errorMap: () => ({ message: 'Role must be admin, editor, or viewer' }), }), }) diff --git a/app/dashboard/company/[slug]/accept-invitation/page.tsx b/app/dashboard/company/[slug]/accept-invitation/page.tsx index 685cb706..f341dff6 100644 --- a/app/dashboard/company/[slug]/accept-invitation/page.tsx +++ b/app/dashboard/company/[slug]/accept-invitation/page.tsx @@ -199,7 +199,7 @@ export default function AcceptInvitationPage() { <>

- You've been invited to join {companyName} on CodeUnia. + You've been invited to join {companyName} on Codeunia.

By accepting this invitation, you'll be able to collaborate with the team and manage events. diff --git a/app/dashboard/company/[slug]/analytics/page.tsx b/app/dashboard/company/[slug]/analytics/page.tsx index 9b888c48..034af72f 100644 --- a/app/dashboard/company/[slug]/analytics/page.tsx +++ b/app/dashboard/company/[slug]/analytics/page.tsx @@ -9,6 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Skeleton } from '@/components/ui/skeleton' import { AnalyticsCharts } from '@/components/dashboard/AnalyticsCharts' import { useCompanyContext } from '@/contexts/CompanyContext' +import { usePendingInvitationRedirect } from '@/lib/hooks/usePendingInvitationRedirect' import { CompanyAnalytics } from '@/types/company' import { format, subDays, startOfMonth, endOfMonth, subMonths } from 'date-fns' import { CalendarIcon, AlertCircle, TrendingUp } from 'lucide-react' @@ -25,6 +26,7 @@ export default function AnalyticsPage() { const params = useParams() const companySlug = params?.slug as string const { currentCompany, loading: companyLoading } = useCompanyContext() + const isPendingInvitation = usePendingInvitationRedirect() const [analytics, setAnalytics] = useState([]) const [loading, setLoading] = useState(true) @@ -67,6 +69,14 @@ export default function AnalyticsPage() { } }, [currentCompany, fetchAnalytics]) + if (companyLoading || isPendingInvitation) { + return ( +

+
+
+ ) + } + const handlePresetChange = (preset: PresetRange) => { setSelectedPreset(preset) const today = new Date() diff --git a/app/dashboard/company/[slug]/events/page.tsx b/app/dashboard/company/[slug]/events/page.tsx index e46252cf..886d8bca 100644 --- a/app/dashboard/company/[slug]/events/page.tsx +++ b/app/dashboard/company/[slug]/events/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { useCompanyContext } from '@/contexts/CompanyContext' +import { usePendingInvitationRedirect } from '@/lib/hooks/usePendingInvitationRedirect' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -25,6 +26,7 @@ import { export default function CompanyEventsPage() { const { currentCompany, loading: companyLoading } = useCompanyContext() + const isPendingInvitation = usePendingInvitationRedirect() const [events, setEvents] = useState([]) const [loading, setLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') @@ -58,6 +60,14 @@ export default function CompanyEventsPage() { } }, [currentCompany, fetchEvents]) + if (companyLoading || isPendingInvitation) { + return ( +
+
+
+ ) + } + const filteredEvents = events.filter(event => event.title.toLowerCase().includes(searchTerm.toLowerCase()) || event.category.toLowerCase().includes(searchTerm.toLowerCase()) diff --git a/app/dashboard/company/[slug]/hackathons/page.tsx b/app/dashboard/company/[slug]/hackathons/page.tsx index 80f862e7..9da9f887 100644 --- a/app/dashboard/company/[slug]/hackathons/page.tsx +++ b/app/dashboard/company/[slug]/hackathons/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { useCompanyContext } from '@/contexts/CompanyContext' +import { usePendingInvitationRedirect } from '@/lib/hooks/usePendingInvitationRedirect' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -29,6 +30,7 @@ interface Hackathon { export default function CompanyHackathonsPage() { const { currentCompany, loading: companyLoading } = useCompanyContext() + const isPendingInvitation = usePendingInvitationRedirect() const [hackathons, setHackathons] = useState([]) const [loading, setLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') @@ -61,6 +63,14 @@ export default function CompanyHackathonsPage() { } }, [currentCompany, fetchHackathons]) + if (companyLoading || isPendingInvitation) { + return ( +
+
+
+ ) + } + const filteredHackathons = hackathons.filter(hackathon => hackathon.title.toLowerCase().includes(searchTerm.toLowerCase()) || hackathon.category?.toLowerCase().includes(searchTerm.toLowerCase()) diff --git a/app/dashboard/company/[slug]/page.tsx b/app/dashboard/company/[slug]/page.tsx index a37526ab..de1c6c03 100644 --- a/app/dashboard/company/[slug]/page.tsx +++ b/app/dashboard/company/[slug]/page.tsx @@ -2,6 +2,7 @@ import React from 'react' import { useCompanyContext } from '@/contexts/CompanyContext' +import { usePendingInvitationRedirect } from '@/lib/hooks/usePendingInvitationRedirect' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import Link from 'next/link' @@ -20,8 +21,9 @@ import { useSubscription } from '@/hooks/useSubscription' export default function CompanySlugDashboardPage() { const { currentCompany, userRole, loading, error } = useCompanyContext() const { usage } = useSubscription(currentCompany?.slug) + const isPendingInvitation = usePendingInvitationRedirect() - if (loading) { + if (loading || isPendingInvitation) { return (
diff --git a/app/dashboard/company/[slug]/settings/page.tsx b/app/dashboard/company/[slug]/settings/page.tsx index b8409c7b..5800333e 100644 --- a/app/dashboard/company/[slug]/settings/page.tsx +++ b/app/dashboard/company/[slug]/settings/page.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react' import { useCompanyContext } from '@/contexts/CompanyContext' +import { usePendingInvitationRedirect } from '@/lib/hooks/usePendingInvitationRedirect' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -34,6 +35,7 @@ import { useSubscription } from '@/hooks/useSubscription' export default function CompanySettingsPage() { const { currentCompany, userRole, loading: contextLoading, refreshCompany } = useCompanyContext() + const isPendingInvitation = usePendingInvitationRedirect() const { usage } = useSubscription(currentCompany?.slug) const { toast } = useToast() const [loading, setLoading] = useState(false) @@ -103,6 +105,14 @@ export default function CompanySettingsPage() { } }, [currentCompany]) + if (contextLoading || isPendingInvitation) { + return ( +
+
+
+ ) + } + const handleInputChange = (field: string, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })) } diff --git a/app/dashboard/company/[slug]/subscription/page.tsx b/app/dashboard/company/[slug]/subscription/page.tsx index 0f384771..9af77795 100644 --- a/app/dashboard/company/[slug]/subscription/page.tsx +++ b/app/dashboard/company/[slug]/subscription/page.tsx @@ -42,13 +42,23 @@ export default async function SubscriptionPage({ params }: PageProps) { .select('role, status') .eq('company_id', company.id) .eq('user_id', user.id) - .eq('status', 'active') .single() + // Redirect if no membership found if (!membership) { redirect('/dashboard/company') } + // Redirect pending invitations to accept page + if (membership.status === 'pending') { + redirect(`/dashboard/company/${slug}/accept-invitation`) + } + + // Only active members can access subscription + if (membership.status !== 'active') { + redirect('/dashboard/company') + } + // Only owners and admins can manage subscription if (!['owner', 'admin'].includes(membership.role)) { redirect(`/dashboard/company/${slug}`) diff --git a/app/dashboard/company/[slug]/team/page.tsx b/app/dashboard/company/[slug]/team/page.tsx index df4ad246..77d6ac57 100644 --- a/app/dashboard/company/[slug]/team/page.tsx +++ b/app/dashboard/company/[slug]/team/page.tsx @@ -4,6 +4,7 @@ import React from 'react' import { useParams } from 'next/navigation' import { TeamManagement } from '@/components/dashboard/TeamManagement' import { useCompanyContext } from '@/contexts/CompanyContext' +import { usePendingInvitationRedirect } from '@/lib/hooks/usePendingInvitationRedirect' import { Skeleton } from '@/components/ui/skeleton' import { Alert, AlertDescription } from '@/components/ui/alert' import { AlertCircle } from 'lucide-react' @@ -12,8 +13,9 @@ export default function TeamPage() { const params = useParams() const companySlug = params?.slug as string const { currentCompany, userRole, loading, error } = useCompanyContext() + const isPendingInvitation = usePendingInvitationRedirect() - if (loading) { + if (loading || isPendingInvitation) { return (
diff --git a/app/dashboard/company/layout.tsx b/app/dashboard/company/layout.tsx index 690a6e6a..844a781d 100644 --- a/app/dashboard/company/layout.tsx +++ b/app/dashboard/company/layout.tsx @@ -124,7 +124,7 @@ function CompanyDashboardContent({ }) { const params = useParams() const companySlug = params?.slug as string - const { currentCompany, loading, error } = useCompanyContext() + const { currentCompany, userCompanies, loading, error } = useCompanyContext() // Show loading while company context is loading if (loading) { @@ -140,6 +140,17 @@ function CompanyDashboardContent({ return
{children}
} + // Check if user has pending invitation (not yet accepted) + const membership = userCompanies.find( + (uc) => uc.company.slug === companySlug + ) + const isPendingInvitation = membership?.status === 'pending' + + // If user has pending invitation, don't show sidebar (only show invitation page) + if (isPendingInvitation) { + return
{children}
+ } + // Generate sidebar items with dynamic company slug const sidebarItems: SidebarGroupType[] = [ { diff --git a/components/dashboard/TeamManagement.tsx b/components/dashboard/TeamManagement.tsx index 6acffe7e..39912dc3 100644 --- a/components/dashboard/TeamManagement.tsx +++ b/components/dashboard/TeamManagement.tsx @@ -83,8 +83,8 @@ export function TeamManagement({ const [removeDialogOpen, setRemoveDialogOpen] = useState(false) const [selectedMember, setSelectedMember] = useState(null) const [inviteEmail, setInviteEmail] = useState('') - const [inviteRole, setInviteRole] = useState<'admin' | 'editor' | 'member'>('member') - const [newRole, setNewRole] = useState<'owner' | 'admin' | 'editor' | 'member'>('member') + const [inviteRole, setInviteRole] = useState<'admin' | 'editor' | 'viewer'>('viewer') + const [newRole, setNewRole] = useState<'owner' | 'admin' | 'editor' | 'viewer'>('viewer') const [submitting, setSubmitting] = useState(false) const { toast } = useToast() @@ -160,7 +160,7 @@ export function TeamManagement({ setInviteDialogOpen(false) setInviteEmail('') - setInviteRole('member') + setInviteRole('viewer') fetchMembers() } catch (error) { console.error('Error inviting member:', error) @@ -257,7 +257,7 @@ export function TeamManagement({ // Open role dialog const openRoleDialog = (member: CompanyMember) => { setSelectedMember(member) - setNewRole(member.role as 'owner' | 'admin' | 'editor' | 'member') + setNewRole(member.role as 'owner' | 'admin' | 'editor' | 'viewer') setRoleDialogOpen(true) } @@ -291,11 +291,11 @@ export function TeamManagement({ Active ) - case 'invited': + case 'pending': return ( - Invited + Pending ) case 'suspended': @@ -494,13 +494,13 @@ export function TeamManagement({ - Member - View only + Viewer - View only Editor - Create drafts Admin - Full event management

- {inviteRole === 'member' && 'Can view company events and analytics'} + {inviteRole === 'viewer' && 'Can view company events and analytics'} {inviteRole === 'editor' && 'Can create draft events'} {inviteRole === 'admin' && 'Can create, edit, and manage events'}

@@ -543,7 +543,7 @@ export function TeamManagement({ Owner - Full control Admin - Full event management Editor - Create drafts - Member - View only + Viewer - View only
diff --git a/components/help/CompanyFAQ.tsx b/components/help/CompanyFAQ.tsx index 7986cb4f..624e08b2 100644 --- a/components/help/CompanyFAQ.tsx +++ b/components/help/CompanyFAQ.tsx @@ -91,7 +91,7 @@ const faqData: FAQItem[] = [ { category: 'Team Management', question: 'Can team members see each other\'s activity?', - answer: 'Owners and Admins can see all team activity. Editors and Members see limited activity relevant to their role.', + answer: 'Owners and Admins can see all team activity. Editors and Viewers see limited activity relevant to their role.', }, { category: 'Team Management', diff --git a/lib/email/templates/team-invitation.tsx b/lib/email/templates/team-invitation.tsx index 7c077f90..452fbab2 100644 --- a/lib/email/templates/team-invitation.tsx +++ b/lib/email/templates/team-invitation.tsx @@ -140,7 +140,7 @@ export const getTeamInvitationEmail = (params: TeamInvitationParams) => { ` return { - subject: `You've been invited to join ${params.companyName} on CodeUnia`, + subject: `You've been invited to join ${params.companyName} on Codeunia`, html: getEmailTemplate(content), } } diff --git a/lib/hooks/usePendingInvitationRedirect.ts b/lib/hooks/usePendingInvitationRedirect.ts new file mode 100644 index 00000000..809cb00e --- /dev/null +++ b/lib/hooks/usePendingInvitationRedirect.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useCompanyContext } from '@/contexts/CompanyContext' + +/** + * Hook to redirect users with pending invitations to the accept-invitation page + * This prevents unauthorized access to company pages before accepting the invitation + */ +export function usePendingInvitationRedirect() { + const router = useRouter() + const { currentCompany, userCompanies, loading } = useCompanyContext() + + useEffect(() => { + if (loading || !currentCompany) return + + const membership = userCompanies.find( + (uc) => uc.company.slug === currentCompany.slug + ) + + if (membership?.status === 'pending') { + router.push(`/dashboard/company/${currentCompany.slug}/accept-invitation`) + } + }, [currentCompany, userCompanies, loading, router]) + + // Return whether the user has a pending invitation + const membership = userCompanies.find( + (uc) => uc.company?.slug === currentCompany?.slug + ) + return membership?.status === 'pending' +} diff --git a/lib/services/company-member-service.ts b/lib/services/company-member-service.ts index 6295a263..92c3acc5 100644 --- a/lib/services/company-member-service.ts +++ b/lib/services/company-member-service.ts @@ -44,15 +44,15 @@ class CompanyMemberService { async inviteMember( companyId: string, email: string, - role: 'admin' | 'editor' | 'member', + role: 'admin' | 'editor' | 'viewer', invitedBy: string ): Promise { const supabase = await createClient() // Validate role - if (!['admin', 'editor', 'member'].includes(role)) { + if (!['admin', 'editor', 'viewer'].includes(role)) { throw new CompanyError( - 'Invalid role. Must be admin, editor, or member', + 'Invalid role. Must be admin, editor, or viewer', CompanyErrorCodes.UNAUTHORIZED, 400 ) @@ -233,14 +233,14 @@ class CompanyMemberService { */ async updateMemberRole( memberId: string, - role: 'owner' | 'admin' | 'editor' | 'member' + role: 'owner' | 'admin' | 'editor' | 'viewer' ): Promise { const supabase = await createClient() // Validate role - if (!['owner', 'admin', 'editor', 'member'].includes(role)) { + if (!['owner', 'admin', 'editor', 'viewer'].includes(role)) { throw new CompanyError( - 'Invalid role. Must be owner, admin, editor, or member', + 'Invalid role. Must be owner, admin, editor, or viewer', CompanyErrorCodes.UNAUTHORIZED, 400 ) @@ -585,7 +585,7 @@ class CompanyMemberService { view_analytics: true, edit_company: false, }, - member: { + viewer: { manage_events: false, manage_team: false, view_analytics: true, @@ -593,7 +593,7 @@ class CompanyMemberService { }, } - const permissions = rolePermissions[member.role] || rolePermissions.member + const permissions = rolePermissions[member.role] || rolePermissions.viewer return permissions[permission] || false } @@ -643,7 +643,7 @@ class CompanyMemberService { await sendEmail({ to: email, - subject: `You've been invited to join ${companyName} on CodeUnia`, + subject: `You've been invited to join ${companyName} on Codeunia`, html: emailContent, }) } @@ -691,7 +691,7 @@ class CompanyMemberService {

- You've been invited to join ${params.companyName} on CodeUnia as a ${params.role}. + You've been invited to join ${params.companyName} on Codeunia as a ${params.role}.

diff --git a/types/company.ts b/types/company.ts index b28b4210..4ddaea5b 100644 --- a/types/company.ts +++ b/types/company.ts @@ -63,9 +63,9 @@ export interface CompanyMember { id: string company_id: string user_id: string - role: 'owner' | 'admin' | 'editor' | 'member' + role: 'owner' | 'admin' | 'editor' | 'viewer' permissions?: Record - status: 'active' | 'invited' | 'suspended' + status: 'active' | 'pending' | 'suspended' invited_by?: string invited_at?: string joined_at: string