nitin
2025-01-21 19:00:59 +05:30
committed by GitHub
parent 86b0a7952b
commit 50f36e345e
31 changed files with 710 additions and 6 deletions

View File

@ -179,6 +179,7 @@ export type ClientConfig = {
frontDomain: Scalars['String']; frontDomain: Scalars['String'];
isEmailVerificationRequired: Scalars['Boolean']; isEmailVerificationRequired: Scalars['Boolean'];
isMultiWorkspaceEnabled: Scalars['Boolean']; isMultiWorkspaceEnabled: Scalars['Boolean'];
publicFeatureFlags: Array<PublicFeatureFlag>;
sentry: Sentry; sentry: Sentry;
signInPrefilled: Scalars['Boolean']; signInPrefilled: Scalars['Boolean'];
support: Support; support: Support;
@ -530,6 +531,7 @@ export type Mutation = {
switchWorkspace: PublicWorkspaceDataOutput; switchWorkspace: PublicWorkspaceDataOutput;
track: Analytics; track: Analytics;
updateBillingSubscription: UpdateBillingEntity; updateBillingSubscription: UpdateBillingEntity;
updateLabPublicFeatureFlag: Scalars['Boolean'];
updateOneField: Field; updateOneField: Field;
updateOneObject: Object; updateOneObject: Object;
updateOneServerlessFunction: ServerlessFunction; updateOneServerlessFunction: ServerlessFunction;
@ -747,6 +749,11 @@ export type MutationTrackArgs = {
}; };
export type MutationUpdateLabPublicFeatureFlagArgs = {
input: UpdateLabPublicFeatureFlagInput;
};
export type MutationUpdateOneFieldArgs = { export type MutationUpdateOneFieldArgs = {
input: UpdateOneFieldMetadataInput; input: UpdateOneFieldMetadataInput;
}; };
@ -890,6 +897,19 @@ export type ProductPricesEntity = {
totalNumberOfPrices: Scalars['Int']; 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 = { export type PublicWorkspaceDataOutput = {
__typename?: 'PublicWorkspaceDataOutput'; __typename?: 'PublicWorkspaceDataOutput';
authProviders: AuthProviders; authProviders: AuthProviders;
@ -1358,6 +1378,11 @@ export type UpdateFieldInput = {
settings?: InputMaybe<Scalars['JSON']>; settings?: InputMaybe<Scalars['JSON']>;
}; };
export type UpdateLabPublicFeatureFlagInput = {
publicFeatureFlag: Scalars['String'];
value: Scalars['Boolean'];
};
export type UpdateObjectPayload = { export type UpdateObjectPayload = {
description?: InputMaybe<Scalars['String']>; description?: InputMaybe<Scalars['String']>;
icon?: 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 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; }>; 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 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<{ export type CreateOidcIdentityProviderMutationVariables = Exact<{
input: SetupOidcSsoInput; input: SetupOidcSsoInput;
}>; }>;
@ -3615,6 +3647,14 @@ export const GetClientConfigDocument = gql`
} }
chromeExtensionId chromeExtensionId
canManageFeatureFlags 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 UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>; export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>; 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` export const CreateOidcIdentityProviderDocument = gql`
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
createOIDCIdentityProvider(input: $input) { createOIDCIdentityProvider(input: $input) {

View File

@ -253,6 +253,12 @@ const SettingsAdminContent = lazy(() =>
), ),
); );
const SettingsLab = lazy(() =>
import('~/pages/settings/lab/SettingsLab').then((module) => ({
default: module.SettingsLab,
})),
);
type SettingsRoutesProps = { type SettingsRoutesProps = {
isBillingEnabled?: boolean; isBillingEnabled?: boolean;
isServerlessFunctionSettingsEnabled?: boolean; isServerlessFunctionSettingsEnabled?: boolean;
@ -379,6 +385,7 @@ export const SettingsRoutes = ({
/> />
</> </>
)} )}
<Route path={SettingsPath.Lab} element={<SettingsLab />} />
</Routes> </Routes>
</Suspense> </Suspense>
); );

View File

@ -10,6 +10,7 @@ import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState'; import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState'; import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
import { sentryConfigState } from '@/client-config/states/sentryConfigState'; import { sentryConfigState } from '@/client-config/states/sentryConfigState';
import { supportChatState } from '@/client-config/states/supportChatState'; import { supportChatState } from '@/client-config/states/supportChatState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
@ -52,6 +53,10 @@ export const ClientConfigProviderEffect = () => {
canManageFeatureFlagsState, canManageFeatureFlagsState,
); );
const setLabPublicFeatureFlags = useSetRecoilState(
labPublicFeatureFlagsState,
);
const { data, loading, error } = useGetClientConfigQuery({ const { data, loading, error } = useGetClientConfigQuery({
skip: clientConfigApiStatus.isLoaded, skip: clientConfigApiStatus.isLoaded,
}); });
@ -117,6 +122,7 @@ export const ClientConfigProviderEffect = () => {
frontDomain: data?.clientConfig?.frontDomain, frontDomain: data?.clientConfig?.frontDomain,
}); });
setCanManageFeatureFlags(data?.clientConfig?.canManageFeatureFlags); setCanManageFeatureFlags(data?.clientConfig?.canManageFeatureFlags);
setLabPublicFeatureFlags(data?.clientConfig?.publicFeatureFlags);
}, [ }, [
data, data,
setIsDebugMode, setIsDebugMode,
@ -136,6 +142,7 @@ export const ClientConfigProviderEffect = () => {
setDomainConfiguration, setDomainConfiguration,
setAuthProviders, setAuthProviders,
setCanManageFeatureFlags, setCanManageFeatureFlags,
setLabPublicFeatureFlags,
]); ]);
return <></>; return <></>;

View File

@ -48,6 +48,14 @@ export const GET_CLIENT_CONFIG = gql`
} }
chromeExtensionId chromeExtensionId
canManageFeatureFlags canManageFeatureFlags
publicFeatureFlags {
key
metadata {
label
description
imagePath
}
}
} }
} }
`; `;

View File

@ -0,0 +1,7 @@
import { atom } from 'recoil';
import { PublicFeatureFlag } from '~/generated/graphql';
export const labPublicFeatureFlagsState = atom<PublicFeatureFlag[]>({
key: 'labPublicFeatureFlagsState',
default: [],
});

View File

@ -8,6 +8,7 @@ import {
IconComponent, IconComponent,
IconCurrencyDollar, IconCurrencyDollar,
IconDoorEnter, IconDoorEnter,
IconFlask,
IconFunction, IconFunction,
IconHierarchy2, IconHierarchy2,
IconKey, IconKey,
@ -22,6 +23,7 @@ import {
import { useAuth } from '@/auth/hooks/useAuth'; import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState'; import { billingState } from '@/client-config/states/billingState';
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper'; import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem'; import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
@ -64,6 +66,7 @@ export const SettingsNavigationDrawerItems = () => {
const currentUser = useRecoilValue(currentUserState); const currentUser = useRecoilValue(currentUserState);
const isAdminPageEnabled = currentUser?.canImpersonate; const isAdminPageEnabled = currentUser?.canImpersonate;
const labPublicFeatureFlags = useRecoilValue(labPublicFeatureFlagsState);
// TODO: Refactor this part to only have arrays of navigation items // TODO: Refactor this part to only have arrays of navigation items
const currentPathName = useLocation().pathname; const currentPathName = useLocation().pathname;
@ -200,6 +203,13 @@ export const SettingsNavigationDrawerItems = () => {
Icon={IconServer} Icon={IconServer}
/> />
)} )}
{labPublicFeatureFlags?.length > 0 && (
<SettingsNavigationDrawerItem
label={t`Lab`}
path={SettingsPath.Lab}
Icon={IconFlask}
/>
)}
<SettingsNavigationDrawerItem <SettingsNavigationDrawerItem
label={t`Releases`} label={t`Releases`}
path={SettingsPath.Releases} path={SettingsPath.Releases}

View File

@ -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; margin-left: auto;
`; `;
@ -40,6 +44,7 @@ type SettingsOptionCardContentToggleProps = {
divider?: boolean; divider?: boolean;
disabled?: boolean; disabled?: boolean;
advancedMode?: boolean; advancedMode?: boolean;
toggleCentered?: boolean;
checked: boolean; checked: boolean;
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
}; };
@ -51,6 +56,7 @@ export const SettingsOptionCardContentToggle = ({
divider, divider,
disabled = false, disabled = false,
advancedMode = false, advancedMode = false,
toggleCentered = true,
checked, checked,
onChange, onChange,
}: SettingsOptionCardContentToggleProps) => { }: SettingsOptionCardContentToggleProps) => {
@ -83,6 +89,7 @@ export const SettingsOptionCardContentToggle = ({
disabled={disabled} disabled={disabled}
toggleSize="small" toggleSize="small"
color={advancedMode ? theme.color.yellow : theme.color.blue} color={advancedMode ? theme.color.yellow : theme.color.blue}
toggleCentered={toggleCentered}
/> />
</StyledSettingsOptionCardToggleContent> </StyledSettingsOptionCardToggleContent>
{divider && <Separator />} {divider && <Separator />}

View File

@ -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>
)
);
};

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const UPDATE_LAB_PUBLIC_FEATURE_FLAG = gql`
mutation UpdateLabPublicFeatureFlag(
$input: UpdateLabPublicFeatureFlagInput!
) {
updateLabPublicFeatureFlag(input: $input)
}
`;

View File

@ -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,
};
};

View File

@ -35,4 +35,5 @@ export enum SettingsPath {
Releases = 'releases', Releases = 'releases',
AdminPanel = 'admin-panel', AdminPanel = 'admin-panel',
FeatureFlags = 'admin-panel/feature-flags', FeatureFlags = 'admin-panel/feature-flags',
Lab = 'lab',
} }

View 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>
);
};

View File

@ -52,4 +52,5 @@ export const mockedClientConfig: ClientConfig = {
}, },
api: { mutationMaximumAffectedRecords: 100 }, api: { mutationMaximumAffectedRecords: 100 },
canManageFeatureFlags: true, canManageFeatureFlags: true,
publicFeatureFlags: [],
}; };

View File

@ -11,6 +11,10 @@ import {
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; 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 { 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 { 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 { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { userValidator } from 'src/engine/core-modules/user/user.validate';
@ -123,9 +127,9 @@ export class AdminPanelService {
) { ) {
featureFlagValidator.assertIsFeatureFlagKey( featureFlagValidator.assertIsFeatureFlagKey(
featureFlag, featureFlag,
new AuthException( new FeatureFlagException(
'Invalid feature flag key', 'Invalid feature flag key',
AuthExceptionCode.INVALID_INPUT, FeatureFlagExceptionCode.INVALID_FEATURE_FLAG_KEY,
), ),
); );

View File

@ -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 { TrialPeriodDTO } from 'src/engine/core-modules/billing/dto/trial-period.dto';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; 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'; import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
registerEnumType(FeatureFlagKey, {
name: 'FeatureFlagKey',
});
@ObjectType() @ObjectType()
class Billing { class Billing {
@Field(() => Boolean) @Field(() => Boolean)
@ -52,6 +57,27 @@ class ApiConfig {
mutationMaximumAffectedRecords: number; 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() @ObjectType()
export class ClientConfig { export class ClientConfig {
@Field(() => AuthProviders, { nullable: false }) @Field(() => AuthProviders, { nullable: false })
@ -98,4 +124,7 @@ export class ClientConfig {
@Field(() => Boolean) @Field(() => Boolean)
canManageFeatureFlags: boolean; canManageFeatureFlags: boolean;
@Field(() => [PublicFeatureFlag])
publicFeatureFlags: PublicFeatureFlag[];
} }

View File

@ -2,6 +2,7 @@ import { Query, Resolver } from '@nestjs/graphql';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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'; import { ClientConfig } from './client-config.entity';
@ -75,6 +76,7 @@ export class ClientConfigResolver {
canManageFeatureFlags: canManageFeatureFlags:
this.environmentService.get('DEBUG_MODE') || this.environmentService.get('DEBUG_MODE') ||
this.environmentService.get('IS_BILLING_ENABLED'), this.environmentService.get('IS_BILLING_ENABLED'),
publicFeatureFlags: PUBLIC_FEATURE_FLAGS,
}; };
return Promise.resolve(clientConfig); return Promise.resolve(clientConfig);

View File

@ -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 { 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 { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { HealthModule } from 'src/engine/core-modules/health/health.module'; 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 { 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 { 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'; import { LLMTracingModule } from 'src/engine/core-modules/llm-tracing/llm-tracing.module';
@ -72,6 +73,7 @@ import { FileModule } from './file/file.module';
ActorModule, ActorModule,
TelemetryModule, TelemetryModule,
AdminPanelModule, AdminPanelModule,
LabModule,
EnvironmentModule.forRoot({}), EnvironmentModule.forRoot({}),
RedisClientModule, RedisClientModule,
FileStorageModule.forRootAsync({ FileStorageModule.forRootAsync({

View File

@ -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[] = [];

View File

@ -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',
}

View File

@ -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 { 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'; import { isDefined } from 'src/utils/is-defined';
const assertIsFeatureFlagKey = ( const assertIsFeatureFlagKey = (

View File

@ -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);
});
});
});

View File

@ -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,
};

View File

@ -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;
}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -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,
});
}
}

View File

@ -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);
});
});

View File

@ -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);
};

View File

@ -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'; import { TypedReflect } from 'src/utils/typed-reflect';
export interface WorkspaceGateOptions { export interface WorkspaceGateOptions {
@ -5,6 +7,15 @@ export interface WorkspaceGateOptions {
} }
export function WorkspaceGate(options: 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) => { return (target: any, propertyKey?: string | symbol) => {
if (propertyKey !== undefined) { if (propertyKey !== undefined) {
TypedReflect.defineMetadata( TypedReflect.defineMetadata(

View File

@ -131,6 +131,7 @@ export {
IconFilterCog, IconFilterCog,
IconFilterOff, IconFilterOff,
IconFlag, IconFlag,
IconFlask,
IconFocusCentered, IconFocusCentered,
IconFolder, IconFolder,
IconFolderOpen, IconFolderOpen,

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB