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
48 changes: 46 additions & 2 deletions packages/twenty-front/src/generated/graphql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ export type Query = {
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
getAvailablePackages: Scalars['JSON'];
getFeatureFlagManagementCapability: Scalars['Boolean'];
getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: ProductPricesEntity;
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
Expand Down Expand Up @@ -1568,10 +1569,16 @@ export type WorkspaceEdge = {
node: Workspace;
};

export type WorkspaceFeatureFlag = {
__typename?: 'WorkspaceFeatureFlag';
key: Scalars['String'];
value: Scalars['Boolean'];
};

export type WorkspaceInfo = {
__typename?: 'WorkspaceInfo';
allowImpersonation: Scalars['Boolean'];
featureFlags: Array<FeatureFlag>;
featureFlags: Array<WorkspaceFeatureFlag>;
id: Scalars['String'];
logo?: Maybe<Scalars['String']>;
name: Scalars['String'];
Expand Down Expand Up @@ -2102,7 +2109,12 @@ 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?: 'WorkspaceFeatureFlag', key: string, value: boolean }> }> } };

export type GetFeatureFlagManagementCapabilityQueryVariables = Exact<{ [key: string]: never; }>;


export type GetFeatureFlagManagementCapabilityQuery = { __typename?: 'Query', getFeatureFlagManagementCapability: boolean };

export type CreateOidcIdentityProviderMutationVariables = Exact<{
input: SetupOidcSsoInput;
Expand Down Expand Up @@ -3668,6 +3680,38 @@ export function useUserLookupAdminPanelMutation(baseOptions?: Apollo.MutationHoo
export type UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
export const GetFeatureFlagManagementCapabilityDocument = gql`
query GetFeatureFlagManagementCapability {
getFeatureFlagManagementCapability
}
`;

/**
* __useGetFeatureFlagManagementCapabilityQuery__
*
* To run a query within a React component, call `useGetFeatureFlagManagementCapabilityQuery` and pass it any options that fit your needs.
* When your component renders, `useGetFeatureFlagManagementCapabilityQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetFeatureFlagManagementCapabilityQuery({
* variables: {
* },
* });
*/
export function useGetFeatureFlagManagementCapabilityQuery(baseOptions?: Apollo.QueryHookOptions<GetFeatureFlagManagementCapabilityQuery, GetFeatureFlagManagementCapabilityQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetFeatureFlagManagementCapabilityQuery, GetFeatureFlagManagementCapabilityQueryVariables>(GetFeatureFlagManagementCapabilityDocument, options);
}
export function useGetFeatureFlagManagementCapabilityLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetFeatureFlagManagementCapabilityQuery, GetFeatureFlagManagementCapabilityQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetFeatureFlagManagementCapabilityQuery, GetFeatureFlagManagementCapabilityQueryVariables>(GetFeatureFlagManagementCapabilityDocument, options);
}
export type GetFeatureFlagManagementCapabilityQueryHookResult = ReturnType<typeof useGetFeatureFlagManagementCapabilityQuery>;
export type GetFeatureFlagManagementCapabilityLazyQueryHookResult = ReturnType<typeof useGetFeatureFlagManagementCapabilityLazyQuery>;
export type GetFeatureFlagManagementCapabilityQueryResult = Apollo.QueryResult<GetFeatureFlagManagementCapabilityQuery, GetFeatureFlagManagementCapabilityQueryVariables>;
export const CreateOidcIdentityProviderDocument = gql`
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
createOIDCIdentityProvider(input: $input) {
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,5 +1,8 @@
import { SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID } from '@/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs';
import { useFeatureFlagManagementCapability } from '@/settings/admin-panel/hooks/useFeatureFlagManagementCapability';
import { useFeatureFlagsManagement } from '@/settings/admin-panel/hooks/useFeatureFlagsManagement';
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
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 @@ -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,13 @@ export const SettingsAdminContent = () => {
error,
} = useFeatureFlagsManagement();

const { canManageFeatureFlags, isLoading: isLoadingCapability } =
useFeatureFlagManagementCapability();

if (isLoadingCapability) {
return <SettingsSkeletonLoader />;
}

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

Expand Down Expand Up @@ -151,37 +160,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 +201,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 it.'
: 'Look up users to impersonate them.'
ehconitin marked this conversation as resolved.
Show resolved Hide resolved
}
/>

<StyledContainer>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';

export const GET_FEATURE_FLAG_MANAGEMENT_CAPABILITY = gql`
query GetFeatureFlagManagementCapability {
getFeatureFlagManagementCapability
ehconitin marked this conversation as resolved.
Show resolved Hide resolved
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useQuery } from '@apollo/client';
import { GET_FEATURE_FLAG_MANAGEMENT_CAPABILITY } from '../graphql/queries/getFeatureFlagManagementCapability';

export const useFeatureFlagManagementCapability = () => {
const { data, loading } = useQuery(GET_FEATURE_FLAG_MANAGEMENT_CAPABILITY);
ehconitin marked this conversation as resolved.
Show resolved Hide resolved

return {
canManageFeatureFlags: data?.getFeatureFlagManagementCapability ?? false,
isLoading: loading,
};
};
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
Loading
Loading