Files
twenty/packages/twenty-front/src/modules/settings/components/SettingsDatePickerInput.tsx
T
Charles Bochet 81f10c586f 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.
2026-04-02 10:17:04 +00:00

176 lines
4.8 KiB
TypeScript

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