forked from PostHog/posthog
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: rbac client side code (PostHog#26694)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent
f4593e1
commit fc19b77
Showing
65 changed files
with
1,811 additions
and
372 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file modified
BIN
+1.38 KB
(100%)
frontend/__snapshots__/components-sharing--dashboard-sharing--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-1.81 KB
(94%)
frontend/__snapshots__/components-sharing--dashboard-sharing--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+1.38 KB
(100%)
frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-1.77 KB
(94%)
frontend/__snapshots__/components-sharing--dashboard-sharing-licensed--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+1.52 KB
(100%)
frontend/__snapshots__/components-sharing--insight-sharing--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+1.59 KB
(100%)
frontend/__snapshots__/components-sharing--insight-sharing--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+1.52 KB
(100%)
frontend/__snapshots__/components-sharing--insight-sharing-licensed--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+1.63 KB
(100%)
frontend/__snapshots__/components-sharing--insight-sharing-licensed--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+1.52 KB
(100%)
frontend/__snapshots__/components-sharing--recording-sharing-licensed--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+1.51 KB
(100%)
frontend/__snapshots__/components-sharing--recording-sharing-licensed--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-145 Bytes
(100%)
.../__snapshots__/scenes-other-org-member-invites--current-user-is-admin--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+28 Bytes
(100%)
...__snapshots__/scenes-other-org-member-invites--current-user-is-admin--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-150 Bytes
(100%)
...__snapshots__/scenes-other-org-member-invites--current-user-is-member--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+36 Bytes
(100%)
..._snapshots__/scenes-other-org-member-invites--current-user-is-member--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-145 Bytes
(100%)
.../__snapshots__/scenes-other-org-member-invites--current-user-is-owner--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+28 Bytes
(100%)
...__snapshots__/scenes-other-org-member-invites--current-user-is-owner--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-1.83 KB
(99%)
frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-1.35 KB
(99%)
frontend/__snapshots__/scenes-other-settings--settings-organization--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-998 Bytes
(99%)
frontend/__snapshots__/scenes-other-settings--settings-web-vitals--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-998 Bytes
(99%)
frontend/__snapshots__/scenes-other-settings--settings-web-vitals--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
383 changes: 383 additions & 0 deletions
383
frontend/src/layout/navigation-3000/sidepanel/panels/access_control/AccessControlObject.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
323 changes: 323 additions & 0 deletions
323
...layout/navigation-3000/sidepanel/panels/access_control/RolesAndResourceAccessControls.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
25 changes: 25 additions & 0 deletions
25
...end/src/layout/navigation-3000/sidepanel/panels/access_control/SidePanelAccessControl.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
250 changes: 250 additions & 0 deletions
250
frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}), | ||
]) |
269 changes: 269 additions & 0 deletions
269
...src/layout/navigation-3000/sidepanel/panels/access_control/roleBasedAccessControlLogic.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
] | ||
}, | ||
})), | ||
]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
11 changes: 1 addition & 10 deletions
11
frontend/src/layout/navigation-3000/sidepanel/panels/exports/sidePanelExportsLogic.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
40 changes: 19 additions & 21 deletions
40
.../panels/activity/activityForSceneLogic.ts → ...sidepanel/panels/sidePanelContextLogic.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
104 changes: 0 additions & 104 deletions
104
frontend/src/scenes/notebooks/Notebook/NotebookShare.tsx
This file was deleted.
Oops, something went wrong.
133 changes: 133 additions & 0 deletions
133
frontend/src/scenes/notebooks/Notebook/NotebookShareModal.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.