feat(gatekeeper): add per workspace feature flags (#5303)
* feat(gatekeeper): add per workspace feature flags * feat(workspaces): add admin api for granting and removing access to workspace features * fix(workspaces): use the correct constant name * fix(workspaces): more test type fixes * fix(shared): fix tests and types * fix(workspaces): properly use exhaustive switch statement * fix(workspaces): add new workspace plan feature to switch * fix(workspaces): use regular integer, its fine for now... * fix(workspaces): feature flag retention post checkout * fix(gatekeeper): fix upsert plan tests
This commit is contained in:
@@ -2,31 +2,98 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
workspacePlanHasAccessToFeature,
|
||||
WorkspacePlanFeatures,
|
||||
WorkspacePlanConfigs
|
||||
WorkspacePlanConfigs,
|
||||
isWorkspaceFeatureFlagOn,
|
||||
WorkspaceFeatureFlags
|
||||
} from './features.js'
|
||||
import { WorkspacePlans } from './plans.js'
|
||||
|
||||
describe('workspacePlanHasAccessToFeature', () => {
|
||||
describe('Comprehensive feature coverage', () => {
|
||||
const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[]
|
||||
const allFeatures = Object.values(WorkspacePlanFeatures) as WorkspacePlanFeatures[]
|
||||
describe('workspace features', () => {
|
||||
describe('workspacePlanHasAccessToFeature', () => {
|
||||
describe('Comprehensive feature coverage', () => {
|
||||
const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[]
|
||||
const allFeatures = Object.values(
|
||||
WorkspacePlanFeatures
|
||||
) as WorkspacePlanFeatures[]
|
||||
|
||||
describe.each(allPlans)('should work for %s plan', (plan) => {
|
||||
it.each(allFeatures)('%s feature combination', (feature) => {
|
||||
const expectedResult = WorkspacePlanConfigs({ featureFlags: undefined })[
|
||||
plan
|
||||
].features.includes(feature)
|
||||
const actualResult = workspacePlanHasAccessToFeature({
|
||||
plan,
|
||||
feature,
|
||||
featureFlags: undefined
|
||||
describe.each(allPlans)('should work for %s plan', (plan) => {
|
||||
it.each(allFeatures)('%s feature combination', (feature) => {
|
||||
const expectedResult = WorkspacePlanConfigs({ featureFlags: undefined })[
|
||||
plan
|
||||
].features.includes(feature)
|
||||
const actualResult = workspacePlanHasAccessToFeature({
|
||||
plan,
|
||||
feature,
|
||||
featureFlags: undefined
|
||||
})
|
||||
expect(
|
||||
actualResult,
|
||||
`Plan ${plan} feature ${feature} access should be ${expectedResult}`
|
||||
).toBe(expectedResult)
|
||||
})
|
||||
|
||||
expect(
|
||||
actualResult,
|
||||
`Plan ${plan} feature ${feature} access should be ${expectedResult}`
|
||||
).toBe(expectedResult)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('isWorkspaceFeatureFlagOn', () => {
|
||||
it('returns false if no flags are on', () => {
|
||||
const workspaceFeatureFlags = WorkspaceFeatureFlags.none
|
||||
const feature = WorkspaceFeatureFlags.dashboards
|
||||
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false if the currently tested flag is off', () => {
|
||||
const workspaceFeatureFlags = WorkspaceFeatureFlags.accIntegration
|
||||
const feature = WorkspaceFeatureFlags.dashboards
|
||||
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true if the currently tested flag is on', () => {
|
||||
const workspaceFeatureFlags = WorkspaceFeatureFlags.dashboards
|
||||
const feature = WorkspaceFeatureFlags.dashboards
|
||||
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true if the currently tested flag is on in a combo flag', () => {
|
||||
const workspaceFeatureFlags =
|
||||
WorkspaceFeatureFlags.dashboards | WorkspaceFeatureFlags.accIntegration
|
||||
let result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.dashboards
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
|
||||
result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.accIntegration
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
it('feature flag can be turned on and off', () => {
|
||||
let workspaceFeatureFlags = WorkspaceFeatureFlags.none
|
||||
let result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.dashboards
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
|
||||
workspaceFeatureFlags |= WorkspaceFeatureFlags.dashboards
|
||||
|
||||
result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.dashboards
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
|
||||
workspaceFeatureFlags ^= WorkspaceFeatureFlags.dashboards
|
||||
|
||||
result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.dashboards
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,13 +24,37 @@ export const WorkspacePlanFeatures = <const>{
|
||||
HideSpeckleBranding: 'hideSpeckleBranding',
|
||||
ExclusiveMembership: 'exclusiveMembership',
|
||||
EmbedPrivateProjects: 'embedPrivateProjects',
|
||||
AccIntegration: 'accIntegration',
|
||||
AccIntegration: 'accIntegration', // TODO: this should be moved to a workspace addon
|
||||
SavedViews: 'savedViews'
|
||||
}
|
||||
|
||||
export type WorkspacePlanFeatures =
|
||||
(typeof WorkspacePlanFeatures)[keyof typeof WorkspacePlanFeatures]
|
||||
|
||||
// this const will be used as a bitwise flag for a per workspace feature access controller
|
||||
// IMPORTANT: always use powers of 2 as the value of the object
|
||||
// read more https://www.hendrik-erz.de/post/bitwise-flags-are-beautiful-and-heres-why
|
||||
// this will make its way to the pricing plan and info setup at some point
|
||||
// but for now its an internal only control
|
||||
export const WorkspaceFeatureFlags = <const>{
|
||||
none: 0,
|
||||
dashboards: 1,
|
||||
accIntegration: 2
|
||||
}
|
||||
|
||||
export type WorkspaceFeatureFlags =
|
||||
(typeof WorkspaceFeatureFlags)[keyof typeof WorkspaceFeatureFlags]
|
||||
|
||||
export const isWorkspaceFeatureFlagOn = ({
|
||||
workspaceFeatureFlags,
|
||||
feature
|
||||
}: {
|
||||
workspaceFeatureFlags: number
|
||||
feature: WorkspaceFeatureFlags
|
||||
}): boolean => (workspaceFeatureFlags & feature) === feature
|
||||
|
||||
export type WorkspaceFeatures = WorkspacePlanFeatures | WorkspaceFeatureFlags
|
||||
|
||||
export const WorkspacePlanFeaturesMetadata = (<const>{
|
||||
[WorkspacePlanFeatures.AutomateBeta]: {
|
||||
displayName: 'Automate beta access',
|
||||
@@ -314,3 +338,12 @@ export const workspacePlanHasAccessToFeature = ({
|
||||
const hasAccess = planConfig.features.includes(feature)
|
||||
return hasAccess
|
||||
}
|
||||
|
||||
export const isPlanFeature = (
|
||||
feature: WorkspaceFeatures
|
||||
): feature is WorkspacePlanFeatures => {
|
||||
if (typeof feature === 'number') {
|
||||
return false
|
||||
}
|
||||
return Object.values(WorkspacePlanFeatures).includes(feature)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ type BaseWorkspacePlan = {
|
||||
workspaceId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
featureFlags: number // this will be a bitwise flag number
|
||||
}
|
||||
|
||||
export type PaidWorkspacePlan = BaseWorkspacePlan & {
|
||||
|
||||
Reference in New Issue
Block a user