diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx
index 2078444a24..81768fe6f9 100644
--- a/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx
+++ b/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx
@@ -22,10 +22,6 @@ 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;
@@ -62,10 +58,7 @@ export const InformationBanner = ({
);
const isPrimary = variant === 'primary';
-
- const CloseIconButton = isPrimary
- ? StyledInvertedIconButton
- : StyledSecondaryIconButton;
+ const buttonAccent = color === 'danger' ? 'danger' : 'blue';
return (
)}
- {onClose && (
-
- )}
+ {onClose &&
+ (isPrimary ? (
+
+ ) : (
+
+ ))}
)}
diff --git a/packages/twenty-front/src/modules/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode.tsx b/packages/twenty-front/src/modules/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode.tsx
index 57cbbab1a5..9ff68f0444 100644
--- a/packages/twenty-front/src/modules/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode.tsx
+++ b/packages/twenty-front/src/modules/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode.tsx
@@ -17,6 +17,7 @@ import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsO
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 { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
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';
@@ -39,6 +40,7 @@ export const SettingsAdminMaintenanceMode = () => {
const maintenanceMode = useAtomStateValue(maintenanceModeState);
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
+ const { userTimezone } = useUserTimezone();
const { enqueueErrorSnackBar } = useSnackBar();
const [setMaintenanceModeMutation] = useMutation(SET_MAINTENANCE_MODE);
@@ -171,6 +173,7 @@ export const SettingsAdminMaintenanceMode = () => {
month: '2-digit',
day: '2-digit',
year: 'numeric',
+ timeZone: userTimezone,
})
: '';
diff --git a/packages/twenty-front/src/modules/settings/components/SettingsDatePickerInput.tsx b/packages/twenty-front/src/modules/settings/components/SettingsDatePickerInput.tsx
index cb3c8b9d2d..fba6ea5e7b 100644
--- a/packages/twenty-front/src/modules/settings/components/SettingsDatePickerInput.tsx
+++ b/packages/twenty-front/src/modules/settings/components/SettingsDatePickerInput.tsx
@@ -15,6 +15,7 @@ import {
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from '@/ui/input/components/internal/date/components/DateTimePicker';
+import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from 'twenty-shared/utils';
@@ -81,6 +82,7 @@ export const SettingsDatePickerInput = ({
const { t } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
+ const { userTimezone } = useUserTimezone();
const { refs, floatingStyles } = useFloating({
open: isOpen,
@@ -105,10 +107,13 @@ export const SettingsDatePickerInput = ({
],
});
- const handleDateTimeSelect = (newDateTime: Temporal.ZonedDateTime | null) => {
+ const handleDateTimeChange = (newDateTime: Temporal.ZonedDateTime | null) => {
if (isDefined(newDateTime)) {
onChange(new Date(newDateTime.epochMilliseconds));
}
+ };
+
+ const handleDateTimeClose = (_newDateTime: Temporal.ZonedDateTime | null) => {
handleClose();
};
@@ -128,13 +133,14 @@ export const SettingsDatePickerInput = ({
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
+ timeZone: userTimezone,
});
};
const zonedDateTime = isDefined(value)
? Temporal.Instant.fromEpochMilliseconds(
value.getTime(),
- ).toZonedDateTimeISO(Temporal.Now.timeZoneId())
+ ).toZonedDateTimeISO(userTimezone)
: null;
return (
@@ -161,8 +167,8 @@ export const SettingsDatePickerInput = ({
diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.spec.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.spec.ts
index 28bbb40522..21e045780f 100644
--- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.spec.ts
@@ -22,7 +22,7 @@ describe('KeyValuePairService', () => {
service = new KeyValuePairService(keyValuePairRepository);
});
- it('should insert a global null/null key when missing', async () => {
+ it('should upsert a global null/null key', async () => {
await service.set({
userId: null,
workspaceId: null,
@@ -31,45 +31,24 @@ describe('KeyValuePairService', () => {
type: KeyValuePairType.CONFIG_VARIABLE,
});
- expect(keyValuePairRepository.findOne).toHaveBeenCalledWith({
- where: {
- userId: expect.any(Object),
- workspaceId: expect.any(Object),
+ expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
+ {
+ userId: null,
+ workspaceId: null,
key: 'MAINTENANCE_MODE',
+ value: { startAt: '2026-04-02T10:00:00.000Z' },
type: KeyValuePairType.CONFIG_VARIABLE,
},
- });
- expect(keyValuePairRepository.insert).toHaveBeenCalledWith({
- userId: null,
- workspaceId: null,
- key: 'MAINTENANCE_MODE',
- value: { startAt: '2026-04-02T10:00:00.000Z' },
- type: KeyValuePairType.CONFIG_VARIABLE,
- });
- expect(keyValuePairRepository.upsert).not.toHaveBeenCalled();
- });
-
- it('should update a global null/null key when present', async () => {
- keyValuePairRepository.findOne.mockResolvedValue({
- id: 'existing-id',
- } as KeyValuePairEntity);
-
- await service.set({
- userId: null,
- workspaceId: null,
- key: 'MAINTENANCE_MODE',
- value: { startAt: '2026-04-02T10:00:00.000Z' },
- type: KeyValuePairType.CONFIG_VARIABLE,
- });
-
- expect(keyValuePairRepository.update).toHaveBeenCalledWith('existing-id', {
- value: { startAt: '2026-04-02T10:00:00.000Z' },
- });
+ {
+ conflictPaths: ['key'],
+ indexPredicate: '"userId" IS NULL AND "workspaceId" IS NULL',
+ },
+ );
+ expect(keyValuePairRepository.findOne).not.toHaveBeenCalled();
expect(keyValuePairRepository.insert).not.toHaveBeenCalled();
- expect(keyValuePairRepository.upsert).not.toHaveBeenCalled();
});
- it('should keep the existing workspace-null index behavior', async () => {
+ it('should upsert with userId-null index when workspaceId is null', async () => {
await service.set({
userId: 'user-id',
workspaceId: null,
@@ -87,8 +66,56 @@ describe('KeyValuePairService', () => {
type: KeyValuePairType.USER_VARIABLE,
},
{
- conflictPaths: ['userId', 'workspaceId', 'key'],
- indexPredicate: '"workspaceId" is NULL',
+ conflictPaths: ['key', 'userId'],
+ indexPredicate: '"workspaceId" IS NULL',
+ },
+ );
+ });
+
+ it('should upsert with workspaceId-null index when userId is null', async () => {
+ await service.set({
+ userId: null,
+ workspaceId: 'workspace-id',
+ key: 'WORKSPACE_SETTING',
+ value: 'test',
+ type: KeyValuePairType.CONFIG_VARIABLE,
+ });
+
+ expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
+ {
+ userId: null,
+ workspaceId: 'workspace-id',
+ key: 'WORKSPACE_SETTING',
+ value: 'test',
+ type: KeyValuePairType.CONFIG_VARIABLE,
+ },
+ {
+ conflictPaths: ['key', 'workspaceId'],
+ indexPredicate: '"userId" IS NULL',
+ },
+ );
+ });
+
+ it('should upsert with full conflict paths when both ids are present', async () => {
+ await service.set({
+ userId: 'user-id',
+ workspaceId: 'workspace-id',
+ key: 'USER_WORKSPACE_SETTING',
+ value: 42,
+ type: KeyValuePairType.USER_VARIABLE,
+ });
+
+ expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
+ {
+ userId: 'user-id',
+ workspaceId: 'workspace-id',
+ key: 'USER_WORKSPACE_SETTING',
+ value: 42,
+ type: KeyValuePairType.USER_VARIABLE,
+ },
+ {
+ conflictPaths: ['key', 'userId', 'workspaceId'],
+ indexPredicate: undefined,
},
);
});
diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts
index 82a1449f35..afc7f5c780 100644
--- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts
@@ -82,43 +82,21 @@ export class KeyValuePairService<
type,
};
+ const conflictPaths: string[] = ['key'];
+ let indexPredicate: string | undefined;
+
if (hasNullUserAndWorkspace) {
- const existingKeyValuePair = await keyValuePairRepository.findOne({
- where: {
- userId: IsNull(),
- workspaceId: IsNull(),
- key,
- type,
- },
- });
-
- if (existingKeyValuePair) {
- await keyValuePairRepository.update(existingKeyValuePair.id, {
- value,
- });
-
- return;
- }
-
- await keyValuePairRepository.insert(upsertData);
-
- return;
+ indexPredicate = '"userId" IS NULL AND "workspaceId" IS NULL';
+ } else if (normalizedUserId === null) {
+ conflictPaths.push('workspaceId');
+ indexPredicate = '"userId" IS NULL';
+ } else if (normalizedWorkspaceId === null) {
+ conflictPaths.push('userId');
+ indexPredicate = '"workspaceId" IS NULL';
+ } else {
+ conflictPaths.push('userId', 'workspaceId');
}
- const conflictPaths = Object.keys(upsertData).filter(
- (conflictPath) =>
- ['userId', 'workspaceId', 'key'].includes(conflictPath) &&
- // @ts-expect-error legacy noImplicitAny
- upsertData[conflictPath] !== undefined,
- );
-
- const indexPredicate =
- normalizedUserId === null
- ? '"userId" is NULL'
- : normalizedWorkspaceId === null
- ? '"workspaceId" is NULL'
- : undefined;
-
await keyValuePairRepository.upsert(upsertData, {
conflictPaths,
indexPredicate,
diff --git a/packages/twenty-ui/src/display/banner/components/__stories__/Banner.stories.tsx b/packages/twenty-ui/src/display/banner/components/__stories__/Banner.stories.tsx
index d4bcbf0f45..e333d2b387 100644
--- a/packages/twenty-ui/src/display/banner/components/__stories__/Banner.stories.tsx
+++ b/packages/twenty-ui/src/display/banner/components/__stories__/Banner.stories.tsx
@@ -1,22 +1,146 @@
+import { styled } from '@linaria/react';
import { type Meta, type StoryObj } from '@storybook/react-vite';
-import { ComponentDecorator } from '@ui/testing';
-import { Banner } from '../Banner';
+import { IconX } from '@ui/display';
+import {
+ CatalogDecorator,
+ type CatalogStory,
+ ComponentDecorator,
+} from '@ui/testing';
+import { themeCssVariables } from '@ui/theme-constants';
+import { Button } from '../../../../input/button/components/Button/Button';
+import { IconButton } from '../../../../input/button/components/IconButton';
+import { Banner, type BannerColor, type BannerVariant } from '../Banner';
+
+const StyledBannerContent = styled.div`
+ align-items: center;
+ display: flex;
+ flex: 1;
+ gap: 8px;
+ justify-content: center;
+`;
+
+const StyledContainer = styled.div`
+ width: 100%;
+`;
+
+const StyledInvertedIconButton = styled(IconButton)`
+ color: ${themeCssVariables.font.color.inverted} !important;
+`;
+
+const getButtonAccent = (color?: BannerColor) =>
+ color === 'danger' ? 'danger' : 'blue';
+
+const BannerCloseButton = ({
+ color,
+ variant,
+}: {
+ color?: BannerColor;
+ variant?: BannerVariant;
+}) =>
+ variant === 'primary' ? (
+
+ ) : (
+
+ );
const meta: Meta = {
title: 'UI/Layout/Banner/Banner',
component: Banner,
- decorators: [ComponentDecorator],
- render: (args) => (
- // oxlint-disable-next-line react/jsx-props-no-spreading
-
- Sync lost with mailbox hello@twenty.com. Please reconnect for updates:
-
- ),
- argTypes: {},
+ argTypes: {
+ color: {
+ control: 'select',
+ options: ['blue', 'danger'] satisfies BannerColor[],
+ },
+ variant: {
+ control: 'select',
+ options: ['primary', 'secondary'] satisfies BannerVariant[],
+ },
+ },
};
export default meta;
type Story = StoryObj;
-export const Default: Story = {};
+export const Default: Story = {
+ args: {
+ color: 'blue',
+ variant: 'primary',
+ },
+ render: (args) => (
+
+ {/* oxlint-disable-next-line react/jsx-props-no-spreading */}
+
+
+ Sync lost with mailbox hello@twenty.com. Please reconnect for updates:
+
+
+
+
+
+ ),
+ decorators: [ComponentDecorator],
+};
+
+export const Catalog: CatalogStory = {
+ args: {},
+ argTypes: {
+ color: { control: false },
+ variant: { control: false },
+ },
+ render: (args) => (
+ // oxlint-disable-next-line react/jsx-props-no-spreading
+
+
+ Sync lost with mailbox hello@twenty.com. Please reconnect for updates:
+
+
+
+
+ ),
+ parameters: {
+ catalog: {
+ dimensions: [
+ {
+ name: 'variant',
+ values: ['primary', 'secondary'] satisfies BannerVariant[],
+ props: (variant: BannerVariant) => ({ variant }),
+ },
+ {
+ name: 'color',
+ values: ['blue', 'danger'] satisfies BannerColor[],
+ props: (color: BannerColor) => ({ color }),
+ },
+ ],
+ options: {
+ elementContainer: {
+ width: 700,
+ },
+ },
+ },
+ },
+ decorators: [CatalogDecorator],
+};