Add workspace DDL lock env var and maintenance mode UI (#19130)

## Summary

- Add `WORKSPACE_SCHEMA_DDL_LOCKED` env-only boolean config variable
that blocks all workspace schema DDL changes when set to `true`. This is
intended for hot upgrades where logical replication cannot handle DDL
changes. Enforced at two chokepoints:
- `WorkspaceMigrationRunnerService.run` — blocks all metadata-driven DDL
(object/field/index CRUD, app sync/uninstall, standard app sync, upgrade
commands)
- `WorkspaceDataSourceService.createWorkspaceDBSchema` /
`deleteWorkspaceDBSchema` — blocks workspace creation (sign-up) and hard
deletion. Uses a dedicated `WorkspaceDataSourceException` (not
ForbiddenException)

- Add maintenance mode feature with Admin Panel UI and user-facing
banner:
- **Backend**: `MaintenanceModeService` stores maintenance window
(startAt, endAt, optional link) in `core.keyValuePair` as
`CONFIG_VARIABLE`. Validates endAt > startAt. Uses `GraphQLISODateTime`
scalar for date fields. Exposed via `clientConfig` REST endpoint and
admin GraphQL mutations (`setMaintenanceMode`, `clearMaintenanceMode`)
- **Admin Panel**: New "Maintenance Mode" section in Health tab with UTC
datetime pickers and activate/deactivate controls
- **Banner**: `InformationBannerMaintenance` displayed at the top of
`DefaultLayout` for all users, using Temporal API for timezone-aware
formatting with an optional "Learn more" link

These two features are **independent** — the DDL lock is controlled via
env var for operational use, while maintenance mode is a UI notification
mechanism controlled from the admin panel.
This commit is contained in:
Charles Bochet
2026-04-02 12:17:04 +02:00
committed by GitHub
parent 9438b9869c
commit 81f10c586f
78 changed files with 2343 additions and 713 deletions
@@ -19,6 +19,7 @@ import {
} from '@/auth/states/signInUpStepState';
import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isNonEmptyString } from '@sniptt/guards';
@@ -136,6 +137,7 @@ const StyledForgotPasswordLinkContainer = styled.div`
export const SignInUpGlobalScopeForm = () => {
const { theme } = useContext(ThemeContext);
const authProviders = useAtomStateValue(authProvidersState);
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const signInUpStep = useAtomStateValue(signInUpStepState);
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { signOut } = useAuth();
@@ -206,19 +208,21 @@ export const SignInUpGlobalScopeForm = () => {
</StyledWorkspaceItem>
</UndecoratedLink>
))}
<StyledWorkspaceItem onClick={() => createWorkspace()}>
<StyledWorkspaceContent>
<StyledWorkspaceLogo>
<IconPlus size={theme.icon.size.lg} />
</StyledWorkspaceLogo>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
{!isDDLLocked && (
<StyledWorkspaceItem onClick={() => createWorkspace()}>
<StyledWorkspaceContent>
<StyledWorkspaceLogo>
<IconPlus size={theme.icon.size.lg} />
</StyledWorkspaceLogo>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
)}
</StyledWorkspaceContainer>
<StyledActionLinkContainer>
<ClickToActionLink onClick={signOut}>
@@ -15,6 +15,7 @@ import { AuthenticatedMethod } from '@/auth/types/AuthenticatedMethod.enum';
import { SignInUpMode } from '@/auth/types/signInUpMode';
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { captchaState } from '@/client-config/states/captchaState';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
@@ -22,6 +23,7 @@ import { useFormContext } from 'react-hook-form';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { InputHint } from '@/ui/input/components/InputHint';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -29,6 +31,7 @@ const StyledForm = styled.form`
align-items: center;
display: flex;
flex-direction: column;
max-width: 100%;
width: 100%;
`;
@@ -43,6 +46,7 @@ export const SignInUpWithCredentials = ({
const [signInUpStep, setSignInUpStep] = useAtomState(signInUpStepState);
const [showErrors, setShowErrors] = useState(false);
const captcha = useAtomStateValue(captchaState);
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const isRequestingCaptchaToken = useAtomStateValue(
isRequestingCaptchaTokenState,
);
@@ -129,9 +133,15 @@ export const SignInUpWithCredentials = ({
form.formState.isSubmitting ||
shouldWaitForCaptchaToken);
const isSignUpBlockedByDDLLock =
isDDLLocked &&
signInUpMode === SignInUpMode.SignUp &&
signInUpStep === SignInUpStep.Password;
const isSubmitButtonDisabled =
isEmailStepSubmitButtonDisabledCondition ||
isPasswordStepSubmitButtonDisabledCondition;
isPasswordStepSubmitButtonDisabledCondition ||
isSignUpBlockedByDDLLock;
return (
<>
@@ -163,6 +173,9 @@ export const SignInUpWithCredentials = ({
fullWidth
/>
{isLastUsed && <LastUsedPill />}
{isSignUpBlockedByDDLLock && (
<InputHint>{t`Sign-up is temporarily unavailable during maintenance.`}</InputHint>
)}
</StyledSSOButtonContainer>
</StyledForm>
)}
@@ -12,6 +12,8 @@ import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConf
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
import { isEmailingDomainsEnabledState } from '@/client-config/states/isEmailingDomainsEnabledState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
@@ -117,6 +119,10 @@ export const useClientConfig = (): UseClientConfigResult => {
isClickHouseConfiguredState,
);
const setIsDDLLocked = useSetAtomState(isDDLLockedState);
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
const setAppVersion = useSetAtomState(appVersionState);
const fetchClientConfig = useCallback(async () => {
@@ -195,6 +201,8 @@ export const useClientConfig = (): UseClientConfigResult => {
clientConfig?.isCloudflareIntegrationEnabled,
);
setIsClickHouseConfigured(clientConfig?.isClickHouseConfigured ?? false);
setIsDDLLocked(clientConfig?.isWorkspaceSchemaDDLLocked ?? false);
setMaintenanceMode(clientConfig?.maintenance ?? null);
} catch (err) {
const error =
err instanceof Error ? err : new Error('Failed to fetch client config');
@@ -229,7 +237,9 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsEmailingDomainsEnabled,
setIsClickHouseConfigured,
setIsCloudflareIntegrationEnabled,
setIsDDLLocked,
setLabPublicFeatureFlags,
setMaintenanceMode,
setIsMicrosoftCalendarEnabled,
setIsMicrosoftMessagingEnabled,
setSentryConfig,
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isDDLLockedState = createAtomState<boolean>({
key: 'isDDLLocked',
defaultValue: false,
});
@@ -0,0 +1,13 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { type ClientConfigMaintenanceMode } from '~/generated-metadata/graphql';
type MaintenanceModeState = Pick<
ClientConfigMaintenanceMode,
'startAt' | 'endAt' | 'link'
>;
export const maintenanceModeState =
createAtomState<MaintenanceModeState | null>({
key: 'maintenanceModeState',
defaultValue: null,
});
@@ -4,6 +4,7 @@ import {
type Billing,
type Captcha,
type ClientAiModelConfig,
type ClientConfigMaintenanceMode,
type PublicFeatureFlag,
type Sentry,
type Support,
@@ -33,10 +34,12 @@ export type ClientConfig = {
isEmailingDomainsEnabled: boolean;
isCloudflareIntegrationEnabled: boolean;
isClickHouseConfigured: boolean;
isWorkspaceSchemaDDLLocked: boolean;
publicFeatureFlags: Array<PublicFeatureFlag>;
sentry: Sentry;
signInPrefilled: boolean;
support: Support;
isTwoFactorAuthenticationEnabled: boolean;
allowRequestsToTwentyIcons: boolean;
maintenance?: ClientConfigMaintenanceMode;
};
@@ -5,6 +5,7 @@ import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import {
Banner,
type BannerColor,
type BannerVariant,
type IconComponent,
IconX,
@@ -21,6 +22,10 @@ const StyledInvertedIconButton = styled(IconButton)`
color: ${themeCssVariables.font.color.inverted} !important;
`;
const StyledSecondaryIconButton = styled(IconButton)`
color: inherit !important;
`;
const StyledContent = styled.div<{ hasCloseButton: boolean }>`
align-items: center;
display: flex;
@@ -32,7 +37,8 @@ const StyledContent = styled.div<{ hasCloseButton: boolean }>`
export const InformationBanner = ({
message,
variant = 'default',
color = 'blue',
variant = 'primary',
buttonTitle,
buttonIcon,
buttonOnClick,
@@ -41,6 +47,7 @@ export const InformationBanner = ({
componentInstanceId,
}: {
message: string;
color?: BannerColor;
variant?: BannerVariant;
buttonTitle?: string;
buttonIcon?: IconComponent;
@@ -54,6 +61,12 @@ export const InformationBanner = ({
componentInstanceId,
);
const isPrimary = variant === 'primary';
const CloseIconButton = isPrimary
? StyledInvertedIconButton
: StyledSecondaryIconButton;
return (
<InformationBannerComponentInstanceContext.Provider
value={{
@@ -61,7 +74,7 @@ export const InformationBanner = ({
}}
>
{informationBannerIsOpen && (
<Banner variant={variant}>
<Banner color={color} variant={variant}>
<StyledContent hasCloseButton={!!onClose}>
<StyledText>{message}</StyledText>
{buttonTitle && buttonOnClick && (
@@ -70,14 +83,14 @@ export const InformationBanner = ({
title={buttonTitle}
Icon={buttonIcon}
size="small"
inverted
inverted={isPrimary}
onClick={buttonOnClick}
disabled={isButtonDisabled}
/>
)}
</StyledContent>
{onClose && (
<StyledInvertedIconButton
<CloseIconButton
Icon={IconX}
size="small"
variant="tertiary"
@@ -7,6 +7,7 @@ import { InformationBannerEndTrialPeriod } from '@/information-banner/components
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
import { InformationBannerLegacyEnterpriseKey } from '@/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey';
import { InformationBannerMaintenance } from '@/information-banner/components/maintenance/InformationBannerMaintenance';
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
@@ -53,6 +54,7 @@ export const InformationBannerWrapper = () => {
return (
<StyledInformationBannerWrapper>
<InformationBannerMaintenance />
<InformationBannerLegacyEnterpriseKey />
{isAccountSyncEnabled && (
<InformationBannerReconnectAccountInsufficientPermissions />
@@ -35,7 +35,8 @@ export const InformationBannerBillingSubscriptionPaused = () => {
return (
<InformationBanner
componentInstanceId="information-banner-billing-subscription-paused"
variant="danger"
color="danger"
variant="secondary"
message={
hasPermissionToUpdateBillingDetails
? t`Trial expired. Please update your billing details.`
@@ -14,7 +14,8 @@ export const InformationBannerEndTrialPeriod = () => {
return (
<InformationBanner
componentInstanceId="information-banner-end-trial-period"
variant="danger"
color="danger"
variant="secondary"
message={
hasPermissionToEndTrialPeriod
? t`End trial period to continue using Workflow or AI features.`
@@ -32,7 +32,8 @@ export const InformationBannerFailPaymentInfo = () => {
return (
<InformationBanner
componentInstanceId="information-banner-fail-payment-info"
variant="danger"
color="danger"
variant="secondary"
message={
hasPermissionToUpdateBillingDetails
? t`Last payment failed. Please update your billing details.`
@@ -21,7 +21,8 @@ export const InformationBannerNoBillingSubscription = () => {
return (
<InformationBanner
componentInstanceId="information-banner-no-billing-subscription"
variant="danger"
color="danger"
variant="secondary"
message={
hasPermissionToSubscribe
? t`Your workspace doesn't have an active subscription.`
@@ -28,7 +28,7 @@ export const InformationBannerDeletedRecord = ({
<StyledInformationBannerDeletedRecord>
<InformationBanner
componentInstanceId="information-banner-deleted-record"
variant="danger"
color="danger"
message={t`This record has been deleted`}
buttonTitle={t`Restore`}
buttonIcon={IconRefresh}
@@ -32,7 +32,7 @@ export const InformationBannerLegacyEnterpriseKey = () => {
return (
<InformationBanner
componentInstanceId={COMPONENT_INSTANCE_ID}
variant="default"
variant="secondary"
message={t`Your enterprise key format is deprecated. Please activate a new key to keep enterprise features.`}
buttonTitle={t`Activate`}
buttonIcon={IconKey}
@@ -0,0 +1,71 @@
import { Temporal } from 'temporal-polyfill';
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { useMaintenanceModeBannerDismissal } from '@/information-banner/hooks/useMaintenanceModeBannerDismissal';
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { IconExternalLink } from 'twenty-ui/display';
const formatMaintenanceDateTime = (
isoString: string,
timeZone: string,
): string => {
const zonedDateTime =
Temporal.Instant.from(isoString).toZonedDateTimeISO(timeZone);
return zonedDateTime.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
});
};
export const InformationBannerMaintenance = () => {
const maintenanceMode = useAtomStateValue(maintenanceModeState);
const { timeZone } = useDateTimeFormat();
const { dismissBanner, isDismissed, isLoading } =
useMaintenanceModeBannerDismissal({
enabled: isDefined(maintenanceMode),
maintenanceStartAt: maintenanceMode?.startAt,
});
if (!isDefined(maintenanceMode) || isLoading || isDismissed) {
return null;
}
const startFormatted = formatMaintenanceDateTime(
maintenanceMode.startAt,
timeZone,
);
const endFormatted = formatMaintenanceDateTime(
maintenanceMode.endAt,
timeZone,
);
const message = t`Scheduled maintenance: ${startFormatted}${endFormatted}`;
const maintenanceLink = isNonEmptyString(maintenanceMode.link?.trim())
? maintenanceMode.link.trim()
: undefined;
return (
<InformationBanner
componentInstanceId="information-banner-maintenance"
variant="secondary"
message={message}
buttonTitle={isDefined(maintenanceLink) ? t`Learn more` : undefined}
buttonIcon={isDefined(maintenanceLink) ? IconExternalLink : undefined}
buttonOnClick={
isDefined(maintenanceLink)
? () => window.open(maintenanceLink, '_blank', 'noopener,noreferrer')
: undefined
}
onClose={dismissBanner}
/>
);
};
@@ -32,6 +32,7 @@ export const InformationBannerReconnectAccountEmailAliases = () => {
return (
<InformationBanner
componentInstanceId={COMPONENT_INSTANCE_ID}
variant="secondary"
message={t`Please reconnect your mailbox ${mailboxHandle} to update your email aliases:`}
buttonTitle={t`Reconnect`}
buttonIcon={IconRefresh}
@@ -32,6 +32,7 @@ export const InformationBannerReconnectAccountInsufficientPermissions = () => {
return (
<InformationBanner
componentInstanceId={COMPONENT_INSTANCE_ID}
variant="secondary"
message={t`Sync lost with mailbox ${mailboxHandle}. Please reconnect for updates:`}
buttonTitle={t`Reconnect`}
buttonIcon={IconRefresh}
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DISMISS_MAINTENANCE_MODE_BANNER = gql`
mutation DismissMaintenanceModeBanner {
dismissMaintenanceModeBanner
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const IS_MAINTENANCE_MODE_BANNER_DISMISSED = gql`
query IsMaintenanceModeBannerDismissed {
isMaintenanceModeBannerDismissed
}
`;
@@ -0,0 +1,59 @@
import { useMutation, useQuery } from '@apollo/client/react';
import { useEffect, useState } from 'react';
import { DISMISS_MAINTENANCE_MODE_BANNER } from '@/information-banner/graphql/mutations/dismissMaintenanceModeBanner';
import { IS_MAINTENANCE_MODE_BANNER_DISMISSED } from '@/information-banner/graphql/queries/isMaintenanceModeBannerDismissed';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
export const useMaintenanceModeBannerDismissal = ({
enabled,
maintenanceStartAt,
}: {
enabled: boolean;
maintenanceStartAt?: string;
}) => {
const apolloCoreClient = useApolloCoreClient();
const [isDismissed, setIsDismissed] = useState(false);
const { data, loading, refetch } = useQuery<{
isMaintenanceModeBannerDismissed: boolean;
}>(IS_MAINTENANCE_MODE_BANNER_DISMISSED, {
client: apolloCoreClient,
skip: !enabled,
fetchPolicy: 'network-only',
});
const [mutate] = useMutation<{
dismissMaintenanceModeBanner: boolean;
}>(DISMISS_MAINTENANCE_MODE_BANNER, {
client: apolloCoreClient,
});
useEffect(() => {
if (!enabled) {
setIsDismissed(false);
return;
}
}, [enabled]);
useEffect(() => {
if (!enabled) {
return;
}
setIsDismissed(false);
void refetch();
}, [enabled, maintenanceStartAt, refetch]);
const dismissBanner = async () => {
await mutate();
setIsDismissed(true);
};
return {
dismissBanner,
isDismissed: isDismissed || data?.isMaintenanceModeBannerDismissed === true,
isLoading: enabled ? loading : false,
};
};
@@ -1,5 +1,7 @@
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsAdminHealthStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatusListCard';
import { SettingsAdminMaintenanceModeFetchEffect } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceModeFetchEffect';
import { SettingsAdminMaintenanceMode } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
@@ -22,6 +24,7 @@ export const SettingsAdminHealthStatus = () => {
return (
<>
<SettingsAdminMaintenanceModeFetchEffect />
<Section>
<H2Title
title={t`Health Status`}
@@ -32,6 +35,7 @@ export const SettingsAdminHealthStatus = () => {
loading={loadingHealthStatus}
/>
</Section>
<SettingsAdminMaintenanceMode />
</>
);
};
@@ -0,0 +1,244 @@
import { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { H2Title, IconLink, IconTool, Status } from 'twenty-ui/display';
import { Card, CardContent, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
import { CLEAR_MAINTENANCE_MODE } from '@/settings/admin-panel/health-status/maintenance-mode/graphql/mutations/clearMaintenanceMode';
import { SET_MAINTENANCE_MODE } from '@/settings/admin-panel/health-status/maintenance-mode/graphql/mutations/setMaintenanceMode';
import { adminPanelMaintenanceModeState } from '@/settings/admin-panel/health-status/maintenance-mode/states/adminPanelMaintenanceModeState';
import { SettingsDatePickerInput } from '@/settings/components/SettingsDatePickerInput';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { InputHint } from '@/ui/input/components/InputHint';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
const StyledFormContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledStatusRow = styled.div`
display: flex;
justify-content: flex-end;
`;
export const SettingsAdminMaintenanceMode = () => {
const [adminPanelMaintenanceMode, setAdminPanelMaintenanceMode] =
useAtomState(adminPanelMaintenanceModeState);
const maintenanceMode = useAtomStateValue(maintenanceModeState);
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
const { enqueueErrorSnackBar } = useSnackBar();
const [setMaintenanceModeMutation] = useMutation(SET_MAINTENANCE_MODE);
const [clearMaintenanceModeMutation] = useMutation(CLEAR_MAINTENANCE_MODE);
const isEnabled = isDefined(adminPanelMaintenanceMode);
const startDate =
isEnabled && isNonEmptyString(adminPanelMaintenanceMode.startAt)
? new Date(adminPanelMaintenanceMode.startAt)
: undefined;
const endDate =
isEnabled && isNonEmptyString(adminPanelMaintenanceMode.endAt)
? new Date(adminPanelMaintenanceMode.endAt)
: undefined;
const hasValidDateRange =
isDefined(startDate) &&
isDefined(endDate) &&
endDate.getTime() > startDate.getTime();
const isScheduled =
isDefined(maintenanceMode) &&
isNonEmptyString(maintenanceMode.startAt) &&
isNonEmptyString(maintenanceMode.endAt);
const saveToBackend = useCallback(
async (startAt: string, endAt: string, link?: string) => {
if (!isNonEmptyString(startAt) || !isNonEmptyString(endAt)) {
return;
}
if (new Date(endAt).getTime() <= new Date(startAt).getTime()) {
return;
}
try {
await setMaintenanceModeMutation({
variables: { startAt, endAt, link },
});
setMaintenanceMode({
startAt,
endAt,
link,
});
} catch (error: unknown) {
enqueueErrorSnackBar({
message:
error instanceof Error
? error.message
: t`Failed to set maintenance mode.`,
});
}
},
[setMaintenanceModeMutation, setMaintenanceMode, enqueueErrorSnackBar],
);
const handleToggle = useCallback(
async (checked: boolean) => {
if (checked) {
setAdminPanelMaintenanceMode({ startAt: '', endAt: '' });
} else {
await clearMaintenanceModeMutation();
setAdminPanelMaintenanceMode(null);
setMaintenanceMode(null);
}
},
[
clearMaintenanceModeMutation,
setAdminPanelMaintenanceMode,
setMaintenanceMode,
],
);
const handleDateChange = useCallback(
(field: 'startAt' | 'endAt') => (value: Date | undefined) => {
if (!isDefined(adminPanelMaintenanceMode)) {
return;
}
const isoValue = isDefined(value) ? value.toISOString() : '';
const nextState = { ...adminPanelMaintenanceMode, [field]: isoValue };
setAdminPanelMaintenanceMode(nextState);
saveToBackend(
nextState.startAt,
nextState.endAt,
isNonEmptyString(nextState.link) ? nextState.link : undefined,
);
},
[adminPanelMaintenanceMode, setAdminPanelMaintenanceMode, saveToBackend],
);
const handleLinkChange = useCallback(
(value: string) => {
if (!isDefined(adminPanelMaintenanceMode)) {
return;
}
setAdminPanelMaintenanceMode({
...adminPanelMaintenanceMode,
link: value,
});
},
[adminPanelMaintenanceMode, setAdminPanelMaintenanceMode],
);
const handleLinkBlur = useCallback(() => {
if (!isDefined(adminPanelMaintenanceMode)) {
return;
}
saveToBackend(
adminPanelMaintenanceMode.startAt,
adminPanelMaintenanceMode.endAt,
isNonEmptyString(adminPanelMaintenanceMode.link)
? adminPanelMaintenanceMode.link
: undefined,
);
}, [adminPanelMaintenanceMode, saveToBackend]);
const scheduledStartDate = isScheduled
? new Date(maintenanceMode.startAt)
: undefined;
const formattedStartDate = isDefined(scheduledStartDate)
? scheduledStartDate.toLocaleDateString(undefined, {
month: '2-digit',
day: '2-digit',
year: 'numeric',
})
: '';
const toggleDescription = isScheduled
? t`Planned for ${formattedStartDate}`
: undefined;
return (
<Section>
<H2Title
title={t`Maintenance`}
description={t`Schedule a maintenance window and notify all users`}
/>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconTool}
title={t`Maintenance mode`}
description={toggleDescription}
checked={isEnabled}
onChange={handleToggle}
/>
{isEnabled && (
<CardContent>
<StyledFormContainer>
<SettingsDatePickerInput
label={t`Start date`}
value={startDate}
onChange={handleDateChange('startAt')}
/>
<div>
<SettingsDatePickerInput
label={t`End date`}
value={endDate}
onChange={handleDateChange('endAt')}
/>
{isDefined(startDate) &&
isDefined(endDate) &&
!hasValidDateRange && (
<InputHint>{t`End date must be after start date.`}</InputHint>
)}
</div>
<div>
<TextInput
label={t`Link`}
type="url"
value={adminPanelMaintenanceMode.link ?? ''}
onChange={handleLinkChange}
onBlur={handleLinkBlur}
LeftIcon={IconLink}
placeholder="https://status.example.com"
fullWidth
/>
<InputHint>
{t`If there's no link, no button will appear.`}
</InputHint>
</div>
{isScheduled && (
<StyledStatusRow>
<Status
color="orange"
text={t`Planned for ${formattedStartDate}`}
/>
</StyledStatusRow>
)}
</StyledFormContainer>
</CardContent>
)}
</Card>
</Section>
);
};
@@ -0,0 +1,30 @@
import { useQuery } from '@apollo/client/react';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { GET_MAINTENANCE_MODE } from '@/settings/admin-panel/health-status/maintenance-mode/graphql/queries/getMaintenanceMode';
import { adminPanelMaintenanceModeState } from '@/settings/admin-panel/health-status/maintenance-mode/states/adminPanelMaintenanceModeState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type GetMaintenanceModeQuery } from '~/generated-metadata/graphql';
export const SettingsAdminMaintenanceModeFetchEffect = () => {
const { data } = useQuery<GetMaintenanceModeQuery>(GET_MAINTENANCE_MODE, {
fetchPolicy: 'network-only',
});
const setAdminPanelMaintenanceMode = useSetAtomState(
adminPanelMaintenanceModeState,
);
useEffect(() => {
const maintenanceMode = data?.getMaintenanceMode;
if (!isDefined(data)) {
return;
}
setAdminPanelMaintenanceMode(maintenanceMode ?? null);
}, [data, setAdminPanelMaintenanceMode]);
return null;
};
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const CLEAR_MAINTENANCE_MODE = gql`
mutation ClearMaintenanceMode {
clearMaintenanceMode
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const SET_MAINTENANCE_MODE = gql`
mutation SetMaintenanceMode(
$startAt: DateTime!
$endAt: DateTime!
$link: String
) {
setMaintenanceMode(startAt: $startAt, endAt: $endAt, link: $link)
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_MAINTENANCE_MODE = gql`
query GetMaintenanceMode {
getMaintenanceMode {
startAt
endAt
link
}
}
`;
@@ -0,0 +1,13 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
type AdminPanelMaintenanceMode = {
startAt: string;
endAt: string;
link?: string | null;
};
export const adminPanelMaintenanceModeState =
createAtomState<AdminPanelMaintenanceMode | null>({
key: 'adminPanelMaintenanceModeState',
defaultValue: null,
});
@@ -0,0 +1,175 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useRef, useState } from 'react';
import { Temporal } from 'temporal-polyfill';
import {
autoUpdate,
FloatingPortal,
offset,
flip,
useFloating,
} from '@floating-ui/react';
import {
DateTimePicker,
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from '@/ui/input/components/internal/date/components/DateTimePicker';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from 'twenty-shared/utils';
import { IconCalendar } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const SETTINGS_DATE_PICKER_CLICK_OUTSIDE_ID = 'settings-date-picker-floating';
const StyledInputContainer = styled.div`
position: relative;
`;
const StyledInput = styled.div<{ hasValue: boolean }>`
align-items: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${({ hasValue }) =>
hasValue
? themeCssVariables.font.color.primary
: themeCssVariables.font.color.light};
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
height: 32px;
padding: 0 ${themeCssVariables.spacing[2]};
width: 100%;
&:hover {
border-color: ${themeCssVariables.border.color.strong};
}
`;
const StyledLabel = styled.div`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.medium};
margin-bottom: ${themeCssVariables.spacing[1]};
`;
const StyledIconContainer = styled.div`
color: ${themeCssVariables.font.color.tertiary};
display: flex;
`;
const StyledFloatingContainer = styled.div`
z-index: 1000;
`;
export type SettingsDatePickerInputProps = {
label: string;
value: Date | undefined;
onChange: (date: Date | undefined) => void;
placeholder?: string;
};
export const SettingsDatePickerInput = ({
label,
value,
onChange,
placeholder,
}: SettingsDatePickerInputProps) => {
const { t } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { refs, floatingStyles } = useFloating({
open: isOpen,
placement: 'bottom-start',
middleware: [offset(4), flip()],
whileElementsMounted: autoUpdate,
});
const handleClose = () => {
setIsOpen(false);
};
useListenClickOutside({
refs: [containerRef],
listenerId: `settings-date-picker-${label}`,
callback: handleClose,
enabled: isOpen,
excludedClickOutsideIds: [
SETTINGS_DATE_PICKER_CLICK_OUTSIDE_ID,
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
],
});
const handleDateTimeSelect = (newDateTime: Temporal.ZonedDateTime | null) => {
if (isDefined(newDateTime)) {
onChange(new Date(newDateTime.epochMilliseconds));
}
handleClose();
};
const handleClear = () => {
onChange(undefined);
handleClose();
};
const formatDisplayValue = (date: Date | undefined): string => {
if (!isDefined(date)) {
return placeholder ?? t`Select date & time`;
}
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const zonedDateTime = isDefined(value)
? Temporal.Instant.fromEpochMilliseconds(
value.getTime(),
).toZonedDateTimeISO(Temporal.Now.timeZoneId())
: null;
return (
<StyledInputContainer ref={containerRef}>
<StyledLabel>{label}</StyledLabel>
<StyledInput
ref={refs.setReference}
hasValue={isDefined(value)}
onClick={() => setIsOpen(true)}
>
<StyledIconContainer>
<IconCalendar size={16} />
</StyledIconContainer>
{formatDisplayValue(value)}
</StyledInput>
{isOpen && (
<FloatingPortal>
<StyledFloatingContainer
ref={refs.setFloating}
style={floatingStyles}
data-click-outside-id={SETTINGS_DATE_PICKER_CLICK_OUTSIDE_ID}
>
<OverlayContainer>
<DateTimePicker
instanceId={`settings-date-picker-${label}`}
date={zonedDateTime}
onChange={handleDateTimeSelect}
onClose={handleClose}
onClear={handleClear}
clearable
/>
</OverlayContainer>
</StyledFloatingContainer>
</FloatingPortal>
)}
</StyledInputContainer>
);
};
@@ -2,6 +2,7 @@ import { useDeleteOneFieldMetadataItem } from '@/object-metadata/hooks/useDelete
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
@@ -13,6 +14,7 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useContext, useMemo } from 'react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
@@ -84,9 +86,12 @@ export const SettingsObjectFieldItemTableRow = ({
const { fieldMetadataItem, objectMetadataItem } =
settingsObjectDetailTableItem;
const readonly = isObjectMetadataReadOnly({
objectMetadataItem,
});
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const readonly =
isObjectMetadataReadOnly({
objectMetadataItem,
}) || isDDLLocked;
const navigate = useNavigateSettings();
@@ -3,6 +3,7 @@ import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataIt
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
@@ -10,6 +11,7 @@ import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { useContext, useMemo } from 'react';
import { Link } from 'react-router-dom';
@@ -109,9 +111,12 @@ export const SettingsObjectRelationItemTableRow = ({
[fieldMetadataItem, getRelationMetadata],
) ?? {};
const readonly = isObjectMetadataReadOnly({
objectMetadataItem,
});
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const readonly =
isObjectMetadataReadOnly({
objectMetadataItem,
}) || isDDLLocked;
const { activateMetadataField } = useFieldMetadataItem();
const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem();
@@ -1,5 +1,6 @@
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { computeUpdatedNavigationMemorizedUrlAfterObjectNamePluralChange } from '@/settings/data-model/object-details/utils/computeUpdatedNavigationMemorizedUrlAfterObjectNamePluralChange';
import { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
@@ -8,6 +9,7 @@ import {
settingsDataModelObjectAboutFormSchema,
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
@@ -23,9 +25,12 @@ type SettingsUpdateDataModelObjectAboutFormProps = {
export const SettingsUpdateDataModelObjectAboutForm = ({
objectMetadataItem,
}: SettingsUpdateDataModelObjectAboutFormProps) => {
const readonly = isObjectMetadataReadOnly({
objectMetadataItem,
});
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const readonly =
isObjectMetadataReadOnly({
objectMetadataItem,
}) || isDDLLocked;
const navigate = useNavigateSettings();
const setUpdatedObjectNamePlural = useSetAtomState(
updatedObjectNamePluralState,
@@ -1,8 +1,10 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { SettingsObjectRelationsTable } from '@/settings/data-model/object-details/components/SettingsObjectRelationsTable';
import { styled } from '@linaria/react';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
@@ -25,9 +27,12 @@ type ObjectFieldsProps = {
export const ObjectFields = ({ objectMetadataItem }: ObjectFieldsProps) => {
const { t } = useLingui();
const readonly = isObjectMetadataReadOnly({
objectMetadataItem,
});
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const readonly =
isObjectMetadataReadOnly({
objectMetadataItem,
}) || isDDLLocked;
const objectLabelSingular = objectMetadataItem.labelSingular;
@@ -2,6 +2,7 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche
import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm';
@@ -11,6 +12,7 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { styled } from '@linaria/react';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, IconArchive, IconTrash } from 'twenty-ui/display';
@@ -56,7 +58,10 @@ export const ObjectSettings = ({
const { enqueueSuccessSnackBar } = useSnackBar();
const { openModal, closeModal } = useModal();
const isReadOnly = isObjectMetadataReadOnly({ objectMetadataItem });
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const isReadOnly =
isObjectMetadataReadOnly({ objectMetadataItem }) || isDDLLocked;
const handleDisable = async () => {
const result = await updateOneObjectMetadataItem({
@@ -2,9 +2,11 @@ import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdat
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { Select } from '@/ui/input/components/Select';
import { styled } from '@linaria/react';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { zodResolver } from '@hookform/resolvers/zod';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
@@ -43,9 +45,12 @@ const StyledContainer = styled.div`
export const SettingsDataModelObjectIdentifiersForm = ({
objectMetadataItem,
}: SettingsDataModelObjectIdentifiersFormProps) => {
const readonly = isObjectMetadataReadOnly({
objectMetadataItem,
});
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const readonly =
isObjectMetadataReadOnly({
objectMetadataItem,
}) || isDDLLocked;
const formConfig = useForm<SettingsDataModelObjectIdentifiersFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsDataModelObjectIdentifiersFormSchema),