Environment variables in admin panel (read only) - front (#10011)
Frontend for https://github.com/twentyhq/core-team-issues/issues/293 POC - https://github.com/twentyhq/twenty/pull/9903 --------- Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@ -1,55 +1,9 @@
|
||||
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 { SettingsAdminTabContent } from '@/settings/admin-panel/components/SettingsAdminTabContent';
|
||||
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
|
||||
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
|
||||
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 { useRecoilValue } from 'recoil';
|
||||
import { getImageAbsoluteURI, isDefined } from 'twenty-shared';
|
||||
import {
|
||||
Button,
|
||||
H1Title,
|
||||
H1TitleFontColor,
|
||||
H2Title,
|
||||
IconSearch,
|
||||
IconUser,
|
||||
Section,
|
||||
Toggle,
|
||||
} from 'twenty-ui';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
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(3)};
|
||||
`;
|
||||
import { IconSettings2, IconVariable } from 'twenty-ui';
|
||||
|
||||
const StyledTabListContainer = styled.div`
|
||||
align-items: center;
|
||||
@ -59,210 +13,30 @@ const StyledTabListContainer = styled.div`
|
||||
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 canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
|
||||
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{canManageFeatureFlags && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const tabs = [
|
||||
{
|
||||
id: SETTINGS_ADMIN_TABS.GENERAL,
|
||||
title: 'General',
|
||||
Icon: IconSettings2,
|
||||
},
|
||||
{
|
||||
id: SETTINGS_ADMIN_TABS.ENV_VARIABLES,
|
||||
title: 'Env Variables',
|
||||
Icon: IconVariable,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
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.'
|
||||
}
|
||||
<StyledTabListContainer>
|
||||
<TabList
|
||||
tabs={tabs}
|
||||
tabListInstanceId={SETTINGS_ADMIN_TABS_ID}
|
||||
behaveAsLinks={true}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</StyledTabListContainer>
|
||||
<SettingsAdminTabContent />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,113 @@
|
||||
import { SettingsAdminEnvVariablesTable } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesTable';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import { Button, H1Title, H1TitleFontColor, Section } from 'twenty-ui';
|
||||
import { useGetEnvironmentVariablesGroupedQuery } from '~/generated/graphql';
|
||||
|
||||
const StyledGroupContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(6)};
|
||||
`;
|
||||
|
||||
const StyledSubGroupContainer = styled.div`
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
padding: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledGroupVariablesContainer = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
padding-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
padding-left: ${({ theme }) => theme.spacing(4)};
|
||||
padding-right: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledSubGroupTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledSubGroupDescription = styled.div``;
|
||||
|
||||
const StyledGroupDescription = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledShowMoreButton = styled(Button)``;
|
||||
|
||||
export const SettingsAdminEnvVariables = () => {
|
||||
const { data: environmentVariables } = useGetEnvironmentVariablesGroupedQuery(
|
||||
{
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
);
|
||||
|
||||
const [showHiddenGroups, setShowHiddenGroups] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const toggleGroupVisibility = (groupName: string) => {
|
||||
setShowHiddenGroups((prev) => ({
|
||||
...prev,
|
||||
[groupName]: !prev[groupName],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Section>
|
||||
{environmentVariables?.getEnvironmentVariablesGrouped.groups.map(
|
||||
(group) => {
|
||||
const isHidden =
|
||||
group.isHiddenOnLoad && !showHiddenGroups[group.name];
|
||||
if (isHidden === true) {
|
||||
return (
|
||||
<StyledShowMoreButton
|
||||
key={group.name}
|
||||
onClick={() => toggleGroupVisibility(group.name)}
|
||||
title={
|
||||
showHiddenGroups[group.name] ? 'Show Less' : 'Show More...'
|
||||
}
|
||||
></StyledShowMoreButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StyledGroupContainer key={group.name}>
|
||||
<H1Title
|
||||
title={group.name}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
/>
|
||||
{group.description !== '' && (
|
||||
<StyledGroupDescription>
|
||||
{group.description}
|
||||
</StyledGroupDescription>
|
||||
)}
|
||||
{group.variables.length > 0 && (
|
||||
<StyledGroupVariablesContainer>
|
||||
<SettingsAdminEnvVariablesTable variables={group.variables} />
|
||||
</StyledGroupVariablesContainer>
|
||||
)}
|
||||
{group.subgroups.map((subgroup) => (
|
||||
<StyledSubGroupContainer key={subgroup.name}>
|
||||
<StyledSubGroupTitle>{subgroup.name}</StyledSubGroupTitle>
|
||||
{subgroup.description !== '' && (
|
||||
<StyledSubGroupDescription>
|
||||
{subgroup.description}
|
||||
</StyledSubGroupDescription>
|
||||
)}
|
||||
<SettingsAdminEnvVariablesTable
|
||||
variables={subgroup.variables}
|
||||
/>
|
||||
</StyledSubGroupContainer>
|
||||
))}
|
||||
</StyledGroupContainer>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,130 @@
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AnimatedExpandableContainer,
|
||||
IconChevronRight,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
LightIconButton,
|
||||
} from 'twenty-ui';
|
||||
type SettingsAdminEnvVariablesRowProps = {
|
||||
variable: {
|
||||
name: string;
|
||||
description: string;
|
||||
value: string;
|
||||
sensitive: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const StyledTruncatedCell = styled(TableCell)`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const StyledExpandedDetails = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
margin: ${({ theme }) => theme.spacing(2)} 0;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: fit-content;
|
||||
min-height: min-content;
|
||||
`;
|
||||
|
||||
const StyledDetailLabel = styled.div`
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
padding-right: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledEllipsisLabel = styled.div`
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledExpandedLabel = styled.div`
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
`;
|
||||
|
||||
const StyledTransitionedIconChevronRight = styled(IconChevronRight)`
|
||||
cursor: pointer;
|
||||
transform: ${({ $isExpanded }: { $isExpanded: boolean }) =>
|
||||
$isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'};
|
||||
transition: ${({ theme }) =>
|
||||
`transform ${theme.animation.duration.normal}s ease`};
|
||||
`;
|
||||
|
||||
export const SettingsAdminEnvVariablesRow = ({
|
||||
variable,
|
||||
}: SettingsAdminEnvVariablesRowProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showSensitiveValue, setShowSensitiveValue] = useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
const displayValue =
|
||||
variable.value === ''
|
||||
? 'null'
|
||||
: variable.sensitive && !showSensitiveValue
|
||||
? '••••••'
|
||||
: variable.value;
|
||||
|
||||
const handleToggleVisibility = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setShowSensitiveValue(!showSensitiveValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
gridAutoColumns="4fr 3fr 2fr 1fr 1fr"
|
||||
>
|
||||
<StyledTruncatedCell color="primary">
|
||||
<StyledEllipsisLabel>{variable.name}</StyledEllipsisLabel>
|
||||
</StyledTruncatedCell>
|
||||
<StyledTruncatedCell>
|
||||
<StyledEllipsisLabel>{variable.description}</StyledEllipsisLabel>
|
||||
</StyledTruncatedCell>
|
||||
<StyledTruncatedCell>
|
||||
<StyledEllipsisLabel>{displayValue}</StyledEllipsisLabel>
|
||||
</StyledTruncatedCell>
|
||||
<TableCell align="right">
|
||||
{variable.sensitive && variable.value !== '' && (
|
||||
<LightIconButton
|
||||
Icon={showSensitiveValue ? IconEyeOff : IconEye}
|
||||
size="small"
|
||||
accent="secondary"
|
||||
onClick={handleToggleVisibility}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<StyledTransitionedIconChevronRight
|
||||
$isExpanded={isExpanded}
|
||||
size={theme.icon.size.sm}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
|
||||
<StyledExpandedDetails>
|
||||
<StyledDetailLabel>Name:</StyledDetailLabel>
|
||||
<StyledEllipsisLabel>{variable.name}</StyledEllipsisLabel>
|
||||
<StyledDetailLabel>Description:</StyledDetailLabel>
|
||||
<StyledExpandedLabel>{variable.description}</StyledExpandedLabel>
|
||||
<StyledDetailLabel>Value:</StyledDetailLabel>
|
||||
<StyledExpandedLabel>{displayValue}</StyledExpandedLabel>
|
||||
</StyledExpandedDetails>
|
||||
</AnimatedExpandableContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
import { SettingsAdminEnvVariablesRow } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesRow';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledTable = styled(Table)`
|
||||
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
type SettingsAdminEnvVariablesTableProps = {
|
||||
variables: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
value: string;
|
||||
sensitive: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const SettingsAdminEnvVariablesTable = ({
|
||||
variables,
|
||||
}: SettingsAdminEnvVariablesTableProps) => (
|
||||
<StyledTable>
|
||||
<TableRow gridAutoColumns="4fr 3fr 2fr 1fr 1fr">
|
||||
<TableHeader>Name</TableHeader>
|
||||
<TableHeader>Description</TableHeader>
|
||||
<TableHeader>Value</TableHeader>
|
||||
<TableHeader align="right"></TableHeader>
|
||||
<TableHeader align="right"></TableHeader>
|
||||
</TableRow>
|
||||
{variables.map((variable) => (
|
||||
<SettingsAdminEnvVariablesRow key={variable.name} variable={variable} />
|
||||
))}
|
||||
</StyledTable>
|
||||
);
|
||||
@ -0,0 +1,192 @@
|
||||
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
||||
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
|
||||
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 { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { TabList } from '@/ui/layout/tab/components/TabList';
|
||||
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
||||
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 { useRecoilValue } from 'recoil';
|
||||
import { getImageAbsoluteURI, isDefined } from 'twenty-shared';
|
||||
import {
|
||||
Button,
|
||||
H1Title,
|
||||
H1TitleFontColor,
|
||||
H2Title,
|
||||
IconSearch,
|
||||
Section,
|
||||
} from 'twenty-ui';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
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 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 SettingsAdminGeneral = () => {
|
||||
const [userIdentifier, setUserIdentifier] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
|
||||
const { error: impersonateError } = useImpersonate();
|
||||
|
||||
const { activeTabId, setActiveTabId } = useTabList(
|
||||
SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID,
|
||||
);
|
||||
const userLookupResult = useRecoilValue(userLookupResultState);
|
||||
const adminPanelError = useRecoilValue(adminPanelErrorState);
|
||||
|
||||
const { handleUserLookup, isLoading } = useUserLookup();
|
||||
|
||||
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
|
||||
|
||||
const handleSearch = async () => {
|
||||
setActiveTabId('');
|
||||
|
||||
const result = await handleUserLookup(userIdentifier);
|
||||
|
||||
if (isDefined(result?.user?.id) && !adminPanelError) {
|
||||
setUserId(result.user.id.trim());
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(result?.workspaces) &&
|
||||
result.workspaces.length > 0 &&
|
||||
!adminPanelError
|
||||
) {
|
||||
setActiveTabId(result.workspaces[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowUserData = userLookupResult && !adminPanelError;
|
||||
|
||||
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 userFullName = `${userLookupResult?.user.firstName || ''} ${
|
||||
userLookupResult?.user.lastName || ''
|
||||
}`.trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
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>
|
||||
<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>
|
||||
|
||||
{(adminPanelError || impersonateError) && (
|
||||
<StyledErrorSection>
|
||||
{adminPanelError ?? impersonateError}
|
||||
</StyledErrorSection>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{shouldShowUserData && (
|
||||
<Section>
|
||||
<StyledUserInfo>
|
||||
<H1Title title="User Info" fontColor={H1TitleFontColor.Primary} />
|
||||
<H2Title title={userFullName} 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_USER_LOOKUP_WORKSPACE_TABS_ID}
|
||||
behaveAsLinks={false}
|
||||
/>
|
||||
</StyledTabListContainer>
|
||||
<StyledContentContainer>
|
||||
<SettingsAdminWorkspaceContent
|
||||
activeWorkspace={activeWorkspace}
|
||||
userId={userId}
|
||||
/>
|
||||
</StyledContentContainer>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,18 @@
|
||||
import { SettingsAdminEnvVariables } from '@/settings/admin-panel/components/SettingsAdminEnvVariables';
|
||||
import { SettingsAdminGeneral } from '@/settings/admin-panel/components/SettingsAdminGeneral';
|
||||
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
|
||||
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
|
||||
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
||||
|
||||
export const SettingsAdminTabContent = () => {
|
||||
const { activeTabId } = useTabList(SETTINGS_ADMIN_TABS_ID);
|
||||
|
||||
switch (activeTabId) {
|
||||
case SETTINGS_ADMIN_TABS.GENERAL:
|
||||
return <SettingsAdminGeneral />;
|
||||
case SETTINGS_ADMIN_TABS.ENV_VARIABLES:
|
||||
return <SettingsAdminEnvVariables />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
import { Button, H2Title, IconUser, Toggle } from 'twenty-ui';
|
||||
|
||||
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
||||
import { useFeatureFlag } from '@/settings/admin-panel/hooks/useFeatureFlag';
|
||||
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
|
||||
import { WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo';
|
||||
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 styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
type SettingsAdminWorkspaceContentProps = {
|
||||
activeWorkspace: WorkspaceInfo | undefined;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const StyledTable = styled(Table)`
|
||||
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const SettingsAdminWorkspaceContent = ({
|
||||
activeWorkspace,
|
||||
userId,
|
||||
}: SettingsAdminWorkspaceContentProps) => {
|
||||
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
|
||||
|
||||
const {
|
||||
handleImpersonate,
|
||||
isLoading: isImpersonateLoading,
|
||||
canImpersonate,
|
||||
} = useImpersonate();
|
||||
|
||||
const { handleFeatureFlagUpdate } = useFeatureFlag();
|
||||
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{canManageFeatureFlags && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,2 +0,0 @@
|
||||
export const SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID =
|
||||
'settings-admin-feature-flags-tab-id';
|
||||
@ -0,0 +1,4 @@
|
||||
export const SETTINGS_ADMIN_TABS = {
|
||||
GENERAL: 'general',
|
||||
ENV_VARIABLES: 'env-variables',
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const SETTINGS_ADMIN_TABS_ID = 'settings-admin-tabs-id';
|
||||
@ -0,0 +1,2 @@
|
||||
export const SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID =
|
||||
'settings-admin-user-lookup-workspace-tabs-id';
|
||||
@ -4,7 +4,9 @@ export const GET_ENVIRONMENT_VARIABLES_GROUPED = gql`
|
||||
query GetEnvironmentVariablesGrouped {
|
||||
getEnvironmentVariablesGrouped {
|
||||
groups {
|
||||
groupName
|
||||
name
|
||||
description
|
||||
isHiddenOnLoad
|
||||
variables {
|
||||
name
|
||||
description
|
||||
@ -12,7 +14,8 @@ export const GET_ENVIRONMENT_VARIABLES_GROUPED = gql`
|
||||
sensitive
|
||||
}
|
||||
subgroups {
|
||||
subgroupName
|
||||
name
|
||||
description
|
||||
variables {
|
||||
name
|
||||
description
|
||||
|
||||
@ -1,46 +1,21 @@
|
||||
import { UserLookup } from '@/settings/admin-panel/types/UserLookup';
|
||||
import { useState } from 'react';
|
||||
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,
|
||||
useUserLookupAdminPanelMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export const useFeatureFlagsManagement = () => {
|
||||
const [userLookupResult, setUserLookupResult] = useState<UserLookup | null>(
|
||||
null,
|
||||
export const useFeatureFlag = () => {
|
||||
const [userLookupResult, setUserLookupResult] = useRecoilState(
|
||||
userLookupResultState,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [userLookup] = useUserLookupAdminPanelMutation({
|
||||
onCompleted: (data) => {
|
||||
setIsLoading(false);
|
||||
if (isDefined(data?.userLookupAdminPanel)) {
|
||||
setUserLookupResult(data.userLookupAdminPanel);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsLoading(false);
|
||||
setError(error.message);
|
||||
},
|
||||
});
|
||||
const setError = useSetRecoilState(adminPanelErrorState);
|
||||
|
||||
const [updateFeatureFlag] = useUpdateWorkspaceFeatureFlagMutation();
|
||||
|
||||
const handleUserLookup = async (userIdentifier: string) => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
setUserLookupResult(null);
|
||||
|
||||
const response = await userLookup({
|
||||
variables: { userIdentifier },
|
||||
});
|
||||
|
||||
return response.data?.userLookupAdminPanel;
|
||||
};
|
||||
|
||||
const handleFeatureFlagUpdate = async (
|
||||
workspaceId: string,
|
||||
featureFlag: FeatureFlagKey,
|
||||
@ -83,10 +58,6 @@ export const useFeatureFlagsManagement = () => {
|
||||
};
|
||||
|
||||
return {
|
||||
userLookupResult,
|
||||
handleUserLookup,
|
||||
handleFeatureFlagUpdate,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const adminPanelErrorState = atom<string | null>({
|
||||
key: 'adminPanelErrorState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
import { UserLookup } from '@/settings/admin-panel/types/UserLookup';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const userLookupResultState = atom<UserLookup | null>({
|
||||
key: 'userLookupResultState',
|
||||
default: null,
|
||||
});
|
||||
Reference in New Issue
Block a user