Billed Account
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts
index ecc8bb8c2f2..68a3f455080 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts
@@ -1,6 +1,7 @@
export { MemberInvitationCard } from './member-invitation-card/member-invitation-card'
export { NoOrganizationView } from './no-organization-view/no-organization-view'
+export { OrganizationRoster } from './organization-roster/organization-roster'
export { RemoveMemberDialog } from './remove-member-dialog/remove-member-dialog'
-export { TeamMembers } from './team-members/team-members'
export { TeamSeats } from './team-seats/team-seats'
export { TeamSeatsOverview } from './team-seats-overview/team-seats-overview'
+export { TransferOwnershipDialog } from './transfer-ownership-dialog/transfer-ownership-dialog'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/member-invitation-card/member-invitation-card.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/member-invitation-card/member-invitation-card.tsx
index 12b2560eff8..9b7ed4f4a2a 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/member-invitation-card/member-invitation-card.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/member-invitation-card/member-invitation-card.tsx
@@ -1,11 +1,8 @@
'use client'
-import React from 'react'
import { ChevronDown } from 'lucide-react'
import {
Button,
- ButtonGroup,
- ButtonGroupItem,
Checkbox,
DropdownMenu,
DropdownMenuContent,
@@ -14,56 +11,11 @@ import {
TagInput,
type TagItem,
} from '@/components/emcn'
+import { PermissionSelector, type PermissionType } from '@/components/permissions'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { AdminWorkspace } from '@/hooks/queries/workspace'
-type PermissionType = 'read' | 'write' | 'admin'
-
-interface PermissionSelectorProps {
- value: PermissionType
- onChange: (value: PermissionType) => void
- disabled?: boolean
- className?: string
-}
-
-const PermissionSelector = React.memo
(
- ({ value, onChange, disabled = false, className = '' }) => {
- return (
- onChange(val as PermissionType)}
- disabled={disabled}
- className={className}
- >
-
- Read
-
-
- Write
-
-
- Admin
-
-
- )
- }
-)
-
-PermissionSelector.displayName = 'PermissionSelector'
-
interface MemberInvitationCardProps {
inviteEmails: TagItem[]
setInviteEmails: (emails: TagItem[]) => void
@@ -122,9 +74,9 @@ export function MemberInvitationCard({
return (
-
Invite Team Members
+
Invite Members
- Add new members to your team and optionally give them access to specific workspaces
+ Invite people to your organization and choose which workspaces they can access.
@@ -228,6 +180,7 @@ export function MemberInvitationCard({
}
onChange={(permission) => onWorkspaceToggle(workspace.id, permission)}
disabled={isInviting}
+ size='compact'
/>
)}
@@ -241,12 +194,24 @@ export function MemberInvitationCard({
onInviteMember()}
- disabled={!hasValidEmails || isInviting || !hasAvailableSeats}
+ disabled={!hasValidEmails || isInviting || !hasAvailableSeats || selectedCount === 0}
>
- {isInviting ? 'Inviting...' : hasAvailableSeats ? 'Invite' : 'No Seats'}
+ {isInviting
+ ? 'Inviting...'
+ : !hasAvailableSeats
+ ? 'No Seats'
+ : selectedCount === 0
+ ? 'Select Workspace'
+ : 'Invite'}
+ {selectedCount === 0 && (
+
+ Select at least one organization workspace before sending an organization invite.
+
+ )}
+
{invitationError && (
{invitationError instanceof Error && invitationError.message
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx
new file mode 100644
index 00000000000..e448baf11fe
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx
@@ -0,0 +1,753 @@
+'use client'
+
+import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
+import { createLogger } from '@sim/logger'
+import { ChevronDown, ChevronRight, Search } from 'lucide-react'
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+ Badge,
+ Button,
+ Input,
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Tooltip,
+} from '@/components/emcn'
+import {
+ type OrgRole,
+ OrgRoleSelector,
+ PermissionSelector,
+ type PermissionType,
+} from '@/components/permissions'
+import { CREDIT_MULTIPLIER, dollarsToCredits } from '@/lib/billing/credits/conversion'
+import { cn } from '@/lib/core/utils/cn'
+import { getUserColor } from '@/lib/workspaces/colors'
+import type { Member } from '@/lib/workspaces/organization'
+import { useUpdateWorkspacePermissions } from '@/hooks/queries/invitations'
+import {
+ type OrganizationRoster as OrganizationRosterData,
+ type RosterMember,
+ type RosterPendingInvitation,
+ type RosterWorkspaceAccess,
+ useCancelInvitation,
+ useOrganizationMembers,
+ useResendInvitation,
+ useUpdateInvitation,
+ useUpdateOrganizationMemberRole,
+} from '@/hooks/queries/organization'
+
+const logger = createLogger('OrganizationRoster')
+
+interface OrganizationRosterProps {
+ organizationId: string
+ roster: OrganizationRosterData | null | undefined
+ isLoadingRoster: boolean
+ currentUserEmail: string
+ currentUserId: string
+ isAdminOrOwner: boolean
+ onRemoveMember: (member: Member) => void
+ onTransferOwnership?: () => void
+}
+
+function apportionCredits(
+ dollarsByUser: Record,
+ totalCredits: number
+): Record {
+ const entries = Object.entries(dollarsByUser).map(([userId, dollars]) => {
+ const exact = dollars * CREDIT_MULTIPLIER
+ return { userId, floor: Math.floor(exact), remainder: exact - Math.floor(exact) }
+ })
+ let floorSum = entries.reduce((s, e) => s + e.floor, 0)
+ const gap = totalCredits - floorSum
+ entries.sort((a, b) => b.remainder - a.remainder)
+ for (let i = 0; i < gap && i < entries.length; i++) {
+ entries[i].floor += 1
+ floorSum += 1
+ }
+ const result: Record = {}
+ for (const e of entries) result[e.userId] = e.floor
+ return result
+}
+
+function RoleBadge({ role }: { role: string }) {
+ const variant = role === 'owner' ? 'blue-secondary' : 'gray-secondary'
+ return (
+
+ {role.charAt(0).toUpperCase() + role.slice(1)}
+
+ )
+}
+
+function MemberIdentity({
+ name,
+ email,
+ image,
+ userId,
+ trailing,
+}: {
+ name: string
+ email: string
+ image?: string | null
+ userId: string
+ trailing?: React.ReactNode
+}) {
+ return (
+
+
+ {image && }
+
+ {name.charAt(0).toUpperCase()}
+
+
+
+
+ {name}
+ {trailing}
+
+
{email}
+
+
+ )
+}
+
+function ChevronCell({ expanded, onClick }: { expanded: boolean; onClick: () => void }) {
+ return (
+
+ {expanded ? : }
+
+ )
+}
+
+function RosterRowSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function MemberExpandedPanel({
+ userId,
+ memberRole,
+ workspaces,
+ currentUserId,
+ currentUserAdminWorkspaceIds,
+ organizationId,
+}: {
+ userId: string
+ memberRole: string
+ workspaces: RosterWorkspaceAccess[]
+ currentUserId: string
+ currentUserAdminWorkspaceIds: Set
+ organizationId: string
+}) {
+ const updatePermissions = useUpdateWorkspacePermissions()
+ const rowUserIsOrgAdmin = memberRole === 'owner' || memberRole === 'admin'
+
+ const handleChange = async (workspaceId: string, next: PermissionType) => {
+ try {
+ await updatePermissions.mutateAsync({
+ workspaceId,
+ organizationId,
+ updates: [{ userId, permissions: next }],
+ })
+ } catch (error) {
+ logger.error('Failed to update workspace permission', { error, workspaceId, userId })
+ }
+ }
+
+ if (workspaces.length === 0) {
+ return (
+
+ Not a member of any workspace in this organization yet.
+
+ )
+ }
+
+ return (
+
+ {workspaces.map((ws, idx) => {
+ const callerIsWorkspaceAdmin = currentUserAdminWorkspaceIds.has(ws.workspaceId)
+ const wouldDemoteSelf = userId === currentUserId && ws.permission === 'admin'
+ const disabled =
+ rowUserIsOrgAdmin ||
+ !callerIsWorkspaceAdmin ||
+ wouldDemoteSelf ||
+ updatePermissions.isPending
+
+ const selector = (
+
handleChange(ws.workspaceId, next)}
+ disabled={disabled}
+ size='compact'
+ />
+ )
+
+ return (
+ 0 && 'border-[var(--border-1)] border-t'
+ )}
+ >
+
+ {ws.workspaceName}
+
+ {rowUserIsOrgAdmin ? (
+
+
+ {selector}
+
+ User is an organization {memberRole}
+
+ ) : (
+ selector
+ )}
+
+ )
+ })}
+
+ )
+}
+
+function InvitationExpandedPanel({
+ invitationId,
+ grants,
+ currentUserAdminWorkspaceIds,
+ organizationId,
+}: {
+ invitationId: string
+ grants: RosterWorkspaceAccess[]
+ currentUserAdminWorkspaceIds: Set
+ organizationId: string
+}) {
+ const updateInvitation = useUpdateInvitation()
+
+ const handleChange = async (workspaceId: string, next: PermissionType) => {
+ try {
+ await updateInvitation.mutateAsync({
+ orgId: organizationId,
+ invitationId,
+ grants: [{ workspaceId, permission: next }],
+ })
+ } catch (error) {
+ logger.error('Failed to update invitation grant permission', {
+ error,
+ workspaceId,
+ invitationId,
+ })
+ }
+ }
+
+ if (grants.length === 0) {
+ return (
+
+ No workspace access will be granted when this invitation is accepted.
+
+ )
+ }
+
+ return (
+
+ {grants.map((g, idx) => {
+ const callerIsWorkspaceAdmin = currentUserAdminWorkspaceIds.has(g.workspaceId)
+ const disabled = !callerIsWorkspaceAdmin || updateInvitation.isPending
+ return (
+
0 && 'border-[var(--border-1)] border-t'
+ )}
+ >
+
+ {g.workspaceName}
+
+
handleChange(g.workspaceId, next)}
+ disabled={disabled}
+ size='compact'
+ />
+
+ )
+ })}
+
+ )
+}
+
+function matchesQuery(haystacks: Array, q: string): boolean {
+ if (!q) return true
+ const needle = q.trim().toLowerCase()
+ if (!needle) return true
+ return haystacks.some((h) => (h ?? '').toLowerCase().includes(needle))
+}
+
+export function OrganizationRoster({
+ organizationId,
+ roster,
+ isLoadingRoster,
+ currentUserEmail,
+ currentUserId,
+ isAdminOrOwner,
+ onRemoveMember,
+ onTransferOwnership,
+}: OrganizationRosterProps) {
+ const [query, setQuery] = useState('')
+ const [expandedRows, setExpandedRows] = useState>(() => new Set())
+ const [cancellingIds, setCancellingIds] = useState>(() => new Set())
+ const [resendingIds, setResendingIds] = useState>(() => new Set())
+ const [resendCooldowns, setResendCooldowns] = useState>({})
+ const cooldownIntervalsRef = useRef>>(new Map())
+
+ useEffect(() => {
+ const intervals = cooldownIntervalsRef.current
+ return () => {
+ intervals.forEach((interval) => clearInterval(interval))
+ intervals.clear()
+ }
+ }, [])
+
+ const cancelInvitation = useCancelInvitation()
+ const resendInvitation = useResendInvitation()
+
+ const { data: memberUsageResponse, isLoading: isLoadingUsage } =
+ useOrganizationMembers(organizationId)
+
+ const memberUsageData: Record = {}
+ if (memberUsageResponse?.data) {
+ memberUsageResponse.data.forEach(
+ (entry: { userId: string; currentPeriodCost?: number | null }) => {
+ if (entry.currentPeriodCost !== null && entry.currentPeriodCost !== undefined) {
+ memberUsageData[entry.userId] = Number.parseFloat(entry.currentPeriodCost.toString())
+ }
+ }
+ )
+ }
+
+ const rawDollars = Object.values(memberUsageData)
+ const totalCredits = dollarsToCredits(rawDollars.reduce((sum, d) => sum + d, 0))
+ const memberCredits = apportionCredits(memberUsageData, totalCredits)
+
+ const members: RosterMember[] = roster?.members ?? []
+ const pendingInvitations: RosterPendingInvitation[] = roster?.pendingInvitations ?? []
+
+ const currentUserAdminWorkspaceIds = useMemo(() => {
+ if (isAdminOrOwner) {
+ return new Set((roster?.workspaces ?? []).map((ws) => ws.id))
+ }
+ const self = members.find((m) => m.userId === currentUserId)
+ if (!self) return new Set()
+ return new Set(
+ self.workspaces.filter((ws) => ws.permission === 'admin').map((ws) => ws.workspaceId)
+ )
+ }, [isAdminOrOwner, roster?.workspaces, members, currentUserId])
+
+ const canEditRoles = isAdminOrOwner
+
+ const updateMemberRole = useUpdateOrganizationMemberRole()
+ const updateInvitation = useUpdateInvitation()
+
+ const filteredMembers = useMemo(() => {
+ return members.filter((m) => {
+ const workspaceNames = m.workspaces.map((ws) => ws.workspaceName)
+ return matchesQuery([m.name, m.email, ...workspaceNames], query)
+ })
+ }, [members, query])
+
+ const filteredInvitations = useMemo(() => {
+ return pendingInvitations.filter((inv) => {
+ const workspaceNames = inv.workspaces.map((ws) => ws.workspaceName)
+ return matchesQuery([inv.inviteeName, inv.email, ...workspaceNames], query)
+ })
+ }, [pendingInvitations, query])
+
+ const totalFiltered = filteredMembers.length + filteredInvitations.length
+ const totalRows = members.length + pendingInvitations.length
+ const currentUser = members.find((m) => m.userId === currentUserId)
+ const canLeave = currentUser && currentUser.role !== 'owner'
+
+ const toggleRow = (id: string) => {
+ setExpandedRows((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const handleCancelInvitation = async (invitationId: string) => {
+ setCancellingIds((prev) => new Set([...prev, invitationId]))
+ try {
+ await cancelInvitation.mutateAsync({ invitationId, orgId: organizationId })
+ } catch (error) {
+ logger.error('Failed to cancel invitation', { error })
+ } finally {
+ setCancellingIds((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }
+ }
+
+ const handleResendInvitation = async (invitationId: string) => {
+ const secondsLeft = resendCooldowns[invitationId]
+ if (secondsLeft && secondsLeft > 0) return
+
+ setResendingIds((prev) => new Set([...prev, invitationId]))
+ try {
+ await resendInvitation.mutateAsync({ invitationId, orgId: organizationId })
+ setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
+ const existing = cooldownIntervalsRef.current.get(invitationId)
+ if (existing) clearInterval(existing)
+ const interval = setInterval(() => {
+ setResendCooldowns((prev) => {
+ const current = prev[invitationId]
+ if (current === undefined) return prev
+ if (current <= 1) {
+ const next = { ...prev }
+ delete next[invitationId]
+ const tracked = cooldownIntervalsRef.current.get(invitationId)
+ if (tracked) {
+ clearInterval(tracked)
+ cooldownIntervalsRef.current.delete(invitationId)
+ }
+ return next
+ }
+ return { ...prev, [invitationId]: current - 1 }
+ })
+ }, 1000)
+ cooldownIntervalsRef.current.set(invitationId, interval)
+ } catch (error) {
+ logger.error('Failed to resend invitation', { error })
+ } finally {
+ setResendingIds((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }
+ }
+
+ const showEmpty = !isLoadingRoster && totalRows === 0
+ const showNoMatches = !isLoadingRoster && totalRows > 0 && totalFiltered === 0
+
+ return (
+
+
+
+ setQuery(e.target.value)}
+ placeholder='Search by name, email, or workspace'
+ className='pl-9'
+ />
+
+
+ {showEmpty ? (
+
+
No members yet
+
+ Invite someone above to get started.
+
+
+ ) : (
+
+
+
+
+
+ Member
+ Role
+
+ {isAdminOrOwner ? 'Usage' : ''}
+
+
+
+
+ {isLoadingRoster && totalRows === 0
+ ? Array.from({ length: 3 }).map((_, i) => (
+
+ ))
+ : null}
+
+ {showNoMatches && (
+
+
+ No matches for “{query}”
+
+
+ )}
+
+ {filteredMembers.map((m) => {
+ const rowKey = `member-${m.memberId}`
+ const expanded = expandedRows.has(rowKey)
+ const isSelf = m.email === currentUserEmail
+ const credits = memberCredits[m.userId] ?? 0
+ const canRemove = isAdminOrOwner && m.role !== 'owner' && !isSelf
+ const canTransferAndLeave = isSelf && m.role === 'owner' && !!onTransferOwnership
+ return (
+
+
+
+ toggleRow(rowKey)} />
+
+
+ toggleRow(rowKey)}
+ className='w-full text-left'
+ >
+
+
+
+
+ {m.role === 'owner' || !canEditRoles || m.userId === currentUserId ? (
+
+ ) : (
+
+ updateMemberRole
+ .mutateAsync({
+ orgId: organizationId,
+ userId: m.userId,
+ role: next,
+ })
+ .catch((error) =>
+ logger.error('Failed to update member role', { error })
+ )
+ }
+ disabled={updateMemberRole.isPending}
+ />
+ )}
+
+
+
+ {isAdminOrOwner ? (
+ isLoadingUsage ? (
+
+ ) : (
+
+ {credits.toLocaleString()} credits
+
+ )
+ ) : null}
+ {canRemove && (
+ {
+ const legacyMember: Member = {
+ id: m.memberId,
+ role: m.role,
+ user: {
+ id: m.userId,
+ name: m.name,
+ email: m.email,
+ image: m.image,
+ },
+ }
+ onRemoveMember(legacyMember)
+ }}
+ className='h-8'
+ >
+ Remove
+
+ )}
+ {canTransferAndLeave && (
+ onTransferOwnership?.()}
+ className='h-8'
+ >
+ Leave
+
+ )}
+
+
+
+ {expanded && (
+
+
+
+
+
+ )}
+
+ )
+ })}
+
+ {filteredInvitations.map((inv) => {
+ const rowKey = `invite-${inv.id}`
+ const expanded = expandedRows.has(rowKey)
+ const isResending = resendingIds.has(inv.id)
+ const isCancelling = cancellingIds.has(inv.id)
+ const cooldown = resendCooldowns[inv.id] ?? 0
+ const resendDisabled = isResending || cooldown > 0
+ return (
+
+
+
+ toggleRow(rowKey)} />
+
+
+ toggleRow(rowKey)}
+ className='w-full text-left'
+ >
+
+ Pending
+
+ }
+ />
+
+
+
+ {isAdminOrOwner ? (
+
+ updateInvitation
+ .mutateAsync({
+ orgId: organizationId,
+ invitationId: inv.id,
+ role: next,
+ })
+ .catch((error) =>
+ logger.error('Failed to update invitation role', { error })
+ )
+ }
+ disabled={updateInvitation.isPending}
+ />
+ ) : (
+
+ )}
+
+
+ {isAdminOrOwner && (
+
+ handleResendInvitation(inv.id)}
+ disabled={resendDisabled}
+ className='h-8'
+ >
+ {isResending
+ ? 'Sending…'
+ : cooldown > 0
+ ? `Resend (${cooldown}s)`
+ : 'Resend'}
+
+ handleCancelInvitation(inv.id)}
+ disabled={isCancelling}
+ className='h-8'
+ >
+ {isCancelling ? 'Cancelling…' : 'Cancel'}
+
+
+ )}
+
+
+ {expanded && (
+
+
+
+
+
+ )}
+
+ )
+ })}
+
+
+
+ )}
+
+ {canLeave && currentUser && (
+
+
+ onRemoveMember({
+ id: currentUser.memberId,
+ role: currentUser.role,
+ user: {
+ id: currentUser.userId,
+ name: currentUser.name,
+ email: currentUser.email,
+ image: currentUser.image,
+ },
+ })
+ }
+ >
+ Leave Organization
+
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-members/team-members.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-members/team-members.tsx
deleted file mode 100644
index 3424c88223f..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-members/team-members.tsx
+++ /dev/null
@@ -1,360 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@/components/emcn'
-import { CREDIT_MULTIPLIER, dollarsToCredits } from '@/lib/billing/credits/conversion'
-import { getUserColor } from '@/lib/workspaces/colors'
-import type { Invitation, Member, Organization } from '@/lib/workspaces/organization'
-import {
- useCancelInvitation,
- useOrganizationMembers,
- useResendInvitation,
-} from '@/hooks/queries/organization'
-
-const logger = createLogger('TeamMembers')
-
-/**
- * Distributes a known credit total across members so per-member values always sum exactly.
- * Uses the largest-remainder method: floor each share, then give +1 to the entries
- * with the biggest fractional parts until the sum matches the total.
- */
-function apportionCredits(
- dollarsByUser: Record,
- totalCredits: number
-): Record {
- const entries = Object.entries(dollarsByUser).map(([userId, dollars]) => {
- const exact = dollars * CREDIT_MULTIPLIER
- return { userId, floor: Math.floor(exact), remainder: exact - Math.floor(exact) }
- })
-
- let floorSum = entries.reduce((s, e) => s + e.floor, 0)
- const gap = totalCredits - floorSum
-
- entries.sort((a, b) => b.remainder - a.remainder)
- for (let i = 0; i < gap && i < entries.length; i++) {
- entries[i].floor += 1
- floorSum += 1
- }
-
- const result: Record = {}
- for (const e of entries) result[e.userId] = e.floor
- return result
-}
-
-interface TeamMembersProps {
- organization: Organization
- currentUserEmail: string
- isAdminOrOwner: boolean
- onRemoveMember: (member: Member) => void
-}
-
-interface BaseItem {
- id: string
- name: string
- email: string
- avatarInitial: string
- avatarUrl?: string | null
- userId?: string
- usage: string
-}
-
-interface MemberItem extends BaseItem {
- type: 'member'
- role: string
- member: Member
-}
-
-interface InvitationItem extends BaseItem {
- type: 'invitation'
- invitation: Invitation
-}
-
-type TeamMemberItem = MemberItem | InvitationItem
-
-export function TeamMembers({
- organization,
- currentUserEmail,
- isAdminOrOwner,
- onRemoveMember,
-}: TeamMembersProps) {
- const [cancellingInvitations, setCancellingInvitations] = useState>(() => new Set())
- const [resendingInvitations, setResendingInvitations] = useState>(() => new Set())
- const [resentInvitations, setResentInvitations] = useState>(() => new Set())
- const [resendCooldowns, setResendCooldowns] = useState>({})
-
- const { data: memberUsageResponse, isLoading: isLoadingUsage } = useOrganizationMembers(
- organization?.id || ''
- )
-
- const cancelInvitationMutation = useCancelInvitation()
- const resendInvitationMutation = useResendInvitation()
-
- const memberUsageData: Record = {}
- if (memberUsageResponse?.data) {
- memberUsageResponse.data.forEach(
- (member: { userId: string; currentPeriodCost?: number | null }) => {
- if (member.currentPeriodCost !== null && member.currentPeriodCost !== undefined) {
- memberUsageData[member.userId] = Number.parseFloat(member.currentPeriodCost.toString())
- }
- }
- )
- }
-
- const rawDollars = Object.values(memberUsageData)
- const totalCredits = dollarsToCredits(rawDollars.reduce((sum, d) => sum + d, 0))
- const memberCredits = apportionCredits(memberUsageData, totalCredits)
-
- const teamItems: TeamMemberItem[] = []
-
- if (organization.members) {
- organization.members.forEach((member: Member) => {
- const userId = member.user?.id
- const credits = userId ? (memberCredits[userId] ?? 0) : 0
- const name = member.user?.name || 'Unknown'
-
- const memberItem: MemberItem = {
- type: 'member',
- id: member.id,
- name,
- email: member.user?.email || '',
- avatarInitial: name.charAt(0).toUpperCase(),
- avatarUrl: member.user?.image,
- userId: member.user?.id,
- usage: `${credits.toLocaleString()} credits`,
- role: member.role,
- member,
- }
-
- teamItems.push(memberItem)
- })
- }
-
- const pendingInvitations = organization.invitations?.filter(
- (invitation) => invitation.status === 'pending'
- )
- if (pendingInvitations) {
- pendingInvitations.forEach((invitation: Invitation) => {
- const emailPrefix = invitation.email.split('@')[0]
-
- const invitationItem: InvitationItem = {
- type: 'invitation',
- id: invitation.id,
- name: emailPrefix,
- email: invitation.email,
- avatarInitial: emailPrefix.charAt(0).toUpperCase(),
- avatarUrl: null,
- userId: invitation.email,
- usage: '-',
- invitation,
- }
-
- teamItems.push(invitationItem)
- })
- }
-
- if (teamItems.length === 0) {
- return No team members yet.
- }
-
- const currentUserMember = organization.members?.find((m) => m.user?.email === currentUserEmail)
- const canLeaveOrganization =
- currentUserMember && currentUserMember.role !== 'owner' && currentUserMember.user?.id
-
- const handleCancelInvitation = async (invitationId: string) => {
- if (!organization?.id) return
-
- setCancellingInvitations((prev) => new Set([...prev, invitationId]))
- try {
- await cancelInvitationMutation.mutateAsync({
- invitationId,
- orgId: organization.id,
- })
- } catch (error) {
- logger.error('Failed to cancel invitation', { error })
- } finally {
- setCancellingInvitations((prev) => {
- const next = new Set(prev)
- next.delete(invitationId)
- return next
- })
- }
- }
-
- const handleResendInvitation = async (invitationId: string) => {
- if (!organization?.id) return
-
- const secondsLeft = resendCooldowns[invitationId]
- if (secondsLeft && secondsLeft > 0) return
-
- setResendingInvitations((prev) => new Set([...prev, invitationId]))
- try {
- await resendInvitationMutation.mutateAsync({
- invitationId,
- orgId: organization.id,
- })
-
- setResentInvitations((prev) => new Set([...prev, invitationId]))
- setTimeout(() => {
- setResentInvitations((prev) => {
- const next = new Set(prev)
- next.delete(invitationId)
- return next
- })
- }, 4000)
-
- // Start 60s cooldown
- setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
- const interval = setInterval(() => {
- setResendCooldowns((prev) => {
- const current = prev[invitationId]
- if (current === undefined) return prev
- if (current <= 1) {
- const next = { ...prev }
- delete next[invitationId]
- clearInterval(interval)
- return next
- }
- return { ...prev, [invitationId]: current - 1 }
- })
- }, 1000)
- } catch (error) {
- logger.error('Failed to resend invitation', { error })
- } finally {
- setResendingInvitations((prev) => {
- const next = new Set(prev)
- next.delete(invitationId)
- return next
- })
- }
- }
-
- return (
-
- {/* Header */}
-
-
Team Members
-
-
- {/* Members list */}
-
- {teamItems.map((item) => (
-
- {/* Left section: Avatar + Name/Role + Action buttons */}
-
- {/* Avatar */}
-
- {item.avatarUrl && }
-
- {item.avatarInitial}
-
-
-
- {/* Name and email */}
-
-
-
- {item.name}
-
- {item.type === 'member' && (
-
- {item.role.charAt(0).toUpperCase() + item.role.slice(1)}
-
- )}
- {item.type === 'invitation' && (
-
- Pending
-
- )}
-
-
{item.email}
-
-
- {/* Action buttons for members */}
- {isAdminOrOwner &&
- item.type === 'member' &&
- item.role !== 'owner' &&
- item.email !== currentUserEmail && (
-
onRemoveMember(item.member)}
- className='h-8'
- >
- Remove
-
- )}
-
-
- {/* Right section */}
- {isAdminOrOwner && (
-
- {item.type === 'member' ? (
- <>
-
Usage
-
- {isLoadingUsage ? (
-
- ) : (
- item.usage
- )}
-
- >
- ) : (
-
- handleResendInvitation(item.invitation.id)}
- disabled={
- resendingInvitations.has(item.invitation.id) ||
- (resendCooldowns[item.invitation.id] ?? 0) > 0
- }
- className='h-8'
- >
- {resendingInvitations.has(item.invitation.id)
- ? 'Sending...'
- : resendCooldowns[item.invitation.id]
- ? `Resend (${resendCooldowns[item.invitation.id]}s)`
- : 'Resend'}
-
- handleCancelInvitation(item.invitation.id)}
- disabled={cancellingInvitations.has(item.invitation.id)}
- className='h-8'
- >
- {cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
-
-
- )}
-
- )}
-
- ))}
-
-
- {/* Leave Organization button */}
- {canLeaveOrganization && (
-
- {
- if (!currentUserMember?.user?.id) {
- logger.error('Cannot leave organization: missing user ID', { currentUserMember })
- return
- }
- onRemoveMember(currentUserMember)
- }}
- >
- Leave Organization
-
-
- )}
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx
index 82ec25ece76..52f6d11c67a 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx
@@ -1,3 +1,4 @@
+import { useParams, useRouter } from 'next/navigation'
import { Badge, Button, Skeleton } from '@/components/emcn'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { cn } from '@/lib/core/utils/cn'
@@ -18,8 +19,6 @@ interface TeamSeatsOverviewProps {
totalSeats: number
usedSeats: number
isLoading: boolean
- onConfirmTeamUpgrade: (seats: number) => Promise
- onReduceSeats: () => Promise
onAddSeatDialog: () => void
}
@@ -50,10 +49,12 @@ export function TeamSeatsOverview({
totalSeats,
usedSeats,
isLoading,
- onConfirmTeamUpgrade,
- onReduceSeats,
onAddSeatDialog,
}: TeamSeatsOverviewProps) {
+ const router = useRouter()
+ const params = useParams<{ workspaceId: string }>()
+ const workspaceId = params?.workspaceId
+
if (isLoadingSubscription) {
return
}
@@ -64,20 +65,22 @@ export function TeamSeatsOverview({
- No Team Subscription Found
+ No active Team subscription
- Your subscription may need to be transferred to this organization.
+ Purchase a Team plan to invite members and manage seats for this organization.
{
- onConfirmTeamUpgrade(2)
+ if (workspaceId) {
+ router.push(`/workspace/${workspaceId}/settings/subscription`)
+ }
}}
- disabled={isLoading}
+ disabled={isLoading || !workspaceId}
>
- Set Up Team Subscription
+ Go to subscription settings
@@ -85,15 +88,26 @@ export function TeamSeatsOverview({
}
const isEnterprise = checkEnterprisePlan(subscriptionData)
+ const isSeatDataPending = !isEnterprise && totalSeats === 0
+ const isOverLimit = totalSeats > 0 && usedSeats > totalSeats
+ const pillCount = Math.max(totalSeats, usedSeats)
+
+ if (isSeatDataPending) {
+ return