nitin
2025-03-10 19:02:40 +05:30
committed by GitHub
parent a1e0d7b7d7
commit 77574594f2
29 changed files with 496 additions and 212 deletions

View File

@ -274,14 +274,6 @@ const SettingsAdmin = lazy(() =>
})),
);
const SettingsAdminContent = lazy(() =>
import('@/settings/admin-panel/components/SettingsAdminContent').then(
(module) => ({
default: module.SettingsAdminContent,
}),
),
);
const SettingsAdminIndicatorHealthStatus = lazy(() =>
import(
'~/pages/settings/admin-panel/SettingsAdminIndicatorHealthStatus'
@ -290,6 +282,14 @@ const SettingsAdminIndicatorHealthStatus = lazy(() =>
})),
);
const SettingsAdminSecondaryEnvVariables = lazy(() =>
import(
'~/pages/settings/admin-panel/SettingsAdminSecondaryEnvVariables'
).then((module) => ({
default: module.SettingsAdminSecondaryEnvVariables,
})),
);
const SettingsLab = lazy(() =>
import('~/pages/settings/lab/SettingsLab').then((module) => ({
default: module.SettingsLab,
@ -482,14 +482,14 @@ export const SettingsRoutes = ({
{isAdminPageEnabled && (
<>
<Route path={SettingsPath.ServerAdmin} element={<SettingsAdmin />} />
<Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} />
<Route
path={SettingsPath.FeatureFlags}
element={<SettingsAdminContent />}
path={SettingsPath.AdminPanelIndicatorHealthStatus}
element={<SettingsAdminIndicatorHealthStatus />}
/>
<Route
path={SettingsPath.ServerAdminIndicatorHealthStatus}
element={<SettingsAdminIndicatorHealthStatus />}
path={SettingsPath.AdminPanelOtherEnvVariables}
element={<SettingsAdminSecondaryEnvVariables />}
/>
</>
)}

View File

@ -1,9 +1,11 @@
import { currentUserState } from '@/auth/states/currentUserState';
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 styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { IconHeart, IconSettings2, IconVariable } from 'twenty-ui';
const StyledTabListContainer = styled.div`
@ -15,21 +17,28 @@ const StyledTabListContainer = styled.div`
`;
export const SettingsAdminContent = () => {
const currentUser = useRecoilValue(currentUserState);
const canAccessFullAdminPanel = currentUser?.canAccessFullAdminPanel;
const canImpersonate = currentUser?.canImpersonate;
const tabs = [
{
id: SETTINGS_ADMIN_TABS.GENERAL,
title: t`General`,
Icon: IconSettings2,
disabled: !canAccessFullAdminPanel && !canImpersonate,
},
{
id: SETTINGS_ADMIN_TABS.ENV_VARIABLES,
title: t`Env Variables`,
Icon: IconVariable,
disabled: !canAccessFullAdminPanel,
},
{
id: SETTINGS_ADMIN_TABS.HEALTH_STATUS,
title: t`Health Status`,
Icon: IconHeart,
disabled: !canAccessFullAdminPanel,
},
];

View File

@ -1,60 +1,35 @@
import { SettingsAdminEnvVariablesTable } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesTable';
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsListItemCardContent } from '@/settings/components/SettingsListItemCardContent';
import { SettingsPath } from '@/types/SettingsPath';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { Button, H1Title, H1TitleFontColor, H2Title, Section } from 'twenty-ui';
import { t } from '@lingui/core/macro';
import { Card, H2Title, IconHeartRateMonitor, Section } from 'twenty-ui';
import { useGetEnvironmentVariablesGroupedQuery } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledGroupContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(6)};
`;
const StyledGroupDescription = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledButtonsRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(6)};
`;
const StyledShowMoreButton = styled(Button)<{ isSelected?: boolean }>`
${({ isSelected, theme }) =>
isSelected &&
`
background-color: ${theme.background.transparent.light};
`}
const StyledInfoText = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
`;
export const SettingsAdminEnvVariables = () => {
const theme = useTheme();
const { data: environmentVariables, loading: environmentVariablesLoading } =
useGetEnvironmentVariablesGroupedQuery({
fetchPolicy: 'network-only',
});
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const toggleGroupVisibility = (groupName: string) => {
setSelectedGroup(selectedGroup === groupName ? null : groupName);
};
const hiddenGroups =
environmentVariables?.getEnvironmentVariablesGrouped.groups.filter(
(group) => group.isHiddenOnLoad,
) ?? [];
const visibleGroups =
environmentVariables?.getEnvironmentVariablesGrouped.groups.filter(
(group) => !group.isHiddenOnLoad,
) ?? [];
const selectedGroupData =
environmentVariables?.getEnvironmentVariablesGrouped.groups.find(
(group) => group.name === selectedGroup,
);
if (environmentVariablesLoading) {
return <SettingsAdminTabSkeletonLoader />;
}
@ -62,9 +37,11 @@ export const SettingsAdminEnvVariables = () => {
return (
<>
<Section>
These are only the server values. Ensure your worker environment has the
<StyledInfoText>
{t` These are only the server values. Ensure your worker environment has the
same variables and values, this is required for asynchronous tasks like
email sync.
email sync.`}
</StyledInfoText>
</Section>
<Section>
{visibleGroups.map((group) => (
@ -76,42 +53,15 @@ export const SettingsAdminEnvVariables = () => {
</StyledGroupContainer>
))}
{hiddenGroups.length > 0 && (
<>
<StyledButtonsRow>
{hiddenGroups.map((group) => (
<StyledShowMoreButton
key={group.name}
onClick={() => toggleGroupVisibility(group.name)}
title={group.name}
variant="secondary"
isSelected={selectedGroup === group.name}
>
{group.name} variables
</StyledShowMoreButton>
))}
</StyledButtonsRow>
{selectedGroupData && (
<StyledGroupContainer>
<H1Title
title={selectedGroupData.name}
fontColor={H1TitleFontColor.Primary}
/>
{selectedGroupData.description !== '' && (
<StyledGroupDescription>
{selectedGroupData.description}
</StyledGroupDescription>
)}
{selectedGroupData.variables.length > 0 && (
<SettingsAdminEnvVariablesTable
variables={selectedGroupData.variables}
/>
)}
</StyledGroupContainer>
)}
</>
)}
<Card rounded>
<SettingsListItemCardContent
label={t`Other Variables`}
to={getSettingsPath(SettingsPath.AdminPanelOtherEnvVariables)}
rightComponent={null}
LeftIcon={IconHeartRateMonitor}
LeftIconColor={theme.font.color.tertiary}
/>
</Card>
</Section>
</>
);

View File

@ -16,7 +16,6 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { getImageAbsoluteURI, isDefined } from 'twenty-shared';
import {
Button,
GithubVersionLink,
H1Title,
H1TitleFontColor,
H2Title,
@ -27,7 +26,7 @@ import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useUserLookupAdminPanelMutation } from '~/generated/graphql';
import { currentUserState } from '@/auth/states/currentUserState';
import packageJson from '../../../../../package.json';
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
const StyledContainer = styled.div`
align-items: center;
@ -54,11 +53,6 @@ const StyledContentContainer = styled.div`
padding: ${({ theme }) => theme.spacing(4)} 0;
`;
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.color.red};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAdminGeneral = () => {
const [userIdentifier, setUserIdentifier] = useState('');
const { enqueueSnackBar } = useSnackBar();
@ -75,6 +69,8 @@ export const SettingsAdminGeneral = () => {
const currentUser = useRecoilValue(currentUserState);
const canAccessFullAdminPanel = currentUser?.canAccessFullAdminPanel;
const canImpersonate = currentUser?.canImpersonate;
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
@ -130,51 +126,51 @@ export const SettingsAdminGeneral = () => {
return (
<>
<Section>
<H2Title title={t`About`} description={t`Version of the application`} />
<GithubVersionLink version={packageJson.version} />
</Section>
<Section>
<H2Title
title={
canManageFeatureFlags
? t`Feature Flags & Impersonation`
: t`User Impersonation`
}
description={
canManageFeatureFlags
? t`Look up users and manage their workspace feature flags or impersonate them.`
: t`Look up users to impersonate them.`
}
/>
<StyledContainer>
<TextInput
value={userIdentifier}
onChange={setUserIdentifier}
onInputEnter={handleSearch}
placeholder={t`Enter user ID or email address`}
fullWidth
disabled={isUserLookupLoading}
{canAccessFullAdminPanel && (
<Section>
<H2Title
title={t`About`}
description={t`Version of the application`}
/>
<Button
Icon={IconSearch}
variant="primary"
accent="blue"
title={t`Search`}
onClick={handleSearch}
disabled={
!userIdentifier.trim() || isUserLookupLoading || !canImpersonate
<SettingsAdminVersionContainer />
</Section>
)}
{canImpersonate && (
<Section>
<H2Title
title={
canManageFeatureFlags
? t`Feature Flags & Impersonation`
: t`User Impersonation`
}
description={
canManageFeatureFlags
? t`Look up users and manage their workspace feature flags or impersonate them.`
: t`Look up users to impersonate them.`
}
/>
</StyledContainer>
{!canImpersonate && (
<StyledErrorMessage>
{t`You do not have access to impersonate users.`}
</StyledErrorMessage>
)}
</Section>
<StyledContainer>
<TextInput
value={userIdentifier}
onChange={setUserIdentifier}
onInputEnter={handleSearch}
placeholder={t`Enter user ID or email address`}
fullWidth
disabled={isUserLookupLoading}
/>
<Button
Icon={IconSearch}
variant="primary"
accent="blue"
title={t`Search`}
onClick={handleSearch}
disabled={!userIdentifier.trim() || isUserLookupLoading}
/>
</StyledContainer>
</Section>
)}
{isDefined(userLookupResult) && (
<Section>

View File

@ -0,0 +1,118 @@
import { IconCircleDot, IconComponent, IconStatusChange } from 'twenty-ui';
import { GITHUB_LINK } from '@ui/navigation/link/constants/GithubLink';
import { checkTwentyVersionExists } from '@/settings/admin-panel/utils/checkTwentyVersionExists';
import { fetchLatestTwentyRelease } from '@/settings/admin-panel/utils/fetchLatestTwentyRelease';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useEffect, useState } from 'react';
import packageJson from '../../../../../package.json';
const StyledVersionContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(3)};
display: grid;
`;
const StyledVersionDetails = styled.div`
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledVersionText = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
`;
const StyledActionLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
padding: 0 ${({ theme }) => theme.spacing(1)};
text-decoration: none;
:hover {
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
}
`;
const StyledSpan = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
type VersionDetail = {
Icon: IconComponent;
text: string;
version: string | null;
link: string;
type: 'current' | 'latest';
};
export const SettingsAdminVersionContainer = () => {
const theme = useTheme();
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [currentVersionExists, setCurrentVersionExists] = useState(false);
useEffect(() => {
fetchLatestTwentyRelease().then(setLatestVersion);
checkTwentyVersionExists(packageJson.version).then(setCurrentVersionExists);
}, []);
const VERSION_DETAILS: VersionDetail[] = [
{
Icon: IconCircleDot,
text: t`Current version:`,
version: packageJson.version,
link: `${GITHUB_LINK}/releases/tag/v${packageJson.version}`,
type: 'current',
},
{
Icon: IconStatusChange,
text: t`Latest version:`,
version: latestVersion,
link: `${GITHUB_LINK}/releases/tag/v${latestVersion}`,
type: 'latest',
},
];
return (
<StyledVersionContainer>
{VERSION_DETAILS.map((versionDetail, index) => (
<StyledVersionDetails key={index}>
<versionDetail.Icon
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
<StyledVersionText>{versionDetail.text}</StyledVersionText>
{versionDetail.version &&
(versionDetail.type === 'current' ? currentVersionExists : true) ? (
<StyledActionLink
href={versionDetail.link}
target="_blank"
rel="noreferrer"
>
{versionDetail.version}
</StyledActionLink>
) : (
<StyledSpan>{versionDetail.version}</StyledSpan>
)}
</StyledVersionDetails>
))}
</StyledVersionContainer>
);
};

View File

@ -1,6 +1,7 @@
import { SettingsAdminHealthAccountSyncCountersTable } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthAccountSyncCountersTable';
import { SettingsAdminIndicatorHealthContext } from '@/settings/admin-panel/health-status/contexts/SettingsAdminIndicatorHealthContext';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
@ -44,14 +45,14 @@ export const ConnectedAccountHealthStatus = () => {
{!isMessageSyncDown && serviceDetails.messageSync?.details && (
<SettingsAdminHealthAccountSyncCountersTable
details={serviceDetails.messageSync.details}
title="Message Sync Status"
title={t`Message Sync Status`}
/>
)}
{!isCalendarSyncDown && serviceDetails.calendarSync?.details && (
<SettingsAdminHealthAccountSyncCountersTable
details={serviceDetails.calendarSync.details}
title="Calendar Sync Status"
title={t`Calendar Sync Status`}
/>
)}
</>

View File

@ -1,5 +1,6 @@
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsHealthStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsHealthStatusListCard';
import { t } from '@lingui/core/macro';
import { H2Title, Section } from 'twenty-ui';
import { useGetSystemHealthStatusQuery } from '~/generated/graphql';
@ -17,7 +18,10 @@ export const SettingsAdminHealthStatus = () => {
return (
<>
<Section>
<H2Title title="Health Status" description="How your system is doing" />
<H2Title
title={t`Health Status`}
description={t`How your system is doing`}
/>
<SettingsHealthStatusListCard
services={services}
loading={loadingHealthStatus}

View File

@ -1,9 +1,26 @@
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { SettingsPath } from '@/types/SettingsPath';
import { SystemHealthService } from '~/generated/graphql';
import { useTheme } from '@emotion/react';
import {
IconAppWindow,
IconComponent,
IconDatabase,
IconServer2,
IconTool,
IconUserCircle,
} from 'twenty-ui';
import { HealthIndicatorId, SystemHealthService } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsAdminHealthStatusRightContainer } from './SettingsAdminHealthStatusRightContainer';
const HealthStatusIcons: { [k in HealthIndicatorId]: IconComponent } = {
[HealthIndicatorId.database]: IconDatabase,
[HealthIndicatorId.redis]: IconServer2,
[HealthIndicatorId.worker]: IconTool,
[HealthIndicatorId.connectedAccount]: IconUserCircle,
[HealthIndicatorId.app]: IconAppWindow,
};
export const SettingsHealthStatusListCard = ({
services,
loading,
@ -11,16 +28,21 @@ export const SettingsHealthStatusListCard = ({
services: Array<SystemHealthService>;
loading?: boolean;
}) => {
const theme = useTheme();
return (
<SettingsListCard
items={services}
rounded={true}
RowIconFn={(row) => HealthStatusIcons[row.id]}
RowIconColor={theme.font.color.tertiary}
getItemLabel={(service) => service.label}
isLoading={loading}
RowRightComponent={({ item: service }) => (
<SettingsAdminHealthStatusRightContainer status={service.status} />
)}
to={(service) =>
getSettingsPath(SettingsPath.ServerAdminIndicatorHealthStatus, {
getSettingsPath(SettingsPath.AdminPanelIndicatorHealthStatus, {
indicatorId: service.id,
})
}

View File

@ -1,5 +1,6 @@
import { WorkerQueueMetricsSection } from '@/settings/admin-panel/health-status/components/WorkerQueueMetricsSection';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
import { SettingsAdminIndicatorHealthContext } from '../contexts/SettingsAdminIndicatorHealthContext';
@ -20,7 +21,7 @@ export const WorkerHealthStatus = () => {
<>
{isWorkerDown ? (
<StyledErrorMessage>
Queue information is not available because the worker is down
{t`Queue information is not available because the worker is down`}
</StyledErrorMessage>
) : (
(indicatorHealth.queues ?? []).map((queue) => (

View File

@ -1,13 +1,13 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ResponsiveLine } from '@nivo/line';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { Table } from '@/ui/layout/table/components/Table';
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 { t } from '@lingui/core/macro';
import { ResponsiveLine } from '@nivo/line';
import {
QueueMetricsTimeRange,
useGetQueueMetricsQuery,
@ -142,17 +142,17 @@ export const WorkerMetricsGraph = ({
const getAxisLabel = () => {
switch (timeRange) {
case QueueMetricsTimeRange.OneHour:
return 'Last 1 Hour (oldest → newest)';
return t`Last 1 Hour (oldest → newest)`;
case QueueMetricsTimeRange.FourHours:
return 'Last 4 Hours (oldest → newest)';
return t`Last 4 Hours (oldest → newest)`;
case QueueMetricsTimeRange.TwelveHours:
return 'Last 12 Hours (oldest → newest)';
return t`Last 12 Hours (oldest → newest)`;
case QueueMetricsTimeRange.OneDay:
return 'Last 24 Hours (oldest → newest)';
return t`Last 24 Hours (oldest → newest)`;
case QueueMetricsTimeRange.SevenDays:
return 'Last 7 Days (oldest → newest)';
return t`Last 7 Days (oldest → newest)`;
default:
return 'Recent Events (oldest → newest)';
return t`Recent Events (oldest → newest)`;
}
};
@ -163,14 +163,14 @@ export const WorkerMetricsGraph = ({
dropdownId={`timerange-${queueName}`}
value={timeRange}
options={[
{ value: QueueMetricsTimeRange.SevenDays, label: 'This week' },
{ value: QueueMetricsTimeRange.OneDay, label: 'Today' },
{ value: QueueMetricsTimeRange.SevenDays, label: t`This week` },
{ value: QueueMetricsTimeRange.OneDay, label: t`Today` },
{
value: QueueMetricsTimeRange.TwelveHours,
label: 'Last 12 hours',
label: t`Last 12 hours`,
},
{ value: QueueMetricsTimeRange.FourHours, label: 'Last 4 hours' },
{ value: QueueMetricsTimeRange.OneHour, label: 'Last 1 hour' },
{ value: QueueMetricsTimeRange.FourHours, label: t`Last 4 hours` },
{ value: QueueMetricsTimeRange.OneHour, label: t`Last 1 hour` },
]}
onChange={onTimeRangeChange}
needIconCheck
@ -179,7 +179,7 @@ export const WorkerMetricsGraph = ({
<StyledGraphContainer>
{loading ? (
<StyledNoDataMessage>Loading metrics data...</StyledNoDataMessage>
<StyledNoDataMessage>{t`Loading metrics data...`}</StyledNoDataMessage>
) : hasData ? (
<ResponsiveLine
data={metricsData}
@ -282,7 +282,7 @@ export const WorkerMetricsGraph = ({
]}
/>
) : (
<StyledNoDataMessage>No metrics data available</StyledNoDataMessage>
<StyledNoDataMessage>{t`No metrics data available`}</StyledNoDataMessage>
)}
</StyledGraphContainer>
{metricsDetails && (

View File

@ -1,3 +1,4 @@
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { H2Title, Section } from 'twenty-ui';
import {
@ -17,7 +18,7 @@ export const WorkerQueueMetricsSection = ({
return (
<Section>
<H2Title title={queue.queueName} description="Queue performance" />
<H2Title title={queue.queueName} description={t`Queue performance`} />
<WorkerMetricsGraph
queueName={queue.queueName}
timeRange={timeRange}

View File

@ -0,0 +1,12 @@
export const checkTwentyVersionExists = async (
version: string,
): Promise<boolean> => {
try {
const response = await fetch(
`https://api.github.com/repos/twentyhq/twenty/releases/tags/v${version}`,
);
return response.status === 200;
} catch (error) {
return false;
}
};

View File

@ -0,0 +1,11 @@
export const fetchLatestTwentyRelease = async (): Promise<string> => {
try {
const response = await fetch(
'https://api.github.com/repos/twentyhq/twenty/releases/latest',
);
const data = await response.json();
return data.tag_name.replace('v', '');
} catch (error) {
return 'Could not fetch latest release';
}
};

View File

@ -42,10 +42,12 @@ type SettingsListCardProps<ListItem extends { id: string }> = {
onRowClick?: (item: ListItem) => void;
RowIcon?: IconComponent;
RowIconFn?: (item: ListItem) => IconComponent;
RowIconColor?: string;
RowRightComponent: ComponentType<{ item: ListItem }>;
footerButtonLabel?: string;
onFooterButtonClick?: () => void;
to?: (item: ListItem) => string;
rounded?: boolean;
};
export const SettingsListCard = <
@ -61,21 +63,24 @@ export const SettingsListCard = <
onRowClick,
RowIcon,
RowIconFn,
RowIconColor,
RowRightComponent,
onFooterButtonClick,
footerButtonLabel,
to,
rounded,
}: SettingsListCardProps<ListItem>) => {
const theme = useTheme();
if (isLoading === true) return <SettingsListSkeletonCard />;
return (
<Card>
<Card rounded={rounded}>
{items.map((item, index) => (
<SettingsListItemCardContent
key={item.id}
LeftIcon={RowIconFn ? RowIconFn(item) : RowIcon}
LeftIconColor={RowIconColor}
label={getItemLabel(item)}
description={getItemDescription?.(item)}
rightComponent={<RowRightComponent item={item} />}

View File

@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { isDefined } from 'twenty-shared';
import { CardContent, IconComponent } from 'twenty-ui';
import { CardContent, IconChevronRight, IconComponent } from 'twenty-ui';
const StyledRow = styled(CardContent)`
align-items: center;
@ -17,6 +17,12 @@ const StyledRow = styled(CardContent)`
min-height: ${({ theme }) => theme.spacing(6)};
`;
const StyledRightContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledContent = styled.div`
flex: 1 0 auto;
display: flex;
@ -43,6 +49,7 @@ type SettingsListItemCardContentProps = {
description?: string;
divider?: boolean;
LeftIcon?: IconComponent;
LeftIconColor?: string;
onClick?: () => void;
rightComponent: ReactNode;
to?: string;
@ -53,6 +60,7 @@ export const SettingsListItemCardContent = ({
description,
divider,
LeftIcon,
LeftIconColor,
onClick,
rightComponent,
to,
@ -61,12 +69,25 @@ export const SettingsListItemCardContent = ({
const content = (
<StyledRow onClick={onClick} divider={divider}>
{!!LeftIcon && <LeftIcon size={theme.icon.size.md} />}
{!!LeftIcon && (
<LeftIcon
size={theme.icon.size.md}
color={LeftIconColor ?? 'currentColor'}
/>
)}
<StyledContent>
{label}
{!!description && <StyledDescription>{description}</StyledDescription>}
</StyledContent>
{rightComponent}
<StyledRightContainer>
{rightComponent}
{!!to && (
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
)}
</StyledRightContainer>
</StyledRow>
);

View File

@ -186,8 +186,8 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
label: t`Other`,
items: [
{
label: t`Server Admin`,
path: SettingsPath.ServerAdmin,
label: t`Admin Panel`,
path: SettingsPath.AdminPanel,
Icon: IconServer,
isHidden: !isAdminEnabled,
},

View File

@ -36,10 +36,10 @@ export enum SettingsPath {
DevelopersNewWebhook = 'developers/webhooks/new',
DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId',
Releases = 'releases',
ServerAdmin = 'server-admin',
FeatureFlags = 'server-admin/feature-flags',
ServerAdminHealthStatus = 'server-admin#health-status',
ServerAdminIndicatorHealthStatus = 'server-admin/health-status/:indicatorId',
AdminPanel = 'admin-panel',
AdminPanelHealthStatus = 'admin-panel#health-status',
AdminPanelIndicatorHealthStatus = 'admin-panel/health-status/:indicatorId',
AdminPanelOtherEnvVariables = 'admin-panel/other-env-variables',
Lab = 'lab',
Roles = 'roles',
RoleDetail = 'roles/:roleId',

View File

@ -10,13 +10,13 @@ export const SettingsAdmin = () => {
return (
<SubMenuTopBarContainer
title={t`Server Admin`}
title={t`Admin Panel`}
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.ServerAdmin),
href: getSettingsPath(SettingsPath.AdminPanel),
},
{ children: t`Server Admin` },
{ children: t`Admin Panel` },
]}
>
<SettingsPageContainer>

View File

@ -8,7 +8,7 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
import { H2Title, Section } from 'twenty-ui';
import { H3Title, Section } from 'twenty-ui';
import {
AdminPanelHealthServiceStatus,
HealthIndicatorId,
@ -16,19 +16,24 @@ import {
} from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledH2Title = styled(H2Title)`
const StyledH3Title = styled(H3Title)`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledTitleContainer = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledHealthStatusContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(1)};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAdminIndicatorHealthStatus = () => {
@ -51,15 +56,15 @@ export const SettingsAdminIndicatorHealthStatus = () => {
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.ServerAdmin),
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Server Admin`,
href: getSettingsPath(SettingsPath.ServerAdmin),
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Health Status`,
href: getSettingsPath(SettingsPath.ServerAdminHealthStatus),
href: getSettingsPath(SettingsPath.AdminPanelHealthStatus),
},
{ children: `${data?.getIndicatorHealthStatus?.label}` },
]}
@ -82,19 +87,20 @@ export const SettingsAdminIndicatorHealthStatus = () => {
>
<Section>
<StyledTitleContainer>
<StyledH2Title
<StyledH3Title
title={`${data?.getIndicatorHealthStatus?.label}`}
description={data?.getIndicatorHealthStatus?.description}
/>
{indicatorId !== HealthIndicatorId.connectedAccount &&
data?.getIndicatorHealthStatus?.status && (
<StyledHealthStatusContainer>
<SettingsAdminHealthStatusRightContainer
status={data?.getIndicatorHealthStatus.status}
/>
</StyledHealthStatusContainer>
)}
{data?.getIndicatorHealthStatus?.status && (
<StyledHealthStatusContainer>
<SettingsAdminHealthStatusRightContainer
status={data?.getIndicatorHealthStatus.status}
/>
</StyledHealthStatusContainer>
)}
</StyledTitleContainer>
<StyledDescription>
{data?.getIndicatorHealthStatus?.description}
</StyledDescription>
</Section>
<SettingsAdminIndicatorHealthStatusContent />

View File

@ -0,0 +1,61 @@
import { SettingsAdminEnvVariablesTable } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesTable';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title, Section } from 'twenty-ui';
import { useGetEnvironmentVariablesGroupedQuery } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledGroupContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(6)};
`;
export const SettingsAdminSecondaryEnvVariables = () => {
const { data: environmentVariables, loading: environmentVariablesLoading } =
useGetEnvironmentVariablesGroupedQuery({
fetchPolicy: 'network-only',
});
const hiddenGroups =
environmentVariables?.getEnvironmentVariablesGrouped.groups.filter(
(group) => group.isHiddenOnLoad,
) ?? [];
if (environmentVariablesLoading) {
return <SettingsSkeletonLoader />;
}
return (
<SubMenuTopBarContainer
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Other Environment Variables`,
},
]}
>
<SettingsPageContainer>
<Section>
{hiddenGroups.map((group) => (
<StyledGroupContainer key={group.name}>
<H2Title title={group.name} description={group.description} />
{group.variables.length > 0 && (
<SettingsAdminEnvVariablesTable variables={group.variables} />
)}
</StyledGroupContainer>
))}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -15,12 +15,14 @@ import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filt
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AdminPanelHealthServiceData } from './dtos/admin-panel-health-service-data.dto';
import { QueueMetricsData } from './dtos/queue-metrics-data.dto';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter)
export class AdminPanelResolver {
@ -60,19 +62,19 @@ export class AdminPanelResolver {
return true;
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Query(() => EnvironmentVariablesOutput)
async getEnvironmentVariablesGrouped(): Promise<EnvironmentVariablesOutput> {
return this.adminService.getEnvironmentVariablesGrouped();
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Query(() => SystemHealth)
async getSystemHealthStatus(): Promise<SystemHealth> {
return this.adminPanelHealthService.getSystemHealthStatus();
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Query(() => AdminPanelHealthServiceData)
async getIndicatorHealthStatus(
@Args('indicatorId', {
@ -83,7 +85,7 @@ export class AdminPanelResolver {
return this.adminPanelHealthService.getIndicatorHealthStatus(indicatorId);
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Query(() => QueueMetricsData)
async getQueueMetrics(
@Args('queueName', { type: () => String })

View File

@ -7,7 +7,6 @@ import { AppHealthIndicator } from 'src/engine/core-modules/health/indicators/ap
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module';
import { HealthCacheService } from './health-cache.service';
@ -19,7 +18,6 @@ import { WorkerHealthIndicator } from './indicators/worker.health';
imports: [
TerminusModule,
RedisClientModule,
WorkspaceHealthModule,
ObjectMetadataModule,
WorkspaceMigrationModule,
],

View File

@ -4,12 +4,10 @@ import { Test, TestingModule } from '@nestjs/testing';
import { AppHealthIndicator } from 'src/engine/core-modules/health/indicators/app.health';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
describe('AppHealthIndicator', () => {
let service: AppHealthIndicator;
let objectMetadataService: jest.Mocked<ObjectMetadataService>;
let workspaceHealthService: jest.Mocked<WorkspaceHealthService>;
let workspaceMigrationService: jest.Mocked<WorkspaceMigrationService>;
let healthIndicatorService: jest.Mocked<HealthIndicatorService>;
@ -18,10 +16,6 @@ describe('AppHealthIndicator', () => {
findMany: jest.fn(),
} as any;
workspaceHealthService = {
healthCheck: jest.fn(),
} as any;
workspaceMigrationService = {
getPendingMigrations: jest.fn(),
} as any;
@ -44,10 +38,7 @@ describe('AppHealthIndicator', () => {
provide: ObjectMetadataService,
useValue: objectMetadataService,
},
{
provide: WorkspaceHealthService,
useValue: workspaceHealthService,
},
{
provide: WorkspaceMigrationService,
useValue: workspaceMigrationService,

View File

@ -7,7 +7,6 @@ import {
import { HealthStateManager } from 'src/engine/core-modules/health/utils/health-state-manager.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
@Injectable()
export class AppHealthIndicator {
@ -15,7 +14,6 @@ export class AppHealthIndicator {
constructor(
private readonly healthIndicatorService: HealthIndicatorService,
private readonly workspaceHealthService: WorkspaceHealthService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly workspaceMigrationService: WorkspaceMigrationService,
) {}

View File

@ -0,0 +1,52 @@
import { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
describe('AdminPanelGuard', () => {
const guard = new AdminPanelGuard();
it('should return true if user can access full admin panel', async () => {
const mockContext = {
getContext: jest.fn(() => ({
req: {
user: {
canAccessFullAdminPanel: true,
},
},
})),
};
jest
.spyOn(GqlExecutionContext, 'create')
.mockReturnValue(mockContext as any);
const mockExecutionContext = {} as ExecutionContext;
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
});
it('should return false if user cannot access full admin panel', async () => {
const mockContext = {
getContext: jest.fn(() => ({
req: {
user: {
canAccessFullAdminPanel: false,
},
},
})),
};
jest
.spyOn(GqlExecutionContext, 'create')
.mockReturnValue(mockContext as any);
const mockExecutionContext = {} as ExecutionContext;
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(false);
});
});

View File

@ -0,0 +1,15 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Observable } from 'rxjs';
export class AdminPanelGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
return request.user.canAccessFullAdminPanel === true;
}
}

View File

@ -6,6 +6,7 @@ export {
IconAlertTriangle,
IconApi,
IconApps,
IconAppWindow,
IconArchive,
IconArchiveOff,
IconArrowBackUp,
@ -158,6 +159,7 @@ export {
IconHeadphones,
IconHeart,
IconHeartOff,
IconHeartRateMonitor,
IconHelpCircle,
IconHierarchy,
IconHierarchy2,
@ -241,6 +243,7 @@ export {
IconSearch,
IconSend,
IconServer,
IconServer2,
IconSettings,
IconSettings2,
IconSettingsAutomation,
@ -254,6 +257,7 @@ export {
IconSquareKey,
IconSquareRoundedCheck,
IconSquareRoundedX,
IconStatusChange,
IconStepInto,
IconTable,
IconTag,
@ -266,6 +270,7 @@ export {
IconTimeDuration30,
IconTimeDuration60,
IconTimelineEvent,
IconTool,
IconTrash,
IconTrashX,
IconTypography,

View File

@ -1,5 +1,5 @@
import React from 'react';
import styled from '@emotion/styled';
import React from 'react';
const StyledButtonLink = styled.a`
align-items: center;
@ -17,9 +17,14 @@ const StyledButtonLink = styled.a`
}
`;
export const ActionLink = (props: React.ComponentProps<'a'>) => {
type ActionLinkProps = React.ComponentProps<'a'> & {
className?: string;
};
export const ActionLink = (props: ActionLinkProps) => {
return (
<StyledButtonLink
className={props.className}
href={props.href}
onClick={props.onClick}
target={props.target}