Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions frontend/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { OAuthType } from './types/requests'
import { SegmentCondition } from './types/responses'
import Utils from './utils/utils'

import Project from './project'
import { integrationCategories } from 'components/pages/IntegrationsPage'
import {
EnvironmentPermission,
EnvironmentPermissionDescriptions,
OrganisationPermission,
OrganisationPermissionDescriptions,
ProjectPermission,
ProjectPermissionDescriptions,
} from './types/permissions.types'

const keywords = {
FEATURE_FUNCTION: 'myCoolFeature',
FEATURE_NAME: 'my_cool_feature',
Expand Down Expand Up @@ -252,8 +260,13 @@ const Constants = {
value: '',
} as SegmentCondition,
defaultTagColor: '#3d4db6',
environmentPermissions: (perm: string) =>
`To manage this feature you need the <i>${perm}</i> permission for this environment.<br/>Please contact a member of this environment who has administrator privileges.`,
environmentPermissions: (perm: EnvironmentPermission) => {
const description =
perm === 'ADMIN'
? 'Administrator'
: EnvironmentPermissionDescriptions[perm]
return `To manage this feature you need the <i>${description}</i> permission for this environment.<br/>Please contact a member of this environment who has administrator privileges.`
},
events: {
'ACCEPT_INVITE': (org: any) => ({
'category': 'Invite',
Expand Down Expand Up @@ -623,8 +636,13 @@ const Constants = {
modals: {
'PAYMENT': 'Payment Modal',
},
organisationPermissions: (perm: string) =>
`To manage this feature you need the <i>${perm}</i> permission for this organisastion.<br/>Please contact a member of this organisation who has administrator privileges.`,
organisationPermissions: (perm: OrganisationPermission) => {
const description =
perm === 'ADMIN'
? 'Administrator'
: OrganisationPermissionDescriptions[perm]
return `To manage this feature you need the <i>${description}</i> permission for this organisation.<br/>Please contact a member of this organisation who has administrator privileges.`
},
pages: {
'ACCOUNT': 'Account Page',
'AUDIT_LOG': 'Audit Log Page',
Expand Down Expand Up @@ -656,8 +674,11 @@ const Constants = {
'#FFBE71',
'#F57C78',
],
projectPermissions: (perm: string) =>
`To use this feature you need the <i>${perm}</i> permission for this project.<br/>Please contact a member of this project who has administrator privileges.`,
projectPermissions: (perm: ProjectPermission) => {
const description =
perm === 'ADMIN' ? 'Administrator' : ProjectPermissionDescriptions[perm]
return `To use this feature you need the <i>${description}</i> permission for this project.<br/>Please contact a member of this project who has administrator privileges.`
},
resourceTypes: {
GITHUB_ISSUE: {
id: 1,
Expand Down
202 changes: 186 additions & 16 deletions frontend/common/providers/Permission.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,93 @@
import React, { FC, ReactNode, useMemo } from 'react'
import { useGetPermissionQuery } from 'common/services/usePermission'
import { PermissionLevel } from 'common/types/requests'
import AccountStore from 'common/stores/account-store'
import intersection from 'lodash/intersection'
import { cloneDeep } from 'lodash'
import Utils from 'common/utils/utils'
import Constants from 'common/constants'
import {
ADMIN_PERMISSION,
EnvironmentPermission,
OrganisationPermission,
ProjectPermission,
} from 'common/types/permissions.types'

type PermissionType = {
id: any
permission: string
/**
* Base props shared across all permission levels
*/
type BasePermissionProps = {
id: number | string
tags?: number[]
level: PermissionLevel
children: (data: { permission: boolean; isLoading: boolean }) => ReactNode
children:
| ReactNode
| ((data: { permission: boolean; isLoading: boolean }) => ReactNode)
fallback?: ReactNode
permissionName?: string
showTooltip?: boolean
}

/**
* Discriminated union types for each permission level
* This means we can detect a mismatch between level and permission
*/
type OrganisationLevelProps = BasePermissionProps & {
level: 'organisation'
permission: OrganisationPermission
}

type ProjectLevelProps = BasePermissionProps & {
level: 'project'
permission: ProjectPermission
}

type EnvironmentLevelProps = BasePermissionProps & {
level: 'environment'
permission: EnvironmentPermission
}

type PermissionType =
| OrganisationLevelProps
| ProjectLevelProps
| EnvironmentLevelProps

type UseHasPermissionParams = {
id: number | string
level: 'organisation' | 'project' | 'environment'
permission: OrganisationPermission | ProjectPermission | EnvironmentPermission
tags?: number[]
}

/**
* Hook to check if the current user has a specific permission
*
* Fetches permission data and checks if the user has the requested permission.
* Supports tag-based permissions where additional permissions can be granted
* based on tag intersection.
*
* @param {Object} params - The permission check parameters
* @param {number | string} params.id - The resource ID to check permissions for
* @param {PermissionLevel} params.level - The permission level to check at
* @param {string} params.permission - The permission key to check
* @param {number[]} [params.tags] - Optional tag IDs for tag-based permission checking
* @returns {Object} Object containing permission status and loading state
* @returns {boolean} returns.isLoading - Whether the permission data is still loading
* @returns {boolean} returns.isSuccess - Whether the permission data was fetched successfully
* @returns {boolean} returns.permission - Whether the user has the requested permission
*/
export const useHasPermission = ({
id,
level,
permission,
tags,
}: Omit<PermissionType, 'children'>) => {
}: UseHasPermissionParams) => {
const {
data: permissionsData,
isLoading,
isSuccess,
} = useGetPermissionQuery({ id: `${id}`, level }, { skip: !id || !level })
} = useGetPermissionQuery(
{ id: id as number, level },
{ skip: !id || !level },
)
const data = useMemo(() => {
if (!tags?.length || !permissionsData?.tag_based_permissions)
return permissionsData
Expand All @@ -45,11 +109,68 @@ export const useHasPermission = ({
}
}

/**
* Permission component for conditional rendering based on user permissions
*
* This component checks if the current user has a specific permission and conditionally
* renders its children. It supports multiple rendering patterns:
*
* @example
* // Basic usage with simple children
* <Permission level="project" permission="CREATE_FEATURE" id={projectId}>
* <Button>Create Feature</Button>
* </Permission>
*
* @example
* // Using render function to access permission state
* <Permission level="project" permission="CREATE_FEATURE" id={projectId}>
* {({ permission, isLoading }) => (
* <Button disabled={!permission || isLoading}>Create Feature</Button>
* )}
* </Permission>
*
* @example
* // With tooltip on permission denial
* <Permission
* level="project"
* permission="CREATE_FEATURE"
* id={projectId}
* showTooltip
* permissionName="Create Features"
* >
* <Button>Create Feature</Button>
* </Permission>
*
* @example
* // With fallback content
* <Permission
* level="project"
* permission="DELETE_FEATURE"
* id={projectId}
* fallback={<Text>You don't have permission to delete features</Text>}
* >
* <Button>Delete Feature</Button>
* </Permission>
*
* @example
* // With tag-based permissions
* <Permission
* level="project"
* permission="UPDATE_FEATURE"
* id={projectId}
* tags={[tagId1, tagId2]}
* >
* <Button>Update Feature</Button>
* </Permission>
*/
const Permission: FC<PermissionType> = ({
children,
fallback,
id,
level,
permission,
permissionName,
showTooltip = false,
tags,
}) => {
const { isLoading, permission: hasPermission } = useHasPermission({
Expand All @@ -58,14 +179,63 @@ const Permission: FC<PermissionType> = ({
permission,
tags,
})
return (
<>
{children({
isLoading,
permission: hasPermission || AccountStore.isAdmin(),
}) || null}
</>
)

const finalPermission = hasPermission || AccountStore.isAdmin()

const getPermissionDescription = (): string => {
if (permission === ADMIN_PERMISSION) {
switch (level) {
case 'environment':
return Constants.environmentPermissions(ADMIN_PERMISSION)
case 'project':
return Constants.projectPermissions(ADMIN_PERMISSION)
default:
return Constants.organisationPermissions(ADMIN_PERMISSION)
}
}

switch (level) {
case 'environment':
return Constants.environmentPermissions(
permission as EnvironmentPermission,
)
case 'project':
return Constants.projectPermissions(permission as ProjectPermission)
default:
return Constants.organisationPermissions(
permission as OrganisationPermission,
)
}
}

const tooltipMessage = permissionName || getPermissionDescription()

if (typeof children === 'function') {
const renderedChildren = children({
isLoading,
permission: finalPermission,
})

if (finalPermission || !showTooltip) {
return <>{renderedChildren || null}</>
}

return Utils.renderWithPermission(
finalPermission,
tooltipMessage,
renderedChildren,
)
}

if (finalPermission) {
return <>{children}</>
}

if (showTooltip) {
return Utils.renderWithPermission(finalPermission, tooltipMessage, children)
}

return <>{fallback || null}</>
}

export default Permission
Loading
Loading