Feature flags env variable gating (#9481)
closes #9032 --------- Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
This commit is contained in:
@ -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'];
|
||||
@ -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; }>;
|
||||
|
||||
@ -3514,6 +3515,7 @@ export const GetClientConfigDocument = gql`
|
||||
mutationMaximumAffectedRecords
|
||||
}
|
||||
chromeExtensionId
|
||||
canManageFeatureFlags
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -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 (isDefined(errorMessage)) {
|
||||
enqueueSnackBar(errorMessage, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isLogged) {
|
||||
getTokens();
|
||||
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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -268,6 +268,8 @@ export const useAuth = () => {
|
||||
|
||||
const handleVerify = useCallback(
|
||||
async (loginToken: string) => {
|
||||
setIsVerifyPendingState(true);
|
||||
|
||||
const verifyResult = await verify({
|
||||
variables: { loginToken },
|
||||
});
|
||||
@ -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(
|
||||
@ -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 () => {
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
@ -45,6 +46,10 @@ export const ClientConfigProviderEffect = () => {
|
||||
|
||||
const setApiConfig = useSetRecoilState(apiConfigState);
|
||||
|
||||
const setCanManageFeatureFlags = useSetRecoilState(
|
||||
canManageFeatureFlagsState,
|
||||
);
|
||||
|
||||
const { data, loading, error } = useGetClientConfigQuery({
|
||||
skip: clientConfigApiStatus.isLoaded,
|
||||
});
|
||||
@ -107,6 +112,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
defaultSubdomain: data?.clientConfig?.defaultSubdomain,
|
||||
frontDomain: data?.clientConfig?.frontDomain,
|
||||
});
|
||||
setCanManageFeatureFlags(data?.clientConfig?.canManageFeatureFlags);
|
||||
}, [
|
||||
data,
|
||||
setIsDebugMode,
|
||||
@ -125,6 +131,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
setDomainConfiguration,
|
||||
setIsSSOEnabledState,
|
||||
setAuthProviders,
|
||||
setCanManageFeatureFlags,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
|
||||
@ -44,6 +44,7 @@ export const GET_CLIENT_CONFIG = gql`
|
||||
mutationMaximumAffectedRecords
|
||||
}
|
||||
chromeExtensionId
|
||||
canManageFeatureFlags
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const canManageFeatureFlagsState = createState<boolean>({
|
||||
key: 'canManageFeatureFlagsState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -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';
|
||||
@ -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,
|
||||
@ -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)};
|
||||
@ -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`
|
||||
@ -87,6 +89,8 @@ export const SettingsAdminContent = () => {
|
||||
error,
|
||||
} = useFeatureFlagsManagement();
|
||||
|
||||
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
|
||||
|
||||
const handleSearch = async () => {
|
||||
setActiveTabId('');
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -40,4 +40,5 @@ export const mockedClientConfig: ClientConfig = {
|
||||
__typename: 'Captcha',
|
||||
},
|
||||
api: { mutationMaximumAffectedRecords: 100 },
|
||||
canManageFeatureFlags: true,
|
||||
};
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 { 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 { 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()
|
||||
export class AdminPanelService {
|
||||
|
||||
@ -94,4 +94,7 @@ export class ClientConfig {
|
||||
|
||||
@Field(() => ApiConfig)
|
||||
api: ApiConfig;
|
||||
|
||||
@Field(() => Boolean)
|
||||
canManageFeatureFlags: boolean;
|
||||
}
|
||||
|
||||
@ -59,6 +59,9 @@ export class ClientConfigResolver {
|
||||
),
|
||||
},
|
||||
analyticsEnabled: this.environmentService.get('ANALYTICS_ENABLED'),
|
||||
canManageFeatureFlags:
|
||||
this.environmentService.get('DEBUG_MODE') ||
|
||||
this.environmentService.get('IS_BILLING_ENABLED'),
|
||||
};
|
||||
|
||||
return Promise.resolve(clientConfig);
|
||||
|
||||
Reference in New Issue
Block a user