Skip to content

Commit

Permalink
feat(admin): Org Admin permissions - billing leader and team lead per…
Browse files Browse the repository at this point in the history
…missions (#9195)

* WIP feat(admin): First pass on Org Admin permissions - org perms

* Org Admin - team lead perms

* Check super-user when demoting org admin

* Fix options in org member view

* Handle bad orgId + userId inputs

* CR: Simplify logic + add comments

* Include love@ email address for contacting support
  • Loading branch information
jmtaber129 committed Dec 6, 2023
1 parent 1c4d9d1 commit fb05fdd
Show file tree
Hide file tree
Showing 34 changed files with 236 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import MenuItemLabel from '../MenuItemLabel'
interface Props {
isLead: boolean
isViewerLead: boolean
isViewerOrgAdmin: boolean
teamMember: TeamMemberAvatarMenu_teamMember$key
menuProps: MenuProps
handleNavigate?: () => void
Expand All @@ -27,6 +28,7 @@ const StyledLabel = styled(MenuItemLabel)({
const TeamMemberAvatarMenu = (props: Props) => {
const {
isViewerLead,
isViewerOrgAdmin,
teamMember: teamMemberRef,
menuProps,
togglePromote,
Expand All @@ -48,17 +50,18 @@ const TeamMemberAvatarMenu = (props: Props) => {
const {preferredName, userId} = teamMember
const {viewerId} = atmosphere
const isSelf = userId === viewerId
const isViewerTeamAdmin = isViewerLead || isViewerOrgAdmin

return (
<Menu ariaLabel={'Select what to do with this team member'} {...menuProps}>
{isViewerLead && !isSelf && (
{isViewerTeamAdmin && (!isSelf || !isViewerLead) && (
<MenuItem
key='promote'
onClick={togglePromote}
label={<StyledLabel>Promote {preferredName} to Team Lead</StyledLabel>}
/>
)}
{isViewerLead && !isSelf && (
{isViewerTeamAdmin && !isSelf && (
<MenuItem
key='remove'
onClick={toggleRemove}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const ManageTeamList = (props: Props) => {
graphql`
fragment ManageTeamList_team on Team {
isLead
isOrgAdmin
teamMembers(sortBy: "preferredName") {
id
preferredName
Expand All @@ -35,14 +36,15 @@ const ManageTeamList = (props: Props) => {
`,
props.team
)
const {isLead: isViewerLead, teamMembers} = team
const {isLead: isViewerLead, isOrgAdmin: isViewerOrgAdmin, teamMembers} = team
return (
<List>
{teamMembers.map((teamMember) => {
return (
<ManageTeamMember
key={teamMember.id}
isViewerLead={isViewerLead}
isViewerOrgAdmin={isViewerOrgAdmin}
manageTeamMemberId={manageTeamMemberId}
teamMember={teamMember}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ const TeamMemberAvatarMenu = lazyPreload(

interface Props {
isViewerLead: boolean
isViewerOrgAdmin: boolean
manageTeamMemberId?: string | null
teamMember: ManageTeamMember_teamMember$key
}

const ManageTeamMember = (props: Props) => {
const {isViewerLead, manageTeamMemberId} = props
const {isViewerLead, isViewerOrgAdmin, manageTeamMemberId} = props
const teamMember = useFragment(
graphql`
fragment ManageTeamMember_teamMember on TeamMember {
Expand All @@ -88,19 +89,34 @@ const ManageTeamMember = (props: Props) => {
...RemoveTeamMemberModal_teamMember
id
isLead
isOrgAdmin
preferredName
picture
userId
}
`,
props.teamMember
)
const {id: teamMemberId, isLead, preferredName, picture, userId} = teamMember
const {id: teamMemberId, isLead, isOrgAdmin, preferredName, picture, userId} = teamMember
const atmosphere = useAtmosphere()
const {viewerId} = atmosphere
const isSelf = userId === viewerId
const isSelectedAvatar = manageTeamMemberId === teamMemberId
const showMenuButton = (isViewerLead && !isSelf) || (!isViewerLead && isSelf)
// Team management permissions:
// * Org admin can do anything, including promote themselves to team lead, and remove non-lead
// team members
// * Team leads can do anything, except manage org admins
// * Non-lead non-admins can only leave the team
// Show the menu iff:
// 1. Viewer is an admin, and the user is not a lead (viewer can promote them a lead, or remove
// from team).
// 2. Viewer is a lead, and user is not the viewer, and not an admin (viewer can promote to lead,
// or remove from team).
// 3. User is the viewer, and the user is not a lead (can leave team).
const showMenuButton =
(isViewerOrgAdmin && !isLead) ||
(isViewerLead && !isSelf && !isOrgAdmin) ||
(!isViewerLead && isSelf)
const {
closePortal: closePromote,
togglePortal: togglePromote,
Expand All @@ -121,7 +137,11 @@ const ManageTeamMember = (props: Props) => {
<Avatar size={24} picture={picture} />
<Content>
<Name>{preferredName}</Name>
<TeamLeadLabel isLead={isLead}>Team Lead</TeamLeadLabel>
<TeamLeadLabel isLead={isLead || isOrgAdmin}>
{isLead && 'Team Lead'}
{isLead && isOrgAdmin && ', '}
{isOrgAdmin && 'Org Admin'}
</TeamLeadLabel>
</Content>
<StyledButton
showMenuButton={showMenuButton}
Expand All @@ -138,6 +158,7 @@ const ManageTeamMember = (props: Props) => {
menuProps={menuProps}
isLead={isLead}
isViewerLead={isViewerLead}
isViewerOrgAdmin={isViewerOrgAdmin}
teamMember={teamMember}
togglePromote={togglePromote}
toggleRemove={toggleRemove}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const query = graphql`
teamMemberId: id
userId
isLead
isOrgAdmin
isSelf
preferredName
email
Expand All @@ -73,7 +74,7 @@ const TeamSettings = (props: Props) => {
const viewerTeamMember = teamMembers.find((m) => m.isSelf)
// if kicked out, the component might reload before the redirect occurs
if (!viewerTeamMember) return null
const {isLead: viewerIsLead} = viewerTeamMember
const {isLead: viewerIsLead, isOrgAdmin: viewerIsOrgAdmin} = viewerTeamMember
const lead = teamMembers.find((m) => m.isLead)
const contact = lead ?? {email: 'love@parabol.co', preferredName: 'Parabol Support'}
return (
Expand All @@ -93,7 +94,7 @@ const TeamSettings = (props: Props) => {
</StyledRow>
</Panel>
)}
{viewerIsLead ? (
{viewerIsLead || viewerIsOrgAdmin ? (
<Panel label='Danger Zone'>
<PanelRow>
<ArchiveTeam team={team!} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ const query = graphql`
lockedAt
name
billingLeaders {
id
preferredName
email
user {
id
preferredName
email
}
}
creditCard {
brand
Expand Down Expand Up @@ -85,9 +87,10 @@ const UnpaidTeamModal = (props: Props) => {

const {id: orgId, billingLeaders, name: orgName} = organization
const [firstBillingLeader] = billingLeaders
const billingLeaderName = firstBillingLeader?.preferredName ?? 'Unknown'
const email = firstBillingLeader?.email ?? 'Unknown'
const isALeader = billingLeaders.findIndex((leader) => leader.id === viewerId) !== -1
const {user: firstBillingLeaderUser} = firstBillingLeader ?? {}
const billingLeaderName = firstBillingLeaderUser?.preferredName ?? 'Unknown'
const email = firstBillingLeaderUser?.email ?? 'Unknown'
const isALeader = billingLeaders.findIndex((leader) => leader.user.id === viewerId) !== -1

const goToBilling = (upgradeCTALocation: UpgradeCTALocationEnumType) => {
SendClientSideEvent(atmosphere, 'Upgrade CTA Clicked', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import styled from '@emotion/styled'
import graphql from 'babel-plugin-relay/macro'
import Avatar from '../../../../components/Avatar/Avatar'
import React from 'react'
import {BillingLeader_user$key} from '../../../../__generated__/BillingLeader_user.graphql'
import {BillingLeader_orgUser$key} from '../../../../__generated__/BillingLeader_orgUser.graphql'
import {BillingLeader_organization$key} from '../../../../__generated__/BillingLeader_organization.graphql'
import Row from '../../../../components/Row/Row'
import {ElementWidth} from '../../../../types/constEnums'
Expand All @@ -21,6 +21,7 @@ import useTooltip from '../../../../hooks/useTooltip'
import LeaveOrgModal from '../LeaveOrgModal/LeaveOrgModal'
import useModal from '../../../../hooks/useModal'
import RemoveFromOrgModal from '../RemoveFromOrgModal/RemoveFromOrgModal'
import BaseTag from '../../../../components/Tag/BaseTag'

const StyledRow = styled(Row)<{isFirstRow: boolean}>(({isFirstRow}) => ({
padding: '12px 16px',
Expand Down Expand Up @@ -53,7 +54,7 @@ const BillingLeaderActionMenu = lazyPreload(
)

type Props = {
billingLeaderRef: BillingLeader_user$key
billingLeaderRef: BillingLeader_orgUser$key
isFirstRow: boolean
billingLeaderCount: number
organizationRef: BillingLeader_organization$key
Expand All @@ -64,10 +65,13 @@ const BillingLeader = (props: Props) => {
const {togglePortal, originRef, menuPortal, menuProps} = useMenu(MenuPosition.UPPER_RIGHT)
const billingLeader = useFragment(
graphql`
fragment BillingLeader_user on User {
id
preferredName
picture
fragment BillingLeader_orgUser on OrganizationUser {
role
user {
id
preferredName
picture
}
}
`,
billingLeaderRef
Expand All @@ -90,16 +94,18 @@ const BillingLeader = (props: Props) => {
} = useTooltip<HTMLDivElement>(MenuPosition.LOWER_CENTER)
const {togglePortal: toggleLeave, modalPortal: leaveModal} = useModal()
const {togglePortal: toggleRemove, modalPortal: removeModal} = useModal()
const {id: userId, preferredName, picture} = billingLeader
const {user: billingLeaderUser} = billingLeader
const {id: userId, preferredName, picture} = billingLeaderUser
const isViewerLastBillingLeader = isViewerBillingLeader && billingLeaderCount === 1
const canViewMenu = !isViewerLastBillingLeader && billingLeader.role !== 'ORG_ADMIN'

const handleClick = () => {
togglePortal()
closeTooltip()
}

const handleMouseOver = () => {
if (isViewerLastBillingLeader) {
if (!canViewMenu) {
openTooltip()
}
}
Expand All @@ -112,6 +118,9 @@ const BillingLeader = (props: Props) => {
<RowInfoHeading>{preferredName}</RowInfoHeading>
</RowInfoHeader>
</RowInfo>
{billingLeader.role === 'ORG_ADMIN' && (
<BaseTag className='bg-gold-500 text-white'>Org Admin</BaseTag>
)}
<RowActions>
<ActionsBlock>
<MenuToggleBlock>
Expand All @@ -123,16 +132,20 @@ const BillingLeader = (props: Props) => {
onMouseOver={handleMouseOver}
onMouseLeave={closeTooltip}
ref={originRef}
disabled={isViewerLastBillingLeader}
disabled={!canViewMenu}
>
<IconLabel icon='more_vert' />
</StyledButton>
{tooltipPortal(
<div>
{'You need to promote another Billing Leader'}
<br />
{'before you can remove this role.'}
</div>
isViewerLastBillingLeader ? (
<div>
{'You need to promote another Billing Leader'}
<br />
{'before you can remove this role.'}
</div>
) : (
<div>Contact support (love@parabol.co) to remove the Org Admin role</div>
)
)}
</MenuToggleBlock>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const BillingLeaders = (props: Props) => {
isViewerBillingLeader: isBillingLeader
billingLeaders {
id
...BillingLeader_user
...BillingLeader_orgUser
}
}
`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const NewBillingLeaderMenu = forwardRef((props: Props, ref: any) => {
fragment NewBillingLeaderMenu_organization on Organization {
id
billingLeaders {
id
userId
}
organizationUsers {
edges {
Expand All @@ -61,7 +61,7 @@ const NewBillingLeaderMenu = forwardRef((props: Props, ref: any) => {
const {user} = node
const {id: userId} = user
return !billingLeaders.some((billingLeader) => {
const {id: billingLeaderId} = billingLeader
const {userId: billingLeaderId} = billingLeader
return billingLeaderId === userId
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ const OrgMembers = (props: Props) => {
if (!organization) return null
const {organizationUsers, name: orgName, isBillingLeader} = organization
const billingLeaderCount = organizationUsers.edges.reduce(
(count, {node}) => (node.role === 'BILLING_LEADER' ? count + 1 : count),
(count, {node}) =>
['BILLING_LEADER', 'ORG_ADMIN'].includes(node.role ?? '') ? count + 1 : count,
0
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const OrgTeamsRow = (props: Props) => {
teamMembers {
id
isLead
isOrgAdmin
isSelf
email
}
Expand All @@ -30,7 +31,9 @@ const OrgTeamsRow = (props: Props) => {
const {id: teamId, teamMembers, name} = team
const teamMembersCount = teamMembers.length
const teamLeadEmail = teamMembers.find((member) => member.isLead)?.email ?? ''
const isViewerTeamLead = teamMembers.some((member) => member.isSelf && member.isLead)
const isViewerTeamLead = teamMembers.some(
(member) => member.isSelf && (member.isLead || member.isOrgAdmin)
)
return (
<Row>
<div className='flex w-full flex-col px-4 py-1'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ const OrgMemberRow = (props: Props) => {
closeTooltip,
originRef: tooltipRef
} = useTooltip<HTMLDivElement>(MenuPosition.LOWER_RIGHT)
const canViewMenu = !isViewerLastBillingLeader && organizationUser.role !== 'ORG_ADMIN'

return (
<StyledRow>
Expand Down Expand Up @@ -176,24 +177,28 @@ const OrgMemberRow = (props: Props) => {
Leave Organization
</StyledFlatButton>
)}
{isViewerLastBillingLeader && userId === viewerId && (
{!canViewMenu && (
<MenuToggleBlock
onClick={closeTooltip}
onMouseOver={openTooltip}
onMouseOut={closeTooltip}
ref={tooltipRef}
>
{tooltipPortal(
<div>
{'You need to promote another Billing Leader'}
<br />
{'before you can leave this role or Organization.'}
</div>
isViewerLastBillingLeader ? (
<div>
{'You need to promote another Billing Leader'}
<br />
{'before you can remove this role.'}
</div>
) : (
<div>Contact support (love@parabol.co) to remove the Org Admin role</div>
)
)}
<MenuButton disabled />
</MenuToggleBlock>
)}
{isViewerBillingLeader && !(isViewerLastBillingLeader && userId === viewerId) && (
{isViewerBillingLeader && canViewMenu && (
<MenuToggleBlock>
<MenuButton
onClick={togglePortal}
Expand Down

0 comments on commit fb05fdd

Please sign in to comment.