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