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>
)}