Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app/api/companies/[slug]/members/[userId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { companyMemberService } from '@/lib/services/company-member-service'
import { CompanyError } from '@/types/company'
import { UnifiedCache } from '@/lib/unified-cache-system'
import { z } from 'zod'
import { getRoleChangeEmail, sendCompanyEmail } from '@/lib/email/company-emails'

// Force Node.js runtime for API routes
export const runtime = 'nodejs'
Expand Down Expand Up @@ -98,12 +99,59 @@ export async function PUT(
)
}

// Store old role for email notification
const oldRole = targetMember.role

// Update member role
const updatedMember = await companyMemberService.updateMemberRole(
targetMember.id,
role
)

// Get member's profile information for email
const { data: memberProfile } = await supabase
.from('profiles')
.select('email, first_name, last_name')
.eq('id', userId)
.single()

// Get requesting user's name for email
const { data: requestingUserProfile } = await supabase
.from('profiles')
.select('first_name, last_name')
.eq('id', user.id)
.single()

const changedByName = requestingUserProfile?.first_name
? `${requestingUserProfile.first_name} ${requestingUserProfile.last_name || ''}`.trim()
: 'a team administrator'

// Send role change notification email
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,
oldRole,
newRole: role,
changedBy: changedByName,
dashboardUrl,
})

// Send email asynchronously (don't wait for it)
console.log(`📧 Sending role change email to ${memberProfile.email}: ${oldRole} → ${role}`)
sendCompanyEmail({
to: memberProfile.email,
subject: emailContent.subject,
html: emailContent.html,
}).catch(error => {
console.error('❌ Failed to send role change email:', error)
// Don't fail the request if email fails
})
}

// Invalidate cache
await UnifiedCache.purgeByTags(['content', 'api'])

Expand Down
63 changes: 48 additions & 15 deletions app/dashboard/company/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from 'lucide-react'
import { useAuth } from '@/lib/hooks/useAuth'
import { useRoleProtection } from '@/lib/hooks/useRoleProtection'
import { createClient } from '@/lib/supabase/client'

export type SidebarGroupType = {
title: string
Expand Down Expand Up @@ -95,37 +96,68 @@ export default function CompanyDashboardLayout({
)
}

const avatar =
user?.user_metadata?.first_name?.[0]?.toUpperCase() ||
user?.email?.[0]?.toUpperCase() ||
'C'
const name = user?.user_metadata?.first_name || user?.email || 'User'
const email = user?.email || 'user@codeunia.com'

return (
<CompanyProvider initialCompanySlug={companySlug}>
<CompanyDashboardContent avatar={avatar} name={name} email={email}>
<CompanyDashboardContent user={user}>
{children}
</CompanyDashboardContent>
</CompanyProvider>
)
}

interface UserProfile {
id: string
email: string
first_name?: string
last_name?: string
avatar_url?: string
}

// Component that uses CompanyContext to conditionally render sidebar
function CompanyDashboardContent({
avatar,
name,
email,
user,
children,
}: {
avatar: string
name: string
email: string
user: { id: string; email?: string; user_metadata?: { first_name?: string } } | null
children: React.ReactNode
}) {
const params = useParams()
const companySlug = params?.slug as string
const { currentCompany, userCompanies, loading, error } = useCompanyContext()
const [userProfile, setUserProfile] = useState<UserProfile | null>(null)

// Fetch user profile data
useEffect(() => {
async function fetchProfile() {
if (!user?.id) return

try {
const supabase = createClient()
const { data, error } = await supabase
.from('profiles')
.select('id, email, first_name, last_name, avatar_url')
.eq('id', user.id)
.single()

if (!error && data) {
setUserProfile(data)
}
} catch (error) {
console.error('Error fetching user profile:', error)
}
}

fetchProfile()
}, [user?.id])

const avatarUrl = userProfile?.avatar_url
const avatarInitial =
userProfile?.first_name?.[0]?.toUpperCase() ||
user?.user_metadata?.first_name?.[0]?.toUpperCase() ||
user?.email?.[0]?.toUpperCase() ||
'C'
const name = userProfile?.first_name || user?.user_metadata?.first_name || user?.email || 'User'
const email = user?.email || 'user@codeunia.com'

// Show loading while company context is loading
if (loading) {
Expand Down Expand Up @@ -213,7 +245,8 @@ function CompanyDashboardContent({

return (
<CompanySidebar
avatar={avatar}
avatarUrl={avatarUrl}
avatarInitial={avatarInitial}
name={name}
email={email}
sidebarItems={sidebarItems}
Expand Down
32 changes: 18 additions & 14 deletions components/dashboard/CompanySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from '@/components/ui/sidebar'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useCompanyContext } from '@/contexts/CompanyContext'

export type SidebarGroupType = {
Expand All @@ -47,7 +48,8 @@ export type SidebarGroupType = {
}

interface CompanySidebarProps {
avatar: React.ReactNode
avatarUrl?: string
avatarInitial: string
name: string
email: string
sidebarItems: SidebarGroupType[]
Expand All @@ -56,13 +58,23 @@ interface CompanySidebarProps {
}

export function CompanySidebar({
avatar,
avatarUrl,
avatarInitial,
name,
email,
sidebarItems,
children,
header,
}: CompanySidebarProps) {
// Avatar component to reuse across mobile and desktop
const AvatarContent = ({ size = 'default' }: { size?: 'default' | 'small' }) => (
<Avatar className={size === 'small' ? 'h-8 w-8' : 'h-10 w-10'}>
<AvatarImage src={avatarUrl} alt={name} />
<AvatarFallback className="bg-gradient-to-br from-primary to-purple-600 text-white font-semibold">
{avatarInitial}
</AvatarFallback>
</Avatar>
)
const [mobileOpen, setMobileOpen] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const closeSidebar = () => setMobileOpen(false)
Expand Down Expand Up @@ -155,9 +167,7 @@ export function CompanySidebar({
variant="ghost"
className="w-full flex items-center gap-3 rounded-xl p-2 hover:bg-purple-700/20 transition-colors"
>
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-purple-600 text-white font-semibold shadow-md">
{avatar}
</div>
<AvatarContent />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold text-white">
{name}
Expand All @@ -177,9 +187,7 @@ export function CompanySidebar({
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-purple-600 text-white font-semibold">
{avatar}
</div>
<AvatarContent size="small" />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-zinc-400">
Expand Down Expand Up @@ -397,9 +405,7 @@ export function CompanySidebar({
className="w-full flex items-center gap-3 rounded-xl p-2 hover:bg-purple-700/20 transition-colors data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
title={collapsed ? name : undefined}
>
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-purple-600 text-white font-semibold shadow-md flex-shrink-0">
{avatar}
</div>
<AvatarContent />
{!collapsed && (
<>
<div className="grid flex-1 text-left text-sm leading-tight min-w-0">
Expand All @@ -423,9 +429,7 @@ export function CompanySidebar({
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-purple-600 text-white font-semibold">
{avatar}
</div>
<AvatarContent size="small" />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-zinc-400">
Expand Down
64 changes: 22 additions & 42 deletions components/dashboard/TeamManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { toast } from 'sonner'
import { Skeleton } from '@/components/ui/skeleton'
import {
UserPlus,
Expand Down Expand Up @@ -86,7 +86,6 @@ export function TeamManagement({
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()

// Check if current user can manage team
const canManageTeam = ['owner', 'admin'].includes(currentUserRole)
Expand All @@ -110,15 +109,11 @@ export function TeamManagement({
setMembers(data.members || [])
} catch (error) {
console.error('Error fetching members:', error)
toast({
title: 'Error',
description: 'Failed to load team members',
variant: 'destructive',
})
toast.error('Failed to load team members')
} finally {
setLoading(false)
}
}, [companySlug, toast])
}, [companySlug])

useEffect(() => {
fetchMembers()
Expand All @@ -127,11 +122,7 @@ export function TeamManagement({
// Handle invite member
const handleInvite = async () => {
if (!inviteEmail) {
toast({
title: 'Error',
description: 'Please enter an email address',
variant: 'destructive',
})
toast.error('Please enter an email address')
return
}

Expand All @@ -150,25 +141,28 @@ export function TeamManagement({

if (!response.ok) {
console.error('Invite error response:', data)
throw new Error(data.error || 'Failed to invite member')

// Check if it's a team limit error
if (data.upgrade_required) {
toast.error('Team Member Limit Reached', {
description: `${data.error} (${data.current_usage}/${data.limit}). Please upgrade your plan to add more members.`,
duration: 6000,
})
} else {
toast.error(data.error || 'Failed to invite member')
}
return
}

toast({
title: 'Success',
description: 'Team member invited successfully',
})
toast.success('Team member invited successfully')

setInviteDialogOpen(false)
setInviteEmail('')
setInviteRole('viewer')
fetchMembers()
} catch (error) {
console.error('Error inviting member:', error)
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to invite member',
variant: 'destructive',
})
toast.error(error instanceof Error ? error.message : 'Failed to invite member')
} finally {
setSubmitting(false)
}
Expand All @@ -195,21 +189,14 @@ export function TeamManagement({
throw new Error(data.error || 'Failed to update role')
}

toast({
title: 'Success',
description: 'Member role updated successfully',
})
toast.success('Member role updated successfully')

setRoleDialogOpen(false)
setSelectedMember(null)
fetchMembers()
} catch (error) {
console.error('Error updating role:', error)
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to update role',
variant: 'destructive',
})
toast.error(error instanceof Error ? error.message : 'Failed to update role')
} finally {
setSubmitting(false)
}
Expand All @@ -234,21 +221,14 @@ export function TeamManagement({
throw new Error(data.error || 'Failed to remove member')
}

toast({
title: 'Success',
description: 'Member removed successfully',
})
toast.success('Member removed successfully')

setRemoveDialogOpen(false)
setSelectedMember(null)
fetchMembers()
} catch (error) {
console.error('Error removing member:', error)
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to remove member',
variant: 'destructive',
})
toast.error(error instanceof Error ? error.message : 'Failed to remove member')
} finally {
setSubmitting(false)
}
Expand Down Expand Up @@ -473,7 +453,7 @@ export function TeamManagement({
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your team. They must have a CodeUnia account.
Send an invitation to join your team. They must have a Codeunia account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
Expand Down
Loading
Loading