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
@@ -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,
});