Skip to content

Commit

Permalink
Feature flags env variable gating (#9481)
Browse files Browse the repository at this point in the history
closes #9032

---------

Co-authored-by: Antoine Moreaux <[email protected]>
  • Loading branch information
ehconitin and AMoreaux authored Jan 10, 2025
1 parent 75bf9e3 commit ddcb3df
Show file tree
Hide file tree
Showing 14 changed files with 112 additions and 88 deletions.
4 changes: 3 additions & 1 deletion packages/twenty-front/src/generated/graphql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export type ClientConfig = {
api: ApiConfig;
authProviders: AuthProviders;
billing: Billing;
canManageFeatureFlags: Scalars['Boolean'];
captcha: Captcha;
chromeExtensionId?: Maybe<Scalars['String']>;
debugMode: Scalars['Boolean'];
Expand Down Expand Up @@ -2081,7 +2082,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, 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; }>;

Expand Down Expand Up @@ -3514,6 +3515,7 @@ export const GetClientConfigDocument = gql`
mutationMaximumAffectedRecords
}
chromeExtensionId
canManageFeatureFlags
}
}
`;
Expand Down
27 changes: 11 additions & 16 deletions packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,17 @@ export const VerifyEffect = () => {
);

useEffect(() => {
const getTokens = async () => {
if (isDefined(errorMessage)) {
enqueueSnackBar(errorMessage, {
variant: SnackBarVariant.Error,
});
}
if (!loginToken) {
navigate(AppPath.SignInUp);
} else {
setIsAppWaitingForFreshObjectMetadata(true);
await verify(loginToken);
}
};

if (!isLogged) {
getTokens();
if (isDefined(errorMessage)) {
enqueueSnackBar(errorMessage, {
variant: SnackBarVariant.Error,
});
}

if (isDefined(loginToken)) {
setIsAppWaitingForFreshObjectMetadata(true);
verify(loginToken);
} else if (!isLogged) {
navigate(AppPath.SignInUp);
}
// Verify only needs to run once at mount
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,7 @@ describe('useAuth', () => {
const { result } = renderHooks();

await act(async () => {
const res = await result.current.signUpWithCredentials(email, password);
expect(res).toHaveProperty('user');
expect(res).toHaveProperty('workspaceMember');
expect(res).toHaveProperty('workspace');
await result.current.signUpWithCredentials(email, password);
});

expect(mocks[2].result).toHaveBeenCalled();
Expand Down
37 changes: 8 additions & 29 deletions packages/twenty-front/src/modules/auth/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ export const useAuth = () => {

const handleVerify = useCallback(
async (loginToken: string) => {
setIsVerifyPendingState(true);

const verifyResult = await verify({
variables: { loginToken },
});
Expand All @@ -282,16 +284,11 @@ export const useAuth = () => {

setTokenPair(verifyResult.data?.verify.tokens);

const { user, workspaceMember, workspace } = await loadCurrentUser();
await loadCurrentUser();

return {
user,
workspaceMember,
workspace,
tokens: verifyResult.data?.verify.tokens,
};
setIsVerifyPendingState(false);
},
[verify, setTokenPair, loadCurrentUser],
[setIsVerifyPendingState, verify, setTokenPair, loadCurrentUser],
);

const handleCrendentialsSignIn = useCallback(
Expand All @@ -301,21 +298,9 @@ export const useAuth = () => {
password,
captchaToken,
);
setIsVerifyPendingState(true);

const { user, workspaceMember, workspace } = await handleVerify(
loginToken.token,
);

setIsVerifyPendingState(false);

return {
user,
workspaceMember,
workspace,
};
await handleVerify(loginToken.token);
},
[handleChallenge, handleVerify, setIsVerifyPendingState],
[handleChallenge, handleVerify],
);

const handleSignOut = useCallback(async () => {
Expand Down Expand Up @@ -360,13 +345,7 @@ export const useAuth = () => {
);
}

const { user, workspace, workspaceMember } = await handleVerify(
signUpResult.data?.signUp.loginToken.token,
);

setIsVerifyPendingState(false);

return { user, workspaceMember, workspace };
await handleVerify(signUpResult.data?.signUp.loginToken.token);
},
[
setIsVerifyPendingState,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
Expand Down Expand Up @@ -45,6 +46,10 @@ export const ClientConfigProviderEffect = () => {

const setApiConfig = useSetRecoilState(apiConfigState);

const setCanManageFeatureFlags = useSetRecoilState(
canManageFeatureFlagsState,
);

const { data, loading, error } = useGetClientConfigQuery({
skip: clientConfigApiStatus.isLoaded,
});
Expand Down Expand Up @@ -107,6 +112,7 @@ export const ClientConfigProviderEffect = () => {
defaultSubdomain: data?.clientConfig?.defaultSubdomain,
frontDomain: data?.clientConfig?.frontDomain,
});
setCanManageFeatureFlags(data?.clientConfig?.canManageFeatureFlags);
}, [
data,
setIsDebugMode,
Expand All @@ -125,6 +131,7 @@ export const ClientConfigProviderEffect = () => {
setDomainConfiguration,
setIsSSOEnabledState,
setAuthProviders,
setCanManageFeatureFlags,
]);

return <></>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const GET_CLIENT_CONFIG = gql`
mutationMaximumAffectedRecords
}
chromeExtensionId
canManageFeatureFlags
}
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';

export const canManageFeatureFlagsState = createState<boolean>({
key: 'canManageFeatureFlagsState',
defaultValue: false,
});
Original file line number Diff line number Diff line change
@@ -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 { useFeatureFlagsManagement } from '@/settings/admin-panel/hooks/useFeatureFlagsManagement';
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
import { TextInput } from '@/ui/input/components/TextInput';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
Expand All @@ -11,6 +13,7 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { getImageAbsoluteURI } from 'twenty-shared';
import {
Button,
Expand All @@ -24,7 +27,6 @@ import {
Toggle,
} from 'twenty-ui';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';

const StyledLinkContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(2)};
Expand All @@ -47,7 +49,7 @@ const StyledUserInfo = styled.div`
`;

const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(0.5)};
margin-top: ${({ theme }) => theme.spacing(3)};
`;

const StyledTabListContainer = styled.div`
Expand Down Expand Up @@ -87,6 +89,8 @@ export const SettingsAdminContent = () => {
error,
} = useFeatureFlagsManagement();

const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);

const handleSearch = async () => {
setActiveTabId('');

Expand Down Expand Up @@ -151,37 +155,39 @@ export const SettingsAdminContent = () => {
/>
)}

<StyledTable>
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
>
<TableHeader>Feature Flag</TableHeader>
<TableHeader align="right">Status</TableHeader>
</TableRow>

{activeWorkspace.featureFlags.map((flag) => (
{canManageFeatureFlags && (
<StyledTable>
<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>
<TableHeader>Feature Flag</TableHeader>
<TableHeader align="right">Status</TableHeader>
</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>
)}
</>
);
};
Expand All @@ -190,8 +196,16 @@ export const SettingsAdminContent = () => {
<>
<Section>
<H2Title
title="Feature Flags & Impersonation"
description="Look up users and manage their workspace feature flags or impersonate it."
title={
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>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { currentUserState } from '@/auth/states/currentUserState';
import { AppPath } from '@/types/AppPath';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useImpersonateMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
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 = () => {
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 [isLoading, setIsLoading] = useState(false);
Expand Down Expand Up @@ -39,6 +48,13 @@ export const useImpersonate = () => {

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, {
loginToken: loginToken.token,
});
Expand Down
1 change: 1 addition & 0 deletions packages/twenty-front/src/testing/mock-data/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ export const mockedClientConfig: ClientConfig = {
__typename: 'Captcha',
},
api: { mutationMaximumAffectedRecords: 100 },
canManageFeatureFlags: true,
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { Args, Mutation, Resolver } from '@nestjs/graphql';

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 { 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 { 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 { 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 { 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()
@UseFilters(AuthGraphqlApiExceptionFilter)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import {
AuthException,
AuthExceptionCode,
} 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 { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
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';

@Injectable()
export class AdminPanelService {
Expand Down
Loading

0 comments on commit ddcb3df

Please sign in to comment.