ProjectAccessSection.tsx195 lines · main
| 1 | import Link from 'next/link' |
| 2 | import { |
| 3 | Badge, |
| 4 | Button, |
| 5 | Card, |
| 6 | CardContent, |
| 7 | Table, |
| 8 | TableBody, |
| 9 | TableCell, |
| 10 | TableHead, |
| 11 | TableHeader, |
| 12 | TableRow, |
| 13 | } from 'ui' |
| 14 | import { |
| 15 | PageSection, |
| 16 | PageSectionContent, |
| 17 | PageSectionMeta, |
| 18 | PageSectionSummary, |
| 19 | PageSectionTitle, |
| 20 | } from 'ui-patterns/PageSection' |
| 21 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 22 | |
| 23 | import { summarizeProjectAccess } from './General.utils' |
| 24 | import AlertError from '@/components/ui/AlertError' |
| 25 | import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query' |
| 26 | import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query' |
| 27 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 28 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 29 | import { useProfile } from '@/lib/profile' |
| 30 | |
| 31 | export const ProjectAccessSection = () => { |
| 32 | const { data: project } = useSelectedProjectQuery() |
| 33 | const { data: organization } = useSelectedOrganizationQuery() |
| 34 | const { profile } = useProfile() |
| 35 | |
| 36 | const isBranch = Boolean(project?.parent_project_ref) |
| 37 | const projectRef = project?.parent_project_ref ?? project?.ref |
| 38 | |
| 39 | const { |
| 40 | data: organizationMembers = [], |
| 41 | error: organizationMembersError, |
| 42 | isPending: isLoadingOrganizationMembers, |
| 43 | isError: isErrorOrganizationMembers, |
| 44 | } = useOrganizationMembersQuery( |
| 45 | { slug: organization?.slug }, |
| 46 | { |
| 47 | enabled: !!organization?.slug, |
| 48 | } |
| 49 | ) |
| 50 | const { |
| 51 | data: organizationRoles, |
| 52 | error: organizationRolesError, |
| 53 | isPending: isLoadingOrganizationRoles, |
| 54 | isError: isErrorOrganizationRoles, |
| 55 | } = useOrganizationRolesV2Query( |
| 56 | { slug: organization?.slug }, |
| 57 | { |
| 58 | enabled: !!organization?.slug, |
| 59 | } |
| 60 | ) |
| 61 | |
| 62 | const userMemberData = organizationMembers.find( |
| 63 | (member) => member.gotrue_id === profile?.gotrue_id |
| 64 | ) |
| 65 | const orgScopedRoleIds = new Set( |
| 66 | (organizationRoles?.org_scoped_roles ?? []).map((role) => role.id) |
| 67 | ) |
| 68 | const hasProjectScopedRoles = (organizationRoles?.project_scoped_roles ?? []).length > 0 |
| 69 | const isOrgScopedRole = (userMemberData?.role_ids ?? []).some((roleId) => |
| 70 | orgScopedRoleIds.has(roleId) |
| 71 | ) |
| 72 | const hasLimitedVisibility = hasProjectScopedRoles && !isOrgScopedRole |
| 73 | |
| 74 | const { |
| 75 | visibleMembers, |
| 76 | hiddenMembersCount, |
| 77 | projectMemberCount, |
| 78 | organizationMemberCount, |
| 79 | shouldShowOrgComparison, |
| 80 | hasOrganizationWideAccess, |
| 81 | } = summarizeProjectAccess({ |
| 82 | organizationMembers, |
| 83 | roles: organizationRoles, |
| 84 | projectRef, |
| 85 | hasLimitedVisibility, |
| 86 | currentUserId: profile?.gotrue_id, |
| 87 | }) |
| 88 | |
| 89 | const isLoadingProjectAccess = isLoadingOrganizationMembers || isLoadingOrganizationRoles |
| 90 | const isErrorProjectAccess = isErrorOrganizationMembers || isErrorOrganizationRoles |
| 91 | const projectAccessError = organizationMembersError ?? organizationRolesError |
| 92 | |
| 93 | if (isBranch) return null |
| 94 | |
| 95 | const projectAccessTitle = hasLimitedVisibility |
| 96 | ? 'You have limited visibility in this organization' |
| 97 | : shouldShowOrgComparison && hasOrganizationWideAccess |
| 98 | ? 'Organization-wide access' |
| 99 | : 'Restricted project access' |
| 100 | |
| 101 | const projectAccessDescription = hasLimitedVisibility |
| 102 | ? 'Your access is limited to specific projects, so you can’t see all members or settings.' |
| 103 | : shouldShowOrgComparison |
| 104 | ? hasOrganizationWideAccess |
| 105 | ? `All ${organizationMemberCount} organization members can access this project.` |
| 106 | : `${projectMemberCount} of ${organizationMemberCount} organization members can access this project.` |
| 107 | : `${projectMemberCount} project member${projectMemberCount === 1 ? '' : 's'} currently ${projectMemberCount === 1 ? 'has' : 'have'} access.` |
| 108 | |
| 109 | return ( |
| 110 | <PageSection> |
| 111 | <PageSectionMeta> |
| 112 | <PageSectionSummary> |
| 113 | <PageSectionTitle>Project access</PageSectionTitle> |
| 114 | </PageSectionSummary> |
| 115 | </PageSectionMeta> |
| 116 | <PageSectionContent> |
| 117 | {isErrorProjectAccess ? ( |
| 118 | <AlertError error={projectAccessError} subject="Failed to retrieve project members" /> |
| 119 | ) : ( |
| 120 | <Card> |
| 121 | {isLoadingProjectAccess ? ( |
| 122 | <CardContent> |
| 123 | <GenericSkeletonLoader /> |
| 124 | </CardContent> |
| 125 | ) : ( |
| 126 | <> |
| 127 | <CardContent className="flex flex-col gap-4"> |
| 128 | <div className="flex flex-col @lg:flex-row @lg:items-center @lg:justify-between gap-3"> |
| 129 | <div> |
| 130 | <p className="text-sm">{projectAccessTitle}</p> |
| 131 | <p className="text-sm text-foreground-light">{projectAccessDescription}</p> |
| 132 | </div> |
| 133 | {!!organization?.slug && ( |
| 134 | <Button asChild type="default"> |
| 135 | <Link href={`/org/${organization.slug}/team`}> |
| 136 | {hasLimitedVisibility ? 'View team' : 'Manage members'} |
| 137 | </Link> |
| 138 | </Button> |
| 139 | )} |
| 140 | </div> |
| 141 | </CardContent> |
| 142 | |
| 143 | {visibleMembers.length > 0 ? ( |
| 144 | <CardContent className="p-0"> |
| 145 | <Table> |
| 146 | <TableHeader> |
| 147 | <TableRow> |
| 148 | <TableHead>Member</TableHead> |
| 149 | <TableHead className="w-[180px]">Role</TableHead> |
| 150 | </TableRow> |
| 151 | </TableHeader> |
| 152 | <TableBody> |
| 153 | {visibleMembers.map((member) => ( |
| 154 | <TableRow key={member.id}> |
| 155 | <TableCell className="align-top"> |
| 156 | <div className="flex items-center gap-2"> |
| 157 | <p className="text-sm text-foreground break-all">{member.email}</p> |
| 158 | {member.id === profile?.gotrue_id && ( |
| 159 | <Badge variant="default">You</Badge> |
| 160 | )} |
| 161 | </div> |
| 162 | </TableCell> |
| 163 | <TableCell className="align-top text-sm text-foreground-light w-[180px]"> |
| 164 | {member.role ?? ''} |
| 165 | </TableCell> |
| 166 | </TableRow> |
| 167 | ))} |
| 168 | {hiddenMembersCount > 0 && ( |
| 169 | <TableRow className="[&>td]:hover:bg-inherit"> |
| 170 | <TableCell colSpan={2}> |
| 171 | <p className="text-sm text-foreground-lighter"> |
| 172 | +{hiddenMembersCount} more project member |
| 173 | {hiddenMembersCount === 1 ? '' : 's'} |
| 174 | </p> |
| 175 | </TableCell> |
| 176 | </TableRow> |
| 177 | )} |
| 178 | </TableBody> |
| 179 | </Table> |
| 180 | </CardContent> |
| 181 | ) : ( |
| 182 | <CardContent className="pt-0"> |
| 183 | <p className="text-sm text-foreground-lighter"> |
| 184 | No visible project members in your current access scope. |
| 185 | </p> |
| 186 | </CardContent> |
| 187 | )} |
| 188 | </> |
| 189 | )} |
| 190 | </Card> |
| 191 | )} |
| 192 | </PageSectionContent> |
| 193 | </PageSection> |
| 194 | ) |
| 195 | } |