Shift positioning of "Leave team" button in Org Team Settings (#36386)
* Shift leave team button position, and fix sorting own user as first in list * Nit
This commit is contained in:
@@ -189,7 +189,7 @@ export const InviteMemberButton = () => {
|
||||
className="pointer-events-auto flex-grow md:flex-grow-0"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Invite
|
||||
Invite member
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{!canInviteMembers && (
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
|
||||
import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation'
|
||||
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { hasMultipleOwners } from './TeamSettings.utils'
|
||||
|
||||
export const LeaveTeamButton = () => {
|
||||
const router = useRouter()
|
||||
const { slug } = useParams()
|
||||
const { profile } = useProfile()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
// if organizationMembersDeletionEnabled is false, you also can't delete yourself
|
||||
const { organizationMembersDelete: organizationMembersDeletionEnabled } = useIsFeatureEnabled([
|
||||
'organization_members:delete',
|
||||
])
|
||||
|
||||
const [isLeaving, setIsLeaving] = useState(false)
|
||||
const [isLeaveTeamModalOpen, setIsLeaveTeamModalOpen] = useState(false)
|
||||
const [_, setLastVisitedOrganization] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
|
||||
''
|
||||
)
|
||||
|
||||
const { refetch: refetchOrganizations } = useOrganizationsQuery()
|
||||
const { data: members } = useOrganizationMembersQuery({ slug })
|
||||
const { data: allRoles } = useOrganizationRolesV2Query({ slug })
|
||||
|
||||
const isOwner = selectedOrganization?.is_owner
|
||||
const roles = allRoles?.org_scoped_roles ?? []
|
||||
const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles))
|
||||
|
||||
const { mutate: deleteMember } = useOrganizationMemberDeleteMutation({
|
||||
onSuccess: async () => {
|
||||
setIsLeaving(false)
|
||||
setIsLeaveTeamModalOpen(false)
|
||||
|
||||
await refetchOrganizations()
|
||||
toast.success(`Successfully left ${selectedOrganization?.name}`)
|
||||
|
||||
setLastVisitedOrganization('')
|
||||
router.push('/organizations')
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsLeaving(false)
|
||||
toast.error(`Failed to leave organization: ${error?.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const leaveTeam = async () => {
|
||||
if (!slug) return console.error('Org slug is required')
|
||||
if (!profile) return console.error('Profile is required')
|
||||
|
||||
setIsLeaving(true)
|
||||
deleteMember({ slug, gotrueId: profile.gotrue_id })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!canLeave || !organizationMembersDeletionEnabled || isLeaving}
|
||||
onClick={() => setIsLeaveTeamModalOpen(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canLeave
|
||||
? 'An organization requires at least 1 owner'
|
||||
: !organizationMembersDeletionEnabled
|
||||
? 'Unable to leave organization'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Leave team
|
||||
</ButtonTooltip>
|
||||
<ConfirmationModal
|
||||
size="medium"
|
||||
visible={isLeaveTeamModalOpen}
|
||||
title="Confirm to leave organization"
|
||||
confirmLabel="Leave"
|
||||
variant="warning"
|
||||
alert={{
|
||||
title: 'All of your user content will be permanently removed.',
|
||||
description: (
|
||||
<div>
|
||||
<p>
|
||||
Leaving the organization will delete all of your saved content in the projects of
|
||||
the organization, which includes:
|
||||
</p>
|
||||
<ul className="list-disc pl-4">
|
||||
<li>
|
||||
SQL snippets <span className="text-foreground">(both private and shared)</span>
|
||||
</li>
|
||||
<li>Custom reports</li>
|
||||
<li>Log Explorer queries</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
onCancel={() => setIsLeaveTeamModalOpen(false)}
|
||||
onConfirm={() => leaveTeam()}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Are you sure you want to leave this organization? This is permanent.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from 'data/organizations/organization-members-query'
|
||||
import { usePermissionsQuery } from 'data/permissions/permissions-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
@@ -29,7 +28,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
|
||||
import { LeaveTeamButton } from './LeaveTeamButton'
|
||||
import { hasMultipleOwners, useGetRolesManagementPermissions } from './TeamSettings.utils'
|
||||
import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
|
||||
|
||||
interface MemberActionsProps {
|
||||
@@ -48,7 +48,11 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
|
||||
const { data: allProjects } = useProjectsQuery()
|
||||
const { data: members } = useOrganizationMembersQuery({ slug })
|
||||
const { data: allRoles } = useOrganizationRolesV2Query({ slug })
|
||||
const isOptedIntoProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string)
|
||||
|
||||
const memberIsUser = member.gotrue_id == profile?.gotrue_id
|
||||
const isOwner = selectedOrganization?.is_owner
|
||||
const roles = allRoles?.org_scoped_roles ?? []
|
||||
const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles))
|
||||
|
||||
const orgScopedRoles = allRoles?.org_scoped_roles ?? []
|
||||
const projectScopedRoles = allRoles?.project_scoped_roles ?? []
|
||||
@@ -166,6 +170,14 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
if (memberIsUser) {
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<LeaveTeamButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
|
||||
@@ -41,7 +41,6 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
|
||||
const orgProjects = projects?.filter((p) => p.organization_id === selectedOrganization?.id)
|
||||
const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
|
||||
const memberIsUser = member.gotrue_id == profile?.gotrue_id
|
||||
const isInvitedUser = Boolean(member.invited_id)
|
||||
const isEmailUser = member.username === member.primary_email
|
||||
const isFlyUser = Boolean(member.primary_email?.endsWith('customer.fly.io'))
|
||||
@@ -204,7 +203,9 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
)}
|
||||
</Table.td>
|
||||
|
||||
<Table.td>{!memberIsUser && <MemberActions member={member} />}</Table.td>
|
||||
<Table.td>
|
||||
<MemberActions member={member} />
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
|
||||
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { partition } from 'lodash'
|
||||
import { useMemo } from 'react'
|
||||
import { Button, Loading, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { MemberRow } from './MemberRow'
|
||||
@@ -20,7 +22,7 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
const { profile } = useProfile()
|
||||
|
||||
const {
|
||||
data: members,
|
||||
data: members = [],
|
||||
error: membersError,
|
||||
isLoading: isLoadingMembers,
|
||||
isError: isErrorMembers,
|
||||
@@ -35,11 +37,10 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
slug,
|
||||
})
|
||||
|
||||
const allMembers = members ?? []
|
||||
const filteredMembers = (
|
||||
!searchString
|
||||
? allMembers
|
||||
: allMembers.filter((member) => {
|
||||
const filteredMembers = useMemo(() => {
|
||||
return !searchString
|
||||
? members
|
||||
: members.filter((member) => {
|
||||
if (member.invited_at) {
|
||||
return member.primary_email?.includes(searchString)
|
||||
}
|
||||
@@ -49,15 +50,17 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
// [Joshen] Have own account show up top
|
||||
if (a.primary_email === profile?.primary_email) return -1
|
||||
return a.username.localeCompare(b.username)
|
||||
})
|
||||
}, [members, searchString])
|
||||
|
||||
const userMember = allMembers.find((m) => m.primary_email === profile?.primary_email)
|
||||
const [[user], otherMembers] = partition(
|
||||
filteredMembers,
|
||||
(m) => m.primary_email === profile?.primary_email
|
||||
)
|
||||
const sortedMembers = otherMembers.sort((a, b) =>
|
||||
(a.primary_email ?? '').localeCompare(b.primary_email ?? '')
|
||||
)
|
||||
|
||||
const userMember = members.find((m) => m.primary_email === profile?.primary_email)
|
||||
const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
|
||||
const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
|
||||
|
||||
@@ -117,7 +120,8 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
</Table.tr>,
|
||||
]
|
||||
: []),
|
||||
...filteredMembers.map((member) => (
|
||||
...(!!user ? [<MemberRow key={user.gotrue_id} member={user} />] : []),
|
||||
...sortedMembers.map((member) => (
|
||||
<MemberRow key={member.gotrue_id} member={member} />
|
||||
)),
|
||||
...(searchString.length > 0 && filteredMembers.length === 0
|
||||
@@ -138,7 +142,7 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
<Table.td colSpan={12}>
|
||||
<p className="text-foreground-light">
|
||||
{searchString ? `${filteredMembers.length} of ` : ''}
|
||||
{allMembers.length || '0'} {allMembers.length == 1 ? 'user' : 'users'}
|
||||
{members.length || '0'} {members.length == 1 ? 'user' : 'users'}
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
ScaffoldActionsContainer,
|
||||
ScaffoldActionsGroup,
|
||||
@@ -12,43 +10,27 @@ import {
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldTitle,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
|
||||
import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation'
|
||||
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { usePermissionsQuery } from 'data/permissions/permissions-query'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { InviteMemberButton } from './InviteMemberButton'
|
||||
import MembersView from './MembersView'
|
||||
import { hasMultipleOwners, useGetRolesManagementPermissions } from './TeamSettings.utils'
|
||||
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
|
||||
|
||||
export const TeamSettings = () => {
|
||||
const [_, setLastVisitedOrganization] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
|
||||
''
|
||||
)
|
||||
|
||||
const {
|
||||
organizationMembersCreate: organizationMembersCreationEnabled,
|
||||
organizationMembersDelete: organizationMembersDeletionEnabled,
|
||||
} = useIsFeatureEnabled(['organization_members:create', 'organization_members:delete'])
|
||||
const { organizationMembersCreate: organizationMembersCreationEnabled } = useIsFeatureEnabled([
|
||||
'organization_members:create',
|
||||
])
|
||||
|
||||
const { slug } = useParams()
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const isOwner = selectedOrganization?.is_owner
|
||||
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const { refetch: refetchOrganizations } = useOrganizationsQuery()
|
||||
const { data: rolesData } = useOrganizationRolesV2Query({ slug })
|
||||
const { data: members } = useOrganizationMembersQuery({ slug })
|
||||
|
||||
const roles = rolesData?.org_scoped_roles ?? []
|
||||
|
||||
@@ -58,117 +40,36 @@ export const TeamSettings = () => {
|
||||
permissions ?? []
|
||||
)
|
||||
|
||||
const [isLeaving, setIsLeaving] = useState(false)
|
||||
const [searchString, setSearchString] = useState('')
|
||||
|
||||
const canAddMembers = rolesAddable.length > 0
|
||||
const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles))
|
||||
|
||||
const { mutate: deleteMember } = useOrganizationMemberDeleteMutation({
|
||||
onSuccess: async () => {
|
||||
setIsLeaving(false)
|
||||
setIsLeaveTeamModalOpen(false)
|
||||
|
||||
await refetchOrganizations()
|
||||
toast.success(`Successfully left ${selectedOrganization?.name}`)
|
||||
|
||||
setLastVisitedOrganization('')
|
||||
router.push('/organizations')
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsLeaving(false)
|
||||
toast.error(`Failed to leave organization: ${error?.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const [isLeaveTeamModalOpen, setIsLeaveTeamModalOpen] = useState(false)
|
||||
|
||||
const leaveTeam = async () => {
|
||||
if (!slug) return console.error('Org slug is required')
|
||||
|
||||
setIsLeaving(true)
|
||||
deleteMember({ slug, gotrueId: profile!.gotrue_id })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainerLegacy>
|
||||
<ScaffoldTitle>Team</ScaffoldTitle>
|
||||
<ScaffoldFilterAndContent>
|
||||
<ScaffoldActionsContainer className="w-full flex-col md:flex-row gap-2 justify-between">
|
||||
<Input
|
||||
size="tiny"
|
||||
autoComplete="off"
|
||||
icon={<Search size={12} />}
|
||||
value={searchString}
|
||||
onChange={(e: any) => setSearchString(e.target.value)}
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="Filter members"
|
||||
/>
|
||||
<ScaffoldActionsGroup className="w-full md:w-auto">
|
||||
{organizationMembersCreationEnabled &&
|
||||
canAddMembers &&
|
||||
profile !== undefined &&
|
||||
selectedOrganization !== undefined && <InviteMemberButton />}
|
||||
{/* if organizationMembersDeletionEnabled is false, you also can't delete yourself */}
|
||||
{organizationMembersDeletionEnabled && (
|
||||
<>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
loading={isLeaving}
|
||||
disabled={!canLeave}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canLeave ? 'An organization requires at least 1 owner' : undefined,
|
||||
},
|
||||
}}
|
||||
onClick={() => setIsLeaveTeamModalOpen(true)}
|
||||
>
|
||||
Leave team
|
||||
</ButtonTooltip>
|
||||
</>
|
||||
)}
|
||||
</ScaffoldActionsGroup>
|
||||
</ScaffoldActionsContainer>
|
||||
<ScaffoldSectionContent className="w-full">
|
||||
<MembersView searchString={searchString} />
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldFilterAndContent>
|
||||
</ScaffoldContainerLegacy>
|
||||
|
||||
<ConfirmationModal
|
||||
size="medium"
|
||||
visible={isLeaveTeamModalOpen}
|
||||
title="Confirm to leave organization"
|
||||
confirmLabel="Leave"
|
||||
variant="warning"
|
||||
alert={{
|
||||
title: 'All of your user content will be permanently removed.',
|
||||
description: (
|
||||
<div>
|
||||
<p>
|
||||
Leaving the organization will delete all of your saved content in the projects of
|
||||
the organization, which includes:
|
||||
</p>
|
||||
<ul className="list-disc pl-4">
|
||||
<li>
|
||||
SQL snippets <span className="text-foreground">(both private and shared)</span>
|
||||
</li>
|
||||
<li>Custom reports</li>
|
||||
<li>Log Explorer queries</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
onCancel={() => setIsLeaveTeamModalOpen(false)}
|
||||
onConfirm={() => leaveTeam()}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Are you sure you want to leave this organization? This is permanent.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
<ScaffoldContainerLegacy>
|
||||
<ScaffoldTitle>Team</ScaffoldTitle>
|
||||
<ScaffoldFilterAndContent>
|
||||
<ScaffoldActionsContainer className="w-full flex-col md:flex-row gap-2 justify-between">
|
||||
<Input
|
||||
size="tiny"
|
||||
autoComplete="off"
|
||||
icon={<Search size={12} />}
|
||||
value={searchString}
|
||||
onChange={(e: any) => setSearchString(e.target.value)}
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="Filter members"
|
||||
/>
|
||||
<ScaffoldActionsGroup className="w-full md:w-auto">
|
||||
{organizationMembersCreationEnabled &&
|
||||
canAddMembers &&
|
||||
profile !== undefined &&
|
||||
selectedOrganization !== undefined && <InviteMemberButton />}
|
||||
</ScaffoldActionsGroup>
|
||||
</ScaffoldActionsContainer>
|
||||
<ScaffoldSectionContent className="w-full">
|
||||
<MembersView searchString={searchString} />
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldFilterAndContent>
|
||||
</ScaffoldContainerLegacy>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user