Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature flags env variable gating #9481

Merged
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to give a bit more context in your PR description when you introduce something like that as it doesn't directly relate to the original issue / I'm not sure exactly what problem we're solving here (race condition / dual execution?). Seems similar to topics discussed here: #9288

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two commits come from this PR #9451

It allows impersonation on the same workspace.

verify(loginToken);
} else if (!isLogged) {
Comment on lines +36 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: verify() returns a Promise but it's not being awaited. This could lead to race conditions if the verification fails and the component unmounts before completion.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: setIsVerifyPendingState(true) should be wrapped in try/catch to ensure it gets set back to false if verification fails


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
Loading