Feature flags env variable gating (#9481)

closes #9032

---------

Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
This commit is contained in:
nitin
2025-01-10 18:34:00 +05:30
committed by GitHub
parent 75bf9e3c69
commit ddcb3dfd28
14 changed files with 113 additions and 89 deletions

View File

@ -171,6 +171,7 @@ export type ClientConfig = {
api: ApiConfig; api: ApiConfig;
authProviders: AuthProviders; authProviders: AuthProviders;
billing: Billing; billing: Billing;
canManageFeatureFlags: Scalars['Boolean'];
captcha: Captcha; captcha: Captcha;
chromeExtensionId?: Maybe<Scalars['String']>; chromeExtensionId?: Maybe<Scalars['String']>;
debugMode: Scalars['Boolean']; debugMode: Scalars['Boolean'];
@ -2081,7 +2082,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, isSSOEnabled: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, 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, isSSOEnabled: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, 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 SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
@ -3514,6 +3515,7 @@ export const GetClientConfigDocument = gql`
mutationMaximumAffectedRecords mutationMaximumAffectedRecords
} }
chromeExtensionId chromeExtensionId
canManageFeatureFlags
} }
} }
`; `;

View File

@ -27,22 +27,17 @@ export const VerifyEffect = () => {
); );
useEffect(() => { useEffect(() => {
const getTokens = async () => { if (isDefined(errorMessage)) {
if (isDefined(errorMessage)) { enqueueSnackBar(errorMessage, {
enqueueSnackBar(errorMessage, { variant: SnackBarVariant.Error,
variant: SnackBarVariant.Error, });
}); }
}
if (!loginToken) {
navigate(AppPath.SignInUp);
} else {
setIsAppWaitingForFreshObjectMetadata(true);
await verify(loginToken);
}
};
if (!isLogged) { if (isDefined(loginToken)) {
getTokens(); setIsAppWaitingForFreshObjectMetadata(true);
verify(loginToken);
} else if (!isLogged) {
navigate(AppPath.SignInUp);
} }
// Verify only needs to run once at mount // Verify only needs to run once at mount
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -141,10 +141,7 @@ describe('useAuth', () => {
const { result } = renderHooks(); const { result } = renderHooks();
await act(async () => { await act(async () => {
const res = await result.current.signUpWithCredentials(email, password); await result.current.signUpWithCredentials(email, password);
expect(res).toHaveProperty('user');
expect(res).toHaveProperty('workspaceMember');
expect(res).toHaveProperty('workspace');
}); });
expect(mocks[2].result).toHaveBeenCalled(); expect(mocks[2].result).toHaveBeenCalled();

View File

@ -268,6 +268,8 @@ export const useAuth = () => {
const handleVerify = useCallback( const handleVerify = useCallback(
async (loginToken: string) => { async (loginToken: string) => {
setIsVerifyPendingState(true);
const verifyResult = await verify({ const verifyResult = await verify({
variables: { loginToken }, variables: { loginToken },
}); });
@ -282,16 +284,11 @@ export const useAuth = () => {
setTokenPair(verifyResult.data?.verify.tokens); setTokenPair(verifyResult.data?.verify.tokens);
const { user, workspaceMember, workspace } = await loadCurrentUser(); await loadCurrentUser();
return { setIsVerifyPendingState(false);
user,
workspaceMember,
workspace,
tokens: verifyResult.data?.verify.tokens,
};
}, },
[verify, setTokenPair, loadCurrentUser], [setIsVerifyPendingState, verify, setTokenPair, loadCurrentUser],
); );
const handleCrendentialsSignIn = useCallback( const handleCrendentialsSignIn = useCallback(
@ -301,21 +298,9 @@ export const useAuth = () => {
password, password,
captchaToken, captchaToken,
); );
setIsVerifyPendingState(true); await handleVerify(loginToken.token);
const { user, workspaceMember, workspace } = await handleVerify(
loginToken.token,
);
setIsVerifyPendingState(false);
return {
user,
workspaceMember,
workspace,
};
}, },
[handleChallenge, handleVerify, setIsVerifyPendingState], [handleChallenge, handleVerify],
); );
const handleSignOut = useCallback(async () => { const handleSignOut = useCallback(async () => {
@ -360,13 +345,7 @@ export const useAuth = () => {
); );
} }
const { user, workspace, workspaceMember } = await handleVerify( await handleVerify(signUpResult.data?.signUp.loginToken.token);
signUpResult.data?.signUp.loginToken.token,
);
setIsVerifyPendingState(false);
return { user, workspaceMember, workspace };
}, },
[ [
setIsVerifyPendingState, setIsVerifyPendingState,

View File

@ -1,6 +1,7 @@
import { apiConfigState } from '@/client-config/states/apiConfigState'; import { apiConfigState } from '@/client-config/states/apiConfigState';
import { authProvidersState } from '@/client-config/states/authProvidersState'; import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState'; import { billingState } from '@/client-config/states/billingState';
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState'; import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState'; import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
@ -45,6 +46,10 @@ export const ClientConfigProviderEffect = () => {
const setApiConfig = useSetRecoilState(apiConfigState); const setApiConfig = useSetRecoilState(apiConfigState);
const setCanManageFeatureFlags = useSetRecoilState(
canManageFeatureFlagsState,
);
const { data, loading, error } = useGetClientConfigQuery({ const { data, loading, error } = useGetClientConfigQuery({
skip: clientConfigApiStatus.isLoaded, skip: clientConfigApiStatus.isLoaded,
}); });
@ -107,6 +112,7 @@ export const ClientConfigProviderEffect = () => {
defaultSubdomain: data?.clientConfig?.defaultSubdomain, defaultSubdomain: data?.clientConfig?.defaultSubdomain,
frontDomain: data?.clientConfig?.frontDomain, frontDomain: data?.clientConfig?.frontDomain,
}); });
setCanManageFeatureFlags(data?.clientConfig?.canManageFeatureFlags);
}, [ }, [
data, data,
setIsDebugMode, setIsDebugMode,
@ -125,6 +131,7 @@ export const ClientConfigProviderEffect = () => {
setDomainConfiguration, setDomainConfiguration,
setIsSSOEnabledState, setIsSSOEnabledState,
setAuthProviders, setAuthProviders,
setCanManageFeatureFlags,
]); ]);
return <></>; return <></>;

View File

@ -44,6 +44,7 @@ export const GET_CLIENT_CONFIG = gql`
mutationMaximumAffectedRecords mutationMaximumAffectedRecords
} }
chromeExtensionId chromeExtensionId
canManageFeatureFlags
} }
} }
`; `;

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const canManageFeatureFlagsState = createState<boolean>({
key: 'canManageFeatureFlagsState',
defaultValue: false,
});

View File

@ -1,5 +1,7 @@
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID } from '@/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs'; import { SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID } from '@/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs';
import { useFeatureFlagsManagement } from '@/settings/admin-panel/hooks/useFeatureFlagsManagement'; import { useFeatureFlagsManagement } from '@/settings/admin-panel/hooks/useFeatureFlagsManagement';
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { TabList } from '@/ui/layout/tab/components/TabList'; import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
@ -11,6 +13,7 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react'; import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { getImageAbsoluteURI } from 'twenty-shared'; import { getImageAbsoluteURI } from 'twenty-shared';
import { import {
Button, Button,
@ -24,7 +27,6 @@ import {
Toggle, Toggle,
} from 'twenty-ui'; } from 'twenty-ui';
import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
const StyledLinkContainer = styled.div` const StyledLinkContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(2)}; margin-right: ${({ theme }) => theme.spacing(2)};
@ -47,7 +49,7 @@ const StyledUserInfo = styled.div`
`; `;
const StyledTable = styled(Table)` const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(0.5)}; margin-top: ${({ theme }) => theme.spacing(3)};
`; `;
const StyledTabListContainer = styled.div` const StyledTabListContainer = styled.div`
@ -87,6 +89,8 @@ export const SettingsAdminContent = () => {
error, error,
} = useFeatureFlagsManagement(); } = useFeatureFlagsManagement();
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
const handleSearch = async () => { const handleSearch = async () => {
setActiveTabId(''); setActiveTabId('');
@ -151,37 +155,39 @@ export const SettingsAdminContent = () => {
/> />
)} )}
<StyledTable> {canManageFeatureFlags && (
<TableRow <StyledTable>
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
>
<TableHeader>Feature Flag</TableHeader>
<TableHeader align="right">Status</TableHeader>
</TableRow>
{activeWorkspace.featureFlags.map((flag) => (
<TableRow <TableRow
gridAutoColumns="1fr 100px" gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px" mobileGridAutoColumns="1fr 80px"
key={flag.key}
> >
<TableCell>{flag.key}</TableCell> <TableHeader>Feature Flag</TableHeader>
<TableCell align="right"> <TableHeader align="right">Status</TableHeader>
<Toggle
value={flag.value}
onChange={(newValue) =>
handleFeatureFlagUpdate(
activeWorkspace.id,
flag.key,
newValue,
)
}
/>
</TableCell>
</TableRow> </TableRow>
))}
</StyledTable> {activeWorkspace.featureFlags.map((flag) => (
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
key={flag.key}
>
<TableCell>{flag.key}</TableCell>
<TableCell align="right">
<Toggle
value={flag.value}
onChange={(newValue) =>
handleFeatureFlagUpdate(
activeWorkspace.id,
flag.key,
newValue,
)
}
/>
</TableCell>
</TableRow>
))}
</StyledTable>
)}
</> </>
); );
}; };
@ -190,8 +196,16 @@ export const SettingsAdminContent = () => {
<> <>
<Section> <Section>
<H2Title <H2Title
title="Feature Flags & Impersonation" title={
description="Look up users and manage their workspace feature flags or impersonate it." canManageFeatureFlags
? 'Feature Flags & Impersonation'
: 'User Impersonation'
}
description={
canManageFeatureFlags
? 'Look up users and manage their workspace feature flags or impersonate them.'
: 'Look up users to impersonate them.'
}
/> />
<StyledContainer> <StyledContainer>

View File

@ -1,15 +1,24 @@
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useState } from 'react'; import { useState } from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useImpersonateMutation } from '~/generated/graphql'; import { useImpersonateMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useAuth } from '@/auth/hooks/useAuth';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
export const useImpersonate = () => { export const useImpersonate = () => {
const [currentUser] = useRecoilState(currentUserState); const [currentUser] = useRecoilState(currentUserState);
const [impersonate] = useImpersonateMutation(); const currentWorkspace = useRecoilValue(currentWorkspaceState);
const setIsAppWaitingForFreshObjectMetadata = useSetRecoilState(
isAppWaitingForFreshObjectMetadataState,
);
const { verify } = useAuth();
const [impersonate] = useImpersonateMutation();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -39,6 +48,13 @@ export const useImpersonate = () => {
const { loginToken, workspace } = impersonateResult.data.impersonate; const { loginToken, workspace } = impersonateResult.data.impersonate;
if (workspace.id === currentWorkspace?.id) {
setIsAppWaitingForFreshObjectMetadata(true);
await verify(loginToken.token);
setIsAppWaitingForFreshObjectMetadata(false);
return;
}
return redirectToWorkspaceDomain(workspace.subdomain, AppPath.Verify, { return redirectToWorkspaceDomain(workspace.subdomain, AppPath.Verify, {
loginToken: loginToken.token, loginToken: loginToken.token,
}); });

View File

@ -40,4 +40,5 @@ export const mockedClientConfig: ClientConfig = {
__typename: 'Captcha', __typename: 'Captcha',
}, },
api: { mutationMaximumAffectedRecords: 100 }, api: { mutationMaximumAffectedRecords: 100 },
canManageFeatureFlags: true,
}; };

View File

@ -3,15 +3,15 @@ import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service'; import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input'; import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input'; import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity'; import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input'; import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@Resolver() @Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter) @UseFilters(AuthGraphqlApiExceptionFilter)

View File

@ -8,14 +8,14 @@ import {
AuthException, AuthException,
AuthExceptionCode, AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
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 { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
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 { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
@Injectable() @Injectable()
export class AdminPanelService { export class AdminPanelService {

View File

@ -94,4 +94,7 @@ export class ClientConfig {
@Field(() => ApiConfig) @Field(() => ApiConfig)
api: ApiConfig; api: ApiConfig;
@Field(() => Boolean)
canManageFeatureFlags: boolean;
} }

View File

@ -59,6 +59,9 @@ export class ClientConfigResolver {
), ),
}, },
analyticsEnabled: this.environmentService.get('ANALYTICS_ENABLED'), analyticsEnabled: this.environmentService.get('ANALYTICS_ENABLED'),
canManageFeatureFlags:
this.environmentService.get('DEBUG_MODE') ||
this.environmentService.get('IS_BILLING_ENABLED'),
}; };
return Promise.resolve(clientConfig); return Promise.resolve(clientConfig);