MemberRow.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import { useParams } from 'common'
  2. import { ArrowRight, Check, ChevronRight, User, X } from 'lucide-react'
  3. import Link from 'next/link'
  4. import { useMemo } from 'react'
  5. import {
  6. Badge,
  7. cn,
  8. HoverCard,
  9. HoverCardContent,
  10. HoverCardTrigger,
  11. ScrollArea,
  12. TableCell,
  13. TableRow,
  14. } from 'ui'
  15. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  16. import { isInviteExpired } from '../Organization.utils'
  17. import { MemberActions } from './MemberActions'
  18. import PartnerIcon from '@/components/ui/PartnerIcon'
  19. import { ProfileImage } from '@/components/ui/ProfileImage'
  20. import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
  21. import { OrganizationMember } from '@/data/organizations/organization-members-query'
  22. import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
  23. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  24. import { useProfile } from '@/lib/profile'
  25. interface MemberRowProps {
  26. member: OrganizationMember
  27. }
  28. const MEMBER_ORIGIN_TO_MANAGED_BY = {
  29. vercel: 'vercel-marketplace',
  30. } as const
  31. export const MemberRow = ({ member }: MemberRowProps) => {
  32. const { slug } = useParams()
  33. const { profile } = useProfile()
  34. const { data: selectedOrganization } = useSelectedOrganizationQuery()
  35. const { data: roles, isPending: isLoadingRoles } = useOrganizationRolesV2Query({
  36. slug: selectedOrganization?.slug,
  37. })
  38. const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
  39. const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug })
  40. const orgProjects =
  41. useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
  42. const isInvitedUser = Boolean(member.invited_id)
  43. // Use generic avatar for all team members instead of attempting to fetch from GitHub
  44. const profileImageUrl = undefined
  45. return (
  46. <TableRow>
  47. <TableCell>
  48. <div className="flex items-center gap-x-4">
  49. <ProfileImage
  50. alt={member.primary_email ?? member.username ?? ''}
  51. src={profileImageUrl}
  52. className="border rounded-full w-[32px] h-[32px] md:w-[40px] md:h-[40px]"
  53. placeholder={
  54. <div
  55. className={cn(
  56. 'w-[32px] h-[32px] md:w-[40px] md:h-[40px]',
  57. 'bg-surface-100 border border-overlay rounded-full text-foreground-lighter flex items-center justify-center'
  58. )}
  59. >
  60. <User size={20} strokeWidth={1.5} />
  61. </div>
  62. }
  63. />
  64. <div className="flex item-center gap-x-3">
  65. <p className="text-foreground-light truncate">{member.primary_email}</p>
  66. <div className="flex items-center gap-x-2">
  67. {member.gotrue_id === profile?.gotrue_id && <Badge>You</Badge>}
  68. {isInvitedUser && member.invited_at && (
  69. <Badge variant={isInviteExpired(member.invited_at) ? 'destructive' : 'warning'}>
  70. {isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'}
  71. </Badge>
  72. )}
  73. {member.is_sso_user && <Badge variant="default">SSO</Badge>}
  74. {(member.metadata as any)?.origin && (
  75. <PartnerIcon
  76. organization={{
  77. managed_by:
  78. MEMBER_ORIGIN_TO_MANAGED_BY[
  79. (member.metadata as any).origin as keyof typeof MEMBER_ORIGIN_TO_MANAGED_BY
  80. ] ?? 'briven',
  81. }}
  82. tooltipText="Managed by Vercel Marketplace."
  83. />
  84. )}
  85. </div>
  86. </div>
  87. </div>
  88. </TableCell>
  89. <TableCell>
  90. <div className="flex items-center gap-x-1.5">
  91. {member.mfa_enabled ? (
  92. <>
  93. <span className="text-foreground-lighter">Enabled</span>
  94. <Check className="text-brand" strokeWidth={2} size={16} />
  95. </>
  96. ) : (
  97. <>
  98. <span className="text-foreground-lighter">Disabled</span>
  99. <X className="text-foreground-muted" strokeWidth={1.5} size={16} />
  100. </>
  101. )}
  102. </div>
  103. </TableCell>
  104. <TableCell className="max-w-64">
  105. {isLoadingRoles ? (
  106. <ShimmeringLoader className="w-32" />
  107. ) : (
  108. member.role_ids.map((id) => {
  109. const orgScopedRole = (roles?.org_scoped_roles ?? []).find((role) => role.id === id)
  110. const projectScopedRole = (roles?.project_scoped_roles ?? []).find(
  111. (role) => role.id === id
  112. )
  113. const role = orgScopedRole || projectScopedRole
  114. const roleName = (role?.name ?? '').split('_')[0]
  115. const projectsApplied =
  116. role?.projects.length === 0
  117. ? (orgProjects?.map((p) => p.name) ?? [])
  118. : (role?.projects ?? [])
  119. .map(({ ref }) => orgProjects?.find((p) => p.ref === ref)?.name ?? '')
  120. .filter((x) => x.length > 0)
  121. return (
  122. <div key={`role-${id}`} className="flex items-center gap-x-2">
  123. <p className="text-foreground-light">{roleName}</p>
  124. {hasProjectScopedRoles && (
  125. <>
  126. <ChevronRight className="text-foreground-muted/50" size={14} />
  127. {projectsApplied.length === 1 ? (
  128. <span className="text-foreground-light truncate" title={projectsApplied[0]}>
  129. {projectsApplied[0]}
  130. </span>
  131. ) : (
  132. <HoverCard openDelay={200}>
  133. <HoverCardTrigger asChild>
  134. <span className="text-foreground-light">
  135. {role?.projects.length === 0
  136. ? 'Organization'
  137. : `${projectsApplied.length} project${projectsApplied.length > 1 ? 's' : ''}`}
  138. </span>
  139. </HoverCardTrigger>
  140. <HoverCardContent className="p-0">
  141. <p className="p-2 text-xs">
  142. {roleName} role applies to {projectsApplied.length} project
  143. {projectsApplied.length > 1 ? 's' : ''}
  144. </p>
  145. <div className="border-t flex flex-col py-1">
  146. <ScrollArea
  147. className={cn(projectsApplied.length > 5 ? 'h-[130px]' : '')}
  148. >
  149. {projectsApplied.map((name) => {
  150. const ref = orgProjects?.find((p) => p.name === name)?.ref
  151. return (
  152. <Link
  153. key={name}
  154. href={`/project/${ref}`}
  155. className="px-2 py-1 group hover:bg-surface-300 hover:text-foreground transition flex items-center justify-between"
  156. >
  157. <span className="text-xs truncate max-w-[60%]">{name}</span>
  158. <span className="text-xs text-foreground flex items-center gap-x-1 opacity-0 group-hover:opacity-100 transition">
  159. Go to project
  160. <ArrowRight size={14} />
  161. </span>
  162. </Link>
  163. )
  164. })}
  165. </ScrollArea>
  166. </div>
  167. </HoverCardContent>
  168. </HoverCard>
  169. )}
  170. </>
  171. )}
  172. </div>
  173. )
  174. })
  175. )}
  176. </TableCell>
  177. <TableCell>
  178. <MemberActions member={member} />
  179. </TableCell>
  180. </TableRow>
  181. )
  182. }