@ -179,6 +179,7 @@ export type ClientConfig = {
|
||||
frontDomain: Scalars['String'];
|
||||
isEmailVerificationRequired: Scalars['Boolean'];
|
||||
isMultiWorkspaceEnabled: Scalars['Boolean'];
|
||||
publicFeatureFlags: Array<PublicFeatureFlag>;
|
||||
sentry: Sentry;
|
||||
signInPrefilled: Scalars['Boolean'];
|
||||
support: Support;
|
||||
@ -530,6 +531,7 @@ export type Mutation = {
|
||||
switchWorkspace: PublicWorkspaceDataOutput;
|
||||
track: Analytics;
|
||||
updateBillingSubscription: UpdateBillingEntity;
|
||||
updateLabPublicFeatureFlag: Scalars['Boolean'];
|
||||
updateOneField: Field;
|
||||
updateOneObject: Object;
|
||||
updateOneServerlessFunction: ServerlessFunction;
|
||||
@ -747,6 +749,11 @@ export type MutationTrackArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateLabPublicFeatureFlagArgs = {
|
||||
input: UpdateLabPublicFeatureFlagInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateOneFieldArgs = {
|
||||
input: UpdateOneFieldMetadataInput;
|
||||
};
|
||||
@ -890,6 +897,19 @@ export type ProductPricesEntity = {
|
||||
totalNumberOfPrices: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type PublicFeatureFlag = {
|
||||
__typename?: 'PublicFeatureFlag';
|
||||
key: FeatureFlagKey;
|
||||
metadata: PublicFeatureFlagMetadata;
|
||||
};
|
||||
|
||||
export type PublicFeatureFlagMetadata = {
|
||||
__typename?: 'PublicFeatureFlagMetadata';
|
||||
description: Scalars['String'];
|
||||
imagePath: Scalars['String'];
|
||||
label: Scalars['String'];
|
||||
};
|
||||
|
||||
export type PublicWorkspaceDataOutput = {
|
||||
__typename?: 'PublicWorkspaceDataOutput';
|
||||
authProviders: AuthProviders;
|
||||
@ -1358,6 +1378,11 @@ export type UpdateFieldInput = {
|
||||
settings?: InputMaybe<Scalars['JSON']>;
|
||||
};
|
||||
|
||||
export type UpdateLabPublicFeatureFlagInput = {
|
||||
publicFeatureFlag: Scalars['String'];
|
||||
value: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type UpdateObjectPayload = {
|
||||
description?: InputMaybe<Scalars['String']>;
|
||||
icon?: InputMaybe<Scalars['String']>;
|
||||
@ -2103,7 +2128,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat
|
||||
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'TrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
|
||||
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'TrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } };
|
||||
|
||||
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@ -2126,6 +2151,13 @@ export type UserLookupAdminPanelMutationVariables = Exact<{
|
||||
|
||||
export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, allowImpersonation: boolean, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> }> } };
|
||||
|
||||
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
|
||||
input: UpdateLabPublicFeatureFlagInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: boolean };
|
||||
|
||||
export type CreateOidcIdentityProviderMutationVariables = Exact<{
|
||||
input: SetupOidcSsoInput;
|
||||
}>;
|
||||
@ -3615,6 +3647,14 @@ export const GetClientConfigDocument = gql`
|
||||
}
|
||||
chromeExtensionId
|
||||
canManageFeatureFlags
|
||||
publicFeatureFlags {
|
||||
key
|
||||
metadata {
|
||||
label
|
||||
description
|
||||
imagePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -3769,6 +3809,37 @@ export function useUserLookupAdminPanelMutation(baseOptions?: Apollo.MutationHoo
|
||||
export type UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>;
|
||||
export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>;
|
||||
export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
|
||||
export const UpdateLabPublicFeatureFlagDocument = gql`
|
||||
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
|
||||
updateLabPublicFeatureFlag(input: $input)
|
||||
}
|
||||
`;
|
||||
export type UpdateLabPublicFeatureFlagMutationFn = Apollo.MutationFunction<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpdateLabPublicFeatureFlagMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpdateLabPublicFeatureFlagMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpdateLabPublicFeatureFlagMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [updateLabPublicFeatureFlagMutation, { data, loading, error }] = useUpdateLabPublicFeatureFlagMutation({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpdateLabPublicFeatureFlagMutation(baseOptions?: Apollo.MutationHookOptions<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>(UpdateLabPublicFeatureFlagDocument, options);
|
||||
}
|
||||
export type UpdateLabPublicFeatureFlagMutationHookResult = ReturnType<typeof useUpdateLabPublicFeatureFlagMutation>;
|
||||
export type UpdateLabPublicFeatureFlagMutationResult = Apollo.MutationResult<UpdateLabPublicFeatureFlagMutation>;
|
||||
export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptions<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
|
||||
export const CreateOidcIdentityProviderDocument = gql`
|
||||
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
|
||||
createOIDCIdentityProvider(input: $input) {
|
||||
|
||||
@ -253,6 +253,12 @@ const SettingsAdminContent = lazy(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const SettingsLab = lazy(() =>
|
||||
import('~/pages/settings/lab/SettingsLab').then((module) => ({
|
||||
default: module.SettingsLab,
|
||||
})),
|
||||
);
|
||||
|
||||
type SettingsRoutesProps = {
|
||||
isBillingEnabled?: boolean;
|
||||
isServerlessFunctionSettingsEnabled?: boolean;
|
||||
@ -379,6 +385,7 @@ export const SettingsRoutes = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Route path={SettingsPath.Lab} element={<SettingsLab />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@ -10,6 +10,7 @@ import { isDebugModeState } from '@/client-config/states/isDebugModeState';
|
||||
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
|
||||
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
|
||||
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
|
||||
import { supportChatState } from '@/client-config/states/supportChatState';
|
||||
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
|
||||
@ -52,6 +53,10 @@ export const ClientConfigProviderEffect = () => {
|
||||
canManageFeatureFlagsState,
|
||||
);
|
||||
|
||||
const setLabPublicFeatureFlags = useSetRecoilState(
|
||||
labPublicFeatureFlagsState,
|
||||
);
|
||||
|
||||
const { data, loading, error } = useGetClientConfigQuery({
|
||||
skip: clientConfigApiStatus.isLoaded,
|
||||
});
|
||||
@ -117,6 +122,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
frontDomain: data?.clientConfig?.frontDomain,
|
||||
});
|
||||
setCanManageFeatureFlags(data?.clientConfig?.canManageFeatureFlags);
|
||||
setLabPublicFeatureFlags(data?.clientConfig?.publicFeatureFlags);
|
||||
}, [
|
||||
data,
|
||||
setIsDebugMode,
|
||||
@ -136,6 +142,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
setDomainConfiguration,
|
||||
setAuthProviders,
|
||||
setCanManageFeatureFlags,
|
||||
setLabPublicFeatureFlags,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
|
||||
@ -48,6 +48,14 @@ export const GET_CLIENT_CONFIG = gql`
|
||||
}
|
||||
chromeExtensionId
|
||||
canManageFeatureFlags
|
||||
publicFeatureFlags {
|
||||
key
|
||||
metadata {
|
||||
label
|
||||
description
|
||||
imagePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { atom } from 'recoil';
|
||||
import { PublicFeatureFlag } from '~/generated/graphql';
|
||||
|
||||
export const labPublicFeatureFlagsState = atom<PublicFeatureFlag[]>({
|
||||
key: 'labPublicFeatureFlagsState',
|
||||
default: [],
|
||||
});
|
||||
@ -8,6 +8,7 @@ import {
|
||||
IconComponent,
|
||||
IconCurrencyDollar,
|
||||
IconDoorEnter,
|
||||
IconFlask,
|
||||
IconFunction,
|
||||
IconHierarchy2,
|
||||
IconKey,
|
||||
@ -22,6 +23,7 @@ import {
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
|
||||
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
|
||||
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
@ -64,6 +66,7 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
const isAdminPageEnabled = currentUser?.canImpersonate;
|
||||
const labPublicFeatureFlags = useRecoilValue(labPublicFeatureFlagsState);
|
||||
// TODO: Refactor this part to only have arrays of navigation items
|
||||
const currentPathName = useLocation().pathname;
|
||||
|
||||
@ -200,6 +203,13 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
Icon={IconServer}
|
||||
/>
|
||||
)}
|
||||
{labPublicFeatureFlags?.length > 0 && (
|
||||
<SettingsNavigationDrawerItem
|
||||
label={t`Lab`}
|
||||
path={SettingsPath.Lab}
|
||||
Icon={IconFlask}
|
||||
/>
|
||||
)}
|
||||
<SettingsNavigationDrawerItem
|
||||
label={t`Releases`}
|
||||
path={SettingsPath.Releases}
|
||||
|
||||
@ -23,7 +23,11 @@ const StyledSettingsOptionCardToggleContent = styled(
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSettingsOptionCardToggleButton = styled(Toggle)`
|
||||
const StyledSettingsOptionCardToggleButton = styled(Toggle)<{
|
||||
toggleCentered?: boolean;
|
||||
}>`
|
||||
align-self: ${({ toggleCentered }) =>
|
||||
toggleCentered ? 'center' : 'flex-start'};
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
@ -40,6 +44,7 @@ type SettingsOptionCardContentToggleProps = {
|
||||
divider?: boolean;
|
||||
disabled?: boolean;
|
||||
advancedMode?: boolean;
|
||||
toggleCentered?: boolean;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
@ -51,6 +56,7 @@ export const SettingsOptionCardContentToggle = ({
|
||||
divider,
|
||||
disabled = false,
|
||||
advancedMode = false,
|
||||
toggleCentered = true,
|
||||
checked,
|
||||
onChange,
|
||||
}: SettingsOptionCardContentToggleProps) => {
|
||||
@ -83,6 +89,7 @@ export const SettingsOptionCardContentToggle = ({
|
||||
disabled={disabled}
|
||||
toggleSize="small"
|
||||
color={advancedMode ? theme.color.yellow : theme.color.blue}
|
||||
toggleCentered={toggleCentered}
|
||||
/>
|
||||
</StyledSettingsOptionCardToggleContent>
|
||||
{divider && <Separator />}
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
|
||||
import { useLabPublicFeatureFlags } from '@/settings/lab/hooks/useLabPublicFeatureFlags';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Card, MOBILE_VIEWPORT } from 'twenty-ui';
|
||||
import { FeatureFlagKey } from '~/generated/graphql';
|
||||
|
||||
const StyledCardGrid = styled.div`
|
||||
display: grid;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
& > *:not(:first-child) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
@media (min-width: ${MOBILE_VIEWPORT}px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
& > *:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img<{ isFirstCard: boolean }>`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
height: ${({ isFirstCard }) => (isFirstCard ? '240px' : '120px')};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledFallbackDiv = styled.div<{ isFirstCard: boolean }>`
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
height: ${({ isFirstCard }) => (isFirstCard ? '240px' : '120px')};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SettingsLabContent = () => {
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const { labPublicFeatureFlags, handleLabPublicFeatureFlagUpdate } =
|
||||
useLabPublicFeatureFlags();
|
||||
const [hasImageLoadingError, setHasImageLoadingError] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleToggle = async (key: FeatureFlagKey, value: boolean) => {
|
||||
await handleLabPublicFeatureFlagUpdate(key, value);
|
||||
};
|
||||
|
||||
const handleImageError = (key: string) => {
|
||||
setHasImageLoadingError((prev) => ({ ...prev, [key]: true }));
|
||||
};
|
||||
|
||||
return (
|
||||
currentWorkspace?.id && (
|
||||
<StyledCardGrid>
|
||||
{labPublicFeatureFlags.map((flag, index) => (
|
||||
<Card key={flag.key} rounded>
|
||||
{flag.metadata.imagePath && !hasImageLoadingError[flag.key] ? (
|
||||
<StyledImage
|
||||
src={flag.metadata.imagePath}
|
||||
alt={flag.metadata.label}
|
||||
isFirstCard={index === 0}
|
||||
onError={() => handleImageError(flag.key)}
|
||||
/>
|
||||
) : (
|
||||
<StyledFallbackDiv isFirstCard={index === 0} />
|
||||
)}
|
||||
<SettingsOptionCardContentToggle
|
||||
title={flag.metadata.label}
|
||||
description={flag.metadata.description}
|
||||
checked={flag.value}
|
||||
onChange={(value) => handleToggle(flag.key, value)}
|
||||
toggleCentered={false}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</StyledCardGrid>
|
||||
)
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_LAB_PUBLIC_FEATURE_FLAG = gql`
|
||||
mutation UpdateLabPublicFeatureFlag(
|
||||
$input: UpdateLabPublicFeatureFlagInput!
|
||||
) {
|
||||
updateLabPublicFeatureFlag(input: $input)
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,66 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
useUpdateLabPublicFeatureFlagMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export const useLabPublicFeatureFlags = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
const labPublicFeatureFlags = useRecoilValue(labPublicFeatureFlagsState);
|
||||
|
||||
const [updateLabPublicFeatureFlag] = useUpdateLabPublicFeatureFlagMutation();
|
||||
|
||||
const handleLabPublicFeatureFlagUpdate = async (
|
||||
publicFeatureFlag: FeatureFlagKey,
|
||||
value: boolean,
|
||||
) => {
|
||||
if (!isDefined(currentWorkspace)) {
|
||||
setError('No workspace selected');
|
||||
return false;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const response = await updateLabPublicFeatureFlag({
|
||||
variables: {
|
||||
input: {
|
||||
publicFeatureFlag,
|
||||
value,
|
||||
},
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(response.data)) {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
featureFlags: currentWorkspace.featureFlags?.map((flag) =>
|
||||
flag.key === publicFeatureFlag ? { ...flag, value } : flag,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return !!response.data;
|
||||
};
|
||||
|
||||
return {
|
||||
labPublicFeatureFlags: labPublicFeatureFlags.map((flag) => ({
|
||||
...flag,
|
||||
value:
|
||||
currentWorkspace?.featureFlags?.find(
|
||||
(workspaceFlag) => workspaceFlag.key === flag.key,
|
||||
)?.value ?? false,
|
||||
})),
|
||||
handleLabPublicFeatureFlagUpdate,
|
||||
error,
|
||||
};
|
||||
};
|
||||
@ -35,4 +35,5 @@ export enum SettingsPath {
|
||||
Releases = 'releases',
|
||||
AdminPanel = 'admin-panel',
|
||||
FeatureFlags = 'admin-panel/feature-flags',
|
||||
Lab = 'lab',
|
||||
}
|
||||
|
||||
24
packages/twenty-front/src/pages/settings/lab/SettingsLab.tsx
Normal file
24
packages/twenty-front/src/pages/settings/lab/SettingsLab.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsLabContent } from '@/settings/lab/components/SettingsLabContent';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
export const SettingsLab = () => {
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title="Lab"
|
||||
links={[
|
||||
{
|
||||
children: 'Other',
|
||||
href: getSettingsPath(SettingsPath.Lab),
|
||||
},
|
||||
{ children: 'Lab' },
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<SettingsLabContent />
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
};
|
||||
@ -52,4 +52,5 @@ export const mockedClientConfig: ClientConfig = {
|
||||
},
|
||||
api: { mutationMaximumAffectedRecords: 100 },
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: [],
|
||||
};
|
||||
|
||||
@ -11,6 +11,10 @@ import {
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import {
|
||||
FeatureFlagException,
|
||||
FeatureFlagExceptionCode,
|
||||
} from 'src/engine/core-modules/feature-flag/feature-flag.exception';
|
||||
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
@ -123,9 +127,9 @@ export class AdminPanelService {
|
||||
) {
|
||||
featureFlagValidator.assertIsFeatureFlagKey(
|
||||
featureFlag,
|
||||
new AuthException(
|
||||
new FeatureFlagException(
|
||||
'Invalid feature flag key',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
FeatureFlagExceptionCode.INVALID_FEATURE_FLAG_KEY,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { TrialPeriodDTO } from 'src/engine/core-modules/billing/dto/trial-period.dto';
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
|
||||
|
||||
registerEnumType(FeatureFlagKey, {
|
||||
name: 'FeatureFlagKey',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
class Billing {
|
||||
@Field(() => Boolean)
|
||||
@ -52,6 +57,27 @@ class ApiConfig {
|
||||
mutationMaximumAffectedRecords: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class PublicFeatureFlagMetadata {
|
||||
@Field(() => String)
|
||||
label: string;
|
||||
|
||||
@Field(() => String)
|
||||
description: string;
|
||||
|
||||
@Field(() => String, { nullable: false, defaultValue: '' })
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class PublicFeatureFlag {
|
||||
@Field(() => FeatureFlagKey)
|
||||
key: FeatureFlagKey;
|
||||
|
||||
@Field(() => PublicFeatureFlagMetadata)
|
||||
metadata: PublicFeatureFlagMetadata;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfig {
|
||||
@Field(() => AuthProviders, { nullable: false })
|
||||
@ -98,4 +124,7 @@ export class ClientConfig {
|
||||
|
||||
@Field(() => Boolean)
|
||||
canManageFeatureFlags: boolean;
|
||||
|
||||
@Field(() => [PublicFeatureFlag])
|
||||
publicFeatureFlags: PublicFeatureFlag[];
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
|
||||
import { ClientConfig } from './client-config.entity';
|
||||
|
||||
@ -75,6 +76,7 @@ export class ClientConfigResolver {
|
||||
canManageFeatureFlags:
|
||||
this.environmentService.get('DEBUG_MODE') ||
|
||||
this.environmentService.get('IS_BILLING_ENABLED'),
|
||||
publicFeatureFlags: PUBLIC_FEATURE_FLAGS,
|
||||
};
|
||||
|
||||
return Promise.resolve(clientConfig);
|
||||
|
||||
@ -22,6 +22,7 @@ import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-sto
|
||||
import { fileStorageModuleFactory } from 'src/engine/core-modules/file-storage/file-storage.module-factory';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||
import { LabModule } from 'src/engine/core-modules/lab/lab.module';
|
||||
import { LLMChatModelModule } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module';
|
||||
import { llmChatModelModuleFactory } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module-factory';
|
||||
import { LLMTracingModule } from 'src/engine/core-modules/llm-tracing/llm-tracing.module';
|
||||
@ -72,6 +73,7 @@ import { FileModule } from './file/file.module';
|
||||
ActorModule,
|
||||
TelemetryModule,
|
||||
AdminPanelModule,
|
||||
LabModule,
|
||||
EnvironmentModule.forRoot({}),
|
||||
RedisClientModule,
|
||||
FileStorageModule.forRootAsync({
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
type FeatureFlagMetadata = {
|
||||
label: string;
|
||||
description: string;
|
||||
imagePath: string;
|
||||
};
|
||||
|
||||
export type PublicFeatureFlag = {
|
||||
key: Extract<FeatureFlagKey, never>;
|
||||
metadata: FeatureFlagMetadata;
|
||||
};
|
||||
|
||||
export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [];
|
||||
@ -0,0 +1,14 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class FeatureFlagException extends CustomException {
|
||||
code: FeatureFlagExceptionCode;
|
||||
constructor(message: string, code: FeatureFlagExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
export enum FeatureFlagExceptionCode {
|
||||
INVALID_FEATURE_FLAG_KEY = 'INVALID_FEATURE_FLAG_KEY',
|
||||
FEATURE_FLAG_IS_NOT_PUBLIC = 'FEATURE_FLAG_IS_NOT_PUBLIC',
|
||||
FEATURE_FLAG_NOT_FOUND = 'FEATURE_FLAG_NOT_FOUND',
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
|
||||
const assertIsFeatureFlagKey = (
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { PublicFeatureFlag } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate';
|
||||
|
||||
jest.mock(
|
||||
'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum',
|
||||
() => ({
|
||||
FeatureFlagKey: {
|
||||
mockKey1: 'MOCK_KEY_1',
|
||||
mockKey2: 'MOCK_KEY_2',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const mockPublicFeatureFlag = {
|
||||
key: 'MOCK_KEY_1',
|
||||
metadata: {
|
||||
label: 'Mock Label 1',
|
||||
description: 'Mock Description 1',
|
||||
imagePath: 'mock/path/1',
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'src/engine/core-modules/lab/utils/is-public-feature-flag.util',
|
||||
() => ({
|
||||
isPublicFeatureFlag: (
|
||||
key: FeatureFlagKey,
|
||||
): key is PublicFeatureFlag['key'] => {
|
||||
if (!key) return false;
|
||||
|
||||
return key === mockPublicFeatureFlag.key;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Note: We're using a single public flag for testing as it's sufficient to verify
|
||||
// the validator's behavior. The validator's role is to check if a flag exists in
|
||||
// the PUBLIC_FEATURE_FLAGS array, so testing with one flag adequately covers this
|
||||
// functionality. Adding more flags wouldn't increase the test coverage meaningfully.
|
||||
|
||||
describe('publicFeatureFlagValidator', () => {
|
||||
describe('assertIsPublicFeatureFlag', () => {
|
||||
const mockException = new AuthException(
|
||||
'Not a public feature flag',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
|
||||
it('should not throw for public feature flags', () => {
|
||||
const publicFlag = mockPublicFeatureFlag.key as FeatureFlagKey;
|
||||
|
||||
expect(() => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
publicFlag,
|
||||
mockException,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw the provided exception for non-public feature flags', () => {
|
||||
const nonPublicFlag = 'MOCK_KEY_2' as FeatureFlagKey;
|
||||
|
||||
expect(() => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
nonPublicFlag,
|
||||
mockException,
|
||||
);
|
||||
}).toThrow(mockException);
|
||||
});
|
||||
|
||||
it('should throw the provided exception for undefined key', () => {
|
||||
expect(() => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
undefined as unknown as FeatureFlagKey,
|
||||
mockException,
|
||||
);
|
||||
}).toThrow(mockException);
|
||||
});
|
||||
|
||||
it('should throw the provided exception for null key', () => {
|
||||
expect(() => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
null as unknown as FeatureFlagKey,
|
||||
mockException,
|
||||
);
|
||||
}).toThrow(mockException);
|
||||
});
|
||||
|
||||
it('should maintain type assertion after validation', () => {
|
||||
const publicFlag = mockPublicFeatureFlag;
|
||||
|
||||
const testTypeAssertion = (flag: FeatureFlagKey) => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
flag,
|
||||
mockException,
|
||||
);
|
||||
const _test: PublicFeatureFlag['key'] = flag;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
expect(testTypeAssertion(publicFlag.key as FeatureFlagKey)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,19 @@
|
||||
import { PublicFeatureFlag } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { isPublicFeatureFlag } from 'src/engine/core-modules/lab/utils/is-public-feature-flag.util';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
const assertIsPublicFeatureFlag = (
|
||||
key: FeatureFlagKey,
|
||||
exceptionToThrow: CustomException,
|
||||
): asserts key is PublicFeatureFlag['key'] => {
|
||||
if (!isPublicFeatureFlag(key)) {
|
||||
throw exceptionToThrow;
|
||||
}
|
||||
};
|
||||
|
||||
export const publicFeatureFlagValidator: {
|
||||
assertIsPublicFeatureFlag: typeof assertIsPublicFeatureFlag;
|
||||
} = {
|
||||
assertIsPublicFeatureFlag: assertIsPublicFeatureFlag,
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsNotEmpty } from 'class-validator';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
@InputType()
|
||||
export class UpdateLabPublicFeatureFlagInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
publicFeatureFlag: FeatureFlagKey;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@IsBoolean()
|
||||
value: boolean;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { LabResolver } from './lab.resolver';
|
||||
|
||||
import { LabService } from './services/lab.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FeatureFlagEntity, Workspace], 'core')],
|
||||
providers: [LabService, LabResolver],
|
||||
exports: [LabService],
|
||||
})
|
||||
export class LabModule {}
|
||||
@ -0,0 +1,26 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { UpdateLabPublicFeatureFlagInput } from 'src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input';
|
||||
import { LabService } from 'src/engine/core-modules/lab/services/lab.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||
export class LabResolver {
|
||||
constructor(private labService: LabService) {}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Mutation(() => Boolean)
|
||||
async updateLabPublicFeatureFlag(
|
||||
@Args('input') input: UpdateLabPublicFeatureFlagInput,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<boolean> {
|
||||
await this.labService.updateLabPublicFeatureFlag(workspace.id, input);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import {
|
||||
FeatureFlagException,
|
||||
FeatureFlagExceptionCode,
|
||||
} from 'src/engine/core-modules/feature-flag/feature-flag.exception';
|
||||
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
|
||||
import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate';
|
||||
import { UpdateLabPublicFeatureFlagInput } from 'src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
|
||||
@Injectable()
|
||||
export class LabService {
|
||||
constructor(
|
||||
@InjectRepository(FeatureFlagEntity, 'core')
|
||||
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {}
|
||||
|
||||
async updateLabPublicFeatureFlag(
|
||||
workspaceId: string,
|
||||
payload: UpdateLabPublicFeatureFlagInput,
|
||||
): Promise<void> {
|
||||
featureFlagValidator.assertIsFeatureFlagKey(
|
||||
payload.publicFeatureFlag,
|
||||
new FeatureFlagException(
|
||||
'Invalid feature flag key',
|
||||
FeatureFlagExceptionCode.INVALID_FEATURE_FLAG_KEY,
|
||||
),
|
||||
);
|
||||
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
FeatureFlagKey[payload.publicFeatureFlag],
|
||||
new FeatureFlagException(
|
||||
'Feature flag is not public',
|
||||
FeatureFlagExceptionCode.FEATURE_FLAG_IS_NOT_PUBLIC,
|
||||
),
|
||||
);
|
||||
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: { id: workspaceId },
|
||||
relations: ['featureFlags'],
|
||||
});
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
new AuthException('Workspace not found', AuthExceptionCode.INVALID_INPUT),
|
||||
);
|
||||
|
||||
const existingFlag = workspace.featureFlags?.find(
|
||||
(flag) => flag.key === FeatureFlagKey[payload.publicFeatureFlag],
|
||||
);
|
||||
|
||||
if (!existingFlag) {
|
||||
throw new FeatureFlagException(
|
||||
'Public feature flag not found',
|
||||
FeatureFlagExceptionCode.FEATURE_FLAG_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.featureFlagRepository.update(existingFlag.id, {
|
||||
value: payload.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
import { isPublicFeatureFlag } from './is-public-feature-flag.util';
|
||||
|
||||
jest.mock(
|
||||
'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum',
|
||||
() => ({
|
||||
FeatureFlagKey: {
|
||||
mockKey1: 'MOCK_KEY_1',
|
||||
mockKey2: 'MOCK_KEY_2',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'src/engine/core-modules/feature-flag/constants/public-feature-flag.const',
|
||||
() => ({
|
||||
PUBLIC_FEATURE_FLAGS: [
|
||||
{
|
||||
key: 'MOCK_KEY_1',
|
||||
metadata: {
|
||||
label: 'Mock Label 1',
|
||||
description: 'Mock Description 1',
|
||||
imagePath: 'mock/path/1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
describe('isPublicFeatureFlag', () => {
|
||||
it('should return true for public flags', () => {
|
||||
const publicFlag = 'MOCK_KEY_1';
|
||||
|
||||
expect(isPublicFeatureFlag(publicFlag as FeatureFlagKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-public flags', () => {
|
||||
const nonPublicFlag = 'MOCK_KEY_2';
|
||||
|
||||
expect(isPublicFeatureFlag(nonPublicFlag as FeatureFlagKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined/null', () => {
|
||||
expect(isPublicFeatureFlag(undefined as any)).toBe(false);
|
||||
expect(isPublicFeatureFlag(null as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,15 @@
|
||||
import {
|
||||
PUBLIC_FEATURE_FLAGS,
|
||||
PublicFeatureFlag,
|
||||
} from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
export const isPublicFeatureFlag = (
|
||||
key: FeatureFlagKey,
|
||||
): key is PublicFeatureFlag['key'] => {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PUBLIC_FEATURE_FLAGS.some((flag) => flag.key === key);
|
||||
};
|
||||
@ -1,3 +1,5 @@
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { isPublicFeatureFlag } from 'src/engine/core-modules/lab/utils/is-public-feature-flag.util';
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
|
||||
export interface WorkspaceGateOptions {
|
||||
@ -5,6 +7,15 @@ export interface WorkspaceGateOptions {
|
||||
}
|
||||
|
||||
export function WorkspaceGate(options: WorkspaceGateOptions) {
|
||||
const flagKey = options.featureFlag as FeatureFlagKey;
|
||||
|
||||
if (isPublicFeatureFlag(flagKey)) {
|
||||
throw new Error(
|
||||
`Public feature flag "${flagKey}" cannot be used to gate entities. ` +
|
||||
'Public flags should not be used for entity gating as they can be toggled by users.',
|
||||
);
|
||||
}
|
||||
|
||||
return (target: any, propertyKey?: string | symbol) => {
|
||||
if (propertyKey !== undefined) {
|
||||
TypedReflect.defineMetadata(
|
||||
|
||||
@ -131,6 +131,7 @@ export {
|
||||
IconFilterCog,
|
||||
IconFilterOff,
|
||||
IconFlag,
|
||||
IconFlask,
|
||||
IconFocusCentered,
|
||||
IconFolder,
|
||||
IconFolderOpen,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 365 KiB |
Reference in New Issue
Block a user