From fb05fddc99a0ece301e5c072becd52666399c8a4 Mon Sep 17 00:00:00 2001 From: James Taber Date: Wed, 6 Dec 2023 10:03:57 -0600 Subject: [PATCH] feat(admin): Org Admin permissions - billing leader and team lead permissions (#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 --- .../DashboardAvatars/TeamMemberAvatarMenu.tsx | 7 +++- .../components/ManageTeam/ManageTeamList.tsx | 4 +- .../ManageTeam/ManageTeamMember.tsx | 29 +++++++++++-- .../components/TeamSettings/TeamSettings.tsx | 5 ++- .../UnpaidTeamModal/UnpaidTeamModal.tsx | 15 ++++--- .../components/OrgBilling/BillingLeader.tsx | 41 ++++++++++++------- .../components/OrgBilling/BillingLeaders.tsx | 2 +- .../OrgBilling/NewBillingLeaderMenu.tsx | 4 +- .../components/OrgMembers/OrgMembers.tsx | 3 +- .../components/OrgTeams/OrgTeamsRow.tsx | 5 ++- .../components/OrgUserRow/OrgMemberRow.tsx | 19 +++++---- .../fragments/CompleteOrganizationFrag.ts | 4 +- .../server/billing/helpers/generateInvoice.ts | 4 +- .../handleEnterpriseOrgQuantityChanges.ts | 4 +- .../database/types/processTeamsLimitsJob.ts | 2 +- .../server/dataloader/customLoaderMakers.ts | 7 +++- .../server/graphql/mutations/archiveTeam.ts | 2 +- .../mutations/helpers/removeFromOrg.ts | 6 ++- .../server/graphql/mutations/moveTeamToOrg.ts | 6 ++- .../graphql/mutations/removeTeamMember.ts | 2 +- .../mutations/draftEnterpriseInvoice.ts | 17 ++++---- .../mutations/sendUpcomingInvoiceEmails.ts | 7 +++- .../private/mutations/stripeFailPayment.ts | 6 ++- .../public/mutations/setOrgUserRole.ts | 40 +++++++++++++----- .../public/rules/isViewerBillingLeader.ts | 3 +- .../public/typeDefs/Organization.graphql | 2 +- .../graphql/public/typeDefs/Team.graphql | 5 +++ .../graphql/public/typeDefs/_legacy.graphql | 5 +++ packages/server/graphql/public/types/Team.ts | 9 +++- .../server/graphql/public/types/TeamMember.ts | 13 ++++++ packages/server/graphql/types/Organization.ts | 11 +++-- packages/server/graphql/types/User.ts | 3 +- packages/server/utils/authorization.ts | 22 ++++++++-- .../utils/isRequestToJoinDomainAllowed.ts | 11 ++++- 34 files changed, 236 insertions(+), 89 deletions(-) create mode 100644 packages/server/graphql/public/types/TeamMember.ts diff --git a/packages/client/components/DashboardAvatars/TeamMemberAvatarMenu.tsx b/packages/client/components/DashboardAvatars/TeamMemberAvatarMenu.tsx index cb50564440f..9bf47a5b7b8 100644 --- a/packages/client/components/DashboardAvatars/TeamMemberAvatarMenu.tsx +++ b/packages/client/components/DashboardAvatars/TeamMemberAvatarMenu.tsx @@ -12,6 +12,7 @@ import MenuItemLabel from '../MenuItemLabel' interface Props { isLead: boolean isViewerLead: boolean + isViewerOrgAdmin: boolean teamMember: TeamMemberAvatarMenu_teamMember$key menuProps: MenuProps handleNavigate?: () => void @@ -27,6 +28,7 @@ const StyledLabel = styled(MenuItemLabel)({ const TeamMemberAvatarMenu = (props: Props) => { const { isViewerLead, + isViewerOrgAdmin, teamMember: teamMemberRef, menuProps, togglePromote, @@ -48,17 +50,18 @@ const TeamMemberAvatarMenu = (props: Props) => { const {preferredName, userId} = teamMember const {viewerId} = atmosphere const isSelf = userId === viewerId + const isViewerTeamAdmin = isViewerLead || isViewerOrgAdmin return ( - {isViewerLead && !isSelf && ( + {isViewerTeamAdmin && (!isSelf || !isViewerLead) && ( Promote {preferredName} to Team Lead} /> )} - {isViewerLead && !isSelf && ( + {isViewerTeamAdmin && !isSelf && ( { graphql` fragment ManageTeamList_team on Team { isLead + isOrgAdmin teamMembers(sortBy: "preferredName") { id preferredName @@ -35,7 +36,7 @@ const ManageTeamList = (props: Props) => { `, props.team ) - const {isLead: isViewerLead, teamMembers} = team + const {isLead: isViewerLead, isOrgAdmin: isViewerOrgAdmin, teamMembers} = team return ( {teamMembers.map((teamMember) => { @@ -43,6 +44,7 @@ const ManageTeamList = (props: Props) => { diff --git a/packages/client/modules/teamDashboard/components/ManageTeam/ManageTeamMember.tsx b/packages/client/modules/teamDashboard/components/ManageTeam/ManageTeamMember.tsx index ad4cfed717a..5156af771c3 100644 --- a/packages/client/modules/teamDashboard/components/ManageTeam/ManageTeamMember.tsx +++ b/packages/client/modules/teamDashboard/components/ManageTeam/ManageTeamMember.tsx @@ -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 { @@ -88,6 +89,7 @@ const ManageTeamMember = (props: Props) => { ...RemoveTeamMemberModal_teamMember id isLead + isOrgAdmin preferredName picture userId @@ -95,12 +97,26 @@ const ManageTeamMember = (props: Props) => { `, 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, @@ -121,7 +137,11 @@ const ManageTeamMember = (props: Props) => { {preferredName} - Team Lead + + {isLead && 'Team Lead'} + {isLead && isOrgAdmin && ', '} + {isOrgAdmin && 'Org Admin'} + { menuProps={menuProps} isLead={isLead} isViewerLead={isViewerLead} + isViewerOrgAdmin={isViewerOrgAdmin} teamMember={teamMember} togglePromote={togglePromote} toggleRemove={toggleRemove} diff --git a/packages/client/modules/teamDashboard/components/TeamSettings/TeamSettings.tsx b/packages/client/modules/teamDashboard/components/TeamSettings/TeamSettings.tsx index 6d0463d2fc5..829308b0006 100644 --- a/packages/client/modules/teamDashboard/components/TeamSettings/TeamSettings.tsx +++ b/packages/client/modules/teamDashboard/components/TeamSettings/TeamSettings.tsx @@ -53,6 +53,7 @@ const query = graphql` teamMemberId: id userId isLead + isOrgAdmin isSelf preferredName email @@ -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 ( @@ -93,7 +94,7 @@ const TeamSettings = (props: Props) => { )} - {viewerIsLead ? ( + {viewerIsLead || viewerIsOrgAdmin ? ( diff --git a/packages/client/modules/teamDashboard/components/UnpaidTeamModal/UnpaidTeamModal.tsx b/packages/client/modules/teamDashboard/components/UnpaidTeamModal/UnpaidTeamModal.tsx index a63657e5c03..0017042dde4 100644 --- a/packages/client/modules/teamDashboard/components/UnpaidTeamModal/UnpaidTeamModal.tsx +++ b/packages/client/modules/teamDashboard/components/UnpaidTeamModal/UnpaidTeamModal.tsx @@ -45,9 +45,11 @@ const query = graphql` lockedAt name billingLeaders { - id - preferredName - email + user { + id + preferredName + email + } } creditCard { brand @@ -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', { diff --git a/packages/client/modules/userDashboard/components/OrgBilling/BillingLeader.tsx b/packages/client/modules/userDashboard/components/OrgBilling/BillingLeader.tsx index 26c89447440..79b5c4d6d86 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/BillingLeader.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/BillingLeader.tsx @@ -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' @@ -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', @@ -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 @@ -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 @@ -90,8 +94,10 @@ const BillingLeader = (props: Props) => { } = useTooltip(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() @@ -99,7 +105,7 @@ const BillingLeader = (props: Props) => { } const handleMouseOver = () => { - if (isViewerLastBillingLeader) { + if (!canViewMenu) { openTooltip() } } @@ -112,6 +118,9 @@ const BillingLeader = (props: Props) => { {preferredName} + {billingLeader.role === 'ORG_ADMIN' && ( + Org Admin + )} @@ -123,16 +132,20 @@ const BillingLeader = (props: Props) => { onMouseOver={handleMouseOver} onMouseLeave={closeTooltip} ref={originRef} - disabled={isViewerLastBillingLeader} + disabled={!canViewMenu} > {tooltipPortal( -
- {'You need to promote another Billing Leader'} -
- {'before you can remove this role.'} -
+ isViewerLastBillingLeader ? ( +
+ {'You need to promote another Billing Leader'} +
+ {'before you can remove this role.'} +
+ ) : ( +
Contact support (love@parabol.co) to remove the Org Admin role
+ ) )} )} diff --git a/packages/client/modules/userDashboard/components/OrgBilling/BillingLeaders.tsx b/packages/client/modules/userDashboard/components/OrgBilling/BillingLeaders.tsx index 9b2b0a4c708..193c1e74ed2 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/BillingLeaders.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/BillingLeaders.tsx @@ -72,7 +72,7 @@ const BillingLeaders = (props: Props) => { isViewerBillingLeader: isBillingLeader billingLeaders { id - ...BillingLeader_user + ...BillingLeader_orgUser } } `, diff --git a/packages/client/modules/userDashboard/components/OrgBilling/NewBillingLeaderMenu.tsx b/packages/client/modules/userDashboard/components/OrgBilling/NewBillingLeaderMenu.tsx index 40532f4dc8a..db19ca6093e 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/NewBillingLeaderMenu.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/NewBillingLeaderMenu.tsx @@ -36,7 +36,7 @@ const NewBillingLeaderMenu = forwardRef((props: Props, ref: any) => { fragment NewBillingLeaderMenu_organization on Organization { id billingLeaders { - id + userId } organizationUsers { edges { @@ -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 }) }) diff --git a/packages/client/modules/userDashboard/components/OrgMembers/OrgMembers.tsx b/packages/client/modules/userDashboard/components/OrgMembers/OrgMembers.tsx index e4f2b52d8ca..a4d365a8e91 100644 --- a/packages/client/modules/userDashboard/components/OrgMembers/OrgMembers.tsx +++ b/packages/client/modules/userDashboard/components/OrgMembers/OrgMembers.tsx @@ -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 ) diff --git a/packages/client/modules/userDashboard/components/OrgTeams/OrgTeamsRow.tsx b/packages/client/modules/userDashboard/components/OrgTeams/OrgTeamsRow.tsx index 253c4eddb2a..00123a43b5d 100644 --- a/packages/client/modules/userDashboard/components/OrgTeams/OrgTeamsRow.tsx +++ b/packages/client/modules/userDashboard/components/OrgTeams/OrgTeamsRow.tsx @@ -20,6 +20,7 @@ const OrgTeamsRow = (props: Props) => { teamMembers { id isLead + isOrgAdmin isSelf email } @@ -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 (
diff --git a/packages/client/modules/userDashboard/components/OrgUserRow/OrgMemberRow.tsx b/packages/client/modules/userDashboard/components/OrgUserRow/OrgMemberRow.tsx index 513894b476a..eda83c7b6dc 100644 --- a/packages/client/modules/userDashboard/components/OrgUserRow/OrgMemberRow.tsx +++ b/packages/client/modules/userDashboard/components/OrgUserRow/OrgMemberRow.tsx @@ -147,6 +147,7 @@ const OrgMemberRow = (props: Props) => { closeTooltip, originRef: tooltipRef } = useTooltip(MenuPosition.LOWER_RIGHT) + const canViewMenu = !isViewerLastBillingLeader && organizationUser.role !== 'ORG_ADMIN' return ( @@ -176,7 +177,7 @@ const OrgMemberRow = (props: Props) => { Leave Organization )} - {isViewerLastBillingLeader && userId === viewerId && ( + {!canViewMenu && ( { ref={tooltipRef} > {tooltipPortal( -
- {'You need to promote another Billing Leader'} -
- {'before you can leave this role or Organization.'} -
+ isViewerLastBillingLeader ? ( +
+ {'You need to promote another Billing Leader'} +
+ {'before you can remove this role.'} +
+ ) : ( +
Contact support (love@parabol.co) to remove the Org Admin role
+ ) )}
)} - {isViewerBillingLeader && !(isViewerLastBillingLeader && userId === viewerId) && ( + {isViewerBillingLeader && canViewMenu && ( r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) .coerceTo('array')('userId') as unknown as string[] }).run() diff --git a/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts b/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts index d21f41f31a6..71dacb582f1 100644 --- a/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts +++ b/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts @@ -1,4 +1,5 @@ import getRethink from '../../database/rethinkDriver' +import {RDatum} from '../../database/stricterR' import Organization from '../../database/types/Organization' import {analytics} from '../../utils/analytics/analytics' import {getStripeManager} from '../../utils/stripe' @@ -25,7 +26,8 @@ const sendEnterpriseOverageEvent = async (organization: Organization) => { const billingLeaderOrgUser = await r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null, role: 'BILLING_LEADER'}) + .filter({removedAt: null}) + .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) .nth(0) .run() const {id: userId} = billingLeaderOrgUser diff --git a/packages/server/database/types/processTeamsLimitsJob.ts b/packages/server/database/types/processTeamsLimitsJob.ts index e6267e39e51..57beb49c170 100644 --- a/packages/server/database/types/processTeamsLimitsJob.ts +++ b/packages/server/database/types/processTeamsLimitsJob.ts @@ -17,7 +17,7 @@ const processTeamsLimitsJob = async (job: ScheduledTeamLimitsJob, dataLoader: Da if (!scheduledLockAt || lockedAt) return const billingLeadersIds = orgUsers - .filter(({role}) => role === 'BILLING_LEADER') + .filter(({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role)) .map(({userId}) => userId) const billingLeaderUsers = (await dataLoader.get('users').loadMany(billingLeadersIds)).filter( isValid diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index 813fbb926ed..d0134f135c6 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -628,7 +628,8 @@ export const billingLeadersIdsByOrgId = (parent: RootDataLoader) => { return r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null, role: 'BILLING_LEADER'}) + .filter({removedAt: null}) + .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) .coerceTo('array')('userId') .run() }) @@ -723,7 +724,9 @@ export const isOrgVerified = (parent: RootDataLoader) => { })) .merge((org: RDatum) => ({ founder: org('members').nth(0).default(null), - billingLeads: org('members').filter({role: 'BILLING_LEADER', inactive: false}) + billingLeads: org('members') + .filter({inactive: false}) + .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) })) .run() diff --git a/packages/server/graphql/mutations/archiveTeam.ts b/packages/server/graphql/mutations/archiveTeam.ts index 547d2347c31..f91305530da 100644 --- a/packages/server/graphql/mutations/archiveTeam.ts +++ b/packages/server/graphql/mutations/archiveTeam.ts @@ -35,7 +35,7 @@ export default { // AUTH const viewerId = getUserId(authToken) - if (!(await isTeamLead(viewerId, teamId)) && !isSuperUser(authToken)) { + if (!(await isTeamLead(viewerId, teamId, dataLoader)) && !isSuperUser(authToken)) { return standardError(new Error('Not team lead'), {userId: viewerId}) } diff --git a/packages/server/graphql/mutations/helpers/removeFromOrg.ts b/packages/server/graphql/mutations/helpers/removeFromOrg.ts index fa7116b2123..63b9bd9f490 100644 --- a/packages/server/graphql/mutations/helpers/removeFromOrg.ts +++ b/packages/server/graphql/mutations/helpers/removeFromOrg.ts @@ -7,6 +7,7 @@ import setUserTierForUserIds from '../../../utils/setUserTierForUserIds' import {DataLoaderWorker} from '../../graphql' import removeTeamMember from './removeTeamMember' import resolveDowngradeToStarter from './resolveDowngradeToStarter' +import {RDatum} from '../../../database/stricterR' const removeFromOrg = async ( userId: string, @@ -57,14 +58,15 @@ const removeFromOrg = async ( // need to make sure the org doc is updated before adjusting this const {role} = organizationUser - if (role === 'BILLING_LEADER') { + if (role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role)) { const organization = await r.table('Organization').get(orgId).run() // if no other billing leader, promote the oldest // if team tier & no other member, downgrade to starter const otherBillingLeaders = await r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null, role: 'BILLING_LEADER'}) + .filter({removedAt: null}) + .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) .run() if (otherBillingLeaders.length === 0) { const nextInLine = await r diff --git a/packages/server/graphql/mutations/moveTeamToOrg.ts b/packages/server/graphql/mutations/moveTeamToOrg.ts index 7000a3f1013..06be3517417 100644 --- a/packages/server/graphql/mutations/moveTeamToOrg.ts +++ b/packages/server/graphql/mutations/moveTeamToOrg.ts @@ -59,7 +59,8 @@ const moveToOrg = async ( if (!newOrganizationUser) { return standardError(new Error('Not on organization'), {userId}) } - const isBillingLeaderForOrg = newOrganizationUser.role === 'BILLING_LEADER' + const isBillingLeaderForOrg = + newOrganizationUser.role === 'BILLING_LEADER' || newOrganizationUser.role === 'ORG_ADMIN' if (!isBillingLeaderForOrg) { return standardError(new Error('Not organization leader'), {userId}) } @@ -69,7 +70,8 @@ const moveToOrg = async ( .filter({orgId: currentOrgId, removedAt: null}) .nth(0) .run() - const isBillingLeaderForTeam = oldOrganizationUser.role === 'BILLING_LEADER' + const isBillingLeaderForTeam = + oldOrganizationUser.role === 'BILLING_LEADER' || oldOrganizationUser.role === 'ORG_ADMIN' if (!isBillingLeaderForTeam) { return standardError(new Error('Not organization leader'), {userId}) } diff --git a/packages/server/graphql/mutations/removeTeamMember.ts b/packages/server/graphql/mutations/removeTeamMember.ts index 3462a1d0cbd..8c17c71b881 100644 --- a/packages/server/graphql/mutations/removeTeamMember.ts +++ b/packages/server/graphql/mutations/removeTeamMember.ts @@ -32,7 +32,7 @@ export default { const {userId, teamId} = fromTeamMemberId(teamMemberId) const isSelf = viewerId === userId if (!isSelf) { - if (!(await isTeamLead(viewerId, teamId))) { + if (!(await isTeamLead(viewerId, teamId, dataLoader))) { return standardError(new Error('Not team lead'), {userId: viewerId}) } } diff --git a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts index 43052601fae..90d4ea4ac8b 100644 --- a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts +++ b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts @@ -31,17 +31,20 @@ const getBillingLeaderUser = async ( if (!organizationUser) { throw new Error('Email not associated with a user on that org') } - await r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({removedAt: null, orgId}) - .update({role: 'BILLING_LEADER'}) - .run() + if (organizationUser.role !== 'ORG_ADMIN') { + await r + .table('OrganizationUser') + .getAll(userId, {index: 'userId'}) + .filter({removedAt: null, orgId}) + .update({role: 'BILLING_LEADER'}) + .run() + } return user } const organizationUsers = await dataLoader.get('organizationUsersByOrgId').load(orgId) const billingLeaders = organizationUsers.filter( - (organizationUser) => organizationUser.role === 'BILLING_LEADER' + (organizationUser) => + organizationUser.role === 'BILLING_LEADER' || organizationUser.role === 'ORG_ADMIN' ) const billingLeaderUserIds = billingLeaders.map(({userId}) => userId) diff --git a/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts b/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts index dcbbeed836d..4b4008eec0a 100644 --- a/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts +++ b/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts @@ -5,7 +5,7 @@ import {isNotNull} from 'parabol-client/utils/predicates' import {Threshold} from '../../../../client/types/constEnums' import appOrigin from '../../../appOrigin' import getRethink from '../../../database/rethinkDriver' -import {RValue} from '../../../database/stricterR' +import {RDatum, RValue} from '../../../database/stricterR' import getMailManager from '../../../email/getMailManager' import UpcomingInvoiceEmailTemplate from '../../../email/UpcomingInvoiceEmailTemplate' import IUser from '../../../postgres/types/IUser' @@ -91,7 +91,10 @@ const sendUpcomingInvoiceEmails: MutationResolvers['sendUpcomingInvoiceEmails'] billingLeaderIds: r .table('OrganizationUser') .getAll(organization('id'), {index: 'orgId'}) - .filter({role: 'BILLING_LEADER', removedAt: null})('userId') + .filter({removedAt: null}) + .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role')))( + 'userId' + ) .coerceTo('array') })) .coerceTo('array') diff --git a/packages/server/graphql/private/mutations/stripeFailPayment.ts b/packages/server/graphql/private/mutations/stripeFailPayment.ts index e75fa74cb5d..d954385b8f9 100644 --- a/packages/server/graphql/private/mutations/stripeFailPayment.ts +++ b/packages/server/graphql/private/mutations/stripeFailPayment.ts @@ -7,6 +7,7 @@ import {isSuperUser} from '../../../utils/authorization' import publish from '../../../utils/publish' import {getStripeManager} from '../../../utils/stripe' import {MutationResolvers} from '../resolverTypes' +import {RDatum} from '../../../database/stricterR' export type StripeFailPaymentPayloadSource = | { @@ -79,7 +80,10 @@ const stripeFailPayment: MutationResolvers['stripeFailPayment'] = async ( const billingLeaderUserIds = (await r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null, role: 'BILLING_LEADER'})('userId') + .filter({removedAt: null}) + .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role')))( + 'userId' + ) .run()) as string[] const {default_source} = customer diff --git a/packages/server/graphql/public/mutations/setOrgUserRole.ts b/packages/server/graphql/public/mutations/setOrgUserRole.ts index d72e0d1db09..db84559beee 100644 --- a/packages/server/graphql/public/mutations/setOrgUserRole.ts +++ b/packages/server/graphql/public/mutations/setOrgUserRole.ts @@ -6,6 +6,7 @@ import {analytics} from '../../../utils/analytics/analytics' import {getUserId, isSuperUser, isUserBillingLeader} from '../../../utils/authorization' import publish from '../../../utils/publish' import standardError from '../../../utils/standardError' +import {RDatum} from '../../../database/stricterR' const addNotifications = async (orgId: string, userId: string) => { const r = await getRethink() @@ -34,15 +35,37 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( }) } - if (role && role !== 'BILLING_LEADER') { + if (role && role !== 'BILLING_LEADER' && role !== 'ORG_ADMIN') { return standardError(new Error('Invalid role'), {userId: viewerId}) } + + const organizationUser = await r + .table('OrganizationUser') + .getAll(userId, {index: 'userId'}) + .filter({orgId, removedAt: null}) + .nth(0) + .default(null) + .run() + + if (!organizationUser) { + return standardError(new Error('Cannot find org user'), { + userId: viewerId + }) + } + + if ((role === 'ORG_ADMIN' || organizationUser.role === 'ORG_ADMIN') && !isSuperUser(authToken)) { + return standardError(new Error('Must be super user to promote/demote user to admin'), { + userId: viewerId + }) + } + // if someone is leaving, make sure there is someone else to take their place if (userId === viewerId) { const leaderCount = await r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null, role: 'BILLING_LEADER'}) + .filter({removedAt: null}) + .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) .count() .run() if (leaderCount === 1) { @@ -53,12 +76,6 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( } // no change required - const organizationUser = await r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({orgId, removedAt: null}) - .nth(0) - .run() const {id: organizationUserId} = organizationUser if (organizationUser.role === role) { return { @@ -69,9 +86,12 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( } await r.table('OrganizationUser').get(organizationUserId).update({role}).run() - const modificationType = role === 'BILLING_LEADER' ? 'add' : 'remove' - analytics.billingLeaderModified(userId, viewerId, orgId, modificationType) + if (role !== 'ORG_ADMIN') { + const modificationType = role === 'BILLING_LEADER' ? 'add' : 'remove' + analytics.billingLeaderModified(userId, viewerId, orgId, modificationType) + } + // Don't add notification when promoting to org admin. const notificationIdsAdded = role === 'BILLING_LEADER' ? await addNotifications(orgId, userId) : [] diff --git a/packages/server/graphql/public/rules/isViewerBillingLeader.ts b/packages/server/graphql/public/rules/isViewerBillingLeader.ts index be6463f4e5c..5af6b47af5b 100644 --- a/packages/server/graphql/public/rules/isViewerBillingLeader.ts +++ b/packages/server/graphql/public/rules/isViewerBillingLeader.ts @@ -9,7 +9,8 @@ const resolve = async (orgId: string, {authToken, dataLoader}: GQLContext) => { .load({orgId, userId: viewerId}) if (!organizationUser) return new Error('Organization User not found') const {role} = organizationUser - if (role !== 'BILLING_LEADER') return new Error('User is not billing leader') + if (role !== 'BILLING_LEADER' && role !== 'ORG_ADMIN') + return new Error('User is not billing leader') return true } diff --git a/packages/server/graphql/public/typeDefs/Organization.graphql b/packages/server/graphql/public/typeDefs/Organization.graphql index e34bd856fc1..47a14c0965e 100644 --- a/packages/server/graphql/public/typeDefs/Organization.graphql +++ b/packages/server/graphql/public/typeDefs/Organization.graphql @@ -149,7 +149,7 @@ type Organization { """ The leaders of the org """ - billingLeaders: [User!]! + billingLeaders: [OrganizationUser!]! """ Minimal details about all teams in the organization diff --git a/packages/server/graphql/public/typeDefs/Team.graphql b/packages/server/graphql/public/typeDefs/Team.graphql index d9a98889875..6a6d00c4c76 100644 --- a/packages/server/graphql/public/typeDefs/Team.graphql +++ b/packages/server/graphql/public/typeDefs/Team.graphql @@ -88,6 +88,11 @@ type Team { """ isLead: Boolean! + """ + true if the viewer is an admin for the team's org, else false + """ + isOrgAdmin: Boolean! + """ The team-specific settings for running all available types of meetings """ diff --git a/packages/server/graphql/public/typeDefs/_legacy.graphql b/packages/server/graphql/public/typeDefs/_legacy.graphql index fd11c6849b0..5892fcd74f7 100644 --- a/packages/server/graphql/public/typeDefs/_legacy.graphql +++ b/packages/server/graphql/public/typeDefs/_legacy.graphql @@ -916,6 +916,11 @@ type TeamMember { """ isLead: Boolean! + """ + Is user an admin of the team's org? + """ + isOrgAdmin: Boolean! + """ true if the user prefers to not vote during a poker meeting """ diff --git a/packages/server/graphql/public/types/Team.ts b/packages/server/graphql/public/types/Team.ts index 93065f81416..be9966ac554 100644 --- a/packages/server/graphql/public/types/Team.ts +++ b/packages/server/graphql/public/types/Team.ts @@ -40,7 +40,14 @@ const Team: TeamResolvers = { tier: ({tier, trialStartDate}) => { return getFeatureTier({tier, trialStartDate}) }, - billingTier: ({tier}) => tier + billingTier: ({tier}) => tier, + isOrgAdmin: async ({orgId}, _args, {authToken, dataLoader}) => { + const viewerId = getUserId(authToken) + const organizationUser = await dataLoader + .get('organizationUsersByUserIdOrgId') + .load({userId: viewerId, orgId}) + return organizationUser?.role === 'ORG_ADMIN' + } } export default Team diff --git a/packages/server/graphql/public/types/TeamMember.ts b/packages/server/graphql/public/types/TeamMember.ts new file mode 100644 index 00000000000..aed0b5d51fe --- /dev/null +++ b/packages/server/graphql/public/types/TeamMember.ts @@ -0,0 +1,13 @@ +import {TeamMemberResolvers} from '../resolverTypes' + +const TeamMember: TeamMemberResolvers = { + isOrgAdmin: async ({teamId, userId}, _args, {dataLoader}) => { + const team = await dataLoader.get('teams').loadNonNull(teamId) + const organizationUser = await dataLoader + .get('organizationUsersByUserIdOrgId') + .load({userId, orgId: team.orgId}) + return organizationUser?.role === 'ORG_ADMIN' + } +} + +export default TeamMember diff --git a/packages/server/graphql/types/Organization.ts b/packages/server/graphql/types/Organization.ts index 75489d21bac..18055a06e5b 100644 --- a/packages/server/graphql/types/Organization.ts +++ b/packages/server/graphql/types/Organization.ts @@ -17,7 +17,6 @@ import GraphQLURLType from './GraphQLURLType' import OrganizationUser, {OrganizationUserConnection} from './OrganizationUser' import OrgUserCount from './OrgUserCount' import Team from './Team' -import User from './User' const Organization: GraphQLObjectType = new GraphQLObjectType({ name: 'Organization', @@ -203,14 +202,14 @@ const Organization: GraphQLObjectType = new GraphQLObjectType { const organizationUsers = await dataLoader.get('organizationUsersByOrgId').load(orgId) - const billingLeaderUserIds = organizationUsers - .filter((organizationUser) => organizationUser.role === 'BILLING_LEADER') - .map(({userId}: {userId: string}) => userId) - return dataLoader.get('users').loadMany(billingLeaderUserIds) + return organizationUsers.filter( + (organizationUser) => + organizationUser.role === 'BILLING_LEADER' || organizationUser.role === 'ORG_ADMIN' + ) } } }) diff --git a/packages/server/graphql/types/User.ts b/packages/server/graphql/types/User.ts index c8a0cb1d2b4..2a3a679dc85 100644 --- a/packages/server/graphql/types/User.ts +++ b/packages/server/graphql/types/User.ts @@ -89,7 +89,8 @@ const User: GraphQLObjectType = new GraphQLObjectType { const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) return organizationUsers.some( - (organizationUser: OrganizationUserType) => organizationUser.role === 'BILLING_LEADER' + (organizationUser: OrganizationUserType) => + organizationUser.role === 'BILLING_LEADER' || organizationUser.role === 'ORG_ADMIN' ) } }, diff --git a/packages/server/utils/authorization.ts b/packages/server/utils/authorization.ts index 1db1b6a5705..4537f03e77c 100644 --- a/packages/server/utils/authorization.ts +++ b/packages/server/utils/authorization.ts @@ -3,6 +3,7 @@ import getRethink from '../database/rethinkDriver' import AuthToken from '../database/types/AuthToken' import OrganizationUser from '../database/types/OrganizationUser' import {DataLoaderWorker} from '../graphql/graphql' +import {RDatum} from '../database/stricterR' export const getUserId = (authToken: any) => { return authToken && typeof authToken === 'object' ? (authToken.sub as string) : '' @@ -33,10 +34,18 @@ export const isTeamMember = (authToken: AuthToken, teamId: string) => { // .run() // } -export const isTeamLead = async (userId: string, teamId: string) => { +export const isTeamLead = async (userId: string, teamId: string, dataLoader: DataLoaderWorker) => { const r = await getRethink() const teamMemberId = toTeamMemberId(teamId, userId) - return r.table('TeamMember').get(teamMemberId)('isLead').default(false).run() + if (await r.table('TeamMember').get(teamMemberId)('isLead').default(false).run()) { + return true + } + + const team = await dataLoader.get('teams').loadNonNull(teamId) + const organizationUser = await dataLoader + .get('organizationUsersByUserIdOrgId') + .load({userId, orgId: team.orgId}) + return organizationUser?.role === 'ORG_ADMIN' } interface Options { @@ -54,7 +63,9 @@ export const isUserBillingLeader = async ( if (options && options.clearCache) { dataLoader.get('organizationUsersByUserId').clear(userId) } - return organizationUser ? organizationUser.role === 'BILLING_LEADER' : false + return organizationUser + ? organizationUser.role === 'BILLING_LEADER' || organizationUser.role === 'ORG_ADMIN' + : false } export const isUserInOrg = async (userId: string, orgId: string, dataLoader: DataLoaderWorker) => { @@ -71,7 +82,10 @@ export const isOrgLeaderOfUser = async (authToken: AuthToken, userId: string) => viewerOrgIds: r .table('OrganizationUser') .getAll(viewerId, {index: 'userId'}) - .filter({removedAt: null, role: 'BILLING_LEADER'})('orgId') + .filter({removedAt: null}) + .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role')))( + 'orgId' + ) .coerceTo('array') as any as OrganizationUser[], userOrgIds: r .table('OrganizationUser') diff --git a/packages/server/utils/isRequestToJoinDomainAllowed.ts b/packages/server/utils/isRequestToJoinDomainAllowed.ts index f5c06672ed3..69df7e2dd2d 100644 --- a/packages/server/utils/isRequestToJoinDomainAllowed.ts +++ b/packages/server/utils/isRequestToJoinDomainAllowed.ts @@ -31,7 +31,9 @@ export const getEligibleOrgIdsByDomain = async ( })) .merge((org: RDatum) => ({ founder: org('members').nth(0).default(null), - billingLeads: org('members').filter({role: 'BILLING_LEADER', inactive: false}), + billingLeads: org('members') + .filter({inactive: false}) + .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))), activeMembers: org('members').filter({inactive: false, removedAt: null}).count() })) .filter((org: RDatum) => @@ -43,7 +45,12 @@ export const getEligibleOrgIdsByDomain = async ( orgs.map(async (org) => { const {founder} = org const importantMembers = org.billingLeads.slice() as TeamMember[] - if (!founder.inactive && !founder.removedAt && founder.role !== 'BILLING_LEADER') { + if ( + !founder.inactive && + !founder.removedAt && + founder.role !== 'BILLING_LEADER' && + founder.role !== 'ORG_ADMIN' + ) { importantMembers.push(founder) }