Skip to content

Commit

Permalink
feat: rbac client side code (PostHog#26694)
Browse files Browse the repository at this point in the history
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
zlwaterfield and github-actions[bot] authored Dec 18, 2024
1 parent f4593e1 commit fc19b77
Showing 65 changed files with 1,811 additions and 372 deletions.
3 changes: 2 additions & 1 deletion cypress/fixtures/api/notebooks/notebook.json
Original file line number Diff line number Diff line change
@@ -60,5 +60,6 @@
"first_name": "Employee 427",
"email": "test@posthog.com",
"is_email_verified": null
}
},
"user_access_level": "editor"
}
3 changes: 2 additions & 1 deletion cypress/fixtures/api/notebooks/notebooks.json
Original file line number Diff line number Diff line change
@@ -65,7 +65,8 @@
"first_name": "Employee 427",
"email": "test@posthog.com",
"is_email_verified": null
}
},
"user_access_level": "editor"
}
]
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion frontend/src/layout/ErrorProjectUnavailable.tsx
Original file line number Diff line number Diff line change
@@ -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<JSX.Element[]>([])

useEffect(() => {
@@ -45,7 +47,8 @@ export function ErrorProjectUnavailable(): JSX.Element {
<PageHeader />
{!user?.organization ? (
<CreateOrganizationModal isVisible inline />
) : 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' ? (
<>
<h1>Project access has been removed</h1>
<p>
8 changes: 7 additions & 1 deletion frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 (
<BindLogic logic={accessControlLogic} props={props}>
<div className="space-y-4">
{canEditAccessControls === false ? (
<LemonBanner type="info">
<b>You don't have permission to edit access controls for {suffix}.</b>
<br />
You must be the creator of it, a Project Admin, or an Organization Admin.
</LemonBanner>
) : null}
<h3>Default access to {suffix}</h3>
<AccessControlObjectDefaults />

<h3>Members</h3>
<PayGateMini feature={AvailableFeature.PROJECT_BASED_PERMISSIONING}>
<AccessControlObjectUsers />
</PayGateMini>

<h3>Roles</h3>
<PayGateMini feature={AvailableFeature.ROLE_BASED_ACCESS}>
<AccessControlObjectRoles />
</PayGateMini>
</div>
</BindLogic>
)
}

function AccessControlObjectDefaults(): JSX.Element | null {
const { accessControlDefault, accessControlDefaultOptions, accessControlsLoading, canEditAccessControls } =
useValues(accessControlLogic)
const { updateAccessControlDefault } = useActions(accessControlLogic)
const { guardAvailableFeature } = useValues(upgradeModalLogic)

return (
<LemonSelect
placeholder="Loading..."
value={accessControlDefault?.access_level ?? undefined}
onChange={(newValue) => {
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<AccessControlTypeMember> = [
{
key: 'user_profile_picture',
render: function ProfilePictureRender(_, ac) {
return <ProfilePicture user={member(ac)?.user} />
},
width: 32,
},
{
title: 'Name',
key: 'user_first_name',
render: (_, ac) => (
<b>
{member(ac)?.user.uuid == user.uuid
? `${member(ac)?.user.first_name} (you)`
: member(ac)?.user.first_name}
</b>
),
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 (
<div className="my-1">
<SimplLevelComponent
size="small"
level={access_level}
levels={availableLevels}
onChange={(level) =>
void updateAccessControlMembers([{ member: organization_member, level }])
}
/>
</div>
)
},
},
{
key: 'remove',
width: 0,
render: (_, { organization_member }) => {
return (
<RemoveAccessButton
subject="member"
onConfirm={() =>
void updateAccessControlMembers([{ member: organization_member, level: null }])
}
/>
)
},
},
]

return (
<div className="space-y-2">
<AddItemsControls
placeholder="Search for team members to add…"
onAdd={async (newValues, level) => {
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: <UserSelectItem user={member.user} />,
}))}
/>

<LemonTable columns={columns} dataSource={accessControlMembers} loading={accessControlsLoading} />
</div>
)
}

function AccessControlObjectRoles(): JSX.Element | null {
const { accessControlRoles, accessControlsLoading, addableRoles, rolesById, availableLevels } =
useValues(accessControlLogic)
const { updateAccessControlRoles } = useAsyncActions(accessControlLogic)
const { guardAvailableFeature } = useValues(upgradeModalLogic)

const columns: LemonTableColumns<AccessControlTypeRole> = [
{
title: 'Role',
key: 'role',
width: 0,
render: (_, { role }) => (
<span className="whitespace-nowrap">
<LemonTableLink
to={urls.settings('organization-roles') + `#role=${role}`}
title={rolesById[role]?.name}
/>
</span>
),
},
{
title: 'Members',
key: 'members',
render: (_, { role }) => {
return (
<ProfileBubbles
people={
rolesById[role]?.members?.map((member) => ({
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 (
<div className="my-1">
<SimplLevelComponent
size="small"
level={access_level}
levels={availableLevels}
onChange={(level) => void updateAccessControlRoles([{ role, level }])}
/>
</div>
)
},
},
{
key: 'remove',
width: 0,
render: (_, { role }) => {
return (
<RemoveAccessButton
subject="role"
onConfirm={() => void updateAccessControlRoles([{ role, level: null }])}
/>
)
},
},
]

return (
<div className="space-y-2">
<AddItemsControls
placeholder="Search for roles to add…"
onAdd={async (newValues, level) => {
if (guardAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING)) {
await updateAccessControlRoles(newValues.map((role) => ({ role, level })))
}
}}
options={addableRoles.map((role) => ({
key: role.id,
label: role.name,
}))}
/>

<LemonTable columns={columns} dataSource={accessControlRoles} loading={accessControlsLoading} />
</div>
)
}

function SimplLevelComponent(props: {
size?: LemonSelectProps<any>['size']
level: AccessControlType['access_level'] | null
levels: AccessControlType['access_level'][]
onChange: (newValue: AccessControlType['access_level']) => void
}): JSX.Element | null {
const { canEditAccessControls } = useValues(accessControlLogic)

return (
<LemonSelect
size={props.size}
placeholder="Select level..."
value={props.level}
onChange={(newValue) => 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 (
<LemonButton
icon={<IconX />}
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<void>
options: {
key: string
label: string
}[]
}): JSX.Element | null {
const { availableLevels, canEditAccessControls } = useValues(accessControlLogic)
// TODO: Move this into a form logic
const [items, setItems] = useState<string[]>([])
const [level, setLevel] = useState<AccessControlType['access_level']>(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 (
<div className="flex gap-2 items-center">
<div className="min-w-[16rem]">
<LemonInputSelect
placeholder={props.placeholder}
value={items}
onChange={(newValues: string[]) => setItems(newValues)}
mode="multiple"
options={props.options}
disabled={!canEditAccessControls}
/>
</div>
<SimplLevelComponent levels={availableLevels} level={level} onChange={setLevel} />

<LemonButton
type="primary"
onClick={onSubmit}
disabledReason={
!canEditAccessControls
? 'You cannot edit this'
: !onSubmit
? 'Please choose what you want to add and at what level'
: undefined
}
>
Add
</LemonButton>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<LemonSelect
size="small"
placeholder="No override"
className="my-1 whitespace-nowrap"
value={role ? ac?.access_level : ac?.access_level ?? defaultAccessLevel}
onChange={(newValue) =>
updateRoleBasedAccessControls([
{
resource,
role: role?.id ?? null,
access_level: newValue,
},
])
}
options={availableLevels.map((level) => ({
value: level,
label: capitalizeFirstLetter(level ?? ''),
}))}
/>
)
},
}))

const columns: LemonTableColumns<RoleWithResourceAccessControls> = [
{
title: 'Role',
key: 'role',
width: 0,
render: (_, { role }) => (
<span className="whitespace-nowrap">
<LemonTableLink
onClick={
role
? () => (role.id === selectedRoleId ? selectRoleId(null) : selectRoleId(role.id))
: undefined
}
title={role?.name ?? 'Default'}
/>
</span>
),
},
{
title: 'Members',
key: 'members',
render: (_, { role }) => {
return role ? (
role.members.length ? (
<ProfileBubbles
people={role.members.map((member) => ({
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 (
<div className="space-y-2">
<p>Use roles to group your organization members and assign them permissions.</p>

<PayGateMini feature={AvailableFeature.ROLE_BASED_ACCESS}>
<div className="space-y-2">
<LemonTable
columns={columns}
dataSource={rolesWithResourceAccessControls}
loading={rolesLoading || roleBasedAccessControlsLoading}
expandable={{
isRowExpanded: ({ role }) => !!selectedRoleId && role?.id === selectedRoleId,
onRowExpand: ({ role }) => (role ? selectRoleId(role.id) : undefined),
onRowCollapse: () => selectRoleId(null),
expandedRowRender: ({ role }) => (role ? <RoleDetails roleId={role?.id} /> : null),
rowExpandable: ({ role }) => !!role,
}}
/>

<LemonButton type="primary" onClick={() => setEditingRoleId('new')} icon={<IconPlus />}>
Add a role
</LemonButton>
<RoleModal />
</div>
</PayGateMini>
</div>
)
}

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<string[]>([])

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 (
<div className="my-2 pr-2 space-y-2">
<div className="flex items-center gap-2 justify-between min-h-10">
<div className="flex items-center gap-2">
<div className="min-w-[16rem]">
<LemonInputSelect
placeholder="Search for members to add..."
value={membersToAdd}
onChange={(newValues: string[]) => setMembersToAdd(newValues)}
mode="multiple"
disabled={!canEditRoleBasedAccessControls}
options={usersLemonSelectOptions(
membersNotInRole.map((member) => member.user),
'uuid'
)}
/>
</div>

<LemonButton
type="primary"
onClick={onSubmit}
disabledReason={
!canEditRoleBasedAccessControls
? 'You cannot edit this'
: !onSubmit
? 'Please select members to add'
: undefined
}
>
Add members
</LemonButton>
</div>
<div className="flex items-center gap-2">
<LemonButton
type="secondary"
onClick={() => setEditingRoleId(role.id)}
disabledReason={!canEditRoleBasedAccessControls ? 'You cannot edit this' : undefined}
>
Edit
</LemonButton>
</div>
</div>

<LemonTable
columns={[
{
key: 'user_profile_picture',
render: function ProfilePictureRender(_, member) {
return <ProfilePicture user={member.user} />
},
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 (
<div className="flex items-center gap-2">
<LemonButton
status="danger"
size="small"
type="tertiary"
disabledReason={
!canEditRoleBasedAccessControls ? 'You cannot edit this' : undefined
}
onClick={() => removeMemberFromRole(role, member.id)}
>
Remove
</LemonButton>
</div>
)
},
},
]}
dataSource={role.members}
/>
</div>
)
}

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 (
<Form logic={roleBasedAccessControlLogic} formKey="editingRole" enableFormOnSubmit>
<LemonModal
isOpen={!!editingRoleId}
onClose={() => setEditingRoleId(null)}
title={!isEditing ? 'Create' : `Edit`}
footer={
<>
<div className="flex-1">
{isEditing ? (
<LemonButton type="secondary" status="danger" onClick={() => onDelete()}>
Delete
</LemonButton>
) : null}
</div>

<LemonButton type="secondary" onClick={() => setEditingRoleId(null)}>
Cancel
</LemonButton>

<LemonButton type="primary" htmlType="submit" onClick={submitEditingRole}>
{!isEditing ? 'Create' : 'Save'}
</LemonButton>
</>
}
>
<LemonField label="Role name" name="name">
<LemonInput placeholder="Please enter a name..." autoFocus />
</LemonField>
</LemonModal>
</Form>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col overflow-hidden">
<SidePanelPaneHeader title="Access control" />
<div className="flex-1 p-4 overflow-y-auto">
{sceneSidePanelContext.access_control_resource && sceneSidePanelContext.access_control_resource_id ? (
<AccessControlObject
resource={sceneSidePanelContext.access_control_resource}
resource_id={sceneSidePanelContext.access_control_resource_id}
/>
) : (
<p>Not supported</p>
)}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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<accessControlLogicType>([
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<AccessControlType, 'access_level' | 'organization_member' | 'role'>
) => ({ 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<AccessControlResponseType>(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<AccessControlType, AccessControlUpdateType>(values.endpoint, {
access_level: level,
})

return values.accessControls
},

updateAccessControlRoles: async ({ accessControls }) => {
for (const { role, level } of accessControls) {
await api.put<AccessControlType, AccessControlUpdateType>(values.endpoint, {
role: role,
access_level: level,
})
}

return values.accessControls
},

updateAccessControlMembers: async ({ accessControls }) => {
for (const { member, level } of accessControls) {
await api.put<AccessControlType, AccessControlUpdateType>(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<string>[] => {
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<string, RoleType> => {
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<string, OrganizationMemberType> => {
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()
}),
])
Original file line number Diff line number Diff line change
@@ -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<APIScopeObject, AccessControlTypeRole>
}

export const roleBasedAccessControlLogic = kea<roleBasedAccessControlLogicType>([
path(['scenes', 'accessControl', 'roleBasedAccessControlLogic']),
connect({
values: [membersLogic, ['sortedMembers'], teamLogic, ['currentTeam'], userLogic, ['hasAvailableFeature']],
actions: [membersLogic, ['ensureAllMembersLoaded']],
}),
actions({
updateRoleBasedAccessControls: (
accessControls: Pick<AccessControlUpdateType, 'resource' | 'access_level' | 'role'>[]
) => ({ 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<AccessControlResponseType>(
'api/projects/@current/global_access_controls'
)
return response
},

updateRoleBasedAccessControls: async ({ accessControls }) => {
for (const control of accessControls) {
await api.put<AccessControlTypeRole>('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<APIScopeObject, AccessControlTypeRole>
)

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<APIScopeObject, AccessControlTypeRole>
)

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,
},
]
},
})),
])
Original file line number Diff line number Diff line change
@@ -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<sidePanelActivityLogicType>([
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<sidePanelActivityLogicType>([
}),

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<sidePanelActivityLogicType>([
afterMount(({ actions, values }) => {
actions.loadImportantChanges()

const activityFilters = values.sceneActivityFilters
const activityFilters = values.sceneSidePanelContext
actions.setFiltersForCurrentPage(activityFilters ? { ...values.filters, ...activityFilters } : null)
}),

Original file line number Diff line number Diff line change
@@ -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<sidePanelDiscussionLogicType>([
@@ -16,7 +16,7 @@ export const sidePanelDiscussionLogic = kea<sidePanelDiscussionLogicType>([
resetCommentCount: true,
}),
connect({
values: [featureFlagLogic, ['featureFlags'], activityForSceneLogic, ['sceneActivityFilters']],
values: [featureFlagLogic, ['featureFlags'], sidePanelContextLogic, ['sceneSidePanelContext']],
}),
loaders(({ values }) => ({
commentCount: [
@@ -45,12 +45,12 @@ export const sidePanelDiscussionLogic = kea<sidePanelDiscussionLogicType>([

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
},
Original file line number Diff line number Diff line change
@@ -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<sidePanelExportsLogicType>([
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 }) => {
Original file line number Diff line number Diff line change
@@ -1,61 +1,59 @@
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('/')
const item_id = pathParts[2]

// 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<activityForSceneLogicType>([
path(['scenes', 'navigation', 'sidepanel', 'activityForSceneLogic']),
export const sidePanelContextLogic = kea<sidePanelContextLogicType>([
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 },
],
}),
14 changes: 12 additions & 2 deletions frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx
Original file line number Diff line number Diff line change
@@ -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<sidePanelLogicType>([
['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<sidePanelLogicType>([
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)
12 changes: 12 additions & 0 deletions frontend/src/layout/navigation-3000/sidepanel/types.ts
Original file line number Diff line number Diff line change
@@ -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'
18 changes: 12 additions & 6 deletions frontend/src/lib/components/Metalytics/metalyticsLogic.ts
Original file line number Diff line number Diff line change
@@ -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<metalyticsLogicType>([
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<metalyticsLogicType>([

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],
5 changes: 4 additions & 1 deletion frontend/src/lib/components/RestrictedArea.tsx
Original file line number Diff line number Diff line change
@@ -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)

1 change: 1 addition & 0 deletions frontend/src/lib/components/Sharing/SharingModal.tsx
Original file line number Diff line number Diff line change
@@ -92,6 +92,7 @@ export function SharingModalContent({
<p>Something went wrong...</p>
) : (
<>
<h3>Sharing</h3>
<LemonSwitch
id="sharing-switch"
label={`Share ${resource} publicly`}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { IconGear, IconTrash } from '@posthog/icons'
import { LemonButton, LemonModal, LemonTable } from '@posthog/lemon-ui'
import { useValues } from 'kea'
import { IconGear, IconOpenSidebar, IconTrash } from '@posthog/icons'
import { LemonBanner, LemonButton, LemonTable } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
import { TitleWithIcon } from 'lib/components/TitleWithIcon'
import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { LemonInputSelect, LemonInputSelectOption } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect'
import { LemonTableColumns } from 'lib/lemon-ui/LemonTable'

import { AccessLevel, Resource, RoleType } from '~/types'
import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
import { AccessLevel, AvailableFeature, FeatureFlagType, Resource, RoleType, SidePanelTab } from '~/types'

import { featureFlagPermissionsLogic } from './feature-flags/featureFlagPermissionsLogic'
import { permissionsLogic } from './settings/organization/Permissions/permissionsLogic'
import { rolesLogic } from './settings/organization/Permissions/Roles/rolesLogic'
import { urls } from './urls'
@@ -23,13 +27,7 @@ interface ResourcePermissionProps {
canEdit: boolean
}

interface ResourcePermissionModalProps extends ResourcePermissionProps {
title: string
visible: boolean
onClose: () => 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 <p>Please save the feature flag before changing the access controls.</p>
}
return (
<div>
<LemonBanner type="info" className="mb-4">
Permissions have moved! We're rolling out our new access control system. Click below to open it.
</LemonBanner>
<LemonButton
type="primary"
icon={<IconOpenSidebar />}
onClick={() => {
openSidePanel(SidePanelTab.AccessControl)
}}
>
Open access control
</LemonButton>
</div>
)
}

return (
<>
<LemonModal title={title} isOpen={visible} onClose={onClose}>
<ResourcePermission
resourceType={Resource.FEATURE_FLAGS}
onChange={onChange}
rolesToAdd={rolesToAdd}
addableRoles={addableRoles}
addableRolesLoading={addableRolesLoading}
onAdd={onAdd}
roles={roles}
deleteAssociatedRole={deleteAssociatedRole}
canEdit={canEdit}
/>
</LemonModal>
</>
<PayGateMini feature={AvailableFeature.ROLE_BASED_ACCESS}>
<ResourcePermission
resourceType={Resource.FEATURE_FLAGS}
onChange={(roleIds) => setRolesToAdd(roleIds)}
rolesToAdd={rolesToAdd}
addableRoles={addableRoles}
addableRolesLoading={unfilteredAddableRolesLoading}
onAdd={() => addAssociatedRoles()}
roles={derivedRoles}
deleteAssociatedRole={(id) => deleteAssociatedRole({ roleId: id })}
canEdit={featureFlag.can_edit}
/>
</PayGateMini>
)
}

@@ -108,7 +123,7 @@ export function ResourcePermission({
icon={
<LemonButton
icon={<IconGear />}
to={`${urls.settings('organization-rbac')}`}
to={`${urls.settings('organization-roles')}`}
targetBlank
size="small"
noPadding
12 changes: 7 additions & 5 deletions frontend/src/scenes/actions/actionLogic.ts
Original file line number Diff line number Diff line change
@@ -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<actionLogicType>([
(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
},
155 changes: 91 additions & 64 deletions frontend/src/scenes/dashboard/DashboardCollaborators.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
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'
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 (
<div>
<h3>Access control</h3>
<LemonBanner type="info" className="mb-4">
Permissions have moved! We're rolling out our new access control system. Click below to open it.
</LemonBanner>
<LemonButton
type="primary"
icon={<IconOpenSidebar />}
onClick={() => {
openSidePanel(SidePanelTab.AccessControl)
push(urls.dashboard(dashboard.id))
}}
>
Open access control
</LemonButton>
</div>
)
}

return (
dashboard && (
<>
<PayGateMini feature={AvailableFeature.ADVANCED_PERMISSIONS}>
{(!canEditDashboard || !canRestrictDashboard) && (
<LemonBanner type="info" className="mb-4">
{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."}
</LemonBanner>
)}
<LemonSelect
value={dashboard.effective_restriction_level}
onChange={(newValue) =>
triggerDashboardUpdate({
restriction_level: newValue,
})
}
options={DASHBOARD_RESTRICTION_OPTIONS}
loading={dashboardLoading}
fullWidth
disabled={!canRestrictDashboard}
/>
{dashboard.restriction_level > DashboardRestrictionLevel.EveryoneInProjectCanEdit && (
<div className="mt-4">
<h5>Collaborators</h5>
{canEditDashboard && (
<div className="flex gap-2">
<div className="flex-1">
<LemonInputSelect
placeholder="Search for team members to add…"
value={explicitCollaboratorsToBeAdded}
loading={explicitCollaboratorsLoading}
onChange={(newValues: string[]) =>
setExplicitCollaboratorsToBeAdded(newValues)
}
mode="multiple"
data-attr="subscribed-emails"
options={usersLemonSelectOptions(addableMembers, 'uuid')}
/>
</div>
<LemonButton
type="primary"
loading={explicitCollaboratorsLoading}
disabled={explicitCollaboratorsToBeAdded.length === 0}
onClick={() => addExplicitCollaborators()}
>
Add
</LemonButton>
</div>
)}
<h5 className="mt-4">Project members with access</h5>
<div className="mt-2 pb-2 rounded overflow-y-auto max-h-80">
{allCollaborators.map((collaborator) => (
<CollaboratorRow
key={collaborator.user.uuid}
collaborator={collaborator}
deleteCollaborator={canEditDashboard ? deleteExplicitCollaborator : undefined}
/>
))}
<PayGateMini feature={AvailableFeature.ADVANCED_PERMISSIONS}>
{(!canEditDashboard || !canRestrictDashboard) && (
<LemonBanner type="info" className="mb-4">
{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."}
</LemonBanner>
)}
<LemonSelect
value={dashboard.effective_restriction_level}
onChange={(newValue) =>
triggerDashboardUpdate({
restriction_level: newValue,
})
}
options={DASHBOARD_RESTRICTION_OPTIONS}
loading={dashboardLoading}
fullWidth
disabled={!canRestrictDashboard}
/>
{dashboard.restriction_level > DashboardRestrictionLevel.EveryoneInProjectCanEdit && (
<div className="mt-4">
<h5>Collaborators</h5>
{canEditDashboard && (
<div className="flex gap-2">
<div className="flex-1">
<LemonInputSelect
placeholder="Search for team members to add…"
value={explicitCollaboratorsToBeAdded}
loading={explicitCollaboratorsLoading}
onChange={(newValues: string[]) => setExplicitCollaboratorsToBeAdded(newValues)}
mode="multiple"
data-attr="subscribed-emails"
options={usersLemonSelectOptions(addableMembers, 'uuid')}
/>
</div>
<LemonButton
type="primary"
loading={explicitCollaboratorsLoading}
disabled={explicitCollaboratorsToBeAdded.length === 0}
onClick={() => addExplicitCollaborators()}
>
Add
</LemonButton>
</div>
)}
</PayGateMini>
</>
)
<h5 className="mt-4">Project members with access</h5>
<div className="mt-2 pb-2 rounded overflow-y-auto max-h-80">
{allCollaborators.map((collaborator) => (
<CollaboratorRow
key={collaborator.user.uuid}
collaborator={collaborator}
deleteCollaborator={canEditDashboard ? deleteExplicitCollaborator : undefined}
/>
))}
</div>
</div>
)}
</PayGateMini>
)
}

17 changes: 17 additions & 0 deletions frontend/src/scenes/dashboard/dashboardLogic.tsx
Original file line number Diff line number Diff line change
@@ -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<dashboardLogicType>([
},
],
],

[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<DashboardTile>) => {
Original file line number Diff line number Diff line change
@@ -29,7 +29,7 @@ export const dataWarehouseViewsLogic = kea<dataWarehouseViewsLogicType>([
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)
}
44 changes: 3 additions & 41 deletions frontend/src/scenes/feature-flags/FeatureFlag.tsx
Original file line number Diff line number Diff line change
@@ -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: (
<PayGateMini feature={AvailableFeature.ROLE_BASED_ACCESS}>
<ResourcePermission
resourceType={Resource.FEATURE_FLAGS}
onChange={(roleIds) => setRolesToAdd(roleIds)}
rolesToAdd={rolesToAdd}
addableRoles={addableRoles}
addableRolesLoading={unfilteredAddableRolesLoading}
onAdd={() => addAssociatedRoles()}
roles={derivedRoles}
deleteAssociatedRole={(id) => deleteAssociatedRole({ roleId: id })}
canEdit={featureFlag.can_edit}
/>
</PayGateMini>
),
content: <FeatureFlagPermissions featureFlag={featureFlag} />,
})
}

@@ -433,21 +409,7 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element {
<h3 className="p-2 mb-0">Permissions</h3>
<LemonDivider className="my-0" />
<div className="p-3">
<PayGateMini feature={AvailableFeature.ROLE_BASED_ACCESS}>
<ResourcePermission
resourceType={Resource.FEATURE_FLAGS}
onChange={(roleIds) => setRolesToAdd(roleIds)}
rolesToAdd={rolesToAdd}
addableRoles={addableRoles}
addableRolesLoading={unfilteredAddableRolesLoading}
onAdd={() => addAssociatedRoles()}
roles={derivedRoles}
deleteAssociatedRole={(id) =>
deleteAssociatedRole({ roleId: id })
}
canEdit={featureFlag.can_edit}
/>
</PayGateMini>
<FeatureFlagPermissions featureFlag={featureFlag} />
</div>
</div>
</>
15 changes: 15 additions & 0 deletions frontend/src/scenes/feature-flags/featureFlagLogic.ts
Original file line number Diff line number Diff line change
@@ -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<featureFlagLogicType>([
{ 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) => {
14 changes: 8 additions & 6 deletions frontend/src/scenes/insights/insightSceneLogic.tsx
Original file line number Diff line number Diff line change
@@ -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<insightSceneLogicType>([
]
},
],
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
},
104 changes: 0 additions & 104 deletions frontend/src/scenes/notebooks/Notebook/NotebookShare.tsx

This file was deleted.

133 changes: 133 additions & 0 deletions frontend/src/scenes/notebooks/Notebook/NotebookShareModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<LemonModal
title="Share notebook"
onClose={() => closeShareModal()}
isOpen={isShareModalOpen}
width={SHARING_MODAL_WIDTH}
footer={
<LemonButton type="secondary" onClick={closeShareModal}>
Done
</LemonButton>
}
>
<div className="space-y-4">
<FlaggedFeature flag="role-based-access-control">
<>
<div>
<h3>Access control</h3>
<LemonBanner type="info" className="mb-4">
Permissions have moved! We're rolling out our new access control system. Click below to
open it.
</LemonBanner>
<LemonButton
type="primary"
icon={<IconOpenSidebar />}
onClick={() => {
openSidePanel(SidePanelTab.AccessControl)
closeShareModal()
}}
>
Open access control
</LemonButton>
</div>
<LemonDivider />
</>
</FlaggedFeature>
<h3>Internal Link</h3>
{!isLocalOnly ? (
<>
<p>
<b>Click the button below</b> to copy a direct link to this Notebook. Make sure the person
you share it with has access to this PostHog project.
</p>
<LemonButton
type="secondary"
fullWidth
center
sideIcon={<IconCopy />}
onClick={() => void copyToClipboard(notebookUrl, 'notebook link')}
title={notebookUrl}
>
<span className="truncate">{notebookUrl}</span>
</LemonButton>

<LemonDivider className="my-4" />
</>
) : (
<LemonBanner type="info">
<p>This Notebook cannot be shared directly with others as it is only visible to you.</p>
</LemonBanner>
)}

<h3>Template Link</h3>
<p>
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.
</p>
<LemonButton
type="secondary"
fullWidth
center
sideIcon={<IconCopy />}
onClick={() => void copyToClipboard(canvasUrl, 'canvas link')}
title={canvasUrl}
>
<span className="truncate">{canvasUrl}</span>
</LemonButton>

<LemonDivider className="my-4" />

<h3>External Sharing</h3>

<LemonBanner
type="warning"
action={{
children: !interestTracked ? 'I would like this!' : 'Thanks!',
onClick: () => {
if (!interestTracked) {
trackInterest()
setInterestTracked(true)
}
},
}}
>
We don’t currently support sharing notebooks externally, but it’s on our roadmap!
</LemonBanner>
</div>
</LemonModal>
)
}
Original file line number Diff line number Diff line change
@@ -59,5 +59,6 @@
"first_name": "Paul",
"email": "paul@posthog.com",
"is_email_verified": false
}
},
"user_access_level": "editor"
}
25 changes: 20 additions & 5 deletions frontend/src/scenes/notebooks/Notebook/notebookLogic.ts
Original file line number Diff line number Diff line change
@@ -133,8 +133,17 @@ export const notebookLogic = kea<notebookLogicType>([
setContainerSize: (containerSize: 'small' | 'medium') => ({ containerSize }),
insertComment: (context: Record<string, any>) => ({ 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<notebookLogicType>([
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<notebookLogicType>([
],

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<notebookLogicType>([
)
},
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
Loading

0 comments on commit fc19b77

Please sign in to comment.