feat: Simplify AccessControlAction usage (#38248)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
@@ -11,16 +11,20 @@ You are an expert in PostHog's access control system. Your role is to help imple
|
||||
## Core Concepts
|
||||
|
||||
### Access Levels
|
||||
|
||||
- **Resource-level**: `none`, `viewer`, `editor`, `manager`
|
||||
- **Project-level**: `none`, `member`, `admin`
|
||||
|
||||
### Resources vs Objects
|
||||
|
||||
- **Resource**: A type of entity (e.g., `notebook`, `feature_flag`)
|
||||
- **Object**: A specific instance (e.g., notebook ID 123)
|
||||
- Users can have different access levels for the resource type vs specific objects
|
||||
|
||||
### Access Sources
|
||||
|
||||
Users can gain access through:
|
||||
|
||||
- Being the creator
|
||||
- Organization admin privileges
|
||||
- Explicit member grants
|
||||
@@ -31,6 +35,7 @@ Users can gain access through:
|
||||
## Code Structure
|
||||
|
||||
### Key Files
|
||||
|
||||
- `posthog/rbac/user_access_control.py` - Core access control logic
|
||||
- `ee/models/rbac/access_control.py` - Database model
|
||||
- `ee/api/rbac/access_control.py` - API endpoints and ViewSet mixin
|
||||
@@ -38,6 +43,7 @@ Users can gain access through:
|
||||
- `posthog/scopes.py` - Resource type definitions
|
||||
|
||||
### Main Classes
|
||||
|
||||
- `UserAccessControl` - Central access control logic
|
||||
- `AccessControlViewSetMixin` - Adds access control endpoints to ViewSets
|
||||
- `AccessControlPermission` - Enforces access controls in API
|
||||
@@ -51,7 +57,7 @@ Users can gain access through:
|
||||
# posthog/scopes.py
|
||||
ACCESS_CONTROL_RESOURCES = [
|
||||
"feature_flag",
|
||||
"dashboard",
|
||||
"dashboard",
|
||||
"insight",
|
||||
"your_resource", # Add your new resource
|
||||
"session_recording",
|
||||
@@ -76,7 +82,7 @@ class YourResourceViewSet(
|
||||
ProjectMembershipNecessaryPermissions,
|
||||
AccessControlPermission, # Add access control permission
|
||||
]
|
||||
|
||||
|
||||
# Rest of your ViewSet implementation
|
||||
```
|
||||
|
||||
@@ -124,7 +130,7 @@ Add your scenes to the access control resource mapping:
|
||||
// frontend/src/scenes/sceneTypes.ts
|
||||
export const sceneToAccessControlResourceType: Partial<Record<Scene, AccessControlResourceType>> = {
|
||||
// Existing mappings...
|
||||
|
||||
|
||||
// Your new resource scenes
|
||||
[Scene.YourResource]: AccessControlResourceType.YourNewResource,
|
||||
[Scene.YourResourceList]: AccessControlResourceType.YourNewResource,
|
||||
@@ -148,101 +154,31 @@ export interface YourResourceType {
|
||||
|
||||
#### 4.4 Block UI Elements Based on Access Levels
|
||||
|
||||
##### Using accessControl Props (Preferred)
|
||||
You should wrap the components you care about with the `AccessControlAction`. It requires the child component to expose a `disabledReason` prop which is automatically set by the wrapper.
|
||||
|
||||
Many PostHog components support `accessControl` props for automatic permission checking:
|
||||
|
||||
```tsx
|
||||
import { LemonButton, LemonSwitch, LemonSelect } from '@posthog/lemon-ui'
|
||||
import { getAppContext } from 'lib/utils/getAppContext'
|
||||
import { AccessControlResourceType, AccessControlLevel } from '~/types'
|
||||
|
||||
// Block buttons based on resource-level access
|
||||
<LemonButton
|
||||
type="primary"
|
||||
onClick={handleEdit}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.YourResource,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: getAppContext()?.resource_access_control?.[AccessControlResourceType.YourResource],
|
||||
}}
|
||||
>
|
||||
Edit Resource
|
||||
</LemonButton>
|
||||
|
||||
// Block switches/toggles
|
||||
<LemonSwitch
|
||||
checked={settings.enabled}
|
||||
onChange={handleToggle}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.YourResource,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: getAppContext()?.resource_access_control?.[AccessControlResourceType.YourResource],
|
||||
}}
|
||||
/>
|
||||
|
||||
// Block selects and other form controls
|
||||
<LemonSelect
|
||||
value={selectedOption}
|
||||
onChange={handleChange}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.YourResource,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: getAppContext()?.resource_access_control?.[AccessControlResourceType.YourResource],
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
|
||||
// For object-specific permissions, use the object's user_access_level
|
||||
<LemonButton
|
||||
type="primary"
|
||||
onClick={handleEdit}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.YourResource,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: yourResource.user_access_level,
|
||||
}}
|
||||
>
|
||||
Edit This Resource
|
||||
</LemonButton>
|
||||
```
|
||||
|
||||
##### Using AccessControlAction Wrapper
|
||||
|
||||
For components that don't support `accessControl` props:
|
||||
If your component doesn't respect that interface you can instead expose a function that accepts `{ disabled, disabledReason }` as parameters.
|
||||
|
||||
```tsx
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { AccessControlResourceType, AccessControlLevel } from '~/types'
|
||||
import { getAppContext } from 'lib/utils/getAppContext'
|
||||
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.YourResource}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.YourResource]}
|
||||
>
|
||||
{({ disabled, disabledReason }) => (
|
||||
<CustomComponent
|
||||
onClick={handleAction}
|
||||
disabled={disabled}
|
||||
tooltip={disabledReason}
|
||||
/>
|
||||
)}
|
||||
</AccessControlAction>
|
||||
|
||||
// For object-specific permissions
|
||||
// Automatically sets `disabled` and `disabledReason` on the child
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.YourResource}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={yourResource.user_access_level}
|
||||
>
|
||||
{({ disabled, disabledReason }) => (
|
||||
<CustomComponent
|
||||
onClick={handleAction}
|
||||
disabled={disabled}
|
||||
tooltip={disabledReason}
|
||||
/>
|
||||
)}
|
||||
<LemonButton>My button</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
// Manually sets the values
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.YourResource}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.YourResource]}
|
||||
>
|
||||
{({ disabledReason }) => (<CustomComponent onClick={handleAction} tooltip={disabledReason} readOnly={!!disabledReason} />)}
|
||||
</AccessControlAction>
|
||||
```
|
||||
|
||||
@@ -254,34 +190,31 @@ Use resource-level permissions for create operations:
|
||||
|
||||
```tsx
|
||||
import { LemonButton } from '@posthog/lemon-ui'
|
||||
|
||||
import { getAppContext } from 'lib/utils/getAppContext'
|
||||
import { AccessControlResourceType, AccessControlLevel } from '~/types'
|
||||
|
||||
import { AccessControlLevel, AccessControlResourceType } from '~/types'
|
||||
|
||||
function YourResourceList() {
|
||||
return (
|
||||
<div>
|
||||
{/* Using accessControl prop (preferred) */}
|
||||
<LemonButton
|
||||
type="primary"
|
||||
onClick={() => router.actions.push('/your-resources/new')}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.YourResource,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: getAppContext()?.resource_access_control?.[AccessControlResourceType.YourResource],
|
||||
}}
|
||||
{/* Using AccessControlAction (preferred) */}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.YourResource}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.YourResource]}
|
||||
>
|
||||
New Resource
|
||||
</LemonButton>
|
||||
|
||||
<LemonButton type="primary" onClick={() => router.actions.push('/your-resources/new')}>
|
||||
New Resource
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
{/* Manual permission check if needed */}
|
||||
{(() => {
|
||||
const userLevel = getAppContext()?.resource_access_control?.[AccessControlResourceType.YourResource]
|
||||
const canCreate = userLevel && ['editor', 'manager'].includes(userLevel)
|
||||
return canCreate ? (
|
||||
<LemonButton
|
||||
type="primary"
|
||||
onClick={() => router.actions.push('/your-resources/new')}
|
||||
>
|
||||
<LemonButton type="primary" onClick={() => router.actions.push('/your-resources/new')}>
|
||||
New Resource
|
||||
</LemonButton>
|
||||
) : null
|
||||
@@ -300,27 +233,20 @@ function YourResourceCard({ yourResource }: { yourResource: YourResourceType })
|
||||
return (
|
||||
<div>
|
||||
<h3>{yourResource.name}</h3>
|
||||
|
||||
{/* Using accessControl prop (preferred) */}
|
||||
<LemonButton
|
||||
onClick={() => openEditModal(yourResource)}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.YourResource,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: yourResource.user_access_level,
|
||||
}}
|
||||
|
||||
{/* Using AccessControlAction (preferred) */}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.YourResource}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={yourResource.user_access_level}
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
|
||||
<LemonButton onClick={() => openEditModal(yourResource)}>Edit</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
{/* Manual permission check if needed */}
|
||||
{(() => {
|
||||
const canEdit = ['editor', 'manager'].includes(yourResource.user_access_level || 'none')
|
||||
return canEdit ? (
|
||||
<LemonButton onClick={() => openEditModal(yourResource)}>
|
||||
Edit
|
||||
</LemonButton>
|
||||
) : null
|
||||
return canEdit ? <LemonButton onClick={() => openEditModal(yourResource)}>Edit</LemonButton> : null
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
@@ -332,17 +258,15 @@ function YourResourceCard({ yourResource }: { yourResource: YourResourceType })
|
||||
Typically requires `editor` level access:
|
||||
|
||||
```tsx
|
||||
<LemonButton
|
||||
status="danger"
|
||||
onClick={() => deleteYourResource(yourResource)}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.YourResource,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: yourResource.user_access_level,
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.YourResource}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={yourResource.user_access_level}
|
||||
>
|
||||
Delete
|
||||
</LemonButton>
|
||||
<LemonButton status="danger" onClick={() => deleteYourResource(yourResource)}>
|
||||
Delete
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
```
|
||||
|
||||
#### 4.6 Review All User Interaction Points
|
||||
@@ -350,6 +274,7 @@ Typically requires `editor` level access:
|
||||
When implementing access controls, audit all places where users can interact with your resource:
|
||||
|
||||
**Common interaction points to review:**
|
||||
|
||||
- List views (create buttons, bulk actions)
|
||||
- Detail views (edit, delete, duplicate buttons)
|
||||
- Settings pages (configuration toggles, saves)
|
||||
@@ -371,13 +296,13 @@ from posthog.rbac.decorators import field_access_control
|
||||
|
||||
class Team(models.Model):
|
||||
# Other fields...
|
||||
|
||||
|
||||
session_recording_opt_in = field_access_control(
|
||||
models.BooleanField(default=False),
|
||||
"session_recording", # Resource type
|
||||
"editor" # Required access level
|
||||
)
|
||||
|
||||
|
||||
capture_console_log_opt_in = field_access_control(
|
||||
models.BooleanField(null=True, blank=True),
|
||||
"session_recording",
|
||||
@@ -401,6 +326,7 @@ RESOURCE_INHERITANCE_MAP = {
|
||||
## Step-by-Step Implementation Checklist
|
||||
|
||||
### Backend
|
||||
|
||||
1. Add resource to `ACCESS_CONTROL_RESOURCES` in `posthog/scopes.py`
|
||||
2. Add `AccessControlViewSetMixin` to your ViewSet
|
||||
3. Set `scope_object` attribute on ViewSet
|
||||
@@ -410,6 +336,7 @@ RESOURCE_INHERITANCE_MAP = {
|
||||
7. **If needed:** Set up resource inheritance in `RESOURCE_INHERITANCE_MAP`
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Add resource to `resourcesAccessControlLogic.ts` resources array
|
||||
2. Add scene mappings to `sceneToAccessControlResourceType` in `sceneTypes.ts`
|
||||
3. Update TypeScript types to include `user_access_level: AccessLevel`
|
||||
@@ -419,6 +346,7 @@ RESOURCE_INHERITANCE_MAP = {
|
||||
7. Handle access control UI (user management modals, permission settings)
|
||||
|
||||
### Testing
|
||||
|
||||
- Add comprehensive tests for all access levels
|
||||
- **If needed:** Test field-level access controls
|
||||
- **If needed:** Test inheritance patterns
|
||||
@@ -454,7 +382,7 @@ user_access_control = UserAccessControl(user, team)
|
||||
if user_access_control.check_access_level_for_resource("notebook", "editor"):
|
||||
# User can edit notebooks
|
||||
|
||||
# Check object-level access
|
||||
# Check object-level access
|
||||
if user_access_control.check_access_level_for_object(notebook, "viewer"):
|
||||
# User can view this specific notebook
|
||||
|
||||
@@ -484,4 +412,4 @@ user_access_control.preload_object_access_controls(notebook_queryset, "notebook"
|
||||
- Always include both server-side enforcement (permissions) and client-side UI (conditional rendering)
|
||||
- The system uses caching to optimize performance - be mindful of bulk operations
|
||||
- Field-level access controls automatically validate during serialization
|
||||
- Resource inheritance allows related resources to share access controls
|
||||
- Resource inheritance allows related resources to share access controls
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,3 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import { AccessControlResourceType } from '~/types'
|
||||
|
||||
type AccessControlLevelNone = 'none'
|
||||
@@ -5,13 +7,6 @@ type AccessControlLevelMember = AccessControlLevelNone | 'member' | 'admin'
|
||||
type AccessControlLevelResource = AccessControlLevelNone | 'viewer' | 'editor' | 'manager'
|
||||
type AccessControlLevel = AccessControlLevelMember | AccessControlLevelResource
|
||||
|
||||
interface AccessControlActionProps {
|
||||
children: (props: { disabled: boolean; disabledReason: string | null }) => React.ReactElement
|
||||
userAccessLevel?: AccessControlLevel
|
||||
minAccessLevel: AccessControlLevel
|
||||
resourceType: AccessControlResourceType
|
||||
}
|
||||
|
||||
const orderedAccessLevels = (resourceType: AccessControlResourceType): AccessControlLevel[] => {
|
||||
if (resourceType === AccessControlResourceType.Project || resourceType === AccessControlResourceType.Organization) {
|
||||
return ['none', 'member', 'admin']
|
||||
@@ -56,18 +51,49 @@ export const getAccessControlDisabledReason = (
|
||||
return null
|
||||
}
|
||||
|
||||
interface AccessControlActionChildrenProps {
|
||||
disabled?: boolean
|
||||
disabledReason: string | null
|
||||
}
|
||||
|
||||
interface AccessControlActionProps<P extends AccessControlActionChildrenProps> {
|
||||
children: React.ComponentType<P> | React.ReactElement<P>
|
||||
userAccessLevel?: AccessControlLevel
|
||||
minAccessLevel: AccessControlLevel
|
||||
resourceType: AccessControlResourceType
|
||||
}
|
||||
|
||||
// This is a wrapper around a component that checks if the user has access to the resource
|
||||
// and if not, it disables the component and shows a reason why
|
||||
export const AccessControlAction = ({
|
||||
// and if not, it sets the `disabled` and `disabledReason` props on the child component
|
||||
//
|
||||
// NOTE: TS is not powerful enough to enforce the fact that the child component *must* receive
|
||||
// the `disabled` and `disabledReason` props. This means we are accepting any component and
|
||||
// then setting the props at runtime even in case they "shouldn't" receive them.
|
||||
// This is not problematic during runtime but it's admitedly slightly confusing
|
||||
export function AccessControlAction<P extends AccessControlActionChildrenProps>({
|
||||
children,
|
||||
userAccessLevel,
|
||||
minAccessLevel,
|
||||
resourceType = AccessControlResourceType.Project,
|
||||
}: AccessControlActionProps): JSX.Element => {
|
||||
}: AccessControlActionProps<P>): JSX.Element {
|
||||
const disabledReason = getAccessControlDisabledReason(resourceType, userAccessLevel, minAccessLevel)
|
||||
|
||||
return children({
|
||||
disabled: !!disabledReason,
|
||||
disabledReason,
|
||||
})
|
||||
// Check if children is a component function or a rendered element
|
||||
// If it's a component function, we need to render it with the props
|
||||
if (typeof children === 'function') {
|
||||
const Component = children as React.ComponentType<P>
|
||||
const componentProps = {
|
||||
disabled: !!disabledReason,
|
||||
disabledReason: disabledReason,
|
||||
} as P
|
||||
|
||||
return <Component {...componentProps} />
|
||||
}
|
||||
|
||||
// If it's a rendered element, we need to clone it overloading the props
|
||||
const element = children as React.FunctionComponentElement<P>
|
||||
return React.cloneElement<P>(element, {
|
||||
disabled: element.props.disabled ?? !!disabledReason,
|
||||
disabledReason: element.props.disabledReason ?? disabledReason,
|
||||
} as Partial<P>)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
QueryBasedInsightModel,
|
||||
} from '~/types'
|
||||
|
||||
import { accessLevelSatisfied } from '../AccessControlAction'
|
||||
import { AccessControlAction, accessLevelSatisfied } from '../AccessControlAction'
|
||||
import { upgradeModalLogic } from '../UpgradeModal/upgradeModalLogic'
|
||||
import { SharePasswordsTable } from './SharePasswordsTable'
|
||||
import { sharingLogic } from './sharingLogic'
|
||||
@@ -151,25 +151,28 @@ export function SharingModalContent({
|
||||
{!sharingAllowed ? (
|
||||
<LemonBanner type="warning">Public sharing is disabled for this organization.</LemonBanner>
|
||||
) : (
|
||||
<LemonSwitch
|
||||
id="sharing-switch"
|
||||
label={`Share ${resource} publicly`}
|
||||
checked={sharingConfiguration.enabled}
|
||||
data-attr="sharing-switch"
|
||||
onChange={(active) => setIsEnabled(active)}
|
||||
bordered
|
||||
fullWidth
|
||||
loading={sharingConfigurationLoading}
|
||||
accessControl={{
|
||||
resourceType: dashboardId
|
||||
<AccessControlAction
|
||||
resourceType={
|
||||
dashboardId
|
||||
? AccessControlResourceType.Dashboard
|
||||
: insightShortId
|
||||
? AccessControlResourceType.Insight
|
||||
: AccessControlResourceType.Project,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: userAccessLevel,
|
||||
}}
|
||||
/>
|
||||
: AccessControlResourceType.Project
|
||||
}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={userAccessLevel}
|
||||
>
|
||||
<LemonSwitch
|
||||
id="sharing-switch"
|
||||
label={`Share ${resource} publicly`}
|
||||
checked={sharingConfiguration.enabled}
|
||||
data-attr="sharing-switch"
|
||||
onChange={(active) => setIsEnabled(active)}
|
||||
bordered
|
||||
fullWidth
|
||||
loading={sharingConfigurationLoading}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
)}
|
||||
|
||||
{sharingAllowed && sharingConfiguration.enabled && sharingConfiguration.access_token ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
||||
import { IconGear, IconInfo, IconPlus } from '@posthog/icons'
|
||||
import { Link } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { useAsyncHandler } from 'lib/hooks/useAsyncHandler'
|
||||
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
|
||||
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
|
||||
@@ -61,10 +62,10 @@ const TypesAndStatusesTemplate: StoryFn<typeof LemonButton> = (props) => {
|
||||
return (
|
||||
<div className="deprecated-space-y-2">
|
||||
{types.map((type) => (
|
||||
<>
|
||||
<div key={type}>
|
||||
<h5>type={capitalizeFirstLetter(type || '')}</h5>
|
||||
<StatusesTemplate {...props} type={type} />
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -104,10 +105,10 @@ export const Sizes = (): JSX.Element => {
|
||||
return (
|
||||
<div className="deprecated-space-y-2">
|
||||
{sizes.map((size) => (
|
||||
<>
|
||||
<div key={size}>
|
||||
<h5>size={size}</h5>
|
||||
<StatusesTemplate size={size} type="secondary" />
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -119,10 +120,10 @@ export const SizesIconOnly = (): JSX.Element => {
|
||||
return (
|
||||
<div className="deprecated-space-y-2">
|
||||
{sizes.map((size) => (
|
||||
<>
|
||||
<div key={size}>
|
||||
<h5>size={size}</h5>
|
||||
<StatusesTemplate size={size} type="secondary" noText />
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -252,7 +253,7 @@ export const WithSideAction = (): JSX.Element => {
|
||||
return (
|
||||
<div className="deprecated-space-y-2">
|
||||
{types.map((type) => (
|
||||
<>
|
||||
<div key={type}>
|
||||
<h5>type={capitalizeFirstLetter(type || '')}</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
{statuses.map((status, i) => (
|
||||
@@ -270,7 +271,7 @@ export const WithSideAction = (): JSX.Element => {
|
||||
</LemonButton>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -446,26 +447,20 @@ export const WithOverflowingContent = (): JSX.Element => {
|
||||
export const WithAccessControl = (): JSX.Element => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<LemonButton
|
||||
type="primary"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Project,
|
||||
minAccessLevel: AccessControlLevel.Admin,
|
||||
userAccessLevel: AccessControlLevel.Admin,
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Project}
|
||||
minAccessLevel={AccessControlLevel.Admin}
|
||||
userAccessLevel={AccessControlLevel.Admin}
|
||||
>
|
||||
Enabled (admin ≥ admin)
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Project,
|
||||
minAccessLevel: AccessControlLevel.Admin,
|
||||
userAccessLevel: AccessControlLevel.Viewer,
|
||||
}}
|
||||
<LemonButton type="primary">Enabled (admin ≥ admin)</LemonButton>
|
||||
</AccessControlAction>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Project}
|
||||
minAccessLevel={AccessControlLevel.Admin}
|
||||
userAccessLevel={AccessControlLevel.Viewer}
|
||||
>
|
||||
Disabled (viewer {'<'} admin)
|
||||
</LemonButton>
|
||||
<LemonButton type="primary">Disabled (viewer {'<'} admin)</LemonButton>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@ import React, { useContext } from 'react'
|
||||
|
||||
import { IconChevronDown } from '@posthog/icons'
|
||||
|
||||
import { getAccessControlDisabledReason } from 'lib/components/AccessControlAction'
|
||||
import { IconChevronRight } from 'lib/lemon-ui/icons'
|
||||
|
||||
import { AccessControlLevel, AccessControlResourceType } from '~/types'
|
||||
|
||||
import { LemonDropdown, LemonDropdownProps } from '../LemonDropdown'
|
||||
import { Link } from '../Link'
|
||||
import { PopoverOverlayContext, PopoverReferenceContext } from '../Popover'
|
||||
@@ -86,12 +83,6 @@ export interface LemonButtonPropsBase
|
||||
tooltipForceMount?: boolean
|
||||
/** Whether to stop event propagation on click */
|
||||
stopPropagation?: boolean
|
||||
/** Access control props for automatic permission checking */
|
||||
accessControl?: {
|
||||
resourceType: AccessControlResourceType
|
||||
minAccessLevel: AccessControlLevel
|
||||
userAccessLevel?: AccessControlLevel
|
||||
}
|
||||
}
|
||||
|
||||
export type SideAction = Pick<
|
||||
@@ -165,7 +156,6 @@ export const LemonButton: React.FunctionComponent<LemonButtonProps & React.RefAt
|
||||
tooltipDocLink,
|
||||
tooltipForceMount,
|
||||
stopPropagation,
|
||||
accessControl,
|
||||
...buttonProps
|
||||
},
|
||||
ref
|
||||
@@ -206,22 +196,6 @@ export const LemonButton: React.FunctionComponent<LemonButtonProps & React.RefAt
|
||||
size = 'small' // Ensure that buttons in the page header are small (but NOT inside dropdowns!)
|
||||
}
|
||||
|
||||
// Handle access control
|
||||
if (accessControl) {
|
||||
const { userAccessLevel, minAccessLevel, resourceType } = accessControl
|
||||
const accessControlDisabledReason = getAccessControlDisabledReason(
|
||||
resourceType,
|
||||
userAccessLevel,
|
||||
minAccessLevel
|
||||
)
|
||||
if (accessControlDisabledReason) {
|
||||
disabled = true
|
||||
if (!disabledReason) {
|
||||
disabledReason = accessControlDisabledReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tooltipContent: TooltipProps['title']
|
||||
if (disabledReason) {
|
||||
disabled = true // Support `disabledReason` while maintaining compatibility with `disabled`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Meta, StoryFn, StoryObj } from '@storybook/react'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { capitalizeFirstLetter } from 'lib/utils'
|
||||
|
||||
import { AccessControlLevel, AccessControlResourceType } from '~/types'
|
||||
@@ -148,24 +149,20 @@ export const WithAccessControl = (): JSX.Element => {
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 items-center">
|
||||
<LemonSelect
|
||||
options={options}
|
||||
placeholder="Enabled (editor ≥ viewer)"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Dashboard,
|
||||
minAccessLevel: AccessControlLevel.Viewer,
|
||||
userAccessLevel: AccessControlLevel.Editor,
|
||||
}}
|
||||
/>
|
||||
<LemonSelect
|
||||
options={options}
|
||||
placeholder="Disabled (viewer < editor)"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Dashboard,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: AccessControlLevel.Viewer,
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Dashboard}
|
||||
minAccessLevel={AccessControlLevel.Viewer}
|
||||
userAccessLevel={AccessControlLevel.Editor}
|
||||
>
|
||||
<LemonSelect options={options} placeholder="Enabled (editor ≥ viewer)" />
|
||||
</AccessControlAction>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Dashboard}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={AccessControlLevel.Viewer}
|
||||
>
|
||||
<LemonSelect options={options} placeholder="Disabled (viewer < editor)" />
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { IconX } from '@posthog/icons'
|
||||
|
||||
import { LemonDropdownProps } from 'lib/lemon-ui/LemonDropdown'
|
||||
|
||||
import { AccessControlLevel, AccessControlResourceType } from '~/types'
|
||||
|
||||
import { LemonButton, LemonButtonProps } from '../LemonButton'
|
||||
import {
|
||||
LemonMenu,
|
||||
@@ -83,12 +81,6 @@ export interface LemonSelectPropsBase<T>
|
||||
menu?: Pick<LemonMenuProps, 'className' | 'closeParentPopoverOnClickInside'>
|
||||
visible?: LemonDropdownProps['visible']
|
||||
startVisible?: LemonDropdownProps['startVisible']
|
||||
/** Access control props for automatic permission checking */
|
||||
accessControl?: {
|
||||
resourceType: AccessControlResourceType
|
||||
minAccessLevel: AccessControlLevel
|
||||
userAccessLevel?: AccessControlLevel
|
||||
}
|
||||
}
|
||||
|
||||
export interface LemonSelectPropsClearable<T> extends LemonSelectPropsBase<T> {
|
||||
@@ -127,7 +119,6 @@ export function LemonSelect<T extends string | number | boolean | null>({
|
||||
renderButtonContent,
|
||||
visible,
|
||||
startVisible,
|
||||
accessControl,
|
||||
...buttonProps
|
||||
}: LemonSelectProps<T>): JSX.Element {
|
||||
const [items, allLeafOptions] = useMemo(
|
||||
@@ -180,7 +171,6 @@ export function LemonSelect<T extends string | number | boolean | null>({
|
||||
: undefined
|
||||
}
|
||||
tooltip={activeLeaf?.tooltip}
|
||||
accessControl={accessControl}
|
||||
{...buttonProps}
|
||||
>
|
||||
<span className="flex flex-1">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Meta, StoryFn, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
|
||||
import { AccessControlLevel, AccessControlResourceType } from '~/types'
|
||||
|
||||
import { LemonSwitchProps, LemonSwitch as RawLemonSwitch } from './LemonSwitch'
|
||||
@@ -122,26 +124,20 @@ SizesLoading.parameters = { testOptions: { waitForLoadersToDisappear: false } }
|
||||
export const WithAccessControl = (): JSX.Element => {
|
||||
return (
|
||||
<div className="deprecated-space-y-2">
|
||||
<LemonSwitch
|
||||
label="Enabled (admin ≥ admin)"
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Project,
|
||||
minAccessLevel: AccessControlLevel.Admin,
|
||||
userAccessLevel: AccessControlLevel.Admin,
|
||||
}}
|
||||
/>
|
||||
<LemonSwitch
|
||||
label="Disabled (viewer < admin)"
|
||||
checked={false}
|
||||
onChange={() => {}}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Project,
|
||||
minAccessLevel: AccessControlLevel.Admin,
|
||||
userAccessLevel: AccessControlLevel.Viewer,
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Project}
|
||||
minAccessLevel={AccessControlLevel.Admin}
|
||||
userAccessLevel={AccessControlLevel.Admin}
|
||||
>
|
||||
<LemonSwitch label="Enabled (admin ≥ admin)" checked={true} onChange={() => {}} />
|
||||
</AccessControlAction>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Project}
|
||||
minAccessLevel={AccessControlLevel.Admin}
|
||||
userAccessLevel={AccessControlLevel.Viewer}
|
||||
>
|
||||
<LemonSwitch label="Disabled (viewer < admin)" checked={false} onChange={() => {}} />
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@ import './LemonSwitch.scss'
|
||||
import clsx from 'clsx'
|
||||
import { forwardRef, useMemo, useState } from 'react'
|
||||
|
||||
import { getAccessControlDisabledReason } from 'lib/components/AccessControlAction'
|
||||
import { Spinner } from 'lib/lemon-ui/Spinner'
|
||||
import { Tooltip } from 'lib/lemon-ui/Tooltip'
|
||||
import { cn } from 'lib/utils/css-classes'
|
||||
|
||||
import { AccessControlLevel, AccessControlResourceType } from '~/types'
|
||||
|
||||
export interface LemonSwitchProps {
|
||||
className?: string
|
||||
onChange?: (newChecked: boolean) => void
|
||||
@@ -29,12 +26,6 @@ export interface LemonSwitchProps {
|
||||
sliderColorOverrideChecked?: string
|
||||
sliderColorOverrideUnchecked?: string
|
||||
loading?: boolean
|
||||
/** Access control props for automatic permission checking */
|
||||
accessControl?: {
|
||||
resourceType: AccessControlResourceType
|
||||
minAccessLevel: AccessControlLevel
|
||||
userAccessLevel?: AccessControlLevel
|
||||
}
|
||||
}
|
||||
|
||||
/** Counter used for collision-less automatic switch IDs. */
|
||||
@@ -60,7 +51,6 @@ export const LemonSwitch: React.FunctionComponent<LemonSwitchProps & React.RefAt
|
||||
sliderColorOverrideChecked,
|
||||
sliderColorOverrideUnchecked,
|
||||
loading = false,
|
||||
accessControl,
|
||||
},
|
||||
ref
|
||||
): JSX.Element {
|
||||
@@ -72,22 +62,6 @@ export const LemonSwitch: React.FunctionComponent<LemonSwitchProps & React.RefAt
|
||||
conditionalProps['aria-label'] = ariaLabel
|
||||
}
|
||||
|
||||
// Handle access control
|
||||
if (accessControl) {
|
||||
const { userAccessLevel, minAccessLevel, resourceType } = accessControl
|
||||
const accessControlDisabledReason = getAccessControlDisabledReason(
|
||||
resourceType,
|
||||
userAccessLevel,
|
||||
minAccessLevel
|
||||
)
|
||||
if (accessControlDisabledReason) {
|
||||
disabled = true
|
||||
if (!disabledReason) {
|
||||
disabledReason = accessControlDisabledReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tooltipContent: JSX.Element | null = null
|
||||
if (disabledReason) {
|
||||
disabled = true // Support `disabledReason` while maintaining compatibility with `disabled`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { BindLogic } from 'kea'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { LemonButton } from 'lib/lemon-ui/LemonButton'
|
||||
import { LemonModal } from 'lib/lemon-ui/LemonModal'
|
||||
import { getAppContext } from 'lib/utils/getAppContext'
|
||||
@@ -32,19 +33,21 @@ export function AddInsightToDashboardModal(): JSX.Element {
|
||||
>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
data-attr="dashboard-add-new-insight"
|
||||
to={urls.insightNew({ dashboardId: dashboard?.id })}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Insight,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.Insight],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Insight}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.Insight]
|
||||
}
|
||||
>
|
||||
New insight
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
data-attr="dashboard-add-new-insight"
|
||||
to={urls.insightNew({ dashboardId: dashboard?.id })}
|
||||
>
|
||||
New insight
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -244,43 +244,43 @@ export function DashboardHeader(): JSX.Element | null {
|
||||
</>
|
||||
)}
|
||||
{dashboard ? (
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Dashboard,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: dashboard.user_access_level,
|
||||
}}
|
||||
onClick={showAddInsightToDashboardModal}
|
||||
type="primary"
|
||||
data-attr="dashboard-add-graph-header"
|
||||
sideAction={{
|
||||
dropdown: {
|
||||
placement: 'bottom-end',
|
||||
overlay: (
|
||||
<>
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Dashboard,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: dashboard.user_access_level,
|
||||
}}
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
push(urls.dashboardTextTile(dashboard.id, 'new'))
|
||||
}}
|
||||
data-attr="add-text-tile-to-dashboard"
|
||||
>
|
||||
Add text card
|
||||
</LemonButton>
|
||||
</>
|
||||
),
|
||||
},
|
||||
disabled: false,
|
||||
'data-attr': 'dashboard-add-dropdown',
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Dashboard}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={dashboard.user_access_level}
|
||||
>
|
||||
Add insight
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
onClick={showAddInsightToDashboardModal}
|
||||
type="primary"
|
||||
data-attr="dashboard-add-graph-header"
|
||||
sideAction={{
|
||||
dropdown: {
|
||||
placement: 'bottom-end',
|
||||
overlay: (
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Dashboard}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={dashboard.user_access_level}
|
||||
>
|
||||
<LemonButton
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
push(urls.dashboardTextTile(dashboard.id, 'new'))
|
||||
}}
|
||||
data-attr="add-text-tile-to-dashboard"
|
||||
>
|
||||
Add text card
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
),
|
||||
},
|
||||
disabled: false,
|
||||
'data-attr': 'dashboard-add-dropdown',
|
||||
}}
|
||||
>
|
||||
Add insight
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useActions, useValues } from 'kea'
|
||||
|
||||
import { LemonButton } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { PageHeader } from 'lib/components/PageHeader'
|
||||
import { LemonTab, LemonTabs } from 'lib/lemon-ui/LemonTabs'
|
||||
import { getAppContext } from 'lib/utils/getAppContext'
|
||||
@@ -54,21 +55,17 @@ export function Dashboards(): JSX.Element {
|
||||
<DeleteDashboardModal />
|
||||
<PageHeader
|
||||
buttons={
|
||||
<LemonButton
|
||||
data-attr="new-dashboard"
|
||||
onClick={() => {
|
||||
showNewDashboardModal()
|
||||
}}
|
||||
type="primary"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Dashboard,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.Dashboard],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Dashboard}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.Dashboard]
|
||||
}
|
||||
>
|
||||
New dashboard
|
||||
</LemonButton>
|
||||
<LemonButton data-attr="new-dashboard" onClick={showNewDashboardModal} type="primary">
|
||||
New dashboard
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
}
|
||||
/>
|
||||
<SceneTitleSection
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useActions, useValues } from 'kea'
|
||||
import { IconHome, IconLock, IconPin, IconPinFilled, IconShare } from '@posthog/icons'
|
||||
import { LemonInput } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { MemberSelect } from 'lib/components/MemberSelect'
|
||||
import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags'
|
||||
import { DashboardPrivilegeLevel } from 'lib/constants'
|
||||
@@ -163,24 +164,25 @@ export function DashboardsTable({
|
||||
View
|
||||
</LemonButton>
|
||||
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Dashboard,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: user_access_level,
|
||||
}}
|
||||
to={urls.dashboard(id)}
|
||||
onClick={() => {
|
||||
dashboardLogic({ id }).mount()
|
||||
dashboardLogic({ id }).actions.setDashboardMode(
|
||||
DashboardMode.Edit,
|
||||
DashboardEventSource.DashboardsList
|
||||
)
|
||||
}}
|
||||
fullWidth
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Dashboard}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={user_access_level}
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
to={urls.dashboard(id)}
|
||||
onClick={() => {
|
||||
dashboardLogic({ id }).mount()
|
||||
dashboardLogic({ id }).actions.setDashboardMode(
|
||||
DashboardMode.Edit,
|
||||
DashboardEventSource.DashboardsList
|
||||
)
|
||||
}}
|
||||
fullWidth
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
<LemonButton
|
||||
onClick={() => {
|
||||
@@ -203,20 +205,19 @@ export function DashboardsTable({
|
||||
|
||||
<LemonDivider />
|
||||
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Dashboard,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: user_access_level,
|
||||
}}
|
||||
onClick={() => {
|
||||
showDeleteDashboardModal(id)
|
||||
}}
|
||||
fullWidth
|
||||
status="danger"
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Dashboard}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={user_access_level}
|
||||
>
|
||||
Delete dashboard
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
onClick={() => showDeleteDashboardModal(id)}
|
||||
fullWidth
|
||||
status="danger"
|
||||
>
|
||||
Delete dashboard
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@posthog/icons'
|
||||
import { LemonDialog, LemonSegmentedButton, LemonSkeleton, LemonSwitch, Tooltip } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { AccessDenied } from 'lib/components/AccessDenied'
|
||||
import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog'
|
||||
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
|
||||
@@ -630,38 +631,39 @@ export function FeatureFlag({ id }: FeatureFlagLogicProps): JSX.Element {
|
||||
</LemonButton>
|
||||
)}
|
||||
<LemonDivider />
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.FeatureFlag,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: featureFlag.user_access_level,
|
||||
}}
|
||||
data-attr={
|
||||
featureFlag.deleted
|
||||
? 'restore-feature-flag'
|
||||
: 'delete-feature-flag'
|
||||
}
|
||||
status="danger"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
featureFlag.deleted
|
||||
? restoreFeatureFlag(featureFlag)
|
||||
: deleteFeatureFlag(featureFlag)
|
||||
}}
|
||||
disabledReason={
|
||||
!featureFlag.can_edit
|
||||
? "You have only 'View' access for this feature flag. To make changes, please contact the flag's creator."
|
||||
: (featureFlag.features?.length || 0) > 0
|
||||
? 'This feature flag is in use with an early access feature. Delete the early access feature to delete this flag'
|
||||
: (featureFlag.experiment_set?.length || 0) > 0
|
||||
? 'This feature flag is linked to an experiment. Delete the experiment to delete this flag'
|
||||
: (featureFlag.surveys?.length || 0) > 0
|
||||
? 'This feature flag is linked to a survey. Delete the survey to delete this flag'
|
||||
: null
|
||||
}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.FeatureFlag}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={featureFlag.user_access_level}
|
||||
>
|
||||
{featureFlag.deleted ? 'Restore' : 'Delete'} feature flag
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
data-attr={
|
||||
featureFlag.deleted
|
||||
? 'restore-feature-flag'
|
||||
: 'delete-feature-flag'
|
||||
}
|
||||
status="danger"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
featureFlag.deleted
|
||||
? restoreFeatureFlag(featureFlag)
|
||||
: deleteFeatureFlag(featureFlag)
|
||||
}}
|
||||
disabledReason={
|
||||
!featureFlag.can_edit
|
||||
? "You have only 'View' access for this feature flag. To make changes, please contact the flag's creator."
|
||||
: (featureFlag.features?.length || 0) > 0
|
||||
? 'This feature flag is in use with an early access feature. Delete the early access feature to delete this flag'
|
||||
: (featureFlag.experiment_set?.length || 0) > 0
|
||||
? 'This feature flag is linked to an experiment. Delete the experiment to delete this flag'
|
||||
: (featureFlag.surveys?.length || 0) > 0
|
||||
? 'This feature flag is linked to a survey. Delete the survey to delete this flag'
|
||||
: null
|
||||
}
|
||||
>
|
||||
{featureFlag.deleted ? 'Restore' : 'Delete'} feature flag
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -676,27 +678,28 @@ export function FeatureFlag({ id }: FeatureFlagLogicProps): JSX.Element {
|
||||
type="secondary"
|
||||
/>
|
||||
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.FeatureFlag,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: featureFlag.user_access_level,
|
||||
}}
|
||||
data-attr="edit-feature-flag"
|
||||
type="secondary"
|
||||
disabledReason={
|
||||
!featureFlag.can_edit
|
||||
? "You have only 'View' access for this feature flag. To make changes, please contact the flag's creator."
|
||||
: featureFlag.deleted
|
||||
? 'This feature flag has been deleted. Restore it to edit.'
|
||||
: null
|
||||
}
|
||||
onClick={() => {
|
||||
editFeatureFlag(true)
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.FeatureFlag}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={featureFlag.user_access_level}
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
data-attr="edit-feature-flag"
|
||||
type="secondary"
|
||||
disabledReason={
|
||||
!featureFlag.can_edit
|
||||
? "You have only 'View' access for this feature flag. To make changes, please contact the flag's creator."
|
||||
: featureFlag.deleted
|
||||
? 'This feature flag has been deleted. Restore it to edit.'
|
||||
: null
|
||||
}
|
||||
onClick={() => {
|
||||
editFeatureFlag(true)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@@ -949,7 +952,11 @@ function FeatureFlagRollout({ readOnly }: { readOnly?: boolean }): JSX.Element {
|
||||
</LemonTag>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.FeatureFlag}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={featureFlag.user_access_level}
|
||||
>
|
||||
<LemonSwitch
|
||||
onChange={(newValue) => {
|
||||
LemonDialog.open({
|
||||
@@ -984,16 +991,12 @@ function FeatureFlagRollout({ readOnly }: { readOnly?: boolean }): JSX.Element {
|
||||
: null
|
||||
}
|
||||
checked={featureFlag.active}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.FeatureFlag,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: featureFlag.user_access_level,
|
||||
}}
|
||||
/>
|
||||
{!featureFlag.is_remote_configuration && (
|
||||
<FeatureFlagStatusIndicator flagStatus={flagStatus} />
|
||||
)}
|
||||
</>
|
||||
</AccessControlAction>
|
||||
|
||||
{!featureFlag.is_remote_configuration && (
|
||||
<FeatureFlagStatusIndicator flagStatus={flagStatus} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { router } from 'kea-router'
|
||||
import { IconLock } from '@posthog/icons'
|
||||
import { LemonDialog, LemonTag, lemonToast } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog'
|
||||
import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags'
|
||||
import { PageHeader } from 'lib/components/PageHeader'
|
||||
@@ -103,65 +104,71 @@ function FeatureFlagRowActions({ featureFlag }: { featureFlag: FeatureFlagType }
|
||||
Copy feature flag key
|
||||
</LemonButton>
|
||||
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.FeatureFlag,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: featureFlag.user_access_level,
|
||||
}}
|
||||
data-attr={`feature-flag-${featureFlag.key}-switch`}
|
||||
onClick={() => {
|
||||
const newValue = !featureFlag.active
|
||||
LemonDialog.open({
|
||||
title: `${newValue === true ? 'Enable' : 'Disable'} this flag?`,
|
||||
description: `This flag will be immediately ${
|
||||
newValue === true ? 'rolled out to' : 'rolled back from'
|
||||
} the users matching the release conditions.`,
|
||||
primaryButton: {
|
||||
children: 'Confirm',
|
||||
type: 'primary',
|
||||
onClick: () => {
|
||||
featureFlag.id
|
||||
? updateFeatureFlag({
|
||||
id: featureFlag.id,
|
||||
payload: { active: newValue },
|
||||
})
|
||||
: null
|
||||
},
|
||||
size: 'small',
|
||||
},
|
||||
secondaryButton: {
|
||||
children: 'Cancel',
|
||||
type: 'tertiary',
|
||||
size: 'small',
|
||||
},
|
||||
})
|
||||
}}
|
||||
id={`feature-flag-${featureFlag.id}-switch`}
|
||||
fullWidth
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.FeatureFlag}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={featureFlag.user_access_level}
|
||||
>
|
||||
{featureFlag.active ? 'Disable' : 'Enable'} feature flag
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
data-attr={`feature-flag-${featureFlag.key}-switch`}
|
||||
onClick={() => {
|
||||
const newValue = !featureFlag.active
|
||||
LemonDialog.open({
|
||||
title: `${newValue === true ? 'Enable' : 'Disable'} this flag?`,
|
||||
description: `This flag will be immediately ${
|
||||
newValue === true ? 'rolled out to' : 'rolled back from'
|
||||
} the users matching the release conditions.`,
|
||||
primaryButton: {
|
||||
children: 'Confirm',
|
||||
type: 'primary',
|
||||
onClick: () => {
|
||||
featureFlag.id
|
||||
? updateFeatureFlag({
|
||||
id: featureFlag.id,
|
||||
payload: { active: newValue },
|
||||
})
|
||||
: null
|
||||
},
|
||||
size: 'small',
|
||||
},
|
||||
secondaryButton: {
|
||||
children: 'Cancel',
|
||||
type: 'tertiary',
|
||||
size: 'small',
|
||||
},
|
||||
})
|
||||
}}
|
||||
id={`feature-flag-${featureFlag.id}-switch`}
|
||||
fullWidth
|
||||
>
|
||||
{featureFlag.active ? 'Disable' : 'Enable'} feature flag
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
{featureFlag.id && (
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.FeatureFlag,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: featureFlag.user_access_level,
|
||||
}}
|
||||
fullWidth
|
||||
disabled={!featureFlag.can_edit}
|
||||
onClick={() => {
|
||||
if (featureFlag.id) {
|
||||
featureFlagLogic({ id: featureFlag.id }).mount()
|
||||
featureFlagLogic({ id: featureFlag.id }).actions.editFeatureFlag(true)
|
||||
router.actions.push(urls.featureFlag(featureFlag.id))
|
||||
}
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.FeatureFlag}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={featureFlag.user_access_level}
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
fullWidth
|
||||
disabledReason={
|
||||
!featureFlag.can_edit
|
||||
? "You don't have permission to edit this feature flag."
|
||||
: null
|
||||
}
|
||||
onClick={() => {
|
||||
if (featureFlag.id) {
|
||||
featureFlagLogic({ id: featureFlag.id }).mount()
|
||||
featureFlagLogic({ id: featureFlag.id }).actions.editFeatureFlag(true)
|
||||
router.actions.push(urls.featureFlag(featureFlag.id))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
)}
|
||||
|
||||
<LemonButton
|
||||
@@ -185,37 +192,38 @@ function FeatureFlagRowActions({ featureFlag }: { featureFlag: FeatureFlagType }
|
||||
<LemonDivider />
|
||||
|
||||
{featureFlag.id && (
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.FeatureFlag,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: featureFlag.user_access_level,
|
||||
}}
|
||||
status="danger"
|
||||
onClick={() => {
|
||||
void deleteWithUndo({
|
||||
endpoint: `projects/${currentProjectId}/feature_flags`,
|
||||
object: { name: featureFlag.key, id: featureFlag.id },
|
||||
callback: loadFeatureFlags,
|
||||
}).catch((e) => {
|
||||
lemonToast.error(`Failed to delete feature flag: ${e.detail}`)
|
||||
})
|
||||
}}
|
||||
disabledReason={
|
||||
!featureFlag.can_edit
|
||||
? "You have only 'View' access for this feature flag. To make changes, please contact the flag's creator."
|
||||
: (featureFlag.features?.length || 0) > 0
|
||||
? 'This feature flag is in use with an early access feature. Delete the early access feature to delete this flag'
|
||||
: (featureFlag.experiment_set?.length || 0) > 0
|
||||
? 'This feature flag is linked to an experiment. Delete the experiment to delete this flag'
|
||||
: (featureFlag.surveys?.length || 0) > 0
|
||||
? 'This feature flag is linked to a survey. Delete the survey to delete this flag'
|
||||
: null
|
||||
}
|
||||
fullWidth
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.FeatureFlag}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={featureFlag.user_access_level}
|
||||
>
|
||||
Delete feature flag
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
status="danger"
|
||||
onClick={() => {
|
||||
void deleteWithUndo({
|
||||
endpoint: `projects/${currentProjectId}/feature_flags`,
|
||||
object: { name: featureFlag.key, id: featureFlag.id },
|
||||
callback: loadFeatureFlags,
|
||||
}).catch((e) => {
|
||||
lemonToast.error(`Failed to delete feature flag: ${e.detail}`)
|
||||
})
|
||||
}}
|
||||
disabledReason={
|
||||
!featureFlag.can_edit
|
||||
? "You have only 'View' access for this feature flag. To make changes, please contact the flag's creator."
|
||||
: (featureFlag.features?.length || 0) > 0
|
||||
? 'This feature flag is in use with an early access feature. Delete the early access feature to delete this flag'
|
||||
: (featureFlag.experiment_set?.length || 0) > 0
|
||||
? 'This feature flag is linked to an experiment. Delete the experiment to delete this flag'
|
||||
: (featureFlag.surveys?.length || 0) > 0
|
||||
? 'This feature flag is linked to a survey. Delete the survey to delete this flag'
|
||||
: null
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
Delete feature flag
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
@@ -457,19 +465,17 @@ export function FeatureFlags(): JSX.Element {
|
||||
<SceneContent className="feature_flags">
|
||||
<PageHeader
|
||||
buttons={
|
||||
<LemonButton
|
||||
type="primary"
|
||||
to={urls.featureFlag('new')}
|
||||
data-attr="new-feature-flag"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.FeatureFlag,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.FeatureFlag],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.FeatureFlag}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.FeatureFlag]
|
||||
}
|
||||
>
|
||||
New feature flag
|
||||
</LemonButton>
|
||||
<LemonButton type="primary" to={urls.featureFlag('new')} data-attr="new-feature-flag">
|
||||
New feature flag
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
}
|
||||
/>
|
||||
<SceneTitleSection
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@posthog/icons'
|
||||
import { LemonButton } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { supportLogic } from 'lib/components/Support/supportLogic'
|
||||
import { BuilderHog3 } from 'lib/components/hedgehogs'
|
||||
import { FEATURE_FLAGS } from 'lib/constants'
|
||||
@@ -177,6 +178,7 @@ export const LOADING_MESSAGES = [
|
||||
'Polishing graphs with tiny hedgehog paws…',
|
||||
'Rolling through data like a spiky ball of insights…',
|
||||
'Gathering nuts and numbers from the data forest…',
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<>
|
||||
Reticulating <s>splines</s> spines…
|
||||
</>,
|
||||
@@ -751,20 +753,22 @@ export function SavedInsightsEmptyState({
|
||||
{filters.tab !== SavedInsightsTabs.Favorites && (
|
||||
<div className="flex justify-center">
|
||||
<Link to={urls.insightNew()}>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
data-attr="add-insight-button-empty-state"
|
||||
icon={<IconPlusSmall />}
|
||||
className="add-insight-button"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Insight,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.Insight],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Insight}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.Insight]
|
||||
}
|
||||
>
|
||||
New insight
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
data-attr="add-insight-button-empty-state"
|
||||
icon={<IconPlusSmall />}
|
||||
className="add-insight-button"
|
||||
>
|
||||
New insight
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -218,33 +218,36 @@ export function InsightPageHeader({ insightLogicProps }: { insightLogicProps: In
|
||||
|
||||
{insightMode !== ItemMode.Edit ? (
|
||||
canEditInsight && (
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Insight,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: insight.user_access_level,
|
||||
}}
|
||||
type="primary"
|
||||
icon={dashboardOverridesExist ? <IconWarning /> : undefined}
|
||||
tooltip={
|
||||
dashboardOverridesExist
|
||||
? `This insight is being viewed with dashboard ${overrideType}. These will be discarded on edit.`
|
||||
: undefined
|
||||
}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
if (isDataVisualizationNode(query) && insight.short_id) {
|
||||
router.actions.push(urls.sqlEditor(undefined, undefined, insight.short_id))
|
||||
} else if (insight.short_id) {
|
||||
push(urls.insightEdit(insight.short_id))
|
||||
} else {
|
||||
setInsightMode(ItemMode.Edit, null)
|
||||
}
|
||||
}}
|
||||
data-attr="insight-edit-button"
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Insight}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={insight.user_access_level}
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
icon={dashboardOverridesExist ? <IconWarning /> : undefined}
|
||||
tooltip={
|
||||
dashboardOverridesExist
|
||||
? `This insight is being viewed with dashboard ${overrideType}. These will be discarded on edit.`
|
||||
: undefined
|
||||
}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
if (isDataVisualizationNode(query) && insight.short_id) {
|
||||
router.actions.push(
|
||||
urls.sqlEditor(undefined, undefined, insight.short_id)
|
||||
)
|
||||
} else if (insight.short_id) {
|
||||
push(urls.insightEdit(insight.short_id))
|
||||
} else {
|
||||
setInsightMode(ItemMode.Edit, null)
|
||||
}
|
||||
}}
|
||||
data-attr="insight-edit-button"
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
)
|
||||
) : (
|
||||
<InsightSaveButton
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ReactChild, ReactElement, useEffect } from 'react'
|
||||
import { IconNotebook, IconPlus } from '@posthog/icons'
|
||||
import { LemonDivider, LemonDropdown, ProfilePicture } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { dayjs } from 'lib/dayjs'
|
||||
import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton'
|
||||
import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput'
|
||||
@@ -131,19 +132,21 @@ export function NotebookSelectList(props: NotebookSelectProps): JSX.Element {
|
||||
onChange={(s) => setSearchQuery(s)}
|
||||
fullWidth
|
||||
/>
|
||||
<LemonButton
|
||||
data-attr="notebooks-select-button-create"
|
||||
fullWidth
|
||||
icon={<IconPlus />}
|
||||
onClick={openNewNotebook}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Notebook,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: getAppContext()?.resource_access_control?.[AccessControlResourceType.Notebook],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Notebook}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.Notebook]}
|
||||
>
|
||||
New notebook
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
data-attr="notebooks-select-button-create"
|
||||
fullWidth
|
||||
icon={<IconPlus />}
|
||||
onClick={openNewNotebook}
|
||||
>
|
||||
New notebook
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
<LemonButton
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { router } from 'kea-router'
|
||||
import { IconEllipsis } from '@posthog/icons'
|
||||
import { LemonButton, LemonMenu, Tooltip, lemonToast } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { PageHeader } from 'lib/components/PageHeader'
|
||||
import { base64Encode } from 'lib/utils'
|
||||
import { getTextFromFile, selectFiles } from 'lib/utils/file-utils'
|
||||
@@ -68,19 +69,17 @@ export function NotebooksScene(): JSX.Element {
|
||||
New canvas
|
||||
</LemonButton>
|
||||
</Tooltip>
|
||||
<LemonButton
|
||||
data-attr="new-notebook"
|
||||
to={urls.notebook('new')}
|
||||
type="primary"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Notebook,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.Notebook],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Notebook}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.Notebook]
|
||||
}
|
||||
>
|
||||
New notebook
|
||||
</LemonButton>
|
||||
<LemonButton data-attr="new-notebook" to={urls.notebook('new')} type="primary">
|
||||
New notebook
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@posthog/icons'
|
||||
import { LemonSelectOptions } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog'
|
||||
import { Alerts } from 'lib/components/Alerts/views/Alerts'
|
||||
import { InsightCard } from 'lib/components/Cards/InsightCard'
|
||||
@@ -564,29 +565,30 @@ export function InsightIcon({
|
||||
|
||||
export function NewInsightButton({ dataAttr }: NewInsightButtonProps): JSX.Element {
|
||||
return (
|
||||
<LemonButton
|
||||
type="primary"
|
||||
to={urls.insightNew()}
|
||||
sideAction={{
|
||||
dropdown: {
|
||||
placement: 'bottom-end',
|
||||
className: 'new-insight-overlay',
|
||||
actionable: true,
|
||||
overlay: <OverlayForNewInsightMenu dataAttr={dataAttr} />,
|
||||
},
|
||||
'data-attr': 'saved-insights-new-insight-dropdown',
|
||||
}}
|
||||
data-attr="saved-insights-new-insight-button"
|
||||
size="small"
|
||||
icon={<IconPlusSmall />}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Insight,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: getAppContext()?.resource_access_control?.[AccessControlResourceType.Insight],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Insight}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.Insight]}
|
||||
>
|
||||
New
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
to={urls.insightNew()}
|
||||
sideAction={{
|
||||
dropdown: {
|
||||
placement: 'bottom-end',
|
||||
className: 'new-insight-overlay',
|
||||
actionable: true,
|
||||
overlay: <OverlayForNewInsightMenu dataAttr={dataAttr} />,
|
||||
},
|
||||
'data-attr': 'saved-insights-new-insight-dropdown',
|
||||
}}
|
||||
data-attr="saved-insights-new-insight-button"
|
||||
size="small"
|
||||
icon={<IconPlusSmall />}
|
||||
>
|
||||
New
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -664,27 +666,28 @@ export function SavedInsights(): JSX.Element {
|
||||
<>
|
||||
{name || <i>{summarizeInsight(insight.query)}</i>}
|
||||
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Insight,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: insight.user_access_level,
|
||||
}}
|
||||
className="ml-1"
|
||||
size="xsmall"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
updateFavoritedInsight(insight, !insight.favorited)
|
||||
}}
|
||||
icon={
|
||||
insight.favorited ? (
|
||||
<IconStarFilled className="text-warning" />
|
||||
) : (
|
||||
<IconStar className="text-secondary" />
|
||||
)
|
||||
}
|
||||
tooltip={`${insight.favorited ? 'Remove from' : 'Add to'} favorite insights`}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Insight}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={insight.user_access_level}
|
||||
>
|
||||
<LemonButton
|
||||
className="ml-1"
|
||||
size="xsmall"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
updateFavoritedInsight(insight, !insight.favorited)
|
||||
}}
|
||||
icon={
|
||||
insight.favorited ? (
|
||||
<IconStarFilled className="text-warning" />
|
||||
) : (
|
||||
<IconStar className="text-secondary" />
|
||||
)
|
||||
}
|
||||
tooltip={`${insight.favorited ? 'Remove from' : 'Add to'} favorite insights`}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
</>
|
||||
}
|
||||
description={insight.description}
|
||||
@@ -737,30 +740,29 @@ export function SavedInsights(): JSX.Element {
|
||||
|
||||
<LemonDivider />
|
||||
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Insight,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: insight.user_access_level,
|
||||
}}
|
||||
to={urls.insightEdit(insight.short_id)}
|
||||
fullWidth
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Insight}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={insight.user_access_level}
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
<LemonButton to={urls.insightEdit(insight.short_id)} fullWidth>
|
||||
Edit
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Insight,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: insight.user_access_level,
|
||||
}}
|
||||
onClick={() => renameInsight(insight)}
|
||||
data-attr={`insight-item-${insight.short_id}-dropdown-rename`}
|
||||
fullWidth
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Insight}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={insight.user_access_level}
|
||||
>
|
||||
Rename
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
onClick={() => renameInsight(insight)}
|
||||
data-attr={`insight-item-${insight.short_id}-dropdown-rename`}
|
||||
fullWidth
|
||||
>
|
||||
Rename
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
<LemonButton
|
||||
onClick={() => duplicateInsight(insight)}
|
||||
@@ -772,25 +774,26 @@ export function SavedInsights(): JSX.Element {
|
||||
|
||||
<LemonDivider />
|
||||
|
||||
<LemonButton
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.Insight,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel: insight.user_access_level,
|
||||
}}
|
||||
status="danger"
|
||||
onClick={() =>
|
||||
void deleteInsightWithUndo({
|
||||
object: insight,
|
||||
endpoint: `projects/${currentProjectId}/insights`,
|
||||
callback: loadInsights,
|
||||
})
|
||||
}
|
||||
data-attr={`insight-item-${insight.short_id}-dropdown-remove`}
|
||||
fullWidth
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.Insight}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={insight.user_access_level}
|
||||
>
|
||||
Delete insight
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
status="danger"
|
||||
onClick={() =>
|
||||
void deleteInsightWithUndo({
|
||||
object: insight,
|
||||
endpoint: `projects/${currentProjectId}/insights`,
|
||||
callback: loadInsights,
|
||||
})
|
||||
}
|
||||
data-attr={`insight-item-${insight.short_id}-dropdown-remove`}
|
||||
fullWidth
|
||||
>
|
||||
Delete insight
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { router } from 'kea-router'
|
||||
import { IconEllipsis, IconGear, IconOpenSidebar } from '@posthog/icons'
|
||||
import { LemonBadge, LemonButton, LemonMenu } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import {
|
||||
AuthorizedUrlListType,
|
||||
authorizedUrlListLogic,
|
||||
@@ -80,22 +81,24 @@ function Header(): JSX.Element {
|
||||
)}
|
||||
|
||||
{tab === ReplayTabs.Playlists && (
|
||||
<LemonButton
|
||||
type="primary"
|
||||
onClick={(e) => newPlaylistHandler.onEvent?.(e)}
|
||||
data-attr="save-recordings-playlist-button"
|
||||
loading={newPlaylistHandler.loading}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
]
|
||||
}
|
||||
>
|
||||
New collection
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
onClick={(e) => newPlaylistHandler.onEvent?.(e)}
|
||||
data-attr="save-recordings-playlist-button"
|
||||
loading={newPlaylistHandler.loading}
|
||||
>
|
||||
New collection
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from 'react'
|
||||
import { IconArrowRight, IconClock, IconEye, IconFilter, IconHide, IconPlus, IconRevert, IconX } from '@posthog/icons'
|
||||
import { LemonBadge, LemonButton, LemonInput, LemonModal, LemonTab, LemonTabs, Popover } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { DateFilter } from 'lib/components/DateFilter/DateFilter'
|
||||
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
|
||||
import UniversalFilters from 'lib/components/UniversalFilters/UniversalFilters'
|
||||
@@ -474,23 +475,27 @@ export const RecordingsUniversalFiltersEmbed = ({
|
||||
Update "{appliedSavedFilter.name || 'Unnamed'}"
|
||||
</LemonButton>
|
||||
) : (
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={() => setIsSaveFiltersModalOpen(true)}
|
||||
disabledReason={(totalFiltersCount ?? 0) === 0 ? 'No filters applied' : undefined}
|
||||
tooltip="Save filters for later"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
]
|
||||
}
|
||||
>
|
||||
Add to "Saved filters"
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={() => setIsSaveFiltersModalOpen(true)}
|
||||
disabledReason={
|
||||
(totalFiltersCount ?? 0) === 0 ? 'No filters applied' : undefined
|
||||
}
|
||||
tooltip="Save filters for later"
|
||||
>
|
||||
Add to "Saved filters"
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
)}
|
||||
</div>
|
||||
<LemonButton
|
||||
|
||||
@@ -4,6 +4,7 @@ import { combineUrl } from 'kea-router'
|
||||
import { IconShare, IconTrash } from '@posthog/icons'
|
||||
import { LemonButton, LemonInput, LemonTable, LemonTableColumn, LemonTableColumns } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
|
||||
import { copyToClipboard } from 'lib/utils/copyToClipboard'
|
||||
import { getAppContext } from 'lib/utils/getAppContext'
|
||||
@@ -100,25 +101,27 @@ export function SavedFilters({
|
||||
width: 40,
|
||||
render: function Render(_, playlist) {
|
||||
return (
|
||||
<LemonButton
|
||||
status="danger"
|
||||
onClick={() => {
|
||||
deletePlaylist(playlist)
|
||||
if (savedFilters.results?.length === 1) {
|
||||
setActiveFilterTab('filters')
|
||||
}
|
||||
}}
|
||||
title="Delete saved filter"
|
||||
tooltip="Delete saved filter"
|
||||
icon={<IconTrash />}
|
||||
size="small"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
>
|
||||
<LemonButton
|
||||
status="danger"
|
||||
onClick={() => {
|
||||
deletePlaylist(playlist)
|
||||
if (savedFilters.results?.length === 1) {
|
||||
setActiveFilterTab('filters')
|
||||
}
|
||||
}}
|
||||
title="Delete saved filter"
|
||||
tooltip="Delete saved filter"
|
||||
icon={<IconTrash />}
|
||||
size="small"
|
||||
/>
|
||||
</AccessControlAction>
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -54,31 +54,27 @@ export function CommentOnRecordingButton({ className }: { className?: string }):
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]}
|
||||
>
|
||||
{({ disabled, disabledReason }) => (
|
||||
<LemonButton
|
||||
size="xsmall"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsCommenting(!isCommenting)
|
||||
}}
|
||||
tooltip={
|
||||
isCommenting ? (
|
||||
<>
|
||||
Stop commenting <KeyboardShortcut c />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Comment on this recording <KeyboardShortcut c />
|
||||
</>
|
||||
)
|
||||
}
|
||||
data-attr={isCommenting ? 'stop-annotating-recording' : 'annotate-recording'}
|
||||
active={isCommenting}
|
||||
icon={<IconComment className={cn('text-lg', className)} />}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
/>
|
||||
)}
|
||||
<LemonButton
|
||||
size="xsmall"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsCommenting(!isCommenting)
|
||||
}}
|
||||
tooltip={
|
||||
isCommenting ? (
|
||||
<>
|
||||
Stop commenting <KeyboardShortcut c />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Comment on this recording <KeyboardShortcut c />
|
||||
</>
|
||||
)
|
||||
}
|
||||
data-attr={isCommenting ? 'stop-annotating-recording' : 'annotate-recording'}
|
||||
active={isCommenting}
|
||||
icon={<IconComment className={cn('text-lg', className)} />}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,17 +64,14 @@ function PinToPlaylistButton(): JSX.Element {
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]}
|
||||
>
|
||||
{({ disabledReason }) => (
|
||||
<PlaylistPopoverButton
|
||||
tooltip={tooltip}
|
||||
setPinnedInCurrentPlaylist={logicProps.setPinned}
|
||||
icon={logicProps.pinned ? <IconMinusSmall /> : <IconPlusSmall />}
|
||||
size="xsmall"
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
{description}
|
||||
</PlaylistPopoverButton>
|
||||
)}
|
||||
<PlaylistPopoverButton
|
||||
tooltip={tooltip}
|
||||
setPinnedInCurrentPlaylist={logicProps.setPinned}
|
||||
icon={logicProps.pinned ? <IconMinusSmall /> : <IconPlusSmall />}
|
||||
size="xsmall"
|
||||
>
|
||||
{description}
|
||||
</PlaylistPopoverButton>
|
||||
</AccessControlAction>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useActions, useValues } from 'kea'
|
||||
import { IconCalendar, IconPin, IconPinFilled } from '@posthog/icons'
|
||||
import { LemonBadge, LemonButton, LemonDivider, LemonInput, LemonTable, Link, Tooltip } from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { DateFilter } from 'lib/components/DateFilter/DateFilter'
|
||||
import { MemberSelect } from 'lib/components/MemberSelect'
|
||||
import { TZLabel } from 'lib/components/TZLabel'
|
||||
@@ -161,17 +162,19 @@ export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPla
|
||||
dataIndex: 'pinned',
|
||||
render: function Render(pinned, { short_id }) {
|
||||
return (
|
||||
<LemonButton
|
||||
size="small"
|
||||
onClick={() => updatePlaylist(short_id, { pinned: !pinned })}
|
||||
icon={pinned ? <IconPinFilled /> : <IconPin />}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
>
|
||||
<LemonButton
|
||||
size="small"
|
||||
onClick={() => updatePlaylist(short_id, { pinned: !pinned })}
|
||||
icon={pinned ? <IconPinFilled /> : <IconPin />}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
)
|
||||
},
|
||||
},
|
||||
@@ -210,40 +213,45 @@ export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPla
|
||||
<More
|
||||
overlay={
|
||||
<>
|
||||
<LemonButton
|
||||
onClick={() => duplicatePlaylist(playlist)}
|
||||
fullWidth
|
||||
data-attr="duplicate-playlist"
|
||||
loading={playlistsLoading}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
]
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
onClick={() => duplicatePlaylist(playlist)}
|
||||
fullWidth
|
||||
data-attr="duplicate-playlist"
|
||||
loading={playlistsLoading}
|
||||
>
|
||||
Duplicate
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
<LemonDivider />
|
||||
|
||||
<LemonButton
|
||||
status="danger"
|
||||
onClick={() => deletePlaylist(playlist)}
|
||||
fullWidth
|
||||
loading={playlistsLoading}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
]
|
||||
}
|
||||
>
|
||||
Delete collection
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
status="danger"
|
||||
onClick={() => deletePlaylist(playlist)}
|
||||
fullWidth
|
||||
loading={playlistsLoading}
|
||||
>
|
||||
Delete collection
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useValues } from 'kea'
|
||||
|
||||
import { IconPlus } from '@posthog/icons'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
|
||||
import { LemonButton } from 'lib/lemon-ui/LemonButton'
|
||||
import { getAppContext } from 'lib/utils/getAppContext'
|
||||
@@ -21,20 +22,22 @@ export function SavedSessionRecordingPlaylistsEmptyState(): JSX.Element {
|
||||
<div className="max-w-248 mt-12 flex flex-col items-center">
|
||||
<h2 className="text-xl">There are no collections that match these filters</h2>
|
||||
<p className="text-secondary">Once you create a collection, it will show up here.</p>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
data-attr="add-session-playlist-button-empty-state"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => void createPlaylist({ type: 'collection' }, true)}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
>
|
||||
New collection
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
data-attr="add-session-playlist-button-empty-state"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => void createPlaylist({ type: 'collection' }, true)}
|
||||
>
|
||||
New collection
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -110,22 +110,24 @@ function LinkedFlagSelector(): JSX.Element | null {
|
||||
)}
|
||||
</AccessControlAction>
|
||||
{currentTeam?.session_recording_linked_flag && (
|
||||
<LemonButton
|
||||
className="ml-2"
|
||||
icon={<IconCancel />}
|
||||
size="small"
|
||||
type="secondary"
|
||||
onClick={() => updateCurrentTeam({ session_recording_linked_flag: null })}
|
||||
title="Clear selected flag"
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[
|
||||
AccessControlResourceType.SessionRecording
|
||||
]
|
||||
}
|
||||
>
|
||||
<LemonButton
|
||||
className="ml-2"
|
||||
icon={<IconCancel />}
|
||||
size="small"
|
||||
type="secondary"
|
||||
onClick={() => updateCurrentTeam({ session_recording_linked_flag: null })}
|
||||
title="Clear selected flag"
|
||||
/>
|
||||
</AccessControlAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,47 +287,47 @@ function UrlConfigRow({
|
||||
<span>{trigger.url}</span>
|
||||
</span>
|
||||
<div className="Actions flex deprecated-space-x-1 shrink-0">
|
||||
<LemonButton
|
||||
icon={<IconPencil />}
|
||||
onClick={() => onEdit(index)}
|
||||
tooltip="Edit"
|
||||
center
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
icon={<IconTrash />}
|
||||
tooltip={`Remove URL ${type}`}
|
||||
center
|
||||
onClick={() => {
|
||||
LemonDialog.open({
|
||||
title: <>Remove URL {type}</>,
|
||||
description: `Are you sure you want to remove this URL ${type}?`,
|
||||
primaryButton: {
|
||||
status: 'danger',
|
||||
children: 'Remove',
|
||||
onClick: () => onRemove(index),
|
||||
},
|
||||
secondaryButton: {
|
||||
children: 'Cancel',
|
||||
},
|
||||
})
|
||||
}}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
<LemonButton icon={<IconPencil />} onClick={() => onEdit(index)} tooltip="Edit" center>
|
||||
Edit
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
icon={<IconTrash />}
|
||||
tooltip={`Remove URL ${type}`}
|
||||
center
|
||||
onClick={() => {
|
||||
LemonDialog.open({
|
||||
title: <>Remove URL {type}</>,
|
||||
description: `Are you sure you want to remove this URL ${type}?`,
|
||||
primaryButton: {
|
||||
status: 'danger',
|
||||
children: 'Remove',
|
||||
onClick: () => onRemove(index),
|
||||
},
|
||||
secondaryButton: {
|
||||
children: 'Cancel',
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -353,20 +355,22 @@ function UrlConfigSection({
|
||||
<div className="flex flex-col deprecated-space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<LemonLabel className="text-base">{title}</LemonLabel>
|
||||
<LemonButton
|
||||
onClick={props.onAdd}
|
||||
type="secondary"
|
||||
icon={<IconPlus />}
|
||||
data-attr={`session-replay-add-url-${type}`}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
>
|
||||
Add
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
onClick={props.onAdd}
|
||||
type="secondary"
|
||||
icon={<IconPlus />}
|
||||
data-attr={`session-replay-add-url-${type}`}
|
||||
>
|
||||
Add
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
<SupportedPlatforms
|
||||
android={false}
|
||||
@@ -469,21 +473,21 @@ function EventSelectButton(): JSX.Element {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LemonButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
icon={<IconPlus />}
|
||||
sideIcon={null}
|
||||
onClick={() => setOpen(!open)}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]}
|
||||
>
|
||||
Add event
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
icon={<IconPlus />}
|
||||
sideIcon={null}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
Add event
|
||||
</LemonButton>
|
||||
</AccessControlAction>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -508,8 +512,9 @@ function EventTriggerOptions(): JSX.Element | null {
|
||||
reactNative={false}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{eventTriggerConfig?.map((evnt) => (
|
||||
{eventTriggerConfig?.map((trigger) => (
|
||||
<AccessControlAction
|
||||
key={trigger}
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
@@ -518,16 +523,15 @@ function EventTriggerOptions(): JSX.Element | null {
|
||||
>
|
||||
{({ disabledReason }) => (
|
||||
<LemonSnack
|
||||
key={evnt}
|
||||
onClose={
|
||||
!disabledReason
|
||||
? () => {
|
||||
updateEventTriggerConfig(eventTriggerConfig?.filter((e) => e !== evnt))
|
||||
updateEventTriggerConfig(eventTriggerConfig?.filter((e) => e !== trigger))
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{evnt}
|
||||
{trigger}
|
||||
</LemonSnack>
|
||||
)}
|
||||
</AccessControlAction>
|
||||
@@ -547,113 +551,115 @@ function Sampling(): JSX.Element {
|
||||
<LemonLabel className="text-base">
|
||||
<TriggerMatchTypeTag /> Sampling
|
||||
</LemonLabel>
|
||||
<LemonSelect
|
||||
onChange={(v) => {
|
||||
updateCurrentTeam({ session_recording_sample_rate: v })
|
||||
}}
|
||||
dropdownMatchSelectWidth={false}
|
||||
options={[
|
||||
{
|
||||
label: '100% (no sampling)',
|
||||
value: '1.00',
|
||||
},
|
||||
{
|
||||
label: '95%',
|
||||
value: '0.95',
|
||||
},
|
||||
{
|
||||
label: '90%',
|
||||
value: '0.90',
|
||||
},
|
||||
{
|
||||
label: '85%',
|
||||
value: '0.85',
|
||||
},
|
||||
{
|
||||
label: '80%',
|
||||
value: '0.80',
|
||||
},
|
||||
{
|
||||
label: '75%',
|
||||
value: '0.75',
|
||||
},
|
||||
{
|
||||
label: '70%',
|
||||
value: '0.70',
|
||||
},
|
||||
{
|
||||
label: '65%',
|
||||
value: '0.65',
|
||||
},
|
||||
{
|
||||
label: '60%',
|
||||
value: '0.60',
|
||||
},
|
||||
{
|
||||
label: '55%',
|
||||
value: '0.55',
|
||||
},
|
||||
{
|
||||
label: '50%',
|
||||
value: '0.50',
|
||||
},
|
||||
{
|
||||
label: '45%',
|
||||
value: '0.45',
|
||||
},
|
||||
{
|
||||
label: '40%',
|
||||
value: '0.40',
|
||||
},
|
||||
{
|
||||
label: '35%',
|
||||
value: '0.35',
|
||||
},
|
||||
{
|
||||
label: '30%',
|
||||
value: '0.30',
|
||||
},
|
||||
{
|
||||
label: '25%',
|
||||
value: '0.25',
|
||||
},
|
||||
{
|
||||
label: '20%',
|
||||
value: '0.20',
|
||||
},
|
||||
{
|
||||
label: '15%',
|
||||
value: '0.15',
|
||||
},
|
||||
{
|
||||
label: '10%',
|
||||
value: '0.10',
|
||||
},
|
||||
{
|
||||
label: '5%',
|
||||
value: '0.05',
|
||||
},
|
||||
{
|
||||
label: '1%',
|
||||
value: '0.01',
|
||||
},
|
||||
{
|
||||
label: '0% (replay disabled)',
|
||||
value: '0.00',
|
||||
},
|
||||
]}
|
||||
value={
|
||||
typeof currentTeam?.session_recording_sample_rate === 'string'
|
||||
? currentTeam?.session_recording_sample_rate
|
||||
: '1.00'
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<LemonSelect
|
||||
onChange={(v) => {
|
||||
updateCurrentTeam({ session_recording_sample_rate: v })
|
||||
}}
|
||||
dropdownMatchSelectWidth={false}
|
||||
options={[
|
||||
{
|
||||
label: '100% (no sampling)',
|
||||
value: '1.00',
|
||||
},
|
||||
{
|
||||
label: '95%',
|
||||
value: '0.95',
|
||||
},
|
||||
{
|
||||
label: '90%',
|
||||
value: '0.90',
|
||||
},
|
||||
{
|
||||
label: '85%',
|
||||
value: '0.85',
|
||||
},
|
||||
{
|
||||
label: '80%',
|
||||
value: '0.80',
|
||||
},
|
||||
{
|
||||
label: '75%',
|
||||
value: '0.75',
|
||||
},
|
||||
{
|
||||
label: '70%',
|
||||
value: '0.70',
|
||||
},
|
||||
{
|
||||
label: '65%',
|
||||
value: '0.65',
|
||||
},
|
||||
{
|
||||
label: '60%',
|
||||
value: '0.60',
|
||||
},
|
||||
{
|
||||
label: '55%',
|
||||
value: '0.55',
|
||||
},
|
||||
{
|
||||
label: '50%',
|
||||
value: '0.50',
|
||||
},
|
||||
{
|
||||
label: '45%',
|
||||
value: '0.45',
|
||||
},
|
||||
{
|
||||
label: '40%',
|
||||
value: '0.40',
|
||||
},
|
||||
{
|
||||
label: '35%',
|
||||
value: '0.35',
|
||||
},
|
||||
{
|
||||
label: '30%',
|
||||
value: '0.30',
|
||||
},
|
||||
{
|
||||
label: '25%',
|
||||
value: '0.25',
|
||||
},
|
||||
{
|
||||
label: '20%',
|
||||
value: '0.20',
|
||||
},
|
||||
{
|
||||
label: '15%',
|
||||
value: '0.15',
|
||||
},
|
||||
{
|
||||
label: '10%',
|
||||
value: '0.10',
|
||||
},
|
||||
{
|
||||
label: '5%',
|
||||
value: '0.05',
|
||||
},
|
||||
{
|
||||
label: '1%',
|
||||
value: '0.01',
|
||||
},
|
||||
{
|
||||
label: '0% (replay disabled)',
|
||||
value: '0.00',
|
||||
},
|
||||
]}
|
||||
value={
|
||||
typeof currentTeam?.session_recording_sample_rate === 'string'
|
||||
? currentTeam?.session_recording_sample_rate
|
||||
: '1.00'
|
||||
}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
<SupportedPlatforms web={{ version: '1.85.0' }} />
|
||||
<p>
|
||||
@@ -674,20 +680,22 @@ function MinimumDurationSetting(): JSX.Element | null {
|
||||
<>
|
||||
<div className="flex flex-row justify-between">
|
||||
<LemonLabel className="text-base">Minimum session duration (seconds)</LemonLabel>
|
||||
<LemonSelect
|
||||
dropdownMatchSelectWidth={false}
|
||||
onChange={(v) => {
|
||||
updateCurrentTeam({ session_recording_minimum_duration_milliseconds: v })
|
||||
}}
|
||||
options={SESSION_REPLAY_MINIMUM_DURATION_OPTIONS}
|
||||
value={currentTeam?.session_recording_minimum_duration_milliseconds}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
>
|
||||
<LemonSelect
|
||||
dropdownMatchSelectWidth={false}
|
||||
onChange={(v) => {
|
||||
updateCurrentTeam({ session_recording_minimum_duration_milliseconds: v })
|
||||
}}
|
||||
options={SESSION_REPLAY_MINIMUM_DURATION_OPTIONS}
|
||||
value={currentTeam?.session_recording_minimum_duration_milliseconds}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
<SupportedPlatforms web={{ version: '1.85.0' }} />
|
||||
<p>
|
||||
@@ -738,46 +746,53 @@ function TriggerMatchChoice(): JSX.Element {
|
||||
</LemonBanner>
|
||||
<div className="flex flex-row gap-x-2 items-center">
|
||||
<div>Start when</div>
|
||||
<LemonSelect
|
||||
options={[
|
||||
{
|
||||
label: 'all',
|
||||
value: 'all',
|
||||
labelInMenu: (
|
||||
<SelectOption
|
||||
title="All"
|
||||
description="Every trigger must match"
|
||||
value="all"
|
||||
selectedValue={currentTeam?.session_recording_trigger_match_type_config || 'all'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'any',
|
||||
value: 'any',
|
||||
labelInMenu: (
|
||||
<SelectOption
|
||||
title="Any"
|
||||
description="One or more triggers must match"
|
||||
value="any"
|
||||
selectedValue={currentTeam?.session_recording_trigger_match_type_config || 'all'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
dropdownMatchSelectWidth={false}
|
||||
data-attr="trigger-match-choice"
|
||||
onChange={(value) => {
|
||||
updateCurrentTeam({ session_recording_trigger_match_type_config: value })
|
||||
}}
|
||||
value={currentTeam?.session_recording_trigger_match_type_config || 'all'}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
>
|
||||
<LemonSelect
|
||||
options={[
|
||||
{
|
||||
label: 'all',
|
||||
value: 'all',
|
||||
labelInMenu: (
|
||||
<SelectOption
|
||||
title="All"
|
||||
description="Every trigger must match"
|
||||
value="all"
|
||||
selectedValue={
|
||||
currentTeam?.session_recording_trigger_match_type_config || 'all'
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'any',
|
||||
value: 'any',
|
||||
labelInMenu: (
|
||||
<SelectOption
|
||||
title="Any"
|
||||
description="One or more triggers must match"
|
||||
value="any"
|
||||
selectedValue={
|
||||
currentTeam?.session_recording_trigger_match_type_config || 'all'
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
dropdownMatchSelectWidth={false}
|
||||
data-attr="trigger-match-choice"
|
||||
onChange={(value) => {
|
||||
updateCurrentTeam({ session_recording_trigger_match_type_config: value })
|
||||
}}
|
||||
value={currentTeam?.session_recording_trigger_match_type_config || 'all'}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
|
||||
<div>triggers below match</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@posthog/lemon-ui'
|
||||
|
||||
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
||||
import { AuthorizedUrlList } from 'lib/components/AuthorizedUrlList/AuthorizedUrlList'
|
||||
import { AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
|
||||
import { EventSelect } from 'lib/components/EventSelect/EventSelect'
|
||||
@@ -161,22 +162,24 @@ function LogCaptureSettings(): JSX.Element {
|
||||
</Link>{' '}
|
||||
, where they can be configured directly in code.
|
||||
</p>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-console-log-switch"
|
||||
onChange={(checked) => {
|
||||
updateCurrentTeam({ capture_console_log_opt_in: checked })
|
||||
}}
|
||||
label="Capture console logs"
|
||||
bordered
|
||||
checked={!!currentTeam?.capture_console_log_opt_in}
|
||||
disabledReason={!currentTeam?.session_recording_opt_in ? 'Session replay must be enabled' : undefined}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]}
|
||||
>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-console-log-switch"
|
||||
onChange={(checked) => {
|
||||
updateCurrentTeam({ capture_console_log_opt_in: checked })
|
||||
}}
|
||||
label="Capture console logs"
|
||||
bordered
|
||||
checked={!!currentTeam?.capture_console_log_opt_in}
|
||||
disabledReason={
|
||||
!currentTeam?.session_recording_opt_in ? 'Session replay must be enabled' : undefined
|
||||
}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -210,34 +213,36 @@ function CanvasCaptureSettings(): JSX.Element | null {
|
||||
</b>
|
||||
</p>
|
||||
<p>Canvas capture is only available for JavaScript Web.</p>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-canvas-switch"
|
||||
onChange={(checked) => {
|
||||
updateCurrentTeam({
|
||||
session_replay_config: {
|
||||
...currentTeam?.session_replay_config,
|
||||
record_canvas: checked,
|
||||
},
|
||||
})
|
||||
}}
|
||||
label={
|
||||
<div className="deprecated-space-x-1">
|
||||
<LemonTag type="success">New</LemonTag>
|
||||
<LemonLabel>Capture canvas elements</LemonLabel>
|
||||
</div>
|
||||
}
|
||||
bordered
|
||||
checked={
|
||||
currentTeam?.session_replay_config ? !!currentTeam?.session_replay_config?.record_canvas : false
|
||||
}
|
||||
disabledReason={!currentTeam?.session_recording_opt_in ? 'Session replay must be enabled' : undefined}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]}
|
||||
>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-canvas-switch"
|
||||
onChange={(checked) => {
|
||||
updateCurrentTeam({
|
||||
session_replay_config: {
|
||||
...currentTeam?.session_replay_config,
|
||||
record_canvas: checked,
|
||||
},
|
||||
})
|
||||
}}
|
||||
label={
|
||||
<div className="deprecated-space-x-1">
|
||||
<LemonTag type="success">New</LemonTag>
|
||||
<LemonLabel>Capture canvas elements</LemonLabel>
|
||||
</div>
|
||||
}
|
||||
bordered
|
||||
checked={
|
||||
currentTeam?.session_replay_config ? !!currentTeam?.session_replay_config?.record_canvas : false
|
||||
}
|
||||
disabledReason={
|
||||
!currentTeam?.session_recording_opt_in ? 'Session replay must be enabled' : undefined
|
||||
}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -286,22 +291,25 @@ export function NetworkCaptureSettings(): JSX.Element {
|
||||
</Link>{' '}
|
||||
, where they can be configured directly in code.
|
||||
</p>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-performance-switch"
|
||||
onChange={(checked) => {
|
||||
updateCurrentTeam({ capture_performance_opt_in: checked })
|
||||
}}
|
||||
label="Capture network performance"
|
||||
bordered
|
||||
checked={!!currentTeam?.capture_performance_opt_in}
|
||||
disabledReason={!currentTeam?.session_recording_opt_in ? 'Session replay must be enabled' : undefined}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]}
|
||||
>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-performance-switch"
|
||||
onChange={(checked) => {
|
||||
updateCurrentTeam({ capture_performance_opt_in: checked })
|
||||
}}
|
||||
label="Capture network performance"
|
||||
bordered
|
||||
checked={!!currentTeam?.capture_performance_opt_in}
|
||||
disabledReason={
|
||||
!currentTeam?.session_recording_opt_in ? 'Session replay must be enabled' : undefined
|
||||
}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
|
||||
<div className="mt-4">
|
||||
<p>
|
||||
@@ -322,84 +330,88 @@ export function NetworkCaptureSettings(): JSX.Element {
|
||||
reactNative={false}
|
||||
/>
|
||||
<div className="flex flex-row deprecated-space-x-2">
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-network-headers-switch"
|
||||
onChange={(checked) => {
|
||||
updateCurrentTeam({
|
||||
session_recording_network_payload_capture_config: {
|
||||
...currentTeam?.session_recording_network_payload_capture_config,
|
||||
recordHeaders: checked,
|
||||
},
|
||||
})
|
||||
}}
|
||||
label="Capture headers"
|
||||
bordered
|
||||
checked={
|
||||
currentTeam?.session_recording_opt_in
|
||||
? !!currentTeam?.session_recording_network_payload_capture_config?.recordHeaders
|
||||
: false
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
disabledReason={
|
||||
!currentTeam?.session_recording_opt_in || !currentTeam?.capture_performance_opt_in
|
||||
? 'session and network performance capture must be enabled'
|
||||
: undefined
|
||||
}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-network-body-switch"
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
LemonDialog.open({
|
||||
maxWidth: '650px',
|
||||
title: 'Network body capture',
|
||||
description: <PayloadWarning />,
|
||||
primaryButton: {
|
||||
'data-attr': 'network-payload-capture-accept-warning-and-enable',
|
||||
children: 'Enable body capture',
|
||||
onClick: () => {
|
||||
updateCurrentTeam({
|
||||
session_recording_network_payload_capture_config: {
|
||||
...currentTeam?.session_recording_network_payload_capture_config,
|
||||
recordBody: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-network-headers-switch"
|
||||
onChange={(checked) => {
|
||||
updateCurrentTeam({
|
||||
session_recording_network_payload_capture_config: {
|
||||
...currentTeam?.session_recording_network_payload_capture_config,
|
||||
recordBody: false,
|
||||
recordHeaders: checked,
|
||||
},
|
||||
})
|
||||
}}
|
||||
label="Capture headers"
|
||||
bordered
|
||||
checked={
|
||||
currentTeam?.session_recording_opt_in
|
||||
? !!currentTeam?.session_recording_network_payload_capture_config?.recordHeaders
|
||||
: false
|
||||
}
|
||||
}}
|
||||
label="Capture body"
|
||||
bordered
|
||||
checked={
|
||||
currentTeam?.session_recording_opt_in
|
||||
? !!currentTeam?.session_recording_network_payload_capture_config?.recordBody
|
||||
: false
|
||||
disabledReason={
|
||||
!currentTeam?.session_recording_opt_in || !currentTeam?.capture_performance_opt_in
|
||||
? 'session and network performance capture must be enabled'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
disabledReason={
|
||||
!currentTeam?.session_recording_opt_in || !currentTeam?.capture_performance_opt_in
|
||||
? 'session and network performance capture must be enabled'
|
||||
: undefined
|
||||
}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-capture-network-body-switch"
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
LemonDialog.open({
|
||||
maxWidth: '650px',
|
||||
title: 'Network body capture',
|
||||
description: <PayloadWarning />,
|
||||
primaryButton: {
|
||||
'data-attr': 'network-payload-capture-accept-warning-and-enable',
|
||||
children: 'Enable body capture',
|
||||
onClick: () => {
|
||||
updateCurrentTeam({
|
||||
session_recording_network_payload_capture_config: {
|
||||
...currentTeam?.session_recording_network_payload_capture_config,
|
||||
recordBody: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
updateCurrentTeam({
|
||||
session_recording_network_payload_capture_config: {
|
||||
...currentTeam?.session_recording_network_payload_capture_config,
|
||||
recordBody: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
label="Capture body"
|
||||
bordered
|
||||
checked={
|
||||
currentTeam?.session_recording_opt_in
|
||||
? !!currentTeam?.session_recording_network_payload_capture_config?.recordBody
|
||||
: false
|
||||
}
|
||||
disabledReason={
|
||||
!currentTeam?.session_recording_opt_in || !currentTeam?.capture_performance_opt_in
|
||||
? 'session and network performance capture must be enabled'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -408,7 +420,7 @@ export function NetworkCaptureSettings(): JSX.Element {
|
||||
|
||||
/**
|
||||
* @deprecated use ReplayTriggers instead, this is only presented to teams that have these settings set
|
||||
* @constructor
|
||||
* @class
|
||||
*/
|
||||
export function ReplayAuthorizedDomains(): JSX.Element {
|
||||
return (
|
||||
@@ -621,21 +633,21 @@ export function ReplayMaskingSettings(): JSX.Element {
|
||||
</Link>
|
||||
</p>
|
||||
<p>If you specify this in code, it will take precedence over the setting here.</p>
|
||||
<LemonSelect
|
||||
value={maskingLevel}
|
||||
onChange={(val) => val && handleMaskingChange(val)}
|
||||
options={[
|
||||
{ value: 'total-privacy', label: 'Total privacy (mask all text/images)' },
|
||||
{ value: 'normal', label: 'Normal (mask inputs but not text/images)' },
|
||||
{ value: 'free-love', label: 'Free love (mask only passwords)' },
|
||||
]}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]}
|
||||
>
|
||||
<LemonSelect
|
||||
value={maskingLevel}
|
||||
onChange={(val) => val && handleMaskingChange(val)}
|
||||
options={[
|
||||
{ value: 'total-privacy', label: 'Total privacy (mask all text/images)' },
|
||||
{ value: 'normal', label: 'Normal (mask inputs but not text/images)' },
|
||||
{ value: 'free-love', label: 'Free love (mask only passwords)' },
|
||||
]}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -677,21 +689,24 @@ export function ReplayGeneral(): JSX.Element {
|
||||
Check out our docs
|
||||
</Link>
|
||||
</p>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-session-recording-switch"
|
||||
onChange={(checked) => {
|
||||
handleOptInChange(checked)
|
||||
}}
|
||||
label="Record user sessions"
|
||||
bordered
|
||||
checked={!!currentTeam?.session_recording_opt_in}
|
||||
accessControl={{
|
||||
resourceType: AccessControlResourceType.SessionRecording,
|
||||
minAccessLevel: AccessControlLevel.Editor,
|
||||
userAccessLevel:
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording],
|
||||
}}
|
||||
/>
|
||||
<AccessControlAction
|
||||
resourceType={AccessControlResourceType.SessionRecording}
|
||||
minAccessLevel={AccessControlLevel.Editor}
|
||||
userAccessLevel={
|
||||
getAppContext()?.resource_access_control?.[AccessControlResourceType.SessionRecording]
|
||||
}
|
||||
>
|
||||
<LemonSwitch
|
||||
data-attr="opt-in-session-recording-switch"
|
||||
onChange={(checked) => {
|
||||
handleOptInChange(checked)
|
||||
}}
|
||||
label="Record user sessions"
|
||||
bordered
|
||||
checked={!!currentTeam?.session_recording_opt_in}
|
||||
/>
|
||||
</AccessControlAction>
|
||||
|
||||
{showSurvey && <InternalMultipleChoiceSurvey surveyId={SESSION_RECORDING_OPT_OUT_SURVEY_ID} />}
|
||||
</div>
|
||||
<LogCaptureSettings />
|
||||
|
||||