diff --git a/cypress/fixtures/api/notebooks/notebook.json b/cypress/fixtures/api/notebooks/notebook.json index a2e4cb7430eea..d9a9a0b2694d6 100644 --- a/cypress/fixtures/api/notebooks/notebook.json +++ b/cypress/fixtures/api/notebooks/notebook.json @@ -60,5 +60,6 @@ "first_name": "Employee 427", "email": "test@posthog.com", "is_email_verified": null - } + }, + "user_access_level": "editor" } diff --git a/cypress/fixtures/api/notebooks/notebooks.json b/cypress/fixtures/api/notebooks/notebooks.json index 1cfd9a7850315..7c52f5bfc985d 100644 --- a/cypress/fixtures/api/notebooks/notebooks.json +++ b/cypress/fixtures/api/notebooks/notebooks.json @@ -65,7 +65,8 @@ "first_name": "Employee 427", "email": "test@posthog.com", "is_email_verified": null - } + }, + "user_access_level": "editor" } ] } diff --git a/frontend/__snapshots__/components-sharing--dashboard-sharing--dark.png b/frontend/__snapshots__/components-sharing--dashboard-sharing--dark.png index e23321530c8ff..9ab3d98f3577d 100644 Binary files a/frontend/__snapshots__/components-sharing--dashboard-sharing--dark.png and b/frontend/__snapshots__/components-sharing--dashboard-sharing--dark.png differ diff --git a/frontend/__snapshots__/components-sharing--dashboard-sharing--light.png b/frontend/__snapshots__/components-sharing--dashboard-sharing--light.png index 87fd2d661366e..e904c3bf6d99a 100644 Binary files a/frontend/__snapshots__/components-sharing--dashboard-sharing--light.png and b/frontend/__snapshots__/components-sharing--dashboard-sharing--light.png differ diff --git a/frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--dark.png b/frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--dark.png index 697989b30eb5c..8563fab7bb03f 100644 Binary files a/frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--dark.png and b/frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--dark.png differ diff --git a/frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--light.png b/frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--light.png index 37aac937be4dc..a50de7a9d7eda 100644 Binary files a/frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--light.png and b/frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--light.png differ diff --git a/frontend/__snapshots__/components-sharing--insight-sharing--dark.png b/frontend/__snapshots__/components-sharing--insight-sharing--dark.png index ee46e956f9965..6683dfaec9684 100644 Binary files a/frontend/__snapshots__/components-sharing--insight-sharing--dark.png and b/frontend/__snapshots__/components-sharing--insight-sharing--dark.png differ diff --git a/frontend/__snapshots__/components-sharing--insight-sharing--light.png b/frontend/__snapshots__/components-sharing--insight-sharing--light.png index d352a9d91d9af..4f3142465f5a4 100644 Binary files a/frontend/__snapshots__/components-sharing--insight-sharing--light.png and b/frontend/__snapshots__/components-sharing--insight-sharing--light.png differ diff --git a/frontend/__snapshots__/components-sharing--insight-sharing-licensed--dark.png b/frontend/__snapshots__/components-sharing--insight-sharing-licensed--dark.png index 9fc969c540f49..af3b2f58aceaf 100644 Binary files a/frontend/__snapshots__/components-sharing--insight-sharing-licensed--dark.png and b/frontend/__snapshots__/components-sharing--insight-sharing-licensed--dark.png differ diff --git a/frontend/__snapshots__/components-sharing--insight-sharing-licensed--light.png b/frontend/__snapshots__/components-sharing--insight-sharing-licensed--light.png index 8a8d5000bbba0..92014379a2bda 100644 Binary files a/frontend/__snapshots__/components-sharing--insight-sharing-licensed--light.png and b/frontend/__snapshots__/components-sharing--insight-sharing-licensed--light.png differ diff --git a/frontend/__snapshots__/components-sharing--recording-sharing-licensed--dark.png b/frontend/__snapshots__/components-sharing--recording-sharing-licensed--dark.png index 391bffcae7f8f..8b7df18f2525f 100644 Binary files a/frontend/__snapshots__/components-sharing--recording-sharing-licensed--dark.png and b/frontend/__snapshots__/components-sharing--recording-sharing-licensed--dark.png differ diff --git a/frontend/__snapshots__/components-sharing--recording-sharing-licensed--light.png b/frontend/__snapshots__/components-sharing--recording-sharing-licensed--light.png index 08eb57f620393..74e95371f39a3 100644 Binary files a/frontend/__snapshots__/components-sharing--recording-sharing-licensed--light.png and b/frontend/__snapshots__/components-sharing--recording-sharing-licensed--light.png differ diff --git a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-admin--dark.png b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-admin--dark.png index 9533045495757..8f7f4f982a03f 100644 Binary files a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-admin--dark.png and b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-admin--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-admin--light.png b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-admin--light.png index e6876dcaf9403..554d344888b78 100644 Binary files a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-admin--light.png and b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-admin--light.png differ diff --git a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-member--dark.png b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-member--dark.png index c299d9caf9d5d..c36d7a90c5649 100644 Binary files a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-member--dark.png and b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-member--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-member--light.png b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-member--light.png index 3a632563f3dd4..7897cc41d9912 100644 Binary files a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-member--light.png and b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-member--light.png differ diff --git a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-owner--dark.png b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-owner--dark.png index 9533045495757..8f7f4f982a03f 100644 Binary files a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-owner--dark.png and b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-owner--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-owner--light.png b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-owner--light.png index e6876dcaf9403..554d344888b78 100644 Binary files a/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-owner--light.png and b/frontend/__snapshots__/scenes-other-org-member-invites--current-user-is-owner--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png index 32d397abf284c..8de0050efb48e 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png b/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png index 7ae06bd540053..d1592c6883fe3 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-web-vitals--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-web-vitals--dark.png index d16b8c42a3cd8..13b181094b3d0 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-web-vitals--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-web-vitals--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-web-vitals--light.png b/frontend/__snapshots__/scenes-other-settings--settings-web-vitals--light.png index 44413dfa61cd9..706089d18de26 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-web-vitals--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-web-vitals--light.png differ diff --git a/frontend/src/layout/ErrorProjectUnavailable.tsx b/frontend/src/layout/ErrorProjectUnavailable.tsx index 1888661bb42de..c38571870c723 100644 --- a/frontend/src/layout/ErrorProjectUnavailable.tsx +++ b/frontend/src/layout/ErrorProjectUnavailable.tsx @@ -3,6 +3,7 @@ import { useValues } from 'kea' import { PageHeader } from 'lib/components/PageHeader' import { useEffect, useState } from 'react' import { CreateOrganizationModal } from 'scenes/organization/CreateOrganizationModal' +import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' @@ -11,6 +12,7 @@ import { organizationLogic } from '../scenes/organizationLogic' export function ErrorProjectUnavailable(): JSX.Element { const { projectCreationForbiddenReason } = useValues(organizationLogic) const { user } = useValues(userLogic) + const { currentTeam } = useValues(teamLogic) const [options, setOptions] = useState([]) useEffect(() => { @@ -45,7 +47,8 @@ export function ErrorProjectUnavailable(): JSX.Element { {!user?.organization ? ( - ) : user?.team && !user.organization?.teams.some((team) => team.id === user?.team?.id) ? ( + ) : (user?.team && !user.organization?.teams.some((team) => team.id === user?.team?.id || user.team)) || + currentTeam?.user_access_level === 'none' ? ( <>

Project access has been removed

diff --git a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx index 34c18f4fc6ff2..a99679f92f88d 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx @@ -1,6 +1,6 @@ import './SidePanel.scss' -import { IconEllipsis, IconFeatures, IconGear, IconInfo, IconNotebook, IconSupport } from '@posthog/icons' +import { IconEllipsis, IconFeatures, IconGear, IconInfo, IconLock, IconNotebook, IconSupport } from '@posthog/icons' import { LemonButton, LemonMenu, LemonMenuItems, LemonModal } from '@posthog/lemon-ui' import clsx from 'clsx' import { useActions, useValues } from 'kea' @@ -16,6 +16,7 @@ import { import { themeLogic } from '~/layout/navigation-3000/themeLogic' import { SidePanelTab } from '~/types' +import { SidePanelAccessControl } from './panels/access_control/SidePanelAccessControl' import { SidePanelActivation, SidePanelActivationIcon } from './panels/activation/SidePanelActivation' import { SidePanelActivity, SidePanelActivityIcon } from './panels/activity/SidePanelActivity' import { SidePanelDiscussion, SidePanelDiscussionIcon } from './panels/discussion/SidePanelDiscussion' @@ -87,6 +88,11 @@ export const SIDE_PANEL_TABS: Record< Content: SidePanelStatus, noModalSupport: true, }, + [SidePanelTab.AccessControl]: { + label: 'Access control', + Icon: IconLock, + Content: SidePanelAccessControl, + }, } const DEFAULT_WIDTH = 512 diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/AccessControlObject.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/AccessControlObject.tsx new file mode 100644 index 0000000000000..93e14755e12d5 --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/AccessControlObject.tsx @@ -0,0 +1,383 @@ +import { IconX } from '@posthog/icons' +import { + LemonBanner, + LemonButton, + LemonDialog, + LemonInputSelect, + LemonSelect, + LemonSelectProps, + LemonTable, +} from '@posthog/lemon-ui' +import { BindLogic, useActions, useAsyncActions, useValues } from 'kea' +import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' +import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' +import { UserSelectItem } from 'lib/components/UserSelectItem' +import { LemonTableColumns } from 'lib/lemon-ui/LemonTable' +import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' +import { ProfileBubbles, ProfilePicture } from 'lib/lemon-ui/ProfilePicture' +import { capitalizeFirstLetter } from 'lib/utils' +import { useEffect, useState } from 'react' +import { urls } from 'scenes/urls' +import { userLogic } from 'scenes/userLogic' + +import { + AccessControlType, + AccessControlTypeMember, + AccessControlTypeRole, + AvailableFeature, + OrganizationMemberType, +} from '~/types' + +import { accessControlLogic, AccessControlLogicProps } from './accessControlLogic' + +export function AccessControlObject(props: AccessControlLogicProps): JSX.Element | null { + const { canEditAccessControls, humanReadableResource } = useValues(accessControlLogic(props)) + + const suffix = `this ${humanReadableResource}` + + return ( + +

+ {canEditAccessControls === false ? ( + + You don't have permission to edit access controls for {suffix}. +
+ You must be the creator of it, a Project Admin, or an Organization Admin. +
+ ) : null} +

Default access to {suffix}

+ + +

Members

+ + + + +

Roles

+ + + +
+ + ) +} + +function AccessControlObjectDefaults(): JSX.Element | null { + const { accessControlDefault, accessControlDefaultOptions, accessControlsLoading, canEditAccessControls } = + useValues(accessControlLogic) + const { updateAccessControlDefault } = useActions(accessControlLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) + + return ( + { + guardAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING, () => { + updateAccessControlDefault(newValue) + }) + }} + disabledReason={ + accessControlsLoading ? 'Loading…' : !canEditAccessControls ? 'You cannot edit this' : undefined + } + dropdownMatchSelectWidth={false} + options={accessControlDefaultOptions} + /> + ) +} + +function AccessControlObjectUsers(): JSX.Element | null { + const { user } = useValues(userLogic) + const { membersById, addableMembers, accessControlMembers, accessControlsLoading, availableLevels } = + useValues(accessControlLogic) + const { updateAccessControlMembers } = useAsyncActions(accessControlLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) + + if (!user) { + return null + } + + const member = (ac: AccessControlTypeMember): OrganizationMemberType => { + return membersById[ac.organization_member] + } + + // TODO: WHAT A MESS - Fix this to do the index mapping beforehand... + const columns: LemonTableColumns = [ + { + key: 'user_profile_picture', + render: function ProfilePictureRender(_, ac) { + return + }, + width: 32, + }, + { + title: 'Name', + key: 'user_first_name', + render: (_, ac) => ( + + {member(ac)?.user.uuid == user.uuid + ? `${member(ac)?.user.first_name} (you)` + : member(ac)?.user.first_name} + + ), + sorter: (a, b) => member(a)?.user.first_name.localeCompare(member(b)?.user.first_name), + }, + { + title: 'Email', + key: 'user_email', + render: (_, ac) => member(ac)?.user.email, + sorter: (a, b) => member(a)?.user.email.localeCompare(member(b)?.user.email), + }, + { + title: 'Level', + key: 'level', + width: 0, + render: function LevelRender(_, { access_level, organization_member }) { + return ( +
+ + void updateAccessControlMembers([{ member: organization_member, level }]) + } + /> +
+ ) + }, + }, + { + key: 'remove', + width: 0, + render: (_, { organization_member }) => { + return ( + + void updateAccessControlMembers([{ member: organization_member, level: null }]) + } + /> + ) + }, + }, + ] + + return ( +
+ { + if (guardAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING)) { + await updateAccessControlMembers(newValues.map((member) => ({ member, level }))) + } + }} + options={addableMembers.map((member) => ({ + key: member.id, + label: `${member.user.first_name} ${member.user.email}`, + labelComponent: , + }))} + /> + + +
+ ) +} + +function AccessControlObjectRoles(): JSX.Element | null { + const { accessControlRoles, accessControlsLoading, addableRoles, rolesById, availableLevels } = + useValues(accessControlLogic) + const { updateAccessControlRoles } = useAsyncActions(accessControlLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) + + const columns: LemonTableColumns = [ + { + title: 'Role', + key: 'role', + width: 0, + render: (_, { role }) => ( + + + + ), + }, + { + title: 'Members', + key: 'members', + render: (_, { role }) => { + return ( + ({ + email: member.user.email, + name: member.user.first_name, + title: `${member.user.first_name} <${member.user.email}>`, + })) ?? [] + } + /> + ) + }, + }, + { + title: 'Level', + key: 'level', + width: 0, + render: (_, { access_level, role }) => { + return ( +
+ void updateAccessControlRoles([{ role, level }])} + /> +
+ ) + }, + }, + { + key: 'remove', + width: 0, + render: (_, { role }) => { + return ( + void updateAccessControlRoles([{ role, level: null }])} + /> + ) + }, + }, + ] + + return ( +
+ { + if (guardAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING)) { + await updateAccessControlRoles(newValues.map((role) => ({ role, level }))) + } + }} + options={addableRoles.map((role) => ({ + key: role.id, + label: role.name, + }))} + /> + + +
+ ) +} + +function SimplLevelComponent(props: { + size?: LemonSelectProps['size'] + level: AccessControlType['access_level'] | null + levels: AccessControlType['access_level'][] + onChange: (newValue: AccessControlType['access_level']) => void +}): JSX.Element | null { + const { canEditAccessControls } = useValues(accessControlLogic) + + return ( + props.onChange(newValue)} + disabledReason={!canEditAccessControls ? 'You cannot edit this' : undefined} + options={props.levels.map((level) => ({ + value: level, + label: capitalizeFirstLetter(level ?? ''), + }))} + /> + ) +} + +function RemoveAccessButton({ + onConfirm, + subject, +}: { + onConfirm: () => void + subject: 'member' | 'role' +}): JSX.Element { + const { canEditAccessControls } = useValues(accessControlLogic) + + return ( + } + status="danger" + size="small" + disabledReason={!canEditAccessControls ? 'You cannot edit this' : undefined} + onClick={() => + LemonDialog.open({ + title: 'Remove access', + content: `Are you sure you want to remove this ${subject}'s explicit access?`, + primaryButton: { + children: 'Remove', + status: 'danger', + onClick: () => onConfirm(), + }, + }) + } + /> + ) +} + +function AddItemsControls(props: { + placeholder: string + onAdd: (newValues: string[], level: AccessControlType['access_level']) => Promise + options: { + key: string + label: string + }[] +}): JSX.Element | null { + const { availableLevels, canEditAccessControls } = useValues(accessControlLogic) + // TODO: Move this into a form logic + const [items, setItems] = useState([]) + const [level, setLevel] = useState(availableLevels[0] ?? null) + + useEffect(() => { + setLevel(availableLevels[0] ?? null) + }, [availableLevels]) + + const onSubmit = + items.length && level + ? (): void => + void props.onAdd(items, level).then(() => { + setItems([]) + setLevel(availableLevels[0] ?? null) + }) + : undefined + + return ( +
+
+ setItems(newValues)} + mode="multiple" + options={props.options} + disabled={!canEditAccessControls} + /> +
+ + + + Add + +
+ ) +} diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/RolesAndResourceAccessControls.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/RolesAndResourceAccessControls.tsx new file mode 100644 index 0000000000000..c235eeacb01ea --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/RolesAndResourceAccessControls.tsx @@ -0,0 +1,323 @@ +import { IconPlus } from '@posthog/icons' +import { + LemonButton, + LemonDialog, + LemonInput, + LemonInputSelect, + LemonModal, + LemonSelect, + LemonTable, + LemonTableColumns, + ProfileBubbles, + ProfilePicture, +} from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { capitalizeFirstLetter, Form } from 'kea-forms' +import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' +import { usersLemonSelectOptions } from 'lib/components/UserSelectItem' +import { LemonField } from 'lib/lemon-ui/LemonField' +import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' +import { fullName } from 'lib/utils' +import { useMemo, useState } from 'react' +import { userLogic } from 'scenes/userLogic' + +import { AvailableFeature } from '~/types' + +import { roleBasedAccessControlLogic, RoleWithResourceAccessControls } from './roleBasedAccessControlLogic' + +export type RolesAndResourceAccessControlsProps = { + noAccessControls?: boolean +} + +export function RolesAndResourceAccessControls({ noAccessControls }: RolesAndResourceAccessControlsProps): JSX.Element { + const { + rolesWithResourceAccessControls, + rolesLoading, + roleBasedAccessControlsLoading, + resources, + availableLevels, + selectedRoleId, + defaultAccessLevel, + } = useValues(roleBasedAccessControlLogic) + + const { updateRoleBasedAccessControls, selectRoleId, setEditingRoleId } = useActions(roleBasedAccessControlLogic) + + const roleColumns = noAccessControls + ? [] + : resources.map((resource) => ({ + title: resource.replace(/_/g, ' ') + 's', + key: resource, + width: 0, + render: (_: any, { accessControlByResource, role }: RoleWithResourceAccessControls) => { + const ac = accessControlByResource[resource] + + return ( + + updateRoleBasedAccessControls([ + { + resource, + role: role?.id ?? null, + access_level: newValue, + }, + ]) + } + options={availableLevels.map((level) => ({ + value: level, + label: capitalizeFirstLetter(level ?? ''), + }))} + /> + ) + }, + })) + + const columns: LemonTableColumns = [ + { + title: 'Role', + key: 'role', + width: 0, + render: (_, { role }) => ( + + (role.id === selectedRoleId ? selectRoleId(null) : selectRoleId(role.id)) + : undefined + } + title={role?.name ?? 'Default'} + /> + + ), + }, + { + title: 'Members', + key: 'members', + render: (_, { role }) => { + return role ? ( + role.members.length ? ( + ({ + email: member.user.email, + name: member.user.first_name, + title: `${member.user.first_name} <${member.user.email}>`, + }))} + onClick={() => (role.id === selectedRoleId ? selectRoleId(null) : selectRoleId(role.id))} + /> + ) : ( + 'No members' + ) + ) : ( + 'All members' + ) + }, + }, + + ...roleColumns, + ] + + return ( +
+

Use roles to group your organization members and assign them permissions.

+ + +
+ !!selectedRoleId && role?.id === selectedRoleId, + onRowExpand: ({ role }) => (role ? selectRoleId(role.id) : undefined), + onRowCollapse: () => selectRoleId(null), + expandedRowRender: ({ role }) => (role ? : null), + rowExpandable: ({ role }) => !!role, + }} + /> + + setEditingRoleId('new')} icon={}> + Add a role + + +
+
+
+ ) +} + +function RoleDetails({ roleId }: { roleId: string }): JSX.Element | null { + const { user } = useValues(userLogic) + const { sortedMembers, roles, canEditRoleBasedAccessControls } = useValues(roleBasedAccessControlLogic) + const { addMembersToRole, removeMemberFromRole, setEditingRoleId } = useActions(roleBasedAccessControlLogic) + const [membersToAdd, setMembersToAdd] = useState([]) + + const role = roles?.find((role) => role.id === roleId) + + const onSubmit = membersToAdd.length + ? () => { + role && addMembersToRole(role, membersToAdd) + setMembersToAdd([]) + } + : undefined + + const membersNotInRole = useMemo(() => { + const membersInRole = new Set(role?.members.map((member) => member.user.uuid)) + return sortedMembers?.filter((member) => !membersInRole.has(member.user.uuid)) ?? [] + }, [role?.members, sortedMembers]) + + if (!role) { + // This is mostly for typing + return null + } + + return ( +
+
+
+
+ setMembersToAdd(newValues)} + mode="multiple" + disabled={!canEditRoleBasedAccessControls} + options={usersLemonSelectOptions( + membersNotInRole.map((member) => member.user), + 'uuid' + )} + /> +
+ + + Add members + +
+
+ setEditingRoleId(role.id)} + disabledReason={!canEditRoleBasedAccessControls ? 'You cannot edit this' : undefined} + > + Edit + +
+
+ + + }, + width: 32, + }, + { + title: 'Name', + key: 'user_name', + render: (_, member) => + member.user.uuid == user?.uuid ? `${fullName(member.user)} (you)` : fullName(member.user), + sorter: (a, b) => fullName(a.user).localeCompare(fullName(b.user)), + }, + { + title: 'Email', + key: 'user_email', + render: (_, member) => { + return <>{member.user.email} + }, + sorter: (a, b) => a.user.email.localeCompare(b.user.email), + }, + { + key: 'actions', + width: 0, + render: (_, member) => { + return ( +
+ removeMemberFromRole(role, member.id)} + > + Remove + +
+ ) + }, + }, + ]} + dataSource={role.members} + /> +
+ ) +} + +function RoleModal(): JSX.Element { + const { editingRoleId } = useValues(roleBasedAccessControlLogic) + const { setEditingRoleId, submitEditingRole, deleteRole } = useActions(roleBasedAccessControlLogic) + const isEditing = editingRoleId !== 'new' + + const onDelete = (): void => { + LemonDialog.open({ + title: 'Delete role', + content: 'Are you sure you want to delete this role? This action cannot be undone.', + primaryButton: { + children: 'Delete permanently', + onClick: () => deleteRole(editingRoleId as string), + status: 'danger', + }, + secondaryButton: { + children: 'Cancel', + }, + }) + } + + return ( +
+ setEditingRoleId(null)} + title={!isEditing ? 'Create' : `Edit`} + footer={ + <> +
+ {isEditing ? ( + onDelete()}> + Delete + + ) : null} +
+ + setEditingRoleId(null)}> + Cancel + + + + {!isEditing ? 'Create' : 'Save'} + + + } + > + + + +
+
+ ) +} diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/SidePanelAccessControl.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/SidePanelAccessControl.tsx new file mode 100644 index 0000000000000..266b012ebcd77 --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/SidePanelAccessControl.tsx @@ -0,0 +1,25 @@ +import { useValues } from 'kea' + +import { SidePanelPaneHeader } from '../../components/SidePanelPaneHeader' +import { sidePanelContextLogic } from '../sidePanelContextLogic' +import { AccessControlObject } from './AccessControlObject' + +export const SidePanelAccessControl = (): JSX.Element => { + const { sceneSidePanelContext } = useValues(sidePanelContextLogic) + + return ( +
+ +
+ {sceneSidePanelContext.access_control_resource && sceneSidePanelContext.access_control_resource_id ? ( + + ) : ( +

Not supported

+ )} +
+
+ ) +} diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts new file mode 100644 index 0000000000000..8182b41c2b602 --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts @@ -0,0 +1,250 @@ +import { LemonSelectOption } from '@posthog/lemon-ui' +import { actions, afterMount, connect, kea, key, listeners, path, props, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import api from 'lib/api' +import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' +import { toSentenceCase } from 'lib/utils' +import { membersLogic } from 'scenes/organization/membersLogic' +import { teamLogic } from 'scenes/teamLogic' + +import { + AccessControlResponseType, + AccessControlType, + AccessControlTypeMember, + AccessControlTypeProject, + AccessControlTypeRole, + AccessControlUpdateType, + APIScopeObject, + OrganizationMemberType, + RoleType, +} from '~/types' + +import type { accessControlLogicType } from './accessControlLogicType' +import { roleBasedAccessControlLogic } from './roleBasedAccessControlLogic' + +export type AccessControlLogicProps = { + resource: APIScopeObject + resource_id: string +} + +export const accessControlLogic = kea([ + props({} as AccessControlLogicProps), + key((props) => `${props.resource}-${props.resource_id}`), + path((key) => ['scenes', 'accessControl', 'accessControlLogic', key]), + connect({ + values: [ + membersLogic, + ['sortedMembers'], + teamLogic, + ['currentTeam'], + roleBasedAccessControlLogic, + ['roles'], + upgradeModalLogic, + ['guardAvailableFeature'], + ], + actions: [membersLogic, ['ensureAllMembersLoaded']], + }), + actions({ + updateAccessControl: ( + accessControl: Pick + ) => ({ accessControl }), + updateAccessControlDefault: (level: AccessControlType['access_level']) => ({ + level, + }), + updateAccessControlRoles: ( + accessControls: { + role: RoleType['id'] + level: AccessControlType['access_level'] + }[] + ) => ({ accessControls }), + updateAccessControlMembers: ( + accessControls: { + member: OrganizationMemberType['id'] + level: AccessControlType['access_level'] + }[] + ) => ({ accessControls }), + }), + loaders(({ values }) => ({ + accessControls: [ + null as AccessControlResponseType | null, + { + loadAccessControls: async () => { + try { + const response = await api.get(values.endpoint) + return response + } catch (error) { + // Return empty access controls + return { + access_controls: [], + available_access_levels: ['none', 'viewer', 'editor'], + user_access_level: 'none', + default_access_level: 'none', + user_can_edit_access_levels: false, + } + } + }, + + updateAccessControlDefault: async ({ level }) => { + await api.put(values.endpoint, { + access_level: level, + }) + + return values.accessControls + }, + + updateAccessControlRoles: async ({ accessControls }) => { + for (const { role, level } of accessControls) { + await api.put(values.endpoint, { + role: role, + access_level: level, + }) + } + + return values.accessControls + }, + + updateAccessControlMembers: async ({ accessControls }) => { + for (const { member, level } of accessControls) { + await api.put(values.endpoint, { + organization_member: member, + access_level: level, + }) + } + + return values.accessControls + }, + }, + ], + })), + listeners(({ actions }) => ({ + updateAccessControlDefaultSuccess: () => actions.loadAccessControls(), + updateAccessControlRolesSuccess: () => actions.loadAccessControls(), + updateAccessControlMembersSuccess: () => actions.loadAccessControls(), + })), + selectors({ + endpoint: [ + () => [(_, props) => props], + (props): string => { + // TODO: This is far from perfect... but it's a start + if (props.resource === 'project') { + return `api/projects/@current/access_controls` + } + return `api/projects/@current/${props.resource}s/${props.resource_id}/access_controls` + }, + ], + humanReadableResource: [ + () => [(_, props) => props], + (props): string => { + return props.resource.replace(/_/g, ' ') + }, + ], + + availableLevelsWithNone: [ + (s) => [s.accessControls], + (accessControls): string[] => { + return accessControls?.available_access_levels ?? [] + }, + ], + + availableLevels: [ + (s) => [s.availableLevelsWithNone], + (availableLevelsWithNone): string[] => { + return availableLevelsWithNone.filter((level) => level !== 'none') + }, + ], + + canEditAccessControls: [ + (s) => [s.accessControls], + (accessControls): boolean | null => { + return accessControls?.user_can_edit_access_levels ?? null + }, + ], + + accessControlDefaultLevel: [ + (s) => [s.accessControls], + (accessControls): string | null => { + return accessControls?.default_access_level ?? null + }, + ], + + accessControlDefaultOptions: [ + (s) => [s.availableLevelsWithNone, (_, props) => props.resource], + (availableLevelsWithNone): LemonSelectOption[] => { + const options = availableLevelsWithNone.map((level) => ({ + value: level, + // TODO: Correct "a" and "an" + label: level === 'none' ? 'No access' : toSentenceCase(level), + })) + + return options + }, + ], + accessControlDefault: [ + (s) => [s.accessControls, s.accessControlDefaultLevel], + (accessControls, accessControlDefaultLevel): AccessControlTypeProject => { + const found = accessControls?.access_controls?.find( + (accessControl) => !accessControl.organization_member && !accessControl.role + ) as AccessControlTypeProject + return ( + found ?? { + access_level: accessControlDefaultLevel, + } + ) + }, + ], + + accessControlMembers: [ + (s) => [s.accessControls], + (accessControls): AccessControlTypeMember[] => { + return (accessControls?.access_controls || []).filter( + (accessControl) => !!accessControl.organization_member + ) as AccessControlTypeMember[] + }, + ], + + accessControlRoles: [ + (s) => [s.accessControls], + (accessControls): AccessControlTypeRole[] => { + return (accessControls?.access_controls || []).filter( + (accessControl) => !!accessControl.role + ) as AccessControlTypeRole[] + }, + ], + + rolesById: [ + (s) => [s.roles], + (roles): Record => { + return Object.fromEntries((roles || []).map((role) => [role.id, role])) + }, + ], + + addableRoles: [ + (s) => [s.roles, s.accessControlRoles], + (roles, accessControlRoles): RoleType[] => { + return roles ? roles.filter((role) => !accessControlRoles.find((ac) => ac.role === role.id)) : [] + }, + ], + + membersById: [ + (s) => [s.sortedMembers], + (members): Record => { + return Object.fromEntries((members || []).map((member) => [member.id, member])) + }, + ], + + addableMembers: [ + (s) => [s.sortedMembers, s.accessControlMembers], + (members, accessControlMembers): any[] => { + return members + ? members.filter( + (member) => !accessControlMembers.find((ac) => ac.organization_member === member.id) + ) + : [] + }, + ], + }), + afterMount(({ actions }) => { + actions.loadAccessControls() + actions.ensureAllMembersLoaded() + }), +]) diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/roleBasedAccessControlLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/roleBasedAccessControlLogic.ts new file mode 100644 index 0000000000000..87d885844bfb1 --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/roleBasedAccessControlLogic.ts @@ -0,0 +1,269 @@ +import { lemonToast } from '@posthog/lemon-ui' +import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { forms } from 'kea-forms' +import { loaders } from 'kea-loaders' +import { actionToUrl, router } from 'kea-router' +import api from 'lib/api' +import { membersLogic } from 'scenes/organization/membersLogic' +import { teamLogic } from 'scenes/teamLogic' +import { userLogic } from 'scenes/userLogic' + +import { + AccessControlResponseType, + AccessControlType, + AccessControlTypeRole, + AccessControlUpdateType, + APIScopeObject, + AvailableFeature, + RoleType, +} from '~/types' + +import type { roleBasedAccessControlLogicType } from './roleBasedAccessControlLogicType' + +export type RoleWithResourceAccessControls = { + role?: RoleType + accessControlByResource: Record +} + +export const roleBasedAccessControlLogic = kea([ + path(['scenes', 'accessControl', 'roleBasedAccessControlLogic']), + connect({ + values: [membersLogic, ['sortedMembers'], teamLogic, ['currentTeam'], userLogic, ['hasAvailableFeature']], + actions: [membersLogic, ['ensureAllMembersLoaded']], + }), + actions({ + updateRoleBasedAccessControls: ( + accessControls: Pick[] + ) => ({ accessControls }), + selectRoleId: (roleId: RoleType['id'] | null) => ({ roleId }), + deleteRole: (roleId: RoleType['id']) => ({ roleId }), + removeMemberFromRole: (role: RoleType, roleMemberId: string) => ({ role, roleMemberId }), + addMembersToRole: (role: RoleType, members: string[]) => ({ role, members }), + setEditingRoleId: (roleId: string | null) => ({ roleId }), + }), + reducers({ + selectedRoleId: [ + null as string | null, + { + selectRoleId: (_, { roleId }) => roleId, + }, + ], + editingRoleId: [ + null as string | null, + { + setEditingRoleId: (_, { roleId }) => roleId, + }, + ], + }), + loaders(({ values }) => ({ + roleBasedAccessControls: [ + null as AccessControlResponseType | null, + { + loadRoleBasedAccessControls: async () => { + const response = await api.get( + 'api/projects/@current/global_access_controls' + ) + return response + }, + + updateRoleBasedAccessControls: async ({ accessControls }) => { + for (const control of accessControls) { + await api.put('api/projects/@current/global_access_controls', { + ...control, + }) + } + + return values.roleBasedAccessControls + }, + }, + ], + + roles: [ + null as RoleType[] | null, + { + loadRoles: async () => { + const response = await api.roles.list() + return response?.results || [] + }, + addMembersToRole: async ({ role, members }) => { + if (!values.roles) { + return null + } + const newMembers = await Promise.all( + members.map(async (userUuid: string) => await api.roles.members.create(role.id, userUuid)) + ) + + role.members = [...role.members, ...newMembers] + + return [...values.roles] + }, + removeMemberFromRole: async ({ role, roleMemberId }) => { + if (!values.roles) { + return null + } + await api.roles.members.delete(role.id, roleMemberId) + role.members = role.members.filter((roleMember) => roleMember.id !== roleMemberId) + return [...values.roles] + }, + deleteRole: async ({ roleId }) => { + const role = values.roles?.find((r) => r.id === roleId) + if (!role) { + return values.roles + } + await api.roles.delete(role.id) + lemonToast.success(`Role "${role.name}" deleted`) + return values.roles?.filter((r) => r.id !== role.id) || [] + }, + }, + ], + })), + + forms(({ values, actions }) => ({ + editingRole: { + defaults: { + name: '', + }, + errors: ({ name }) => { + return { + name: !name ? 'Please choose a name for the role' : null, + } + }, + submit: async ({ name }) => { + if (!values.editingRoleId) { + return + } + let role: RoleType | null = null + if (values.editingRoleId === 'new') { + role = await api.roles.create(name) + } else { + role = await api.roles.update(values.editingRoleId, { name }) + } + + actions.loadRoles() + actions.setEditingRoleId(null) + actions.selectRoleId(role.id) + }, + }, + })), + + listeners(({ actions, values }) => ({ + updateRoleBasedAccessControlsSuccess: () => actions.loadRoleBasedAccessControls(), + loadRolesSuccess: () => { + if (router.values.hashParams.role) { + actions.selectRoleId(router.values.hashParams.role) + } + }, + deleteRoleSuccess: () => { + actions.loadRoles() + actions.setEditingRoleId(null) + actions.selectRoleId(null) + }, + + setEditingRoleId: () => { + const existingRole = values.roles?.find((role) => role.id === values.editingRoleId) + actions.resetEditingRole({ + name: existingRole?.name || '', + }) + }, + })), + + selectors({ + availableLevels: [ + (s) => [s.roleBasedAccessControls], + (roleBasedAccessControls): string[] => { + return roleBasedAccessControls?.available_access_levels ?? [] + }, + ], + + defaultAccessLevel: [ + (s) => [s.roleBasedAccessControls], + (roleBasedAccessControls): string | null => { + return roleBasedAccessControls?.default_access_level ?? null + }, + ], + + defaultResourceAccessControls: [ + (s) => [s.roleBasedAccessControls], + (roleBasedAccessControls): RoleWithResourceAccessControls => { + const accessControls = roleBasedAccessControls?.access_controls ?? [] + + // Find all acs without a roles (they are the default ones) + const accessControlByResource = accessControls + .filter((control) => !control.role) + .reduce( + (acc, control) => ({ + ...acc, + [control.resource]: control, + }), + {} as Record + ) + + return { accessControlByResource } + }, + ], + + rolesWithResourceAccessControls: [ + (s) => [s.roles, s.roleBasedAccessControls, s.defaultResourceAccessControls], + (roles, roleBasedAccessControls, defaultResourceAccessControls): RoleWithResourceAccessControls[] => { + if (!roles) { + return [] + } + + const accessControls = roleBasedAccessControls?.access_controls ?? [] + + return [ + defaultResourceAccessControls, + ...roles.map((role) => { + const accessControlByResource = accessControls + .filter((control) => control.role === role.id) + .reduce( + (acc, control) => ({ + ...acc, + [control.resource]: control, + }), + {} as Record + ) + + return { role, accessControlByResource } + }), + ] + }, + ], + + resources: [ + () => [], + (): AccessControlType['resource'][] => { + // TODO: Sync this as an enum + return ['feature_flag', 'dashboard', 'insight', 'notebook'] + }, + ], + + canEditRoleBasedAccessControls: [ + (s) => [s.roleBasedAccessControls], + (roleBasedAccessControls): boolean | null => { + return roleBasedAccessControls?.user_can_edit_access_levels ?? null + }, + ], + }), + afterMount(({ actions, values }) => { + if (values.hasAvailableFeature(AvailableFeature.ROLE_BASED_ACCESS)) { + actions.loadRoles() + actions.loadRoleBasedAccessControls() + actions.ensureAllMembersLoaded() + } + }), + + actionToUrl(({ values }) => ({ + selectRoleId: () => { + const { currentLocation } = router.values + return [ + currentLocation.pathname, + currentLocation.searchParams, + { + ...currentLocation.hashParams, + role: values.selectedRoleId ?? undefined, + }, + ] + }, + })), +]) diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx index 244e42c52d936..079433affb717 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx @@ -10,12 +10,21 @@ import { toParams } from 'lib/utils' import posthog from 'posthog-js' import { projectLogic } from 'scenes/projectLogic' +import { ActivityScope, UserBasicType } from '~/types' + import { sidePanelStateLogic } from '../../sidePanelStateLogic' -import { ActivityFilters, activityForSceneLogic } from './activityForSceneLogic' +import { SidePanelSceneContext } from '../../types' +import { sidePanelContextLogic } from '../sidePanelContextLogic' import type { sidePanelActivityLogicType } from './sidePanelActivityLogicType' const POLL_TIMEOUT = 5 * 60 * 1000 +export type ActivityFilters = { + scope?: ActivityScope + item_id?: ActivityLogItem['item_id'] + user?: UserBasicType['id'] +} + export interface ChangelogFlagPayload { notificationDate: dayjs.Dayjs markdown: string @@ -36,7 +45,7 @@ export enum SidePanelActivityTab { export const sidePanelActivityLogic = kea([ path(['scenes', 'navigation', 'sidepanel', 'sidePanelActivityLogic']), connect({ - values: [activityForSceneLogic, ['sceneActivityFilters'], projectLogic, ['currentProjectId']], + values: [sidePanelContextLogic, ['sceneSidePanelContext'], projectLogic, ['currentProjectId']], actions: [sidePanelStateLogic, ['openSidePanel']], }), actions({ @@ -267,8 +276,16 @@ export const sidePanelActivityLogic = kea([ }), subscriptions(({ actions, values }) => ({ - sceneActivityFilters: (activityFilters) => { - actions.setFiltersForCurrentPage(activityFilters ? { ...values.filters, ...activityFilters } : null) + sceneSidePanelContext: (sceneSidePanelContext: SidePanelSceneContext) => { + actions.setFiltersForCurrentPage( + sceneSidePanelContext + ? { + ...values.filters, + scope: sceneSidePanelContext.activity_scope, + item_id: sceneSidePanelContext.activity_item_id, + } + : null + ) }, filters: () => { if (values.activeTab === SidePanelActivityTab.All) { @@ -280,7 +297,7 @@ export const sidePanelActivityLogic = kea([ afterMount(({ actions, values }) => { actions.loadImportantChanges() - const activityFilters = values.sceneActivityFilters + const activityFilters = values.sceneSidePanelContext actions.setFiltersForCurrentPage(activityFilters ? { ...values.filters, ...activityFilters } : null) }), diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts index 5793deba3469f..9d1ba1d536d9b 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts @@ -6,7 +6,7 @@ import { FEATURE_FLAGS } from 'lib/constants' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { CommentsLogicProps } from 'scenes/comments/commentsLogic' -import { activityForSceneLogic } from '../activity/activityForSceneLogic' +import { sidePanelContextLogic } from '../sidePanelContextLogic' import type { sidePanelDiscussionLogicType } from './sidePanelDiscussionLogicType' export const sidePanelDiscussionLogic = kea([ @@ -16,7 +16,7 @@ export const sidePanelDiscussionLogic = kea([ resetCommentCount: true, }), connect({ - values: [featureFlagLogic, ['featureFlags'], activityForSceneLogic, ['sceneActivityFilters']], + values: [featureFlagLogic, ['featureFlags'], sidePanelContextLogic, ['sceneSidePanelContext']], }), loaders(({ values }) => ({ commentCount: [ @@ -45,12 +45,12 @@ export const sidePanelDiscussionLogic = kea([ selectors({ commentsLogicProps: [ - (s) => [s.sceneActivityFilters], - (activityFilters): CommentsLogicProps | null => { - return activityFilters?.scope + (s) => [s.sceneSidePanelContext], + (sceneSidePanelContext): CommentsLogicProps | null => { + return sceneSidePanelContext.activity_scope ? { - scope: activityFilters.scope, - item_id: activityFilters.item_id, + scope: sceneSidePanelContext.activity_scope, + item_id: sceneSidePanelContext.activity_item_id, } : null }, diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/exports/sidePanelExportsLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/exports/sidePanelExportsLogic.ts index c9107c4ac695f..8f26e5927842e 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/exports/sidePanelExportsLogic.ts +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/exports/sidePanelExportsLogic.ts @@ -1,23 +1,14 @@ import { afterMount, connect, kea, path } from 'kea' import { exportsLogic } from 'lib/components/ExportButton/exportsLogic' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic' -import { activityForSceneLogic } from '../activity/activityForSceneLogic' import type { sidePanelExportsLogicType } from './sidePanelExportsLogicType' export const sidePanelExportsLogic = kea([ path(['scenes', 'navigation', 'sidepanel', 'sidePanelExportsLogic']), connect({ - values: [ - featureFlagLogic, - ['featureFlags'], - activityForSceneLogic, - ['sceneActivityFilters'], - exportsLogic, - ['exports', 'freshUndownloadedExports'], - ], + values: [exportsLogic, ['exports', 'freshUndownloadedExports']], actions: [sidePanelStateLogic, ['openSidePanel'], exportsLogic, ['loadExports', 'removeFresh']], }), afterMount(({ actions }) => { diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelContextLogic.ts similarity index 59% rename from frontend/src/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic.ts rename to frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelContextLogic.ts index 641c0900638ef..1de9b8e00e251 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic.ts +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelContextLogic.ts @@ -1,22 +1,15 @@ import { connect, kea, path, selectors } from 'kea' import { router } from 'kea-router' import { objectsEqual } from 'kea-test-utils' -import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' import { removeProjectIdIfPresent } from 'lib/utils/router-utils' import { sceneLogic } from 'scenes/sceneLogic' import { SceneConfig } from 'scenes/sceneTypes' -import { ActivityScope, UserBasicType } from '~/types' +import { SidePanelSceneContext } from '../types' +import { SIDE_PANEL_CONTEXT_KEY } from '../types' +import type { sidePanelContextLogicType } from './sidePanelContextLogicType' -import type { activityForSceneLogicType } from './activityForSceneLogicType' - -export type ActivityFilters = { - scope?: ActivityScope - item_id?: ActivityLogItem['item_id'] - user?: UserBasicType['id'] -} - -export const activityFiltersForScene = (sceneConfig: SceneConfig | null): ActivityFilters | null => { +export const activityFiltersForScene = (sceneConfig: SceneConfig | null): SidePanelSceneContext | null => { if (sceneConfig?.activityScope) { // NOTE: - HACKY, we are just parsing the item_id from the url optimistically... const pathParts = removeProjectIdIfPresent(router.values.currentLocation.pathname).split('/') @@ -24,38 +17,43 @@ export const activityFiltersForScene = (sceneConfig: SceneConfig | null): Activi // Loose check for the item_id being a number, a short_id (8 chars) or a uuid if (item_id && (item_id.length === 8 || item_id.length === 36 || !isNaN(parseInt(item_id)))) { - return { scope: sceneConfig.activityScope, item_id } + return { activity_scope: sceneConfig.activityScope, activity_item_id: item_id } } - return { scope: sceneConfig.activityScope } + return { activity_scope: sceneConfig.activityScope } } return null } -export const activityForSceneLogic = kea([ - path(['scenes', 'navigation', 'sidepanel', 'activityForSceneLogic']), +export const sidePanelContextLogic = kea([ + path(['scenes', 'navigation', 'sidepanel', 'sidePanelContextLogic']), connect({ values: [sceneLogic, ['sceneConfig']], }), selectors({ - sceneActivityFilters: [ + sceneSidePanelContext: [ (s) => [ + s.sceneConfig, // Similar to "breadcrumbs" (state, props) => { const activeSceneLogic = sceneLogic.selectors.activeSceneLogic(state, props) - const sceneConfig = s.sceneConfig(state, props) - if (activeSceneLogic && 'activityFilters' in activeSceneLogic.selectors) { + if (activeSceneLogic && SIDE_PANEL_CONTEXT_KEY in activeSceneLogic.selectors) { const activeLoadedScene = sceneLogic.selectors.activeLoadedScene(state, props) - return activeSceneLogic.selectors.activityFilters( + return activeSceneLogic.selectors[SIDE_PANEL_CONTEXT_KEY]( state, activeLoadedScene?.paramsToProps?.(activeLoadedScene?.sceneParams) || props ) } - return activityFiltersForScene(sceneConfig) + return null }, ], - (filters): ActivityFilters | null => filters, + (sceneConfig, context): SidePanelSceneContext => { + return { + ...(context ?? {}), + ...(!context?.activity_scope ? activityFiltersForScene(sceneConfig) : {}), + } + }, { equalityCheck: objectsEqual }, ], }), diff --git a/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx b/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx index 029b34b6cbf4a..b220fd505c4a8 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx @@ -8,6 +8,7 @@ import { activationLogic } from '~/layout/navigation-3000/sidepanel/panels/activ import { AvailableFeature, SidePanelTab } from '~/types' import { sidePanelActivityLogic } from './panels/activity/sidePanelActivityLogic' +import { sidePanelContextLogic } from './panels/sidePanelContextLogic' import { sidePanelStatusLogic } from './panels/sidePanelStatusLogic' import type { sidePanelLogicType } from './sidePanelLogicType' import { sidePanelStateLogic } from './sidePanelStateLogic' @@ -39,14 +40,16 @@ export const sidePanelLogic = kea([ ['status'], userLogic, ['hasAvailableFeature'], + sidePanelContextLogic, + ['sceneSidePanelContext'], ], actions: [sidePanelStateLogic, ['closeSidePanel', 'openSidePanel']], }), selectors({ enabledTabs: [ - (s) => [s.isCloudOrDev, s.isReady, s.hasCompletedAllTasks, s.featureFlags], - (isCloudOrDev, isReady, hasCompletedAllTasks, featureflags) => { + (s) => [s.isCloudOrDev, s.isReady, s.hasCompletedAllTasks, s.featureFlags, s.sceneSidePanelContext], + (isCloudOrDev, isReady, hasCompletedAllTasks, featureflags, sceneSidePanelContext) => { const tabs: SidePanelTab[] = [] tabs.push(SidePanelTab.Notebooks) @@ -61,6 +64,13 @@ export const sidePanelLogic = kea([ if (isReady && !hasCompletedAllTasks) { tabs.push(SidePanelTab.Activation) } + if ( + featureflags[FEATURE_FLAGS.ROLE_BASED_ACCESS_CONTROL] && + sceneSidePanelContext.access_control_resource && + sceneSidePanelContext.access_control_resource_id + ) { + tabs.push(SidePanelTab.AccessControl) + } tabs.push(SidePanelTab.Exports) tabs.push(SidePanelTab.FeaturePreviews) tabs.push(SidePanelTab.Settings) diff --git a/frontend/src/layout/navigation-3000/sidepanel/types.ts b/frontend/src/layout/navigation-3000/sidepanel/types.ts new file mode 100644 index 0000000000000..28da07acb1c89 --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/types.ts @@ -0,0 +1,12 @@ +import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' + +import { ActivityScope, APIScopeObject } from '~/types' + +/** Allows scenes to set a context which enables richer features of the side panel */ +export type SidePanelSceneContext = { + access_control_resource?: APIScopeObject + access_control_resource_id?: string + activity_scope?: ActivityScope + activity_item_id?: ActivityLogItem['item_id'] +} +export const SIDE_PANEL_CONTEXT_KEY = 'sidePanelContext' diff --git a/frontend/src/lib/components/Metalytics/metalyticsLogic.ts b/frontend/src/lib/components/Metalytics/metalyticsLogic.ts index 8ddc838701121..06d0f384d81b5 100644 --- a/frontend/src/lib/components/Metalytics/metalyticsLogic.ts +++ b/frontend/src/lib/components/Metalytics/metalyticsLogic.ts @@ -4,7 +4,8 @@ import { subscriptions } from 'kea-subscriptions' import api from 'lib/api' import { membersLogic } from 'scenes/organization/membersLogic' -import { activityForSceneLogic } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' +import { sidePanelContextLogic } from '~/layout/navigation-3000/sidepanel/panels/sidePanelContextLogic' +import { SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { HogQLQuery, NodeKind } from '~/queries/schema' import { hogql } from '~/queries/utils' @@ -13,7 +14,7 @@ import type { metalyticsLogicType } from './metalyticsLogicType' export const metalyticsLogic = kea([ path(['lib', 'components', 'metalytics', 'metalyticsLogic']), connect({ - values: [activityForSceneLogic, ['sceneActivityFilters'], membersLogic, ['members']], + values: [sidePanelContextLogic, ['sceneSidePanelContext'], membersLogic, ['members']], }), loaders(({ values }) => ({ @@ -62,11 +63,16 @@ export const metalyticsLogic = kea([ selectors({ instanceId: [ - (s) => [s.sceneActivityFilters], - (sceneActivityFilters) => - sceneActivityFilters?.item_id ? `${sceneActivityFilters.scope}:${sceneActivityFilters.item_id}` : null, + (s) => [s.sceneSidePanelContext], + (sidePanelContext: SidePanelSceneContext) => + sidePanelContext?.activity_item_id + ? `${sidePanelContext.activity_scope}:${sidePanelContext.activity_item_id}` + : null, + ], + scope: [ + (s) => [s.sceneSidePanelContext], + (sidePanelContext: SidePanelSceneContext) => sidePanelContext?.activity_scope, ], - scope: [(s) => [s.sceneActivityFilters], (sceneActivityFilters) => sceneActivityFilters?.scope], recentUserMembers: [ (s) => [s.recentUsers, s.members], diff --git a/frontend/src/lib/components/RestrictedArea.tsx b/frontend/src/lib/components/RestrictedArea.tsx index ade847740c42a..852d1606bb0d0 100644 --- a/frontend/src/lib/components/RestrictedArea.tsx +++ b/frontend/src/lib/components/RestrictedArea.tsx @@ -27,7 +27,10 @@ export interface RestrictedAreaProps extends UseRestrictedAreaProps { Component: (props: RestrictedComponentProps) => JSX.Element } -export function useRestrictedArea({ scope, minimumAccessLevel }: UseRestrictedAreaProps): null | string { +export function useRestrictedArea({ + scope = RestrictionScope.Organization, + minimumAccessLevel, +}: UseRestrictedAreaProps): null | string { const { currentOrganization } = useValues(organizationLogic) const { currentTeam } = useValues(teamLogic) diff --git a/frontend/src/lib/components/Sharing/SharingModal.tsx b/frontend/src/lib/components/Sharing/SharingModal.tsx index 7348b1bc56610..7ef76f6fc54d6 100644 --- a/frontend/src/lib/components/Sharing/SharingModal.tsx +++ b/frontend/src/lib/components/Sharing/SharingModal.tsx @@ -92,6 +92,7 @@ export function SharingModalContent({

Something went wrong...

) : ( <> +

Sharing

void -} - -export function roleLemonSelectOptions(roles: RoleType[]): LemonInputSelectOption[] { +function roleLemonSelectOptions(roles: RoleType[]): LemonInputSelectOption[] { return roles.map((role) => ({ key: role.id, label: `${role.name}`, @@ -41,35 +39,52 @@ export function roleLemonSelectOptions(roles: RoleType[]): LemonInputSelectOptio })) } -export function ResourcePermissionModal({ - title, - visible, - onClose, - rolesToAdd, - addableRoles, - onChange, - addableRolesLoading, - onAdd, - roles, - deleteAssociatedRole, - canEdit, -}: ResourcePermissionModalProps): JSX.Element { +export function FeatureFlagPermissions({ featureFlag }: { featureFlag: FeatureFlagType }): JSX.Element { + const { addableRoles, unfilteredAddableRolesLoading, rolesToAdd, derivedRoles } = useValues( + featureFlagPermissionsLogic({ flagId: featureFlag.id }) + ) + const { setRolesToAdd, addAssociatedRoles, deleteAssociatedRole } = useActions( + featureFlagPermissionsLogic({ flagId: featureFlag.id }) + ) + const { openSidePanel } = useActions(sidePanelStateLogic) + + const newAccessControls = useFeatureFlag('ROLE_BASED_ACCESS_CONTROL') + if (newAccessControls) { + if (!featureFlag.id) { + return

Please save the feature flag before changing the access controls.

+ } + return ( +
+ + Permissions have moved! We're rolling out our new access control system. Click below to open it. + + } + onClick={() => { + openSidePanel(SidePanelTab.AccessControl) + }} + > + Open access control + +
+ ) + } + return ( - <> - - - - + + setRolesToAdd(roleIds)} + rolesToAdd={rolesToAdd} + addableRoles={addableRoles} + addableRolesLoading={unfilteredAddableRolesLoading} + onAdd={() => addAssociatedRoles()} + roles={derivedRoles} + deleteAssociatedRole={(id) => deleteAssociatedRole({ roleId: id })} + canEdit={featureFlag.can_edit} + /> + ) } @@ -108,7 +123,7 @@ export function ResourcePermission({ icon={ } - to={`${urls.settings('organization-rbac')}`} + to={`${urls.settings('organization-roles')}`} targetBlank size="small" noPadding diff --git a/frontend/src/scenes/actions/actionLogic.ts b/frontend/src/scenes/actions/actionLogic.ts index a3101cb8d9daf..f650b616ba944 100644 --- a/frontend/src/scenes/actions/actionLogic.ts +++ b/frontend/src/scenes/actions/actionLogic.ts @@ -5,7 +5,7 @@ import { DataManagementTab } from 'scenes/data-management/DataManagementScene' import { Scene } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' -import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' +import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { ActionType, ActivityScope, Breadcrumb, HogFunctionType } from '~/types' import { actionEditLogic } from './actionEditLogic' @@ -106,13 +106,15 @@ export const actionLogic = kea([ (action) => action?.steps?.some((step) => step.properties?.find((p) => p.type === 'cohort')) ?? false, ], - activityFilters: [ + [SIDE_PANEL_CONTEXT_KEY]: [ (s) => [s.action], - (action): ActivityFilters | null => { + (action): SidePanelSceneContext | null => { return action?.id ? { - scope: ActivityScope.ACTION, - item_id: String(action.id), + activity_scope: ActivityScope.ACTION, + activity_item_id: `${action.id}`, + // access_control_resource: 'action', + // access_control_resource_id: `${action.id}`, } : null }, diff --git a/frontend/src/scenes/dashboard/DashboardCollaborators.tsx b/frontend/src/scenes/dashboard/DashboardCollaborators.tsx index 048d668bc71fd..75b83719330d4 100644 --- a/frontend/src/scenes/dashboard/DashboardCollaborators.tsx +++ b/frontend/src/scenes/dashboard/DashboardCollaborators.tsx @@ -1,8 +1,10 @@ -import { IconLock, IconTrash, IconUnlock } from '@posthog/icons' +import { IconLock, IconOpenSidebar, IconTrash, IconUnlock } from '@posthog/icons' import { useActions, useValues } from 'kea' +import { router } from 'kea-router' import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' import { usersLemonSelectOptions } from 'lib/components/UserSelectItem' import { DashboardPrivilegeLevel, DashboardRestrictionLevel, privilegeLevelToName } from 'lib/constants' +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonInputSelect } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect' @@ -10,8 +12,10 @@ import { LemonSelect, LemonSelectOptions } from 'lib/lemon-ui/LemonSelect' import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture' import { Tooltip } from 'lib/lemon-ui/Tooltip' import { dashboardLogic } from 'scenes/dashboard/dashboardLogic' +import { urls } from 'scenes/urls' -import { AvailableFeature, DashboardType, FusedDashboardCollaboratorType, UserType } from '~/types' +import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic' +import { AvailableFeature, DashboardType, FusedDashboardCollaboratorType, SidePanelTab, UserType } from '~/types' import { dashboardCollaboratorsLogic } from './dashboardCollaboratorsLogic' @@ -36,73 +40,96 @@ export function DashboardCollaboration({ dashboardId }: { dashboardId: Dashboard const { deleteExplicitCollaborator, setExplicitCollaboratorsToBeAdded, addExplicitCollaborators } = useActions( dashboardCollaboratorsLogic({ dashboardId }) ) + const { push } = useActions(router) + const { openSidePanel } = useActions(sidePanelStateLogic) + + const newAccessControl = useFeatureFlag('ROLE_BASED_ACCESS_CONTROL') + + if (!dashboard) { + return null + } + + if (newAccessControl) { + return ( +
+

Access control

+ + Permissions have moved! We're rolling out our new access control system. Click below to open it. + + } + onClick={() => { + openSidePanel(SidePanelTab.AccessControl) + push(urls.dashboard(dashboard.id)) + }} + > + Open access control + +
+ ) + } return ( - dashboard && ( - <> - - {(!canEditDashboard || !canRestrictDashboard) && ( - - {canEditDashboard - ? "You aren't allowed to change the restriction level – only the dashboard owner and project admins can." - : "You aren't allowed to change sharing settings – only dashboard collaborators with edit settings can."} - - )} - - triggerDashboardUpdate({ - restriction_level: newValue, - }) - } - options={DASHBOARD_RESTRICTION_OPTIONS} - loading={dashboardLoading} - fullWidth - disabled={!canRestrictDashboard} - /> - {dashboard.restriction_level > DashboardRestrictionLevel.EveryoneInProjectCanEdit && ( -
-
Collaborators
- {canEditDashboard && ( -
-
- - setExplicitCollaboratorsToBeAdded(newValues) - } - mode="multiple" - data-attr="subscribed-emails" - options={usersLemonSelectOptions(addableMembers, 'uuid')} - /> -
- addExplicitCollaborators()} - > - Add - -
- )} -
Project members with access
-
- {allCollaborators.map((collaborator) => ( - - ))} + + {(!canEditDashboard || !canRestrictDashboard) && ( + + {canEditDashboard + ? "You aren't allowed to change the restriction level – only the dashboard owner and project admins can." + : "You aren't allowed to change sharing settings – only dashboard collaborators with edit settings can."} + + )} + + triggerDashboardUpdate({ + restriction_level: newValue, + }) + } + options={DASHBOARD_RESTRICTION_OPTIONS} + loading={dashboardLoading} + fullWidth + disabled={!canRestrictDashboard} + /> + {dashboard.restriction_level > DashboardRestrictionLevel.EveryoneInProjectCanEdit && ( +
+
Collaborators
+ {canEditDashboard && ( +
+
+ setExplicitCollaboratorsToBeAdded(newValues)} + mode="multiple" + data-attr="subscribed-emails" + options={usersLemonSelectOptions(addableMembers, 'uuid')} + />
+ addExplicitCollaborators()} + > + Add +
)} - - - ) +
Project members with access
+
+ {allCollaborators.map((collaborator) => ( + + ))} +
+
+ )} +
) } diff --git a/frontend/src/scenes/dashboard/dashboardLogic.tsx b/frontend/src/scenes/dashboard/dashboardLogic.tsx index 4addf1f04f4c0..0b6236931cf2b 100644 --- a/frontend/src/scenes/dashboard/dashboardLogic.tsx +++ b/frontend/src/scenes/dashboard/dashboardLogic.tsx @@ -30,6 +30,7 @@ import { Scene } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' +import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { dashboardsModel } from '~/models/dashboardsModel' import { insightsModel } from '~/models/insightsModel' import { variableDataLogic } from '~/queries/nodes/DataVisualization/Components/Variables/variableDataLogic' @@ -38,6 +39,7 @@ import { getQueryBasedDashboard, getQueryBasedInsightModel } from '~/queries/nod import { pollForResults } from '~/queries/query' import { DashboardFilter, DataVisualizationNode, HogQLVariable, NodeKind, RefreshType } from '~/queries/schema' import { + ActivityScope, AnyPropertyFilter, Breadcrumb, DashboardLayoutSize, @@ -991,6 +993,21 @@ export const dashboardLogic = kea([ }, ], ], + + [SIDE_PANEL_CONTEXT_KEY]: [ + (s) => [s.dashboard], + (dashboard): SidePanelSceneContext | null => { + return dashboard + ? { + activity_scope: ActivityScope.DASHBOARD, + activity_item_id: `${dashboard.id}`, + access_control_resource: 'dashboard', + access_control_resource_id: `${dashboard.id}`, + } + : null + }, + ], + sortTilesByLayout: [ (s) => [s.layoutForItem], (layoutForItem) => (tiles: Array) => { diff --git a/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx b/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx index ae61570189150..a8e8107380ab5 100644 --- a/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx +++ b/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx @@ -29,7 +29,7 @@ export const dataWarehouseViewsLogic = kea([ const savedQueries = await api.dataWarehouseSavedQueries.list() if (router.values.location.pathname.includes(urls.dataModel()) && !cache.pollingInterval) { - cache.pollingInterval = setInterval(actions.loadDataWarehouseSavedQueries, 5000) + cache.pollingInterval = setInterval(() => actions.loadDataWarehouseSavedQueries(), 5000) } else { clearInterval(cache.pollingInterval) } diff --git a/frontend/src/scenes/feature-flags/FeatureFlag.tsx b/frontend/src/scenes/feature-flags/FeatureFlag.tsx index d7e2ad01c9133..c9d7b50b6ab78 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlag.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlag.tsx @@ -10,7 +10,6 @@ import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' import { NotFound } from 'lib/components/NotFound' import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags' import { PageHeader } from 'lib/components/PageHeader' -import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' import { FEATURE_FLAGS } from 'lib/constants' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonButton } from 'lib/lemon-ui/LemonButton' @@ -34,9 +33,9 @@ import { dashboardLogic } from 'scenes/dashboard/dashboardLogic' import { EmptyDashboardComponent } from 'scenes/dashboard/EmptyDashboardComponent' import { UTM_TAGS } from 'scenes/feature-flags/FeatureFlagSnippets' import { JSONEditorInput } from 'scenes/feature-flags/JSONEditorInput' +import { FeatureFlagPermissions } from 'scenes/FeatureFlagPermissions' import { concatWithPunctuation } from 'scenes/insights/utils' import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton' -import { ResourcePermission } from 'scenes/ResourcePermissionModal' import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' @@ -58,14 +57,12 @@ import { PropertyOperator, QueryBasedInsightModel, ReplayTabs, - Resource, } from '~/types' import { AnalysisTab } from './FeatureFlagAnalysisTab' import { FeatureFlagAutoRollback } from './FeatureFlagAutoRollout' import { FeatureFlagCodeExample } from './FeatureFlagCodeExample' import { featureFlagLogic, getRecordingFilterForFlagVariant } from './featureFlagLogic' -import { featureFlagPermissionsLogic } from './featureFlagPermissionsLogic' import FeatureFlagProjects from './FeatureFlagProjects' import { FeatureFlagReleaseConditions } from './FeatureFlagReleaseConditions' import FeatureFlagSchedule from './FeatureFlagSchedule' @@ -103,13 +100,6 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element { setActiveTab, } = useActions(featureFlagLogic) - const { addableRoles, unfilteredAddableRolesLoading, rolesToAdd, derivedRoles } = useValues( - featureFlagPermissionsLogic({ flagId: featureFlag.id }) - ) - const { setRolesToAdd, addAssociatedRoles, deleteAssociatedRole } = useActions( - featureFlagPermissionsLogic({ flagId: featureFlag.id }) - ) - const { tags } = useValues(tagsModel) const { hasAvailableFeature } = useValues(userLogic) @@ -221,21 +211,7 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element { tabs.push({ label: 'Permissions', key: FeatureFlagsTab.PERMISSIONS, - content: ( - - setRolesToAdd(roleIds)} - rolesToAdd={rolesToAdd} - addableRoles={addableRoles} - addableRolesLoading={unfilteredAddableRolesLoading} - onAdd={() => addAssociatedRoles()} - roles={derivedRoles} - deleteAssociatedRole={(id) => deleteAssociatedRole({ roleId: id })} - canEdit={featureFlag.can_edit} - /> - - ), + content: , }) } @@ -433,21 +409,7 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element {

Permissions

- - setRolesToAdd(roleIds)} - rolesToAdd={rolesToAdd} - addableRoles={addableRoles} - addableRolesLoading={unfilteredAddableRolesLoading} - onAdd={() => addAssociatedRoles()} - roles={derivedRoles} - deleteAssociatedRole={(id) => - deleteAssociatedRole({ roleId: id }) - } - canEdit={featureFlag.can_edit} - /> - +
diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.ts index 978348e795149..4a9fd05113d3e 100644 --- a/frontend/src/scenes/feature-flags/featureFlagLogic.ts +++ b/frontend/src/scenes/feature-flags/featureFlagLogic.ts @@ -23,9 +23,11 @@ import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic' +import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { groupsModel } from '~/models/groupsModel' import { getQueryBasedInsightModel } from '~/queries/nodes/InsightViz/utils' import { + ActivityScope, AvailableFeature, Breadcrumb, CohortType, @@ -973,6 +975,19 @@ export const featureFlagLogic = kea([ { key: [Scene.FeatureFlag, featureFlag.id || 'unknown'], name: featureFlag.key || 'Unnamed' }, ], ], + [SIDE_PANEL_CONTEXT_KEY]: [ + (s) => [s.featureFlag], + (featureFlag): SidePanelSceneContext | null => { + return featureFlag?.id + ? { + activity_scope: ActivityScope.FEATURE_FLAG, + activity_item_id: `${featureFlag.id}`, + access_control_resource: 'feature_flag', + access_control_resource_id: `${featureFlag.id}`, + } + : null + }, + ], filteredDashboards: [ (s) => [s.dashboards, s.featureFlag], (dashboards, featureFlag) => { diff --git a/frontend/src/scenes/insights/insightSceneLogic.tsx b/frontend/src/scenes/insights/insightSceneLogic.tsx index 3f79ace2432d9..ab58df3d1ec86 100644 --- a/frontend/src/scenes/insights/insightSceneLogic.tsx +++ b/frontend/src/scenes/insights/insightSceneLogic.tsx @@ -15,7 +15,7 @@ import { teamLogic } from 'scenes/teamLogic' import { mathsLogic } from 'scenes/trends/mathsLogic' import { urls } from 'scenes/urls' -import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' +import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { cohortsModel } from '~/models/cohortsModel' import { groupsModel } from '~/models/groupsModel' import { getDefaultQuery } from '~/queries/nodes/InsightViz/utils' @@ -210,13 +210,15 @@ export const insightSceneLogic = kea([ ] }, ], - activityFilters: [ + [SIDE_PANEL_CONTEXT_KEY]: [ (s) => [s.insight], - (insight): ActivityFilters | null => { - return insight + (insight): SidePanelSceneContext | null => { + return insight?.id ? { - scope: ActivityScope.INSIGHT, - item_id: `${insight.id}`, + activity_scope: ActivityScope.INSIGHT, + activity_item_id: `${insight.id}`, + access_control_resource: 'insight', + access_control_resource_id: `${insight.id}`, } : null }, diff --git a/frontend/src/scenes/notebooks/Notebook/NotebookShare.tsx b/frontend/src/scenes/notebooks/Notebook/NotebookShare.tsx deleted file mode 100644 index 1a9233289616c..0000000000000 --- a/frontend/src/scenes/notebooks/Notebook/NotebookShare.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { IconCopy } from '@posthog/icons' -import { LemonBanner, LemonButton, LemonDivider } from '@posthog/lemon-ui' -import { useValues } from 'kea' -import { LemonDialog } from 'lib/lemon-ui/LemonDialog' -import { base64Encode } from 'lib/utils' -import { copyToClipboard } from 'lib/utils/copyToClipboard' -import posthog from 'posthog-js' -import { useState } from 'react' -import { urls } from 'scenes/urls' - -import { notebookLogic } from './notebookLogic' - -export type NotebookShareProps = { - shortId: string -} -export function NotebookShare({ shortId }: NotebookShareProps): JSX.Element { - const { content, isLocalOnly } = useValues(notebookLogic({ shortId })) - - const notebookUrl = urls.absolute(urls.currentProject(urls.notebook(shortId))) - const canvasUrl = urls.absolute(urls.canvas()) + `#🦔=${base64Encode(JSON.stringify(content))}` - - const [interestTracked, setInterestTracked] = useState(false) - - const trackInterest = (): void => { - posthog.capture('pressed interested in notebook sharing', { url: notebookUrl }) - } - - return ( -
-

Internal Link

- {!isLocalOnly ? ( - <> -

- Click the button below to copy a direct link to this Notebook. Make sure the person you - share it with has access to this PostHog project. -

- } - onClick={() => void copyToClipboard(notebookUrl, 'notebook link')} - title={notebookUrl} - > - {notebookUrl} - - - - - ) : ( - -

This Notebook cannot be shared directly with others as it is only visible to you.

-
- )} - -

Template Link

-

- The link below will open a Canvas with the contents of this Notebook, allowing the receiver to view it, - edit it or create their own Notebook without affecting this one. -

- } - onClick={() => void copyToClipboard(canvasUrl, 'canvas link')} - title={canvasUrl} - > - {canvasUrl} - - - - -

External Sharing

- - { - if (!interestTracked) { - trackInterest() - setInterestTracked(true) - } - }, - }} - > - We don’t currently support sharing notebooks externally, but it’s on our roadmap! - -
- ) -} - -export function openNotebookShareDialog({ shortId }: NotebookShareProps): void { - LemonDialog.open({ - title: 'Share notebook', - content: , - width: 600, - primaryButton: { - children: 'Close', - type: 'secondary', - }, - }) -} diff --git a/frontend/src/scenes/notebooks/Notebook/NotebookShareModal.tsx b/frontend/src/scenes/notebooks/Notebook/NotebookShareModal.tsx new file mode 100644 index 0000000000000..534599664149f --- /dev/null +++ b/frontend/src/scenes/notebooks/Notebook/NotebookShareModal.tsx @@ -0,0 +1,133 @@ +import { IconCopy, IconOpenSidebar } from '@posthog/icons' +import { LemonBanner, LemonButton, LemonDivider, LemonModal } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { FlaggedFeature } from 'lib/components/FlaggedFeature' +import { SHARING_MODAL_WIDTH } from 'lib/components/Sharing/SharingModal' +import { base64Encode } from 'lib/utils' +import { copyToClipboard } from 'lib/utils/copyToClipboard' +import posthog from 'posthog-js' +import { useState } from 'react' +import { urls } from 'scenes/urls' + +import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic' +import { SidePanelTab } from '~/types' + +import { notebookLogic } from './notebookLogic' + +export type NotebookShareModalProps = { + shortId: string +} + +export function NotebookShareModal({ shortId }: NotebookShareModalProps): JSX.Element { + const { content, isLocalOnly, isShareModalOpen } = useValues(notebookLogic({ shortId })) + const { closeShareModal } = useActions(notebookLogic({ shortId })) + const { openSidePanel } = useActions(sidePanelStateLogic) + + const notebookUrl = urls.absolute(urls.currentProject(urls.notebook(shortId))) + const canvasUrl = urls.absolute(urls.canvas()) + `#🦔=${base64Encode(JSON.stringify(content))}` + + const [interestTracked, setInterestTracked] = useState(false) + + const trackInterest = (): void => { + posthog.capture('pressed interested in notebook sharing', { url: notebookUrl }) + } + + return ( + closeShareModal()} + isOpen={isShareModalOpen} + width={SHARING_MODAL_WIDTH} + footer={ + + Done + + } + > +
+ + <> +
+

Access control

+ + Permissions have moved! We're rolling out our new access control system. Click below to + open it. + + } + onClick={() => { + openSidePanel(SidePanelTab.AccessControl) + closeShareModal() + }} + > + Open access control + +
+ + +
+

Internal Link

+ {!isLocalOnly ? ( + <> +

+ Click the button below to copy a direct link to this Notebook. Make sure the person + you share it with has access to this PostHog project. +

+ } + onClick={() => void copyToClipboard(notebookUrl, 'notebook link')} + title={notebookUrl} + > + {notebookUrl} + + + + + ) : ( + +

This Notebook cannot be shared directly with others as it is only visible to you.

+
+ )} + +

Template Link

+

+ The link below will open a Canvas with the contents of this Notebook, allowing the receiver to view + it, edit it or create their own Notebook without affecting this one. +

+ } + onClick={() => void copyToClipboard(canvasUrl, 'canvas link')} + title={canvasUrl} + > + {canvasUrl} + + + + +

External Sharing

+ + { + if (!interestTracked) { + trackInterest() + setInterestTracked(true) + } + }, + }} + > + We don’t currently support sharing notebooks externally, but it’s on our roadmap! + +
+
+ ) +} diff --git a/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-12345.json b/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-12345.json index f2ac6bd3c8d16..4e31800d43919 100644 --- a/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-12345.json +++ b/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-12345.json @@ -59,5 +59,6 @@ "first_name": "Paul", "email": "paul@posthog.com", "is_email_verified": false - } + }, + "user_access_level": "editor" } diff --git a/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts b/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts index bc0593c22bff3..68fc4d6e7f0f1 100644 --- a/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts +++ b/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts @@ -133,8 +133,17 @@ export const notebookLogic = kea([ setContainerSize: (containerSize: 'small' | 'medium') => ({ containerSize }), insertComment: (context: Record) => ({ context }), selectComment: (itemContextId: string) => ({ itemContextId }), + openShareModal: true, + closeShareModal: true, }), reducers(({ props }) => ({ + isShareModalOpen: [ + false, + { + openShareModal: () => true, + closeShareModal: () => false, + }, + ], localContent: [ null as JSONContent | null, { persist: props.mode !== 'canvas', prefix: NOTEBOOKS_VERSION }, @@ -348,9 +357,9 @@ export const notebookLogic = kea([ mode: [() => [(_, props) => props], (props): NotebookLogicMode => props.mode ?? 'notebook'], isTemplate: [(s) => [s.shortId], (shortId): boolean => shortId.startsWith('template-')], isLocalOnly: [ - () => [(_, props) => props], - (props): boolean => { - return props.shortId === 'scratchpad' || props.mode === 'canvas' + (s) => [(_, props) => props, s.isTemplate], + (props, isTemplate): boolean => { + return props.shortId === 'scratchpad' || props.mode === 'canvas' || isTemplate }, ], notebookMissing: [ @@ -443,8 +452,9 @@ export const notebookLogic = kea([ ], isEditable: [ - (s) => [s.shouldBeEditable, s.previewContent], - (shouldBeEditable, previewContent) => shouldBeEditable && !previewContent, + (s) => [s.shouldBeEditable, s.previewContent, s.notebook], + (shouldBeEditable, previewContent, notebook) => + shouldBeEditable && !previewContent && notebook?.user_access_level === 'editor', ], }), listeners(({ values, actions, cache }) => ({ @@ -518,6 +528,11 @@ export const notebookLogic = kea([ ) }, setLocalContent: async ({ updateEditor, jsonContent }, breakpoint) => { + if (values.notebook?.user_access_level !== 'editor') { + actions.clearLocalContent() + return + } + if (values.previewContent) { // We don't want to modify the content if we are viewing a preview return diff --git a/frontend/src/scenes/notebooks/NotebookMenu.tsx b/frontend/src/scenes/notebooks/NotebookMenu.tsx index 9cea5e74fbe37..aeeebfa35cdfa 100644 --- a/frontend/src/scenes/notebooks/NotebookMenu.tsx +++ b/frontend/src/scenes/notebooks/NotebookMenu.tsx @@ -10,10 +10,10 @@ import { urls } from 'scenes/urls' import { notebooksModel } from '~/models/notebooksModel' import { notebookLogic, NotebookLogicProps } from './Notebook/notebookLogic' -import { openNotebookShareDialog } from './Notebook/NotebookShare' export function NotebookMenu({ shortId }: NotebookLogicProps): JSX.Element { const { notebook, showHistory, isLocalOnly } = useValues(notebookLogic({ shortId })) + const { openShareModal } = useActions(notebookLogic({ shortId })) const { exportJSON, setShowHistory } = useActions(notebookLogic({ shortId })) return ( @@ -32,14 +32,17 @@ export function NotebookMenu({ shortId }: NotebookLogicProps): JSX.Element { { label: 'Share', icon: , - onClick: () => openNotebookShareDialog({ shortId }), + onClick: () => openShareModal(), }, !isLocalOnly && !notebook?.is_template && { label: 'Delete', icon: , status: 'danger', - + disabledReason: + notebook?.user_access_level !== 'editor' + ? 'You do not have permission to delete this notebook.' + : undefined, onClick: () => { notebooksModel.actions.deleteNotebook(shortId, notebook?.title) router.actions.push(urls.notebooks()) diff --git a/frontend/src/scenes/notebooks/NotebookScene.tsx b/frontend/src/scenes/notebooks/NotebookScene.tsx index e24c3bdd498c5..a0cc87a441c74 100644 --- a/frontend/src/scenes/notebooks/NotebookScene.tsx +++ b/frontend/src/scenes/notebooks/NotebookScene.tsx @@ -14,6 +14,7 @@ import { Notebook } from './Notebook/Notebook' import { NotebookLoadingState } from './Notebook/NotebookLoadingState' import { notebookLogic } from './Notebook/notebookLogic' import { NotebookExpandButton, NotebookSyncInfo } from './Notebook/NotebookMeta' +import { NotebookShareModal } from './Notebook/NotebookShareModal' import { NotebookMenu } from './NotebookMenu' import { notebookPanelLogic } from './NotebookPanel/notebookPanelLogic' import { notebookSceneLogic, NotebookSceneLogicProps } from './notebookSceneLogic' @@ -128,6 +129,7 @@ export function NotebookScene(): JSX.Element {
+ ) } diff --git a/frontend/src/scenes/notebooks/notebookSceneLogic.ts b/frontend/src/scenes/notebooks/notebookSceneLogic.ts index 592a1b39e09ed..6d987f3a780a4 100644 --- a/frontend/src/scenes/notebooks/notebookSceneLogic.ts +++ b/frontend/src/scenes/notebooks/notebookSceneLogic.ts @@ -2,8 +2,9 @@ import { afterMount, connect, kea, key, path, props, selectors } from 'kea' import { Scene } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' +import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { notebooksModel } from '~/models/notebooksModel' -import { Breadcrumb } from '~/types' +import { ActivityScope, Breadcrumb } from '~/types' import { notebookLogic } from './Notebook/notebookLogic' import type { notebookSceneLogicType } from './notebookSceneLogicType' @@ -16,7 +17,12 @@ export const notebookSceneLogic = kea([ props({} as NotebookSceneLogicProps), key(({ shortId }) => shortId), connect((props: NotebookSceneLogicProps) => ({ - values: [notebookLogic(props), ['notebook', 'notebookLoading'], notebooksModel, ['notebooksLoading']], + values: [ + notebookLogic(props), + ['notebook', 'notebookLoading', 'isLocalOnly'], + notebooksModel, + ['notebooksLoading'], + ], actions: [notebookLogic(props), ['loadNotebook'], notebooksModel, ['createNotebook']], })), selectors(() => ({ @@ -41,6 +47,20 @@ export const notebookSceneLogic = kea([ }, ], ], + + [SIDE_PANEL_CONTEXT_KEY]: [ + (s) => [s.notebookId, s.isLocalOnly], + (notebookId, isLocalOnly): SidePanelSceneContext | null => { + return notebookId && !isLocalOnly + ? { + activity_scope: ActivityScope.NOTEBOOK, + activity_item_id: notebookId, + access_control_resource: 'notebook', + access_control_resource_id: notebookId, + } + : null + }, + ], })), afterMount(({ actions, props }) => { diff --git a/frontend/src/scenes/persons/personsLogic.tsx b/frontend/src/scenes/persons/personsLogic.tsx index d408ec3a74ed0..fcfb21200a7c4 100644 --- a/frontend/src/scenes/persons/personsLogic.tsx +++ b/frontend/src/scenes/persons/personsLogic.tsx @@ -13,7 +13,7 @@ import { Scene } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' -import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' +import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { hogqlQuery } from '~/queries/query' import { ActivityScope, @@ -256,13 +256,13 @@ export const personsLogic = kea([ }, ], - activityFilters: [ + [SIDE_PANEL_CONTEXT_KEY]: [ (s) => [s.person], - (person): ActivityFilters => { + (person): SidePanelSceneContext => { return { - scope: ActivityScope.PERSON, + activity_scope: ActivityScope.PERSON, // TODO: Is this correct? It doesn't seem to work... - item_id: person?.id ? `${person?.id}` : undefined, + activity_item_id: person?.id ? `${person?.id}` : undefined, } }, ], diff --git a/frontend/src/scenes/pipeline/pipelineLogic.tsx b/frontend/src/scenes/pipeline/pipelineLogic.tsx index 38ea5f1d54b4f..23438fbe86185 100644 --- a/frontend/src/scenes/pipeline/pipelineLogic.tsx +++ b/frontend/src/scenes/pipeline/pipelineLogic.tsx @@ -5,7 +5,7 @@ import { Scene } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' -import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' +import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { ActivityScope, Breadcrumb, PipelineTab } from '~/types' import type { pipelineLogicType } from './pipelineLogicType' @@ -44,11 +44,11 @@ export const pipelineLogic = kea([ }, ], - activityFilters: [ + [SIDE_PANEL_CONTEXT_KEY]: [ () => [], - (): ActivityFilters | null => { + (): SidePanelSceneContext => { return { - scope: ActivityScope.PLUGIN, + activity_scope: ActivityScope.PLUGIN, } }, ], diff --git a/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx b/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx index 4faedce085b8a..2d2e7b977aec5 100644 --- a/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx +++ b/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx @@ -4,7 +4,7 @@ import { capitalizeFirstLetter } from 'lib/utils' import { Scene } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' -import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' +import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { ActivityScope, Breadcrumb, PipelineNodeTab, PipelineStage } from '~/types' import type { pipelineNodeLogicType } from './pipelineNodeLogicType' @@ -78,13 +78,15 @@ export const pipelineNodeLogic = kea([ ], ], - activityFilters: [ + [SIDE_PANEL_CONTEXT_KEY]: [ (s) => [s.node], - (node): ActivityFilters | null => { + (node): SidePanelSceneContext | null => { return node.backend === PipelineBackend.Plugin ? { - scope: ActivityScope.PLUGIN, - item_id: `${node.id}`, + activity_scope: ActivityScope.PLUGIN, + activity_item_id: `${node.id}`, + // access_control_resource: 'plugin', + // access_control_resource_id: `${node.id}`, } : null }, diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 3f007fdaf0e6b..98990142b8d32 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -501,6 +501,7 @@ export const redirects: Record< '/apps': urls.pipeline(PipelineTab.Overview), '/apps/:id': ({ id }) => urls.pipelineNode(PipelineStage.Transformation, id), '/messaging': urls.messagingBroadcasts(), + '/settings/organization-rbac': urls.settings('organization-roles'), } export const routes: Record = { diff --git a/frontend/src/scenes/session-recordings/sessionReplaySceneLogic.ts b/frontend/src/scenes/session-recordings/sessionReplaySceneLogic.ts index 5f1bee532fdaa..246fa495ba5a2 100644 --- a/frontend/src/scenes/session-recordings/sessionReplaySceneLogic.ts +++ b/frontend/src/scenes/session-recordings/sessionReplaySceneLogic.ts @@ -6,7 +6,7 @@ import { capitalizeFirstLetter } from 'lib/utils' import { Scene } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' -import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' +import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types' import { ActivityScope, Breadcrumb, ReplayTabs } from '~/types' import type { sessionReplaySceneLogicType } from './sessionReplaySceneLogicType' @@ -92,13 +92,13 @@ export const sessionReplaySceneLogic = kea([ return breadcrumbs }, ], - activityFilters: [ + [SIDE_PANEL_CONTEXT_KEY]: [ () => [router.selectors.searchParams], - (searchParams): ActivityFilters | null => { + (searchParams): SidePanelSceneContext | null => { return searchParams.sessionRecordingId ? { - scope: ActivityScope.REPLAY, - item_id: searchParams.sessionRecordingId, + activity_scope: ActivityScope.REPLAY, + activity_item_id: searchParams.sessionRecordingId, } : null }, diff --git a/frontend/src/scenes/settings/SettingsMap.tsx b/frontend/src/scenes/settings/SettingsMap.tsx index 8985441f89067..0a2e3e432a2fb 100644 --- a/frontend/src/scenes/settings/SettingsMap.tsx +++ b/frontend/src/scenes/settings/SettingsMap.tsx @@ -50,7 +50,7 @@ import { OrganizationDangerZone } from './organization/OrganizationDangerZone' import { OrganizationDisplayName } from './organization/OrgDisplayName' import { OrganizationEmailPreferences } from './organization/OrgEmailPreferences' import { OrganizationLogo } from './organization/OrgLogo' -import { PermissionsGrid } from './organization/Permissions/PermissionsGrid' +import { RoleBasedAccess } from './organization/Permissions/RoleBasedAccess' import { VerifiedDomains } from './organization/VerifiedDomains/VerifiedDomains' import { ProjectDangerZone } from './project/ProjectDangerZone' import { ProjectDisplayName, ProjectProductDescription } from './project/ProjectSettings' @@ -314,11 +314,11 @@ export const SETTINGS_MAP: SettingSection[] = [ }, { level: 'environment', - id: 'environment-rbac', + id: 'environment-access-control', title: 'Access control', settings: [ { - id: 'environment-rbac', + id: 'environment-access-control', title: 'Access control', component: , }, @@ -413,25 +413,25 @@ export const SETTINGS_MAP: SettingSection[] = [ }, { level: 'organization', - id: 'organization-authentication', - title: 'Authentication domains & SSO', + id: 'organization-roles', + title: 'Roles', settings: [ { - id: 'authentication-domains', - title: 'Authentication Domains', - component: , + id: 'organization-roles', + title: 'Roles', + component: , }, ], }, { level: 'organization', - id: 'organization-rbac', - title: 'Role-based access', + id: 'organization-authentication', + title: 'Authentication domains & SSO', settings: [ { - id: 'organization-rbac', - title: 'Role-based access', - component: , + id: 'authentication-domains', + title: 'Authentication Domains', + component: , }, ], }, diff --git a/frontend/src/scenes/settings/environment/TeamAccessControl.tsx b/frontend/src/scenes/settings/environment/TeamAccessControl.tsx index 88cfdf5f2caee..6674c261800e8 100644 --- a/frontend/src/scenes/settings/environment/TeamAccessControl.tsx +++ b/frontend/src/scenes/settings/environment/TeamAccessControl.tsx @@ -4,6 +4,7 @@ import { useActions, useValues } from 'kea' import { RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea' import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import { OrganizationMembershipLevel, TeamMembershipLevel } from 'lib/constants' +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { IconCancel } from 'lib/lemon-ui/icons' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' import { LemonTableColumns } from 'lib/lemon-ui/LemonTable' @@ -19,6 +20,7 @@ import { organizationLogic } from 'scenes/organizationLogic' import { isAuthenticatedTeam, teamLogic } from 'scenes/teamLogic' import { userLogic } from 'scenes/userLogic' +import { AccessControlObject } from '~/layout/navigation-3000/sidepanel/panels/access_control/AccessControlObject' import { AvailableFeature, FusedTeamMemberType } from '~/types' import { AddMembersModalWithButton } from './AddMembersModal' @@ -154,7 +156,7 @@ export function TeamMembers(): JSX.Element | null { title: 'Name', key: 'user_first_name', render: (_, member) => - member.user.uuid == user.uuid ? `${member.user.first_name} (me)` : member.user.first_name, + member.user.uuid == user.uuid ? `${member.user.first_name} (you)` : member.user.first_name, sorter: (a, b) => a.user.first_name.localeCompare(b.user.first_name), }, { @@ -214,6 +216,11 @@ export function TeamAccessControl(): JSX.Element { minimumAccessLevel: OrganizationMembershipLevel.Admin, }) + const newAccessControl = useFeatureFlag('ROLE_BASED_ACCESS_CONTROL') + if (newAccessControl) { + return + } + return ( <>

diff --git a/frontend/src/scenes/settings/organization/Members.tsx b/frontend/src/scenes/settings/organization/Members.tsx index 997582fa81982..42face838324a 100644 --- a/frontend/src/scenes/settings/organization/Members.tsx +++ b/frontend/src/scenes/settings/organization/Members.tsx @@ -1,6 +1,7 @@ import { LemonInput, LemonSwitch } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' +import { useRestrictedArea } from 'lib/components/RestrictedArea' import { TZLabel } from 'lib/components/TZLabel' import { OrganizationMembershipLevel } from 'lib/constants' import { LemonButton } from 'lib/lemon-ui/LemonButton' @@ -141,11 +142,12 @@ export function Members(): JSX.Element | null { const { currentOrganization } = useValues(organizationLogic) const { preflight } = useValues(preflightLogic) const { user } = useValues(userLogic) - const { setSearch, ensureAllMembersLoaded } = useActions(membersLogic) const { updateOrganization } = useActions(organizationLogic) const { openTwoFactorSetupModal } = useActions(twoFactorLogic) + const twoFactorRestrictionReason = useRestrictedArea({ minimumAccessLevel: OrganizationMembershipLevel.Admin }) + useEffect(() => { ensureAllMembersLoaded() }, []) @@ -166,7 +168,7 @@ export function Members(): JSX.Element | null { title: 'Name', key: 'user_name', render: (_, member) => - member.user.uuid == user.uuid ? `${fullName(member.user)} (me)` : fullName(member.user), + member.user.uuid == user.uuid ? `${fullName(member.user)} (you)` : fullName(member.user), sorter: (a, b) => fullName(a.user).localeCompare(fullName(b.user)), }, { @@ -290,6 +292,7 @@ export function Members(): JSX.Element | null { bordered checked={!!currentOrganization?.enforce_2fa} onChange={(enforce_2fa) => updateOrganization({ enforce_2fa })} + disabledReason={twoFactorRestrictionReason} /> diff --git a/frontend/src/scenes/settings/organization/Permissions/RoleBasedAccess.tsx b/frontend/src/scenes/settings/organization/Permissions/RoleBasedAccess.tsx new file mode 100644 index 0000000000000..62ef8ff7a1f95 --- /dev/null +++ b/frontend/src/scenes/settings/organization/Permissions/RoleBasedAccess.tsx @@ -0,0 +1,12 @@ +// NOTE: This is only to allow testing the new RBAC system + +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' + +import { RolesAndResourceAccessControls } from '~/layout/navigation-3000/sidepanel/panels/access_control/RolesAndResourceAccessControls' + +import { PermissionsGrid } from './PermissionsGrid' + +export function RoleBasedAccess(): JSX.Element { + const newAccessControl = useFeatureFlag('ROLE_BASED_ACCESS_CONTROL') + return newAccessControl ? : +} diff --git a/frontend/src/scenes/settings/types.ts b/frontend/src/scenes/settings/types.ts index 0103298077232..56db33d95d3cf 100644 --- a/frontend/src/scenes/settings/types.ts +++ b/frontend/src/scenes/settings/types.ts @@ -24,7 +24,8 @@ export type SettingSectionId = | 'environment-surveys' | 'environment-toolbar' | 'environment-integrations' - | 'environment-rbac' + | 'environment-access-control' + | 'environment-role-based-access-control' | 'environment-danger-zone' | 'project-details' | 'project-autocapture' // TODO: This section is for backward compat – remove when Environments are rolled out @@ -33,12 +34,13 @@ export type SettingSectionId = | 'project-surveys' // TODO: This section is for backward compat – remove when Environments are rolled out | 'project-toolbar' // TODO: This section is for backward compat – remove when Environments are rolled out | 'project-integrations' // TODO: This section is for backward compat – remove when Environments are rolled out - | 'project-rbac' // TODO: This section is for backward compat – remove when Environments are rolled out + | 'project-access-control' // TODO: This section is for backward compat – remove when Environments are rolled out + | 'project-role-based-access-control' // TODO: This section is for backward compat – remove when Environments are rolled out | 'project-danger-zone' | 'organization-details' | 'organization-members' | 'organization-authentication' - | 'organization-rbac' + | 'organization-roles' | 'organization-proxy' | 'organization-danger-zone' | 'user-profile' @@ -72,7 +74,8 @@ export type SettingId = | 'integration-slack' | 'integration-other' | 'integration-ip-allowlist' - | 'environment-rbac' + | 'environment-access-control' + | 'environment-role-based-access-control' | 'environment-delete' | 'project-delete' | 'organization-logo' @@ -81,7 +84,7 @@ export type SettingId = | 'members' | 'email-members' | 'authentication-domains' - | 'organization-rbac' + | 'organization-roles' | 'organization-delete' | 'organization-proxy' | 'product-description' diff --git a/frontend/src/scenes/teamLogic.tsx b/frontend/src/scenes/teamLogic.tsx index b27c8621db68a..19cb9ac10c840 100644 --- a/frontend/src/scenes/teamLogic.tsx +++ b/frontend/src/scenes/teamLogic.tsx @@ -188,7 +188,8 @@ export const teamLogic = kea([ (selectors) => [selectors.currentTeam, selectors.currentTeamLoading], // If project has been loaded and is still null, it means the user just doesn't have access. (currentTeam, currentTeamLoading): boolean => - !currentTeam?.effective_membership_level && !currentTeamLoading, + (!currentTeam?.effective_membership_level || currentTeam.user_access_level === 'none') && + !currentTeamLoading, ], demoOnlyProject: [ (selectors) => [selectors.currentTeam, organizationLogic.selectors.currentOrganization], @@ -210,8 +211,9 @@ export const teamLogic = kea([ isTeamTokenResetAvailable: [ (selectors) => [selectors.currentTeam], (currentTeam): boolean => - !!currentTeam?.effective_membership_level && - currentTeam.effective_membership_level >= OrganizationMembershipLevel.Admin, + (!!currentTeam?.effective_membership_level && + currentTeam.effective_membership_level >= OrganizationMembershipLevel.Admin) || + currentTeam?.user_access_level === 'admin', ], testAccountFilterFrequentMistakes: [ (selectors) => [selectors.currentTeam], diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 317b046e7acf6..d8e85d4cdc027 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4488,7 +4488,7 @@ export enum SidePanelTab { Discussion = 'discussion', Status = 'status', Exports = 'exports', - // AccessControl = 'access-control', + AccessControl = 'access-control', } export interface SourceFieldOauthConfig {