refacto(*): remove everything about default workspace (#9157)
## Summary - [x] Remove defaultWorkspace in user - [x] Remove all occurrence of defaultWorkspace and defaultWorkspaceId - [x] Improve activate workspace flow - [x] Improve security on social login - [x] Add `ImpersonateGuard` - [x] Allow to use impersonation with couple `User/Workspace` - [x] Prevent unexpected reload on activate workspace - [x] Scope login token with workspaceId Fix https://github.com/twentyhq/twenty/issues/9033#event-15714863042
This commit is contained in:
@ -254,10 +254,10 @@ const SettingsAdmin = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsAdminFeatureFlags = lazy(() =>
|
||||
import('~/pages/settings/admin-panel/SettingsAdminFeatureFlags').then(
|
||||
const SettingsAdminContent = lazy(() =>
|
||||
import('@/settings/admin-panel/components/SettingsAdminContent').then(
|
||||
(module) => ({
|
||||
default: module.SettingsAdminFeatureFlags,
|
||||
default: module.SettingsAdminContent,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@ -402,7 +402,7 @@ export const SettingsRoutes = ({
|
||||
<Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} />
|
||||
<Route
|
||||
path={SettingsPath.FeatureFlags}
|
||||
element={<SettingsAdminFeatureFlags />}
|
||||
element={<SettingsAdminContent />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -2,13 +2,14 @@ import { gql } from '@apollo/client';
|
||||
|
||||
// TODO: Fragments should be used instead of duplicating the user fields !
|
||||
export const IMPERSONATE = gql`
|
||||
mutation Impersonate($userId: String!) {
|
||||
impersonate(userId: $userId) {
|
||||
user {
|
||||
...UserQueryFragment
|
||||
mutation Impersonate($userId: String!, $workspaceId: String!) {
|
||||
impersonate(userId: $userId, workspaceId: $workspaceId) {
|
||||
workspace {
|
||||
subdomain
|
||||
id
|
||||
}
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,10 @@ export const SIGN_UP = gql`
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
workspace {
|
||||
id
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -3,9 +3,6 @@ import { gql } from '@apollo/client';
|
||||
export const VERIFY = gql`
|
||||
mutation Verify($loginToken: String!) {
|
||||
verify(loginToken: $loginToken) {
|
||||
user {
|
||||
...UserQueryFragment
|
||||
}
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ export const CHECK_USER_EXISTS = gql`
|
||||
__typename
|
||||
... on UserExists {
|
||||
exists
|
||||
defaultWorkspaceId
|
||||
availableWorkspaces {
|
||||
id
|
||||
displayName
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
ChallengeDocument,
|
||||
GetCurrentUserDocument,
|
||||
SignUpDocument,
|
||||
VerifyDocument,
|
||||
} from '~/generated/graphql';
|
||||
@ -8,6 +9,7 @@ export const queries = {
|
||||
challenge: ChallengeDocument,
|
||||
verify: VerifyDocument,
|
||||
signup: SignUpDocument,
|
||||
getCurrentUser: GetCurrentUserDocument,
|
||||
};
|
||||
|
||||
export const email = 'test@test.com';
|
||||
@ -22,6 +24,7 @@ export const variables = {
|
||||
},
|
||||
verify: { loginToken: token },
|
||||
signup: {},
|
||||
getCurrentUser: {},
|
||||
};
|
||||
|
||||
export const results = {
|
||||
@ -32,7 +35,14 @@ export const results = {
|
||||
},
|
||||
},
|
||||
verify: {
|
||||
user: {
|
||||
tokens: {
|
||||
accessToken: { token, expiresAt: 'expiresAt' },
|
||||
refreshToken: { token, expiresAt: 'expiresAt' },
|
||||
},
|
||||
},
|
||||
signUp: { loginToken: { token, expiresAt: 'expiresAt' } },
|
||||
getCurrentUser: {
|
||||
currentUser: {
|
||||
id: 'id',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
@ -49,7 +59,7 @@ export const results = {
|
||||
avatarUrl: 'avatarUrl',
|
||||
locale: 'locale',
|
||||
},
|
||||
defaultWorkspace: {
|
||||
currentWorkspace: {
|
||||
id: 'id',
|
||||
displayName: 'displayName',
|
||||
logo: 'logo',
|
||||
@ -65,13 +75,7 @@ export const results = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
accessToken: { token, expiresAt: 'expiresAt' },
|
||||
refreshToken: { token, expiresAt: 'expiresAt' },
|
||||
},
|
||||
signup: {},
|
||||
},
|
||||
signUp: { loginToken: { token, expiresAt: 'expiresAt' } },
|
||||
};
|
||||
|
||||
export const mocks = [
|
||||
@ -108,4 +112,13 @@ export const mocks = [
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: queries.getCurrentUser,
|
||||
variables: variables.getCurrentUser,
|
||||
},
|
||||
result: jest.fn(() => ({
|
||||
data: results.getCurrentUser,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { expect } from '@storybook/test';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, act } from 'react';
|
||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
import { iconsState } from 'twenty-ui';
|
||||
|
||||
@ -15,6 +14,7 @@ import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthPro
|
||||
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { email, mocks, password, results, token } from '../__mocks__/useAuth';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
@ -59,6 +59,7 @@ describe('useAuth', () => {
|
||||
});
|
||||
|
||||
expect(mocks[1].result).toHaveBeenCalled();
|
||||
expect(mocks[3].result).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle credential sign-in', async () => {
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
snapshot_UNSTABLE,
|
||||
useGotoRecoilSnapshot,
|
||||
useRecoilCallback,
|
||||
useRecoilValue,
|
||||
useSetRecoilState,
|
||||
} from 'recoil';
|
||||
import { iconsState } from 'twenty-ui';
|
||||
@ -23,6 +24,7 @@ import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import {
|
||||
useChallengeMutation,
|
||||
useCheckUserExistsLazyQuery,
|
||||
useGetCurrentUserLazyQuery,
|
||||
useSignUpMutation,
|
||||
useVerifyMutation,
|
||||
} from '~/generated/graphql';
|
||||
@ -48,6 +50,8 @@ import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/h
|
||||
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
|
||||
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
|
||||
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
||||
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
|
||||
|
||||
export const useAuth = () => {
|
||||
@ -62,15 +66,19 @@ export const useAuth = () => {
|
||||
const setCurrentWorkspaceMembers = useSetRecoilState(
|
||||
currentWorkspaceMembersState,
|
||||
);
|
||||
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
|
||||
|
||||
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
|
||||
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
|
||||
const setWorkspaces = useSetRecoilState(workspacesState);
|
||||
const { redirect } = useRedirect();
|
||||
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
||||
|
||||
const [challenge] = useChallengeMutation();
|
||||
const [signUp] = useSignUpMutation();
|
||||
const [verify] = useVerifyMutation();
|
||||
const [getCurrentUser] = useGetCurrentUserLazyQuery();
|
||||
|
||||
const { isOnAWorkspaceSubdomain } =
|
||||
useIsCurrentLocationOnAWorkspaceSubdomain();
|
||||
const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation();
|
||||
@ -165,6 +173,98 @@ export const useAuth = () => {
|
||||
[challenge],
|
||||
);
|
||||
|
||||
const loadCurrentUser = useCallback(async () => {
|
||||
const currentUserResult = await getCurrentUser();
|
||||
|
||||
const user = currentUserResult.data?.currentUser;
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No current user result');
|
||||
}
|
||||
|
||||
let workspaceMember = null;
|
||||
|
||||
setCurrentUser(user);
|
||||
|
||||
if (isDefined(user.workspaceMembers)) {
|
||||
const workspaceMembers = user.workspaceMembers.map((workspaceMember) => ({
|
||||
...workspaceMember,
|
||||
colorScheme: workspaceMember.colorScheme as ColorScheme,
|
||||
locale: workspaceMember.locale ?? 'en',
|
||||
}));
|
||||
|
||||
setCurrentWorkspaceMembers(workspaceMembers);
|
||||
}
|
||||
|
||||
if (isDefined(user.workspaceMember)) {
|
||||
workspaceMember = {
|
||||
...user.workspaceMember,
|
||||
colorScheme: user.workspaceMember?.colorScheme as ColorScheme,
|
||||
locale: user.workspaceMember?.locale ?? 'en',
|
||||
};
|
||||
|
||||
setCurrentWorkspaceMember(workspaceMember);
|
||||
|
||||
// TODO: factorize with UserProviderEffect
|
||||
setDateTimeFormat({
|
||||
timeZone:
|
||||
workspaceMember.timeZone && workspaceMember.timeZone !== 'system'
|
||||
? workspaceMember.timeZone
|
||||
: detectTimeZone(),
|
||||
dateFormat: isDefined(user.workspaceMember.dateFormat)
|
||||
? getDateFormatFromWorkspaceDateFormat(
|
||||
user.workspaceMember.dateFormat,
|
||||
)
|
||||
: DateFormat[detectDateFormat()],
|
||||
timeFormat: isDefined(user.workspaceMember.timeFormat)
|
||||
? getTimeFormatFromWorkspaceTimeFormat(
|
||||
user.workspaceMember.timeFormat,
|
||||
)
|
||||
: TimeFormat[detectTimeFormat()],
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = user.currentWorkspace ?? null;
|
||||
|
||||
setCurrentWorkspace(workspace);
|
||||
|
||||
if (isDefined(workspace) && isOnAWorkspaceSubdomain) {
|
||||
setLastAuthenticateWorkspaceDomain({
|
||||
workspaceId: workspace.id,
|
||||
subdomain: workspace.subdomain,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(user.workspaces)) {
|
||||
const validWorkspaces = user.workspaces
|
||||
.filter(
|
||||
({ workspace }) => workspace !== null && workspace !== undefined,
|
||||
)
|
||||
.map((validWorkspace) => validWorkspace.workspace)
|
||||
.filter(isDefined);
|
||||
|
||||
setWorkspaces(validWorkspaces);
|
||||
}
|
||||
setIsAppWaitingForFreshObjectMetadataState(true);
|
||||
|
||||
return {
|
||||
user,
|
||||
workspaceMember,
|
||||
workspace,
|
||||
};
|
||||
}, [
|
||||
getCurrentUser,
|
||||
isOnAWorkspaceSubdomain,
|
||||
setCurrentUser,
|
||||
setCurrentWorkspace,
|
||||
setCurrentWorkspaceMember,
|
||||
setCurrentWorkspaceMembers,
|
||||
setDateTimeFormat,
|
||||
setIsAppWaitingForFreshObjectMetadataState,
|
||||
setLastAuthenticateWorkspaceDomain,
|
||||
setWorkspaces,
|
||||
]);
|
||||
|
||||
const handleVerify = useCallback(
|
||||
async (loginToken: string) => {
|
||||
const verifyResult = await verify({
|
||||
@ -181,74 +281,7 @@ export const useAuth = () => {
|
||||
|
||||
setTokenPair(verifyResult.data?.verify.tokens);
|
||||
|
||||
const user = verifyResult.data?.verify.user;
|
||||
|
||||
let workspaceMember = null;
|
||||
|
||||
setCurrentUser(user);
|
||||
|
||||
if (isDefined(user.workspaceMembers)) {
|
||||
const workspaceMembers = user.workspaceMembers.map(
|
||||
(workspaceMember) => ({
|
||||
...workspaceMember,
|
||||
colorScheme: workspaceMember.colorScheme as ColorScheme,
|
||||
locale: workspaceMember.locale ?? 'en',
|
||||
}),
|
||||
);
|
||||
|
||||
setCurrentWorkspaceMembers(workspaceMembers);
|
||||
}
|
||||
|
||||
if (isDefined(user.workspaceMember)) {
|
||||
workspaceMember = {
|
||||
...user.workspaceMember,
|
||||
colorScheme: user.workspaceMember?.colorScheme as ColorScheme,
|
||||
locale: user.workspaceMember?.locale ?? 'en',
|
||||
};
|
||||
|
||||
setCurrentWorkspaceMember(workspaceMember);
|
||||
|
||||
// TODO: factorize with UserProviderEffect
|
||||
setDateTimeFormat({
|
||||
timeZone:
|
||||
workspaceMember.timeZone && workspaceMember.timeZone !== 'system'
|
||||
? workspaceMember.timeZone
|
||||
: detectTimeZone(),
|
||||
dateFormat: isDefined(user.workspaceMember.dateFormat)
|
||||
? getDateFormatFromWorkspaceDateFormat(
|
||||
user.workspaceMember.dateFormat,
|
||||
)
|
||||
: DateFormat[detectDateFormat()],
|
||||
timeFormat: isDefined(user.workspaceMember.timeFormat)
|
||||
? getTimeFormatFromWorkspaceTimeFormat(
|
||||
user.workspaceMember.timeFormat,
|
||||
)
|
||||
: TimeFormat[detectTimeFormat()],
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = user.defaultWorkspace ?? null;
|
||||
|
||||
setCurrentWorkspace(workspace);
|
||||
|
||||
if (isDefined(workspace) && isOnAWorkspaceSubdomain) {
|
||||
setLastAuthenticateWorkspaceDomain({
|
||||
workspaceId: workspace.id,
|
||||
subdomain: workspace.subdomain,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(verifyResult.data?.verify.user.workspaces)) {
|
||||
const validWorkspaces = verifyResult.data?.verify.user.workspaces
|
||||
.filter(
|
||||
({ workspace }) => workspace !== null && workspace !== undefined,
|
||||
)
|
||||
.map((validWorkspace) => validWorkspace.workspace)
|
||||
.filter(isDefined);
|
||||
|
||||
setWorkspaces(validWorkspaces);
|
||||
}
|
||||
setIsAppWaitingForFreshObjectMetadataState(true);
|
||||
const { user, workspaceMember, workspace } = await loadCurrentUser();
|
||||
|
||||
return {
|
||||
user,
|
||||
@ -257,19 +290,7 @@ export const useAuth = () => {
|
||||
tokens: verifyResult.data?.verify.tokens,
|
||||
};
|
||||
},
|
||||
[
|
||||
verify,
|
||||
setTokenPair,
|
||||
setCurrentUser,
|
||||
setCurrentWorkspace,
|
||||
isOnAWorkspaceSubdomain,
|
||||
setIsAppWaitingForFreshObjectMetadataState,
|
||||
setCurrentWorkspaceMembers,
|
||||
setCurrentWorkspaceMember,
|
||||
setDateTimeFormat,
|
||||
setLastAuthenticateWorkspaceDomain,
|
||||
setWorkspaces,
|
||||
],
|
||||
[verify, setTokenPair, loadCurrentUser],
|
||||
);
|
||||
|
||||
const handleCrendentialsSignIn = useCallback(
|
||||
@ -328,6 +349,16 @@ export const useAuth = () => {
|
||||
throw new Error('No login token');
|
||||
}
|
||||
|
||||
if (isMultiWorkspaceEnabled) {
|
||||
return redirectToWorkspaceDomain(
|
||||
signUpResult.data.signUp.workspace.subdomain,
|
||||
AppPath.Verify,
|
||||
{
|
||||
loginToken: signUpResult.data.signUp.loginToken.token,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { user, workspace, workspaceMember } = await handleVerify(
|
||||
signUpResult.data?.signUp.loginToken.token,
|
||||
);
|
||||
@ -336,7 +367,13 @@ export const useAuth = () => {
|
||||
|
||||
return { user, workspaceMember, workspace };
|
||||
},
|
||||
[setIsVerifyPendingState, signUp, handleVerify],
|
||||
[
|
||||
setIsVerifyPendingState,
|
||||
signUp,
|
||||
isMultiWorkspaceEnabled,
|
||||
handleVerify,
|
||||
redirectToWorkspaceDomain,
|
||||
],
|
||||
);
|
||||
|
||||
const buildRedirectUrl = useCallback(
|
||||
@ -357,6 +394,7 @@ export const useAuth = () => {
|
||||
params.workspacePersonalInviteToken,
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(workspaceSubdomain)) {
|
||||
url.searchParams.set('workspaceSubdomain', workspaceSubdomain);
|
||||
}
|
||||
@ -390,6 +428,8 @@ export const useAuth = () => {
|
||||
challenge: handleChallenge,
|
||||
verify: handleVerify,
|
||||
|
||||
loadCurrentUser,
|
||||
|
||||
checkUserExists: { checkUserExistsData, checkUserExistsQuery },
|
||||
clearSession,
|
||||
signOut: handleSignOut,
|
||||
|
||||
@ -86,10 +86,7 @@ export const SignInUpGlobalScopeForm = () => {
|
||||
const response = data.checkUserExists;
|
||||
if (response.__typename === 'UserExists') {
|
||||
if (response.availableWorkspaces.length >= 1) {
|
||||
const workspace =
|
||||
response.availableWorkspaces.find(
|
||||
(workspace) => workspace.id === response.defaultWorkspaceId,
|
||||
) ?? response.availableWorkspaces[0];
|
||||
const workspace = response.availableWorkspaces[0];
|
||||
return redirectToWorkspaceDomain(workspace.subdomain, pathname, {
|
||||
email: form.getValues('email'),
|
||||
});
|
||||
|
||||
@ -134,7 +134,7 @@ export const queries = {
|
||||
workspaceMembers {
|
||||
...WorkspaceMemberQueryFragment
|
||||
}
|
||||
defaultWorkspace {
|
||||
currentWorkspace {
|
||||
id
|
||||
displayName
|
||||
logo
|
||||
@ -281,7 +281,7 @@ export const responseData = {
|
||||
timeFormat: '24',
|
||||
},
|
||||
workspaceMembers: [],
|
||||
defaultWorkspace: {
|
||||
currentWorkspace: {
|
||||
id: 'test-workspace-id',
|
||||
displayName: 'Test Workspace',
|
||||
logo: null,
|
||||
|
||||
@ -0,0 +1,255 @@
|
||||
import { SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID } from '@/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs';
|
||||
import { useFeatureFlagsManagement } from '@/settings/admin-panel/hooks/useFeatureFlagsManagement';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { TabList } from '@/ui/layout/tab/components/TabList';
|
||||
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
|
||||
import styled from '@emotion/styled';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useState } from 'react';
|
||||
import { getImageAbsoluteURI } from 'twenty-shared';
|
||||
import {
|
||||
Button,
|
||||
H1Title,
|
||||
H1TitleFontColor,
|
||||
H2Title,
|
||||
IconSearch,
|
||||
IconUser,
|
||||
isDefined,
|
||||
Section,
|
||||
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)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const StyledErrorSection = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.danger};
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledUserInfo = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(5)};
|
||||
`;
|
||||
|
||||
const StyledTable = styled(Table)`
|
||||
margin-top: ${({ theme }) => theme.spacing(0.5)};
|
||||
`;
|
||||
|
||||
const StyledTabListContainer = styled.div`
|
||||
align-items: center;
|
||||
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: ${({ theme }) => theme.spacing(4)} 0;
|
||||
`;
|
||||
|
||||
export const SettingsAdminContent = () => {
|
||||
const [userIdentifier, setUserIdentifier] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
|
||||
const {
|
||||
handleImpersonate,
|
||||
isLoading: isImpersonateLoading,
|
||||
error: impersonateError,
|
||||
canImpersonate,
|
||||
} = useImpersonate();
|
||||
|
||||
const { activeTabId, setActiveTabId } = useTabList(
|
||||
SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID,
|
||||
);
|
||||
|
||||
const {
|
||||
userLookupResult,
|
||||
handleUserLookup,
|
||||
handleFeatureFlagUpdate,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFeatureFlagsManagement();
|
||||
|
||||
const handleSearch = async () => {
|
||||
setActiveTabId('');
|
||||
|
||||
const result = await handleUserLookup(userIdentifier);
|
||||
|
||||
if (isDefined(result?.user?.id) && !error) {
|
||||
setUserId(result.user.id.trim());
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(result?.workspaces) &&
|
||||
result.workspaces.length > 0 &&
|
||||
!error
|
||||
) {
|
||||
setActiveTabId(result.workspaces[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowUserData = userLookupResult && !error;
|
||||
|
||||
const activeWorkspace = userLookupResult?.workspaces.find(
|
||||
(workspace) => workspace.id === activeTabId,
|
||||
);
|
||||
|
||||
const tabs =
|
||||
userLookupResult?.workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
title: workspace.name,
|
||||
logo:
|
||||
getImageAbsoluteURI({
|
||||
imageUrl: isNonEmptyString(workspace.logo)
|
||||
? workspace.logo
|
||||
: DEFAULT_WORKSPACE_LOGO,
|
||||
baseUrl: REACT_APP_SERVER_BASE_URL,
|
||||
}) ?? '',
|
||||
})) ?? [];
|
||||
|
||||
const renderWorkspaceContent = () => {
|
||||
if (!activeWorkspace) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<H2Title title={activeWorkspace.name} description={'Workspace Name'} />
|
||||
<H2Title
|
||||
title={`${activeWorkspace.totalUsers} ${
|
||||
activeWorkspace.totalUsers > 1 ? 'Users' : 'User'
|
||||
}`}
|
||||
description={'Total Users'}
|
||||
/>
|
||||
{canImpersonate && (
|
||||
<Button
|
||||
Icon={IconUser}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
title={'Impersonate'}
|
||||
onClick={() => handleImpersonate(userId, activeWorkspace.id)}
|
||||
disabled={
|
||||
isImpersonateLoading ||
|
||||
activeWorkspace.allowImpersonation === false
|
||||
}
|
||||
dataTestId="impersonate-button"
|
||||
/>
|
||||
)}
|
||||
|
||||
<StyledTable>
|
||||
<TableRow
|
||||
gridAutoColumns="1fr 100px"
|
||||
mobileGridAutoColumns="1fr 80px"
|
||||
>
|
||||
<TableHeader>Feature Flag</TableHeader>
|
||||
<TableHeader align="right">Status</TableHeader>
|
||||
</TableRow>
|
||||
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Feature Flags & Impersonation"
|
||||
description="Look up users and manage their workspace feature flags or impersonate it."
|
||||
/>
|
||||
|
||||
<StyledContainer>
|
||||
<StyledLinkContainer>
|
||||
<TextInput
|
||||
value={userIdentifier}
|
||||
onChange={setUserIdentifier}
|
||||
onInputEnter={handleSearch}
|
||||
placeholder="Enter user ID or email address"
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</StyledLinkContainer>
|
||||
<Button
|
||||
Icon={IconSearch}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
title="Search"
|
||||
onClick={handleSearch}
|
||||
disabled={!userIdentifier.trim() || isLoading}
|
||||
/>
|
||||
</StyledContainer>
|
||||
|
||||
{(error || impersonateError) && (
|
||||
<StyledErrorSection>{error ?? impersonateError}</StyledErrorSection>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{shouldShowUserData && (
|
||||
<Section>
|
||||
<StyledUserInfo>
|
||||
<H1Title title="User Info" fontColor={H1TitleFontColor.Primary} />
|
||||
<H2Title
|
||||
title={`${userLookupResult.user.firstName || ''} ${
|
||||
userLookupResult.user.lastName || ''
|
||||
}`.trim()}
|
||||
description="User Name"
|
||||
/>
|
||||
<H2Title
|
||||
title={userLookupResult.user.email}
|
||||
description="User Email"
|
||||
/>
|
||||
<H2Title title={userLookupResult.user.id} description="User ID" />
|
||||
</StyledUserInfo>
|
||||
|
||||
<H1Title title="Workspaces" fontColor={H1TitleFontColor.Primary} />
|
||||
<StyledTabListContainer>
|
||||
<TabList
|
||||
tabs={tabs}
|
||||
tabListInstanceId={SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID}
|
||||
behaveAsLinks={false}
|
||||
/>
|
||||
</StyledTabListContainer>
|
||||
<StyledContentContainer>
|
||||
{renderWorkspaceContent()}
|
||||
</StyledContentContainer>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,67 +0,0 @@
|
||||
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import { Button, H2Title, IconUser, Section } from 'twenty-ui';
|
||||
|
||||
const StyledLinkContainer = styled.div`
|
||||
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const StyledErrorSection = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.danger};
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export const SettingsAdminImpersonateUsers = () => {
|
||||
const [userId, setUserId] = useState('');
|
||||
const { handleImpersonate, isLoading, error, canImpersonate } =
|
||||
useImpersonate();
|
||||
|
||||
if (!canImpersonate) {
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Impersonate"
|
||||
description="You don't have permission to impersonate other users. Please contact your administrator if you need this access."
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title title="Impersonate" description="Impersonate a user." />
|
||||
<StyledContainer>
|
||||
<StyledLinkContainer>
|
||||
<TextInput
|
||||
value={userId}
|
||||
onChange={setUserId}
|
||||
placeholder="Enter user ID or email address"
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
dataTestId="impersonate-input"
|
||||
onInputEnter={() => handleImpersonate(userId)}
|
||||
/>
|
||||
</StyledLinkContainer>
|
||||
<Button
|
||||
Icon={IconUser}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
title={'Impersonate'}
|
||||
onClick={() => handleImpersonate(userId)}
|
||||
disabled={!userId.trim() || isLoading}
|
||||
dataTestId="impersonate-button"
|
||||
/>
|
||||
</StyledContainer>
|
||||
{error && <StyledErrorSection>{error}</StyledErrorSection>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@ -14,6 +14,7 @@ export const USER_LOOKUP_ADMIN_PANEL = gql`
|
||||
name
|
||||
logo
|
||||
totalUsers
|
||||
allowImpersonation
|
||||
users {
|
||||
id
|
||||
email
|
||||
|
||||
@ -1,24 +1,21 @@
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { tokenPairState } from '@/auth/states/tokenPairState';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useImpersonateMutation } from '~/generated/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
|
||||
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
||||
|
||||
export const useImpersonate = () => {
|
||||
const { clearSession } = useAuth();
|
||||
const { redirect } = useRedirect();
|
||||
const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
|
||||
const setTokenPair = useSetRecoilState(tokenPairState);
|
||||
const [currentUser] = useRecoilState(currentUserState);
|
||||
const [impersonate] = useImpersonateMutation();
|
||||
|
||||
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleImpersonate = async (userId: string) => {
|
||||
const handleImpersonate = async (userId: string, workspaceId: string) => {
|
||||
if (!userId.trim()) {
|
||||
setError('Please enter a user ID');
|
||||
return;
|
||||
@ -29,7 +26,7 @@ export const useImpersonate = () => {
|
||||
|
||||
try {
|
||||
const impersonateResult = await impersonate({
|
||||
variables: { userId },
|
||||
variables: { userId, workspaceId },
|
||||
});
|
||||
|
||||
if (isDefined(impersonateResult.errors)) {
|
||||
@ -40,11 +37,11 @@ export const useImpersonate = () => {
|
||||
throw new Error('No impersonate result');
|
||||
}
|
||||
|
||||
const { user, tokens } = impersonateResult.data.impersonate;
|
||||
await clearSession();
|
||||
setCurrentUser(user);
|
||||
setTokenPair(tokens);
|
||||
redirect(AppPath.Index);
|
||||
const { loginToken, workspace } = impersonateResult.data.impersonate;
|
||||
|
||||
return redirectToWorkspaceDomain(workspace.subdomain, AppPath.Verify, {
|
||||
loginToken: loginToken.token,
|
||||
});
|
||||
} catch (error) {
|
||||
setError('Failed to impersonate user. Please try again.');
|
||||
setIsLoading(false);
|
||||
|
||||
@ -12,4 +12,5 @@ export type WorkspaceInfo = {
|
||||
lastName?: string | null;
|
||||
}[];
|
||||
featureFlags: FeatureFlag[];
|
||||
allowImpersonation: boolean;
|
||||
};
|
||||
|
||||
@ -52,7 +52,10 @@ export const UserProviderEffect = () => {
|
||||
if (!isDefined(queryData?.currentUser)) return;
|
||||
|
||||
setCurrentUser(queryData.currentUser);
|
||||
setCurrentWorkspace(queryData.currentUser.defaultWorkspace);
|
||||
|
||||
if (isDefined(queryData.currentUser.currentWorkspace)) {
|
||||
setCurrentWorkspace(queryData.currentUser.currentWorkspace);
|
||||
}
|
||||
|
||||
const {
|
||||
workspaceMember,
|
||||
|
||||
@ -24,7 +24,7 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
workspaceMembers {
|
||||
...WorkspaceMemberQueryFragment
|
||||
}
|
||||
defaultWorkspace {
|
||||
currentWorkspace {
|
||||
id
|
||||
displayName
|
||||
logo
|
||||
|
||||
@ -3,13 +3,8 @@ import { gql } from '@apollo/client';
|
||||
export const ACTIVATE_WORKSPACE = gql`
|
||||
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
|
||||
activateWorkspace(data: $input) {
|
||||
workspace {
|
||||
id
|
||||
subdomain
|
||||
}
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
id
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user