Skip to content

Commit

Permalink
Add organization settings (#3324)
Browse files Browse the repository at this point in the history
* Add organization settings

* Update test_organization.py

* Fix TS issues

* Improve permissioning frontend

* Fix an import

* minor typos

* fix cypress

* Make use of AnalyticsDestroyModelMixin

* Solve nits

* Rework Invites empty state

* Update organization test

* Improve organization tests and permissioning

* Polish Organization Settings frontend

* Improve Members UX

* Use RestrictedArea in Project Settings

* Fix APITestMixin._create_user

* some minor copy adjustments

* Use email initial in ProfilePicture

* Clean up org deletion tests

* Improve org/project deletion UX and reliability

* Enhance org deletion test

Co-authored-by: Paolo D'Amico <[email protected]>
  • Loading branch information
Twixes and paolodamico authored Mar 10, 2021
1 parent 506d8a0 commit e50d591
Show file tree
Hide file tree
Showing 28 changed files with 524 additions and 207 deletions.
2 changes: 1 addition & 1 deletion cypress/integration/organizationSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ describe('Organization settings', () => {
it('can navigate to organization settings', () => {
cy.get('[data-attr=top-navigation-whoami]').click()
cy.get('[data-attr=top-menu-item-org-settings]').click()
cy.location('pathname').should('include', '/organization/members')
cy.location('pathname').should('include', '/organization/settings')
})
})
139 changes: 98 additions & 41 deletions ee/api/test/test_organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from rest_framework import status

from ee.api.test.base import APILicensedTest
from posthog.models import organization
from posthog.models.organization import Organization, OrganizationMembership
from posthog.models.team import Team
from posthog.models.user import User


class TestOrganizationEnterpriseAPI(APILicensedTest):
Expand All @@ -29,51 +31,106 @@ def test_delete_second_managed_organization(self):
self.assertFalse(Organization.objects.filter(id=organization.id).exists())
self.assertFalse(Team.objects.filter(id=team.id).exists())

def test_no_delete_last_organization(self):
def test_delete_last_organization(self):
org_id = self.organization.id
self.assertTrue(Organization.objects.filter(id=org_id).exists())

response = self.client.delete(f"/api/organizations/{org_id}")
self.assertEqual(
response.data,
{
"attr": None,
"detail": f"Cannot remove organization since that would leave member {self.CONFIG_USER_EMAIL} organization-less, which is not supported yet.",
"code": "invalid_input",
"type": "validation_error",
},
)
self.assertEqual(response.status_code, 400)
self.assertTrue(Organization.objects.filter(id=org_id).exists())

def test_no_delete_organization_not_administrating(self):
organization, organization_membership, team = Organization.objects.bootstrap(self.user)
organization_membership = cast(OrganizationMembership, organization_membership)
organization_membership.level = OrganizationMembership.Level.MEMBER
organization_membership.save()
self.assertTrue(Organization.objects.filter(id=organization.id).exists())
self.assertTrue(Team.objects.filter(id=team.id).exists())
response = self.client.delete(f"/api/organizations/{organization.id}")
self.assertEqual(response.status_code, 403)
self.assertTrue(Organization.objects.filter(id=organization.id).exists())
self.assertTrue(Team.objects.filter(id=team.id).exists())
self.assertEqual(response.status_code, 204, "Did not successfully delete last organization on the instance")
self.assertFalse(Organization.objects.filter(id=org_id).exists())
self.assertFalse(Organization.objects.exists())

def test_no_delete_organization_not_belonging_to(self):
# as member only
self.organization_membership.level = OrganizationMembership.Level.ADMIN
self.organization_membership.save()
organization = Organization.objects.create(name="Some Other Org")
response_1 = self.client.delete(f"/api/organizations/{organization.id}")
self.assertEqual(
response_1.data, {"attr": None, "detail": "Not found.", "code": "not_found", "type": "invalid_request"}
)
self.assertEqual(response_1.status_code, 404)
self.assertTrue(Organization.objects.filter(id=organization.id).exists())
# as admin
self.organization_membership.level = OrganizationMembership.Level.MEMBER
response_bis = self.client.delete(f"/api/organizations/{org_id}")

self.assertEqual(response_bis.status_code, 404, "Did not return a 404 on trying to delete a nonexistent org")

def test_no_delete_organization_not_owning(self):
for level in (OrganizationMembership.Level.MEMBER, OrganizationMembership.Level.ADMIN):
self.organization_membership.level = level
self.organization_membership.save()
response = self.client.delete(f"/api/organizations/{self.organization.id}")
potential_err_message = f"Somehow managed to delete the org as a level {level} (which is not owner)"
self.assertEqual(
response.data,
{
"attr": None,
"detail": "Your organization access level is insufficient.",
"code": "permission_denied",
"type": "authentication_error",
},
potential_err_message,
)
self.assertEqual(response.status_code, 403, potential_err_message)
self.assertTrue(self.organization.name, self.CONFIG_ORGANIZATION_NAME)

def test_delete_organization_owning(self):
self.organization_membership.level = OrganizationMembership.Level.OWNER
self.organization_membership.save()
response_2 = self.client.delete(f"/api/organizations/{organization.id}")
self.assertEqual(
response_2.data, {"attr": None, "detail": "Not found.", "code": "not_found", "type": "invalid_request"}
membership_ids = OrganizationMembership.objects.filter(organization=self.organization).values_list(
"id", flat=True
)
self.assertEqual(response_2.status_code, 404)
self.assertTrue(Organization.objects.filter(id=organization.id).exists())

response = self.client.delete(f"/api/organizations/{self.organization.id}")

potential_err_message = f"Somehow did not delete the org as the owner"
self.assertEqual(response.status_code, 204, potential_err_message)
self.assertFalse(Organization.objects.filter(id=self.organization.id).exists(), potential_err_message)
self.assertFalse(OrganizationMembership.objects.filter(id__in=membership_ids).exists())
self.assertTrue(User.objects.filter(id=self.user.pk).exists())

def test_no_delete_organization_not_belonging_to(self):
for level in OrganizationMembership.Level:
self.organization_membership.level = level
self.organization_membership.save()
organization = Organization.objects.create(name="Some Other Org")
response = self.client.delete(f"/api/organizations/{organization.id}")
potential_err_message = f"Somehow managed to delete someone else's org as a level {level} in own org"
self.assertEqual(
response.data,
{"attr": None, "detail": "Not found.", "code": "not_found", "type": "invalid_request"},
potential_err_message,
)
self.assertEqual(response.status_code, 404, potential_err_message)
self.assertTrue(Organization.objects.filter(id=organization.id).exists(), potential_err_message)

def test_rename_org(self):
for level in OrganizationMembership.Level:
self.organization_membership.level = level
self.organization_membership.save()
response = self.client.patch(f"/api/organizations/{self.organization.id}", {"name": "Woof"})
self.organization.refresh_from_db()
if level < OrganizationMembership.Level.ADMIN:
potential_err_message = f"Somehow managed to rename the org as a level {level} (which is below admin)"
self.assertEqual(
response.data,
{
"attr": None,
"detail": "Your organization access level is insufficient.",
"code": "permission_denied",
"type": "authentication_error",
},
potential_err_message,
)
self.assertEqual(response.status_code, 403, potential_err_message)
self.assertTrue(self.organization.name, self.CONFIG_ORGANIZATION_NAME)
else:
potential_err_message = f"Somehow did not rename the org as a level {level} (which is at least admin)"
self.assertEqual(response.status_code, 200, potential_err_message)
self.assertTrue(self.organization.name, "Woof")

def test_no_rename_organization_not_belonging_to(self):
for level in OrganizationMembership.Level:
self.organization_membership.level = level
self.organization_membership.save()
organization = Organization.objects.create(name="Meow")
response = self.client.patch(f"/api/organizations/{organization.id}", {"name": "Mooooooooo"})
potential_err_message = f"Somehow managed to rename someone else's org as a level {level} in own org"
self.assertEqual(
response.data,
{"attr": None, "detail": "Not found.", "code": "not_found", "type": "invalid_request"},
potential_err_message,
)
self.assertEqual(response.status_code, 404, potential_err_message)
organization.refresh_from_db()
self.assertTrue(organization.name, "Meow")
9 changes: 6 additions & 3 deletions frontend/src/layout/navigation/TopNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ import { isMobile, platformCommandControlKey } from 'lib/utils'
import { commandPaletteLogic } from 'lib/components/CommandPalette/commandPaletteLogic'
import { Link } from 'lib/components/Link'
import { LinkButton } from 'lib/components/LinkButton'
import { BulkInviteModal } from 'scenes/organization/TeamMembers/BulkInviteModal'
import { BulkInviteModal } from 'scenes/organization/Settings/BulkInviteModal'
import { UserType } from '~/types'
import { CreateInviteModalWithButton } from 'scenes/organization/TeamMembers/CreateInviteModal'
import { CreateInviteModalWithButton } from 'scenes/organization/Settings/CreateInviteModal'
import MD5 from 'crypto-js/md5'

export interface ProfilePictureProps {
Expand All @@ -48,10 +48,13 @@ export function ProfilePicture({ name, email }: ProfilePictureProps): JSX.Elemen
src={gravatarUrl}
onError={() => setDidImageError(true)}
title={`This is ${email}'s Gravatar.`}
alt=""
/>
)
} else if (name) {
return <div className="profile-picture">{name[0]?.toUpperCase()}</div>
} else if (email) {
return <div className="profile-picture">{email[0]?.toUpperCase()}</div>
}
return <div className="profile-picture">?</div>
}
Expand Down Expand Up @@ -116,7 +119,7 @@ export function _TopNavigation(): JSX.Element {
</div>
<div style={{ marginTop: 10 }}>
<LinkButton
to="/organization/members"
to="/organization/settings"
data-attr="top-menu-item-org-settings"
style={{ width: '100%' }}
icon={<SettingOutlined />}
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/lib/components/RestrictedArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Tooltip } from 'antd'
import { useValues } from 'kea'
import React, { useMemo } from 'react'
import { organizationLogic } from '../../scenes/organizationLogic'
import { OrganizationMembershipLevel, organizationMembershipLevelToName } from '../constants'

export interface RestrictedComponentProps {
isRestricted: boolean
restrictionReason: null | string
}

export interface RestrictedAreaProps {
Component: (props: RestrictedComponentProps) => JSX.Element
minimumAccessLevel: OrganizationMembershipLevel
}

export function RestrictedArea({ Component, minimumAccessLevel }: RestrictedAreaProps): JSX.Element {
const { currentOrganization } = useValues(organizationLogic)

const restrictionReason: null | string = useMemo(() => {
if (!currentOrganization) {
return 'Loading current organization…'
}
if (currentOrganization.membership_level === null) {
return 'Your organization membership level is unknown.'
}
if (currentOrganization.membership_level < minimumAccessLevel) {
if (minimumAccessLevel === OrganizationMembershipLevel.Owner) {
return 'This area is restricted to the organization owner.'
}
return `This area is restricted to organization ${organizationMembershipLevelToName.get(
minimumAccessLevel
)}s and up. Your level is ${organizationMembershipLevelToName.get(currentOrganization.membership_level)}.`
}
return null
}, [currentOrganization])

return restrictionReason ? (
<Tooltip title={restrictionReason}>
<span>
<Component isRestricted={true} restrictionReason={restrictionReason} />
</span>
</Tooltip>
) : (
<Component isRestricted={false} restrictionReason={null} />
)
}
2 changes: 1 addition & 1 deletion frontend/src/scenes/onboarding/OnboardingSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { CreateProjectModal } from 'scenes/project/CreateProjectModal'
import { Link } from 'lib/components/Link'
import { IconExternalLink } from 'lib/components/icons'
import { userLogic } from 'scenes/userLogic'
import { BulkInviteModal } from 'scenes/organization/TeamMembers/BulkInviteModal'
import { BulkInviteModal } from 'scenes/organization/Settings/BulkInviteModal'
import { LinkButton } from 'lib/components/LinkButton'
import { organizationLogic } from 'scenes/organizationLogic'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
Expand Down
77 changes: 77 additions & 0 deletions frontend/src/scenes/organization/Settings/DangerZone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useActions, useValues } from 'kea'
import { DeleteOutlined } from '@ant-design/icons'
import { Button, Modal } from 'antd'
import { organizationLogic } from 'scenes/organizationLogic'
import Paragraph from 'antd/lib/typography/Paragraph'
import { RestrictedComponentProps } from '../../../lib/components/RestrictedArea'
import React, { Dispatch, SetStateAction, useState } from 'react'

export function DeleteOrganizationModal({
isVisible,
setIsVisible,
}: {
isVisible: boolean
setIsVisible: Dispatch<SetStateAction<boolean>>
}): JSX.Element {
const { currentOrganization, organizationBeingDeleted } = useValues(organizationLogic)
const { deleteOrganization } = useActions(organizationLogic)

const isDeletionInProgress = !!currentOrganization && organizationBeingDeleted?.id === currentOrganization.id

return (
<Modal
title="Delete the entire organization?"
okText={`Delete ${currentOrganization ? currentOrganization.name : 'the current organization'}`}
okType="danger"
onOk={currentOrganization ? () => deleteOrganization(currentOrganization) : undefined}
okButtonProps={{
// @ts-expect-error - data-attr works just fine despite not being in ButtonProps
'data-attr': 'delete-organization-ok',
loading: isDeletionInProgress,
}}
onCancel={() => setIsVisible(false)}
cancelButtonProps={{
disabled: isDeletionInProgress,
}}
visible={isVisible}
>
Organization deletion <b>cannot be undone</b>. You will lose all data, <b>including all events</b>, related
to all projects within this organization.
</Modal>
)
}

export function DangerZone({ isRestricted }: RestrictedComponentProps): JSX.Element {
const { currentOrganization } = useValues(organizationLogic)

const [isModalVisible, setIsModalVisible] = useState(false)

return (
<>
<div style={{ color: 'var(--danger)' }}>
<h2 style={{ color: 'var(--danger)' }} className="subtitle">
Danger Zone
</h2>
<div className="mt">
{!isRestricted && (
<Paragraph type="danger">
This is <b>irreversible</b>. Please be certain.
</Paragraph>
)}
<Button
type="default"
danger
onClick={() => setIsModalVisible(true)}
className="mr-05"
data-attr="delete-project-button"
icon={<DeleteOutlined />}
disabled={isRestricted}
>
Delete {currentOrganization?.name || 'the current organization'}
</Button>
</div>
</div>
<DeleteOrganizationModal isVisible={isModalVisible} setIsVisible={setIsModalVisible} />
</>
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import { Table, Modal, Divider } from 'antd'
import { Table, Modal } from 'antd'
import { useValues, useActions } from 'kea'
import { invitesLogic } from './invitesLogic'
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
Expand Down Expand Up @@ -99,11 +99,11 @@ function _Invites(): JSX.Element {
},
]

return invites.length ? (
<>
return (
<div>
<h2 className="subtitle" style={{ justifyContent: 'space-between' }}>
Pending Organization Invites
<CreateInviteModalWithButton />
Pending Invites
{!!invites.length && <CreateInviteModalWithButton />}
</h2>
<Table
dataSource={invites}
Expand All @@ -112,12 +112,12 @@ function _Invites(): JSX.Element {
pagination={false}
loading={invitesLoading}
style={{ marginTop: '1rem' }}
locale={{
emptyText: function InvitesTableCTA() {
return <CreateInviteModalWithButton />
},
}}
/>
<Divider />
</>
) : (
<div className="text-right">
<CreateInviteModalWithButton />
</div>
)
}
Loading

0 comments on commit e50d591

Please sign in to comment.