Admin panel refactor (#10119)
addressing > There are two patterns to avoid: Creating functions that return JSX like renderThing() -> this was taken already addressed in https://github.com/twentyhq/twenty/pull/10011 Making a hook that "stores" all the logic of a component - > this PR is addressing this particular pattern In essence, handlers should remain in the component and be connected to their events. And everything in a handler can be abstracted into its dedicated hook. For example: const { myReactiveState } = useRecoilValue(myReactiveStateComponentState); const { removeThingFromOtherThing } = useRemoveThingFromOtherThing(); const handleClick = () => { if (isDefined(myReactiveState)) { removeThingFromOtherThing(); } } Broadly speaking, this is how you can split large components into several sub-hooks. --------- Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
@ -36,6 +36,8 @@ const StyledShowMoreButton = styled(Button)<{ isSelected?: boolean }>`
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledEnvVariablesDescription = styled.div``;
|
||||||
|
|
||||||
export const SettingsAdminEnvVariables = () => {
|
export const SettingsAdminEnvVariables = () => {
|
||||||
const { data: environmentVariables } = useGetEnvironmentVariablesGroupedQuery(
|
const { data: environmentVariables } = useGetEnvironmentVariablesGroupedQuery(
|
||||||
{
|
{
|
||||||
@ -65,59 +67,68 @@ export const SettingsAdminEnvVariables = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<>
|
||||||
{visibleGroups.map((group) => (
|
<StyledEnvVariablesDescription>
|
||||||
<StyledGroupContainer key={group.name}>
|
These are only the server values. Ensure your worker environment has the
|
||||||
<H1Title title={group.name} fontColor={H1TitleFontColor.Primary} />
|
same variables and values, this is required for asynchronous tasks like
|
||||||
{group.description !== '' && (
|
email sync.
|
||||||
<StyledGroupDescription>{group.description}</StyledGroupDescription>
|
</StyledEnvVariablesDescription>
|
||||||
)}
|
<Section>
|
||||||
{group.variables.length > 0 && (
|
{visibleGroups.map((group) => (
|
||||||
<StyledGroupVariablesContainer>
|
<StyledGroupContainer key={group.name}>
|
||||||
<SettingsAdminEnvVariablesTable variables={group.variables} />
|
<H1Title title={group.name} fontColor={H1TitleFontColor.Primary} />
|
||||||
</StyledGroupVariablesContainer>
|
{group.description !== '' && (
|
||||||
)}
|
<StyledGroupDescription>
|
||||||
</StyledGroupContainer>
|
{group.description}
|
||||||
))}
|
</StyledGroupDescription>
|
||||||
|
)}
|
||||||
|
{group.variables.length > 0 && (
|
||||||
|
<StyledGroupVariablesContainer>
|
||||||
|
<SettingsAdminEnvVariablesTable variables={group.variables} />
|
||||||
|
</StyledGroupVariablesContainer>
|
||||||
|
)}
|
||||||
|
</StyledGroupContainer>
|
||||||
|
))}
|
||||||
|
|
||||||
{hiddenGroups.length > 0 && (
|
{hiddenGroups.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<StyledButtonsRow>
|
<StyledButtonsRow>
|
||||||
{hiddenGroups.map((group) => (
|
{hiddenGroups.map((group) => (
|
||||||
<StyledShowMoreButton
|
<StyledShowMoreButton
|
||||||
key={group.name}
|
key={group.name}
|
||||||
onClick={() => toggleGroupVisibility(group.name)}
|
onClick={() => toggleGroupVisibility(group.name)}
|
||||||
title={group.name}
|
title={group.name}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
isSelected={selectedGroup === group.name}
|
isSelected={selectedGroup === group.name}
|
||||||
>
|
>
|
||||||
{group.name} variables
|
{group.name} variables
|
||||||
</StyledShowMoreButton>
|
</StyledShowMoreButton>
|
||||||
))}
|
))}
|
||||||
</StyledButtonsRow>
|
</StyledButtonsRow>
|
||||||
|
|
||||||
{selectedGroupData && (
|
{selectedGroupData && (
|
||||||
<StyledGroupContainer>
|
<StyledGroupContainer>
|
||||||
<H1Title
|
<H1Title
|
||||||
title={selectedGroupData.name}
|
title={selectedGroupData.name}
|
||||||
fontColor={H1TitleFontColor.Primary}
|
fontColor={H1TitleFontColor.Primary}
|
||||||
/>
|
/>
|
||||||
{selectedGroupData.description !== '' && (
|
{selectedGroupData.description !== '' && (
|
||||||
<StyledGroupDescription>
|
<StyledGroupDescription>
|
||||||
{selectedGroupData.description}
|
{selectedGroupData.description}
|
||||||
</StyledGroupDescription>
|
</StyledGroupDescription>
|
||||||
)}
|
)}
|
||||||
{selectedGroupData.variables.length > 0 && (
|
{selectedGroupData.variables.length > 0 && (
|
||||||
<StyledGroupVariablesContainer>
|
<StyledGroupVariablesContainer>
|
||||||
<SettingsAdminEnvVariablesTable
|
<SettingsAdminEnvVariablesTable
|
||||||
variables={selectedGroupData.variables}
|
variables={selectedGroupData.variables}
|
||||||
/>
|
/>
|
||||||
</StyledGroupVariablesContainer>
|
</StyledGroupVariablesContainer>
|
||||||
)}
|
)}
|
||||||
</StyledGroupContainer>
|
</StyledGroupContainer>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
||||||
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
|
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
|
||||||
import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId';
|
import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId';
|
||||||
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
|
|
||||||
import { useUserLookup } from '@/settings/admin-panel/hooks/useUserLookup';
|
|
||||||
import { adminPanelErrorState } from '@/settings/admin-panel/states/adminPanelErrorState';
|
|
||||||
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
|
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
|
||||||
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||||
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
import { TabList } from '@/ui/layout/tab/components/TabList';
|
import { TabList } from '@/ui/layout/tab/components/TabList';
|
||||||
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
||||||
@ -12,7 +11,7 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { isNonEmptyString } from '@sniptt/guards';
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
import { getImageAbsoluteURI, isDefined } from 'twenty-shared';
|
import { getImageAbsoluteURI, isDefined } from 'twenty-shared';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -23,6 +22,7 @@ import {
|
|||||||
Section,
|
Section,
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||||
|
import { useUserLookupAdminPanelMutation } from '~/generated/graphql';
|
||||||
|
|
||||||
const StyledLinkContainer = styled.div`
|
const StyledLinkContainer = styled.div`
|
||||||
margin-right: ${({ theme }) => theme.spacing(2)};
|
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||||
@ -35,11 +35,6 @@ const StyledContainer = styled.div`
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledErrorSection = styled.div`
|
|
||||||
color: ${({ theme }) => theme.font.color.danger};
|
|
||||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledUserInfo = styled.div`
|
const StyledUserInfo = styled.div`
|
||||||
margin-bottom: ${({ theme }) => theme.spacing(5)};
|
margin-bottom: ${({ theme }) => theme.spacing(5)};
|
||||||
`;
|
`;
|
||||||
@ -60,40 +55,48 @@ const StyledContentContainer = styled.div`
|
|||||||
|
|
||||||
export const SettingsAdminGeneral = () => {
|
export const SettingsAdminGeneral = () => {
|
||||||
const [userIdentifier, setUserIdentifier] = useState('');
|
const [userIdentifier, setUserIdentifier] = useState('');
|
||||||
const [userId, setUserId] = useState('');
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
|
||||||
const { error: impersonateError } = useImpersonate();
|
|
||||||
|
|
||||||
const { activeTabId, setActiveTabId } = useTabList(
|
const { activeTabId, setActiveTabId } = useTabList(
|
||||||
SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID,
|
SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID,
|
||||||
);
|
);
|
||||||
const userLookupResult = useRecoilValue(userLookupResultState);
|
const [userLookupResult, setUserLookupResult] = useRecoilState(
|
||||||
const adminPanelError = useRecoilValue(adminPanelErrorState);
|
userLookupResultState,
|
||||||
|
);
|
||||||
|
const [isUserLookupLoading, setIsUserLookupLoading] = useState(false);
|
||||||
|
|
||||||
const { handleUserLookup, isLoading } = useUserLookup();
|
const [userLookup] = useUserLookupAdminPanelMutation();
|
||||||
|
|
||||||
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
|
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
setActiveTabId('');
|
setActiveTabId('');
|
||||||
|
setIsUserLookupLoading(true);
|
||||||
|
setUserLookupResult(null);
|
||||||
|
|
||||||
const result = await handleUserLookup(userIdentifier);
|
const response = await userLookup({
|
||||||
|
variables: { userIdentifier },
|
||||||
|
onCompleted: (data) => {
|
||||||
|
setIsUserLookupLoading(false);
|
||||||
|
if (isDefined(data?.userLookupAdminPanel)) {
|
||||||
|
setUserLookupResult(data.userLookupAdminPanel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setIsUserLookupLoading(false);
|
||||||
|
enqueueSnackBar(error.message, {
|
||||||
|
variant: SnackBarVariant.Error,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (isDefined(result?.user?.id) && !adminPanelError) {
|
const result = response.data?.userLookupAdminPanel;
|
||||||
setUserId(result.user.id.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (isDefined(result?.workspaces) && result.workspaces.length > 0) {
|
||||||
isDefined(result?.workspaces) &&
|
|
||||||
result.workspaces.length > 0 &&
|
|
||||||
!adminPanelError
|
|
||||||
) {
|
|
||||||
setActiveTabId(result.workspaces[0].id);
|
setActiveTabId(result.workspaces[0].id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldShowUserData = userLookupResult && !adminPanelError;
|
|
||||||
|
|
||||||
const activeWorkspace = userLookupResult?.workspaces.find(
|
const activeWorkspace = userLookupResult?.workspaces.find(
|
||||||
(workspace) => workspace.id === activeTabId,
|
(workspace) => workspace.id === activeTabId,
|
||||||
);
|
);
|
||||||
@ -139,7 +142,7 @@ export const SettingsAdminGeneral = () => {
|
|||||||
onInputEnter={handleSearch}
|
onInputEnter={handleSearch}
|
||||||
placeholder="Enter user ID or email address"
|
placeholder="Enter user ID or email address"
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={isLoading}
|
disabled={isUserLookupLoading}
|
||||||
/>
|
/>
|
||||||
</StyledLinkContainer>
|
</StyledLinkContainer>
|
||||||
<Button
|
<Button
|
||||||
@ -148,18 +151,12 @@ export const SettingsAdminGeneral = () => {
|
|||||||
accent="blue"
|
accent="blue"
|
||||||
title="Search"
|
title="Search"
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
disabled={!userIdentifier.trim() || isLoading}
|
disabled={!userIdentifier.trim() || isUserLookupLoading}
|
||||||
/>
|
/>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
|
|
||||||
{(adminPanelError || impersonateError) && (
|
|
||||||
<StyledErrorSection>
|
|
||||||
{adminPanelError ?? impersonateError}
|
|
||||||
</StyledErrorSection>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{shouldShowUserData && (
|
{isDefined(userLookupResult) && (
|
||||||
<Section>
|
<Section>
|
||||||
<StyledUserInfo>
|
<StyledUserInfo>
|
||||||
<H1Title title="User Info" fontColor={H1TitleFontColor.Primary} />
|
<H1Title title="User Info" fontColor={H1TitleFontColor.Primary} />
|
||||||
@ -180,10 +177,7 @@ export const SettingsAdminGeneral = () => {
|
|||||||
/>
|
/>
|
||||||
</StyledTabListContainer>
|
</StyledTabListContainer>
|
||||||
<StyledContentContainer>
|
<StyledContentContainer>
|
||||||
<SettingsAdminWorkspaceContent
|
<SettingsAdminWorkspaceContent activeWorkspace={activeWorkspace} />
|
||||||
activeWorkspace={activeWorkspace}
|
|
||||||
userId={userId}
|
|
||||||
/>
|
|
||||||
</StyledContentContainer>
|
</StyledContentContainer>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,19 +1,30 @@
|
|||||||
import { Button, H2Title, IconUser, Toggle } from 'twenty-ui';
|
import { Button, H2Title, IconUser, Toggle } from 'twenty-ui';
|
||||||
|
|
||||||
|
import { currentUserState } from '@/auth/states/currentUserState';
|
||||||
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
||||||
import { useFeatureFlag } from '@/settings/admin-panel/hooks/useFeatureFlag';
|
import { useFeatureFlagState } from '@/settings/admin-panel/hooks/useFeatureFlagState';
|
||||||
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
|
import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
|
||||||
|
import { useImpersonationRedirect } from '@/settings/admin-panel/hooks/useImpersonationRedirect';
|
||||||
|
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
|
||||||
import { WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo';
|
import { WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo';
|
||||||
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||||
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
import { Table } from '@/ui/layout/table/components/Table';
|
import { Table } from '@/ui/layout/table/components/Table';
|
||||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useState } from 'react';
|
||||||
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
|
import { isDefined } from 'twenty-shared';
|
||||||
|
import {
|
||||||
|
FeatureFlagKey,
|
||||||
|
useImpersonateMutation,
|
||||||
|
useUpdateWorkspaceFeatureFlagMutation,
|
||||||
|
} from '~/generated/graphql';
|
||||||
|
|
||||||
type SettingsAdminWorkspaceContentProps = {
|
type SettingsAdminWorkspaceContentProps = {
|
||||||
activeWorkspace: WorkspaceInfo | undefined;
|
activeWorkspace: WorkspaceInfo | undefined;
|
||||||
userId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledTable = styled(Table)`
|
const StyledTable = styled(Table)`
|
||||||
@ -22,17 +33,85 @@ const StyledTable = styled(Table)`
|
|||||||
|
|
||||||
export const SettingsAdminWorkspaceContent = ({
|
export const SettingsAdminWorkspaceContent = ({
|
||||||
activeWorkspace,
|
activeWorkspace,
|
||||||
userId,
|
|
||||||
}: SettingsAdminWorkspaceContentProps) => {
|
}: SettingsAdminWorkspaceContentProps) => {
|
||||||
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
|
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
|
||||||
|
const [isImpersonateLoading, setIsImpersonationLoading] = useState(false);
|
||||||
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
const [currentUser] = useRecoilState(currentUserState);
|
||||||
|
|
||||||
const {
|
const { executeImpersonationAuth } = useImpersonationAuth();
|
||||||
handleImpersonate,
|
const { executeImpersonationRedirect } = useImpersonationRedirect();
|
||||||
isLoading: isImpersonateLoading,
|
const [updateFeatureFlag] = useUpdateWorkspaceFeatureFlagMutation();
|
||||||
canImpersonate,
|
const [impersonate] = useImpersonateMutation();
|
||||||
} = useImpersonate();
|
|
||||||
|
|
||||||
const { handleFeatureFlagUpdate } = useFeatureFlag();
|
const { updateFeatureFlagState } = useFeatureFlagState();
|
||||||
|
const [userLookupResult, setUserLookupResult] = useRecoilState(
|
||||||
|
userLookupResultState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImpersonate = async (workspaceId: string) => {
|
||||||
|
if (!userLookupResult?.user.id) {
|
||||||
|
enqueueSnackBar('Please search for a user first', {
|
||||||
|
variant: SnackBarVariant.Error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsImpersonationLoading(true);
|
||||||
|
|
||||||
|
await impersonate({
|
||||||
|
variables: { userId: userLookupResult.user.id, workspaceId },
|
||||||
|
onCompleted: async (data) => {
|
||||||
|
const { loginToken, workspace } = data.impersonate;
|
||||||
|
const isCurrentWorkspace = workspace.id === activeWorkspace?.id;
|
||||||
|
setUserLookupResult(null);
|
||||||
|
if (isCurrentWorkspace) {
|
||||||
|
await executeImpersonationAuth(loginToken.token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeImpersonationRedirect(
|
||||||
|
workspace.workspaceUrls,
|
||||||
|
loginToken.token,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
enqueueSnackBar(`Failed to impersonate user. ${error.message}`, {
|
||||||
|
variant: SnackBarVariant.Error,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}).finally(() => {
|
||||||
|
setIsImpersonationLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFeatureFlagUpdate = async (
|
||||||
|
workspaceId: string,
|
||||||
|
featureFlag: FeatureFlagKey,
|
||||||
|
value: boolean,
|
||||||
|
) => {
|
||||||
|
const previousValue = userLookupResult?.workspaces
|
||||||
|
.find((workspace) => workspace.id === workspaceId)
|
||||||
|
?.featureFlags.find((flag) => flag.key === featureFlag)?.value;
|
||||||
|
|
||||||
|
updateFeatureFlagState(workspaceId, featureFlag, value);
|
||||||
|
await updateFeatureFlag({
|
||||||
|
variables: {
|
||||||
|
workspaceId,
|
||||||
|
featureFlag,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (error) => {
|
||||||
|
if (isDefined(previousValue)) {
|
||||||
|
updateFeatureFlagState(workspaceId, featureFlag, previousValue);
|
||||||
|
}
|
||||||
|
enqueueSnackBar(`Failed to update feature flag. ${error.message}`, {
|
||||||
|
variant: SnackBarVariant.Error,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!activeWorkspace) return null;
|
if (!activeWorkspace) return null;
|
||||||
|
|
||||||
@ -45,13 +124,13 @@ export const SettingsAdminWorkspaceContent = ({
|
|||||||
}`}
|
}`}
|
||||||
description={'Total Users'}
|
description={'Total Users'}
|
||||||
/>
|
/>
|
||||||
{canImpersonate && (
|
{currentUser?.canImpersonate && (
|
||||||
<Button
|
<Button
|
||||||
Icon={IconUser}
|
Icon={IconUser}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
accent="blue"
|
accent="blue"
|
||||||
title={'Impersonate'}
|
title={'Impersonate'}
|
||||||
onClick={() => handleImpersonate(userId, activeWorkspace.id)}
|
onClick={() => handleImpersonate(activeWorkspace.id)}
|
||||||
disabled={
|
disabled={
|
||||||
isImpersonateLoading || activeWorkspace.allowImpersonation === false
|
isImpersonateLoading || activeWorkspace.allowImpersonation === false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
import { adminPanelErrorState } from '@/settings/admin-panel/states/adminPanelErrorState';
|
|
||||||
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
|
|
||||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
|
||||||
import { isDefined } from 'twenty-shared';
|
|
||||||
import {
|
|
||||||
FeatureFlagKey,
|
|
||||||
useUpdateWorkspaceFeatureFlagMutation,
|
|
||||||
} from '~/generated/graphql';
|
|
||||||
|
|
||||||
export const useFeatureFlag = () => {
|
|
||||||
const [userLookupResult, setUserLookupResult] = useRecoilState(
|
|
||||||
userLookupResultState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const setError = useSetRecoilState(adminPanelErrorState);
|
|
||||||
|
|
||||||
const [updateFeatureFlag] = useUpdateWorkspaceFeatureFlagMutation();
|
|
||||||
|
|
||||||
const handleFeatureFlagUpdate = async (
|
|
||||||
workspaceId: string,
|
|
||||||
featureFlag: FeatureFlagKey,
|
|
||||||
value: boolean,
|
|
||||||
) => {
|
|
||||||
setError(null);
|
|
||||||
const previousState = userLookupResult;
|
|
||||||
|
|
||||||
if (isDefined(userLookupResult)) {
|
|
||||||
setUserLookupResult({
|
|
||||||
...userLookupResult,
|
|
||||||
workspaces: userLookupResult.workspaces.map((workspace) =>
|
|
||||||
workspace.id === workspaceId
|
|
||||||
? {
|
|
||||||
...workspace,
|
|
||||||
featureFlags: workspace.featureFlags.map((flag) =>
|
|
||||||
flag.key === featureFlag ? { ...flag, value } : flag,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: workspace,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await updateFeatureFlag({
|
|
||||||
variables: {
|
|
||||||
workspaceId,
|
|
||||||
featureFlag,
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
if (isDefined(previousState)) {
|
|
||||||
setUserLookupResult(previousState);
|
|
||||||
}
|
|
||||||
setError(error.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return !!response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleFeatureFlagUpdate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
|
import { isDefined } from 'twenty-shared';
|
||||||
|
import { FeatureFlagKey } from '~/generated/graphql';
|
||||||
|
|
||||||
|
export const useFeatureFlagState = () => {
|
||||||
|
const [userLookupResult, setUserLookupResult] = useRecoilState(
|
||||||
|
userLookupResultState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateFeatureFlagState = (
|
||||||
|
workspaceId: string,
|
||||||
|
featureFlag: FeatureFlagKey,
|
||||||
|
value: boolean,
|
||||||
|
) => {
|
||||||
|
if (!isDefined(userLookupResult)) return;
|
||||||
|
|
||||||
|
setUserLookupResult({
|
||||||
|
...userLookupResult,
|
||||||
|
workspaces: userLookupResult.workspaces.map((workspace) =>
|
||||||
|
workspace.id === workspaceId
|
||||||
|
? {
|
||||||
|
...workspace,
|
||||||
|
featureFlags: workspace.featureFlags.map((flag) =>
|
||||||
|
flag.key === featureFlag ? { ...flag, value } : flag,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: workspace,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateFeatureFlagState,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import { useAuth } from '@/auth/hooks/useAuth';
|
|
||||||
import { currentUserState } from '@/auth/states/currentUserState';
|
|
||||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
|
||||||
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
|
||||||
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
|
|
||||||
import { AppPath } from '@/types/AppPath';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
|
||||||
import { isDefined } from 'twenty-shared';
|
|
||||||
import { useImpersonateMutation } from '~/generated/graphql';
|
|
||||||
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
|
|
||||||
|
|
||||||
export const useImpersonate = () => {
|
|
||||||
const [currentUser] = useRecoilState(currentUserState);
|
|
||||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
|
||||||
const setIsAppWaitingForFreshObjectMetadata = useSetRecoilState(
|
|
||||||
isAppWaitingForFreshObjectMetadataState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getAuthTokensFromLoginToken } = useAuth();
|
|
||||||
|
|
||||||
const [impersonate] = useImpersonateMutation();
|
|
||||||
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleImpersonate = async (userId: string, workspaceId: string) => {
|
|
||||||
if (!userId.trim()) {
|
|
||||||
setError('Please enter a user ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const impersonateResult = await impersonate({
|
|
||||||
variables: { userId, workspaceId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDefined(impersonateResult.errors)) {
|
|
||||||
throw impersonateResult.errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!impersonateResult.data?.impersonate) {
|
|
||||||
throw new Error('No impersonate result');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { loginToken, workspace } = impersonateResult.data.impersonate;
|
|
||||||
|
|
||||||
if (workspace.id === currentWorkspace?.id) {
|
|
||||||
setIsAppWaitingForFreshObjectMetadata(true);
|
|
||||||
await getAuthTokensFromLoginToken(loginToken.token);
|
|
||||||
setIsAppWaitingForFreshObjectMetadata(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirectToWorkspaceDomain(
|
|
||||||
getWorkspaceUrl(workspace.workspaceUrls),
|
|
||||||
AppPath.Verify,
|
|
||||||
{
|
|
||||||
loginToken: loginToken.token,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
setError('Failed to impersonate user. Please try again.');
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleImpersonate,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
canImpersonate: currentUser?.canImpersonate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { useAuth } from '@/auth/hooks/useAuth';
|
||||||
|
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
|
||||||
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
|
||||||
|
export const useImpersonationAuth = () => {
|
||||||
|
const { getAuthTokensFromLoginToken } = useAuth();
|
||||||
|
const setIsAppWaitingForFreshObjectMetadata = useSetRecoilState(
|
||||||
|
isAppWaitingForFreshObjectMetadataState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeImpersonationAuth = async (loginToken: string) => {
|
||||||
|
setIsAppWaitingForFreshObjectMetadata(true);
|
||||||
|
await getAuthTokensFromLoginToken(loginToken);
|
||||||
|
setIsAppWaitingForFreshObjectMetadata(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { executeImpersonationAuth };
|
||||||
|
};
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
||||||
|
import { AppPath } from '@/types/AppPath';
|
||||||
|
import { WorkspaceUrls } from '~/generated/graphql';
|
||||||
|
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
|
||||||
|
|
||||||
|
export const useImpersonationRedirect = () => {
|
||||||
|
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
||||||
|
|
||||||
|
const executeImpersonationRedirect = (
|
||||||
|
workspaceUrls: WorkspaceUrls,
|
||||||
|
loginToken: string,
|
||||||
|
) => {
|
||||||
|
return redirectToWorkspaceDomain(
|
||||||
|
getWorkspaceUrl(workspaceUrls),
|
||||||
|
AppPath.Verify,
|
||||||
|
{ loginToken },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { executeImpersonationRedirect };
|
||||||
|
};
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { adminPanelErrorState } from '@/settings/admin-panel/states/adminPanelErrorState';
|
|
||||||
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useSetRecoilState } from 'recoil';
|
|
||||||
import { isDefined } from 'twenty-shared';
|
|
||||||
import { useUserLookupAdminPanelMutation } from '~/generated/graphql';
|
|
||||||
|
|
||||||
export const useUserLookup = () => {
|
|
||||||
const setUserLookupResult = useSetRecoilState(userLookupResultState);
|
|
||||||
const setError = useSetRecoilState(adminPanelErrorState);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const [userLookup] = useUserLookupAdminPanelMutation({
|
|
||||||
onCompleted: (data) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
if (isDefined(data?.userLookupAdminPanel)) {
|
|
||||||
setUserLookupResult(data.userLookupAdminPanel);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
setError(error.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleUserLookup = async (userIdentifier: string) => {
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(true);
|
|
||||||
setUserLookupResult(null);
|
|
||||||
|
|
||||||
const response = await userLookup({
|
|
||||||
variables: { userIdentifier },
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data?.userLookupAdminPanel;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleUserLookup,
|
|
||||||
isLoading,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { atom } from 'recoil';
|
|
||||||
|
|
||||||
export const adminPanelErrorState = atom<string | null>({
|
|
||||||
key: 'adminPanelErrorState',
|
|
||||||
default: null,
|
|
||||||
});
|
|
||||||
@ -19,7 +19,7 @@ export const ENVIRONMENT_VARIABLES_GROUP_METADATA: Record<
|
|||||||
position: 200,
|
position: 200,
|
||||||
description:
|
description:
|
||||||
'We use this to limit the number of requests to the server. This is useful to prevent abuse.',
|
'We use this to limit the number of requests to the server. This is useful to prevent abuse.',
|
||||||
isHiddenOnLoad: false,
|
isHiddenOnLoad: true,
|
||||||
},
|
},
|
||||||
[EnvironmentVariablesGroup.StorageConfig]: {
|
[EnvironmentVariablesGroup.StorageConfig]: {
|
||||||
position: 300,
|
position: 300,
|
||||||
@ -46,13 +46,13 @@ export const ENVIRONMENT_VARIABLES_GROUP_METADATA: Record<
|
|||||||
[EnvironmentVariablesGroup.Logging]: {
|
[EnvironmentVariablesGroup.Logging]: {
|
||||||
position: 700,
|
position: 700,
|
||||||
description: '',
|
description: '',
|
||||||
isHiddenOnLoad: false,
|
isHiddenOnLoad: true,
|
||||||
},
|
},
|
||||||
[EnvironmentVariablesGroup.ExceptionHandler]: {
|
[EnvironmentVariablesGroup.ExceptionHandler]: {
|
||||||
position: 800,
|
position: 800,
|
||||||
description:
|
description:
|
||||||
'By default, exceptions are sent to the logs. This should be enough for most self-hosting use-cases. For our cloud app we use Sentry.',
|
'By default, exceptions are sent to the logs. This should be enough for most self-hosting use-cases. For our cloud app we use Sentry.',
|
||||||
isHiddenOnLoad: false,
|
isHiddenOnLoad: true,
|
||||||
},
|
},
|
||||||
[EnvironmentVariablesGroup.Other]: {
|
[EnvironmentVariablesGroup.Other]: {
|
||||||
position: 900,
|
position: 900,
|
||||||
|
|||||||
Reference in New Issue
Block a user