MembersView.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import { useParams } from 'common'
  2. import { partition } from 'lodash'
  3. import { AlertCircle } from 'lucide-react'
  4. import { useMemo } from 'react'
  5. import {
  6. Card,
  7. Loading,
  8. Table,
  9. TableBody,
  10. TableCell,
  11. TableFooter,
  12. TableHead,
  13. TableHeader,
  14. TableRow,
  15. } from 'ui'
  16. import { Admonition } from 'ui-patterns'
  17. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  18. import { MemberRow } from './MemberRow'
  19. import AlertError from '@/components/ui/AlertError'
  20. import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
  21. import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
  22. import { useProfile } from '@/lib/profile'
  23. export interface MembersViewProps {
  24. searchString: string
  25. }
  26. const MembersView = ({ searchString }: MembersViewProps) => {
  27. const { slug } = useParams()
  28. const { profile } = useProfile()
  29. const {
  30. data: members = [],
  31. error: membersError,
  32. isPending: isLoadingMembers,
  33. isError: isErrorMembers,
  34. isSuccess: isSuccessMembers,
  35. } = useOrganizationMembersQuery({ slug })
  36. const {
  37. data: roles,
  38. error: rolesError,
  39. isSuccess: isSuccessRoles,
  40. isError: isErrorRoles,
  41. } = useOrganizationRolesV2Query({
  42. slug,
  43. })
  44. const filteredMembers = useMemo(() => {
  45. return !searchString
  46. ? members
  47. : members.filter((member) => {
  48. if (member.invited_at) {
  49. return member.primary_email?.includes(searchString)
  50. }
  51. if (member.gotrue_id) {
  52. return (
  53. member.username.includes(searchString) || member.primary_email?.includes(searchString)
  54. )
  55. }
  56. return false
  57. })
  58. }, [members, searchString])
  59. const [[user], _otherMembers] = partition(
  60. filteredMembers,
  61. (m) => m.gotrue_id === profile?.gotrue_id
  62. )
  63. const userMember = members.find((m) => m.gotrue_id === profile?.gotrue_id)
  64. const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
  65. const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
  66. // [Joshen] Temp wait on API level changes but I think it makes sense to hide invites for
  67. // project scoped users since they can't see other members to begin with. Not a security issue nonetheless
  68. const otherMembers = isOrgScopedRole
  69. ? _otherMembers
  70. : _otherMembers.filter((x) => !('invited_id' in x))
  71. const sortedMembers = otherMembers.sort((a, b) =>
  72. (a.primary_email ?? '').localeCompare(b.primary_email ?? '')
  73. )
  74. return (
  75. <>
  76. {isLoadingMembers && <GenericSkeletonLoader />}
  77. {isErrorMembers && (
  78. <AlertError error={membersError} subject="Failed to retrieve organization members" />
  79. )}
  80. {isErrorRoles && (
  81. <AlertError error={rolesError} subject="Failed to retrieve organization roles" />
  82. )}
  83. {isSuccessMembers && (
  84. <div className="rounded-sm w-full overflow-hidden overflow-x-scroll">
  85. <Card>
  86. <Loading active={!filteredMembers}>
  87. <Table>
  88. <TableHeader>
  89. <TableRow>
  90. <TableHead key="header-user">Member</TableHead>
  91. <TableHead key="header-mfa">MFA</TableHead>
  92. <TableHead key="header-role">Role</TableHead>
  93. <TableHead key="header-action" />
  94. </TableRow>
  95. </TableHeader>
  96. <TableBody>
  97. {[
  98. ...(isSuccessRoles && isSuccessMembers && !isOrgScopedRole
  99. ? [
  100. <TableRow key="project-scope-notice">
  101. <TableCell colSpan={12} className="p-0!">
  102. <Admonition
  103. type="note"
  104. title="You have limited visibility in this organization"
  105. description="Your access is limited to specific projects, so you can’t see all members or settings."
  106. className="border-0 rounded-none"
  107. />
  108. </TableCell>
  109. </TableRow>,
  110. ]
  111. : []),
  112. ...(!!user ? [<MemberRow key={user.gotrue_id} member={user} />] : []),
  113. ...sortedMembers.map((member) => (
  114. <MemberRow key={member.gotrue_id} member={member} />
  115. )),
  116. ...(searchString.length > 0 && filteredMembers.length === 0
  117. ? [
  118. <TableRow key="no-results" className="bg-panel-secondary-light">
  119. <TableCell colSpan={12}>
  120. <div className="flex items-center space-x-3 opacity-75">
  121. <AlertCircle size={16} strokeWidth={2} />
  122. <p className="text-foreground-light">
  123. No members matched the search query "{searchString}"
  124. </p>
  125. </div>
  126. </TableCell>
  127. </TableRow>,
  128. ]
  129. : []),
  130. ]}
  131. </TableBody>
  132. <TableFooter className="font-normal">
  133. <TableRow className="border-b-0 [&>td]:hover:bg-inherit">
  134. <TableCell colSpan={4} className="text-foreground-muted">
  135. {searchString
  136. ? `${filteredMembers.length} of ${members.length} ${members.length === 1 ? 'member' : 'members'}`
  137. : `${members.length || 0} ${members.length === 1 ? 'member' : 'members'}`}
  138. </TableCell>
  139. </TableRow>
  140. </TableFooter>
  141. </Table>
  142. </Loading>
  143. </Card>
  144. </div>
  145. )}
  146. </>
  147. )
  148. }
  149. export default MembersView