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:
@@ -1737,6 +1737,12 @@ enum FeatureFlagKey {
|
||||
IS_DATASOURCE_MIGRATED
|
||||
}
|
||||
|
||||
type ClientConfigMaintenanceMode {
|
||||
startAt: DateTime!
|
||||
endAt: DateTime!
|
||||
link: String
|
||||
}
|
||||
|
||||
type ClientConfig {
|
||||
appVersion: String
|
||||
authProviders: AuthProviders!
|
||||
@@ -1765,6 +1771,8 @@ type ClientConfig {
|
||||
calendarBookingPageId: String
|
||||
isCloudflareIntegrationEnabled: Boolean!
|
||||
isClickHouseConfigured: Boolean!
|
||||
isWorkspaceSchemaDDLLocked: Boolean!
|
||||
maintenance: ClientConfigMaintenanceMode
|
||||
}
|
||||
|
||||
type UsageBreakdownItem {
|
||||
@@ -1961,6 +1969,12 @@ type AdminPanelHealthServiceData {
|
||||
queues: [AdminPanelWorkerQueueHealth!]
|
||||
}
|
||||
|
||||
type MaintenanceMode {
|
||||
startAt: DateTime!
|
||||
endAt: DateTime!
|
||||
link: String
|
||||
}
|
||||
|
||||
type ModelsDevModelSuggestion {
|
||||
modelId: String!
|
||||
name: String!
|
||||
@@ -3225,6 +3239,7 @@ type Query {
|
||||
getModelsDevProviders: [ModelsDevProviderSuggestion!]!
|
||||
getModelsDevSuggestions(providerType: String!): [ModelsDevModelSuggestion!]!
|
||||
getAdminAiUsageByWorkspace(periodStart: DateTime, periodEnd: DateTime): [UsageBreakdownItem!]!
|
||||
getMaintenanceMode: MaintenanceMode
|
||||
getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics!
|
||||
getPostgresCredentials: PostgresCredentials
|
||||
findManyPublicDomains: [PublicDomain!]!
|
||||
@@ -3545,6 +3560,8 @@ type Mutation {
|
||||
removeAiProvider(providerName: String!): Boolean!
|
||||
addModelToProvider(providerName: String!, modelConfig: JSON!): Boolean!
|
||||
removeModelFromProvider(providerName: String!, modelName: String!): Boolean!
|
||||
setMaintenanceMode(startAt: DateTime!, endAt: DateTime!, link: String): Boolean!
|
||||
clearMaintenanceMode: Boolean!
|
||||
enablePostgresProxy: PostgresCredentials!
|
||||
disablePostgresProxy: PostgresCredentials!
|
||||
createPublicDomain(domain: String!): PublicDomain!
|
||||
|
||||
@@ -1424,6 +1424,13 @@ export interface PublicFeatureFlag {
|
||||
|
||||
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_GRAPHQL_QUERY_TIMING_ENABLED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
|
||||
|
||||
export interface ClientConfigMaintenanceMode {
|
||||
startAt: Scalars['DateTime']
|
||||
endAt: Scalars['DateTime']
|
||||
link?: Scalars['String']
|
||||
__typename: 'ClientConfigMaintenanceMode'
|
||||
}
|
||||
|
||||
export interface ClientConfig {
|
||||
appVersion?: Scalars['String']
|
||||
authProviders: AuthProviders
|
||||
@@ -1452,6 +1459,8 @@ export interface ClientConfig {
|
||||
calendarBookingPageId?: Scalars['String']
|
||||
isCloudflareIntegrationEnabled: Scalars['Boolean']
|
||||
isClickHouseConfigured: Scalars['Boolean']
|
||||
isWorkspaceSchemaDDLLocked: Scalars['Boolean']
|
||||
maintenance?: ClientConfigMaintenanceMode
|
||||
__typename: 'ClientConfig'
|
||||
}
|
||||
|
||||
@@ -1621,6 +1630,13 @@ export interface AdminPanelHealthServiceData {
|
||||
__typename: 'AdminPanelHealthServiceData'
|
||||
}
|
||||
|
||||
export interface MaintenanceMode {
|
||||
startAt: Scalars['DateTime']
|
||||
endAt: Scalars['DateTime']
|
||||
link?: Scalars['String']
|
||||
__typename: 'MaintenanceMode'
|
||||
}
|
||||
|
||||
export interface ModelsDevModelSuggestion {
|
||||
modelId: Scalars['String']
|
||||
name: Scalars['String']
|
||||
@@ -2776,6 +2792,7 @@ export interface Query {
|
||||
getModelsDevProviders: ModelsDevProviderSuggestion[]
|
||||
getModelsDevSuggestions: ModelsDevModelSuggestion[]
|
||||
getAdminAiUsageByWorkspace: UsageBreakdownItem[]
|
||||
getMaintenanceMode?: MaintenanceMode
|
||||
getUsageAnalytics: UsageAnalytics
|
||||
getPostgresCredentials?: PostgresCredentials
|
||||
findManyPublicDomains: PublicDomain[]
|
||||
@@ -2991,6 +3008,8 @@ export interface Mutation {
|
||||
removeAiProvider: Scalars['Boolean']
|
||||
addModelToProvider: Scalars['Boolean']
|
||||
removeModelFromProvider: Scalars['Boolean']
|
||||
setMaintenanceMode: Scalars['Boolean']
|
||||
clearMaintenanceMode: Scalars['Boolean']
|
||||
enablePostgresProxy: PostgresCredentials
|
||||
disablePostgresProxy: PostgresCredentials
|
||||
createPublicDomain: PublicDomain
|
||||
@@ -4508,6 +4527,14 @@ export interface PublicFeatureFlagGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ClientConfigMaintenanceModeGenqlSelection{
|
||||
startAt?: boolean | number
|
||||
endAt?: boolean | number
|
||||
link?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ClientConfigGenqlSelection{
|
||||
appVersion?: boolean | number
|
||||
authProviders?: AuthProvidersGenqlSelection
|
||||
@@ -4536,6 +4563,8 @@ export interface ClientConfigGenqlSelection{
|
||||
calendarBookingPageId?: boolean | number
|
||||
isCloudflareIntegrationEnabled?: boolean | number
|
||||
isClickHouseConfigured?: boolean | number
|
||||
isWorkspaceSchemaDDLLocked?: boolean | number
|
||||
maintenance?: ClientConfigMaintenanceModeGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
@@ -4711,6 +4740,14 @@ export interface AdminPanelHealthServiceDataGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MaintenanceModeGenqlSelection{
|
||||
startAt?: boolean | number
|
||||
endAt?: boolean | number
|
||||
link?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ModelsDevModelSuggestionGenqlSelection{
|
||||
modelId?: boolean | number
|
||||
name?: boolean | number
|
||||
@@ -5966,6 +6003,7 @@ export interface QueryGenqlSelection{
|
||||
getModelsDevProviders?: ModelsDevProviderSuggestionGenqlSelection
|
||||
getModelsDevSuggestions?: (ModelsDevModelSuggestionGenqlSelection & { __args: {providerType: Scalars['String']} })
|
||||
getAdminAiUsageByWorkspace?: (UsageBreakdownItemGenqlSelection & { __args?: {periodStart?: (Scalars['DateTime'] | null), periodEnd?: (Scalars['DateTime'] | null)} })
|
||||
getMaintenanceMode?: MaintenanceModeGenqlSelection
|
||||
getUsageAnalytics?: (UsageAnalyticsGenqlSelection & { __args?: {input?: (UsageAnalyticsInput | null)} })
|
||||
getPostgresCredentials?: PostgresCredentialsGenqlSelection
|
||||
findManyPublicDomains?: PublicDomainGenqlSelection
|
||||
@@ -6200,6 +6238,8 @@ export interface MutationGenqlSelection{
|
||||
removeAiProvider?: { __args: {providerName: Scalars['String']} }
|
||||
addModelToProvider?: { __args: {providerName: Scalars['String'], modelConfig: Scalars['JSON']} }
|
||||
removeModelFromProvider?: { __args: {providerName: Scalars['String'], modelName: Scalars['String']} }
|
||||
setMaintenanceMode?: { __args: {startAt: Scalars['DateTime'], endAt: Scalars['DateTime'], link?: (Scalars['String'] | null)} }
|
||||
clearMaintenanceMode?: boolean | number
|
||||
enablePostgresProxy?: PostgresCredentialsGenqlSelection
|
||||
disablePostgresProxy?: PostgresCredentialsGenqlSelection
|
||||
createPublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String']} })
|
||||
@@ -7502,6 +7542,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const ClientConfigMaintenanceMode_possibleTypes: string[] = ['ClientConfigMaintenanceMode']
|
||||
export const isClientConfigMaintenanceMode = (obj?: { __typename?: any } | null): obj is ClientConfigMaintenanceMode => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isClientConfigMaintenanceMode"')
|
||||
return ClientConfigMaintenanceMode_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ClientConfig_possibleTypes: string[] = ['ClientConfig']
|
||||
export const isClientConfig = (obj?: { __typename?: any } | null): obj is ClientConfig => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isClientConfig"')
|
||||
@@ -7662,6 +7710,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const MaintenanceMode_possibleTypes: string[] = ['MaintenanceMode']
|
||||
export const isMaintenanceMode = (obj?: { __typename?: any } | null): obj is MaintenanceMode => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMaintenanceMode"')
|
||||
return MaintenanceMode_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ModelsDevModelSuggestion_possibleTypes: string[] = ['ModelsDevModelSuggestion']
|
||||
export const isModelsDevModelSuggestion = (obj?: { __typename?: any } | null): obj is ModelsDevModelSuggestion => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isModelsDevModelSuggestion"')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -904,12 +904,21 @@ export type ClientConfig = {
|
||||
isMicrosoftCalendarEnabled: Scalars['Boolean'];
|
||||
isMicrosoftMessagingEnabled: Scalars['Boolean'];
|
||||
isMultiWorkspaceEnabled: Scalars['Boolean'];
|
||||
isWorkspaceSchemaDDLLocked: Scalars['Boolean'];
|
||||
maintenance?: Maybe<ClientConfigMaintenanceMode>;
|
||||
publicFeatureFlags: Array<PublicFeatureFlag>;
|
||||
sentry: Sentry;
|
||||
signInPrefilled: Scalars['Boolean'];
|
||||
support: Support;
|
||||
};
|
||||
|
||||
export type ClientConfigMaintenanceMode = {
|
||||
__typename?: 'ClientConfigMaintenanceMode';
|
||||
endAt: Scalars['DateTime'];
|
||||
link?: Maybe<Scalars['String']>;
|
||||
startAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
export type CollectionHash = {
|
||||
__typename?: 'CollectionHash';
|
||||
collectionName: AllMetadataName;
|
||||
@@ -2289,6 +2298,13 @@ export type LoginToken = {
|
||||
loginToken: AuthToken;
|
||||
};
|
||||
|
||||
export type MaintenanceMode = {
|
||||
__typename?: 'MaintenanceMode';
|
||||
endAt: Scalars['DateTime'];
|
||||
link?: Maybe<Scalars['String']>;
|
||||
startAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
export type MarketplaceApp = {
|
||||
__typename?: 'MarketplaceApp';
|
||||
author: Scalars['String'];
|
||||
@@ -2496,6 +2512,7 @@ export type Mutation = {
|
||||
checkCustomDomainValidRecords?: Maybe<DomainValidRecords>;
|
||||
checkPublicDomainValidRecords?: Maybe<DomainValidRecords>;
|
||||
checkoutSession: BillingSession;
|
||||
clearMaintenanceMode: Scalars['Boolean'];
|
||||
createApiKey: ApiKey;
|
||||
createApplicationRegistration: CreateApplicationRegistration;
|
||||
createApplicationRegistrationVariable: ApplicationRegistrationVariable;
|
||||
@@ -2618,6 +2635,7 @@ export type Mutation = {
|
||||
setAdminAiModelRecommended: Scalars['Boolean'];
|
||||
setAdminDefaultAiModel: Scalars['Boolean'];
|
||||
setEnterpriseKey: EnterpriseLicenseInfoDto;
|
||||
setMaintenanceMode: Scalars['Boolean'];
|
||||
setMeteredSubscriptionPrice: BillingUpdate;
|
||||
signIn: AvailableWorkspacesAndAccessTokens;
|
||||
signUp: AvailableWorkspacesAndAccessTokens;
|
||||
@@ -3358,6 +3376,13 @@ export type MutationSetEnterpriseKeyArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetMaintenanceModeArgs = {
|
||||
endAt: Scalars['DateTime'];
|
||||
link?: InputMaybe<Scalars['String']>;
|
||||
startAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetMeteredSubscriptionPriceArgs = {
|
||||
priceId: Scalars['String'];
|
||||
};
|
||||
@@ -4246,6 +4271,7 @@ export type Query = {
|
||||
getEmailingDomains: Array<EmailingDomain>;
|
||||
getIndicatorHealthStatus: AdminPanelHealthServiceData;
|
||||
getLogicFunctionSourceCode?: Maybe<Scalars['String']>;
|
||||
getMaintenanceMode?: Maybe<MaintenanceMode>;
|
||||
getMeteredProductsUsage: Array<BillingMeteredProductUsage>;
|
||||
getModelsDevProviders: Array<ModelsDevProviderSuggestion>;
|
||||
getModelsDevSuggestions: Array<ModelsDevModelSuggestion>;
|
||||
@@ -7215,6 +7241,25 @@ export type GetSystemHealthStatusQueryVariables = Exact<{ [key: string]: never;
|
||||
|
||||
export type GetSystemHealthStatusQuery = { __typename?: 'Query', getSystemHealthStatus: { __typename?: 'SystemHealth', services: Array<{ __typename?: 'SystemHealthService', id: HealthIndicatorId, label: string, status: AdminPanelHealthServiceStatus }> } };
|
||||
|
||||
export type ClearMaintenanceModeMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ClearMaintenanceModeMutation = { __typename?: 'Mutation', clearMaintenanceMode: boolean };
|
||||
|
||||
export type SetMaintenanceModeMutationVariables = Exact<{
|
||||
startAt: Scalars['DateTime'];
|
||||
endAt: Scalars['DateTime'];
|
||||
link?: InputMaybe<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type SetMaintenanceModeMutation = { __typename?: 'Mutation', setMaintenanceMode: boolean };
|
||||
|
||||
export type GetMaintenanceModeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetMaintenanceModeQuery = { __typename?: 'Query', getMaintenanceMode?: { __typename?: 'MaintenanceMode', startAt: string, endAt: string, link?: string | null } | null };
|
||||
|
||||
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string };
|
||||
|
||||
export type DeleteApplicationRegistrationMutationVariables = Exact<{
|
||||
@@ -8330,6 +8375,9 @@ export const GetIndicatorHealthStatusDocument = {"kind":"Document","definitions"
|
||||
export const GetQueueJobsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetQueueJobs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"state"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JobState"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getQueueJobs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"queueName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}}},{"kind":"Argument","name":{"kind":"Name","value":"state"},"value":{"kind":"Variable","name":{"kind":"Name","value":"state"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jobs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"failedReason"}},{"kind":"Field","name":{"kind":"Name","value":"processedOn"}},{"kind":"Field","name":{"kind":"Name","value":"finishedOn"}},{"kind":"Field","name":{"kind":"Name","value":"attemptsMade"}},{"kind":"Field","name":{"kind":"Name","value":"returnValue"}},{"kind":"Field","name":{"kind":"Name","value":"logs"}},{"kind":"Field","name":{"kind":"Name","value":"stackTrace"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"hasMore"}},{"kind":"Field","name":{"kind":"Name","value":"retentionConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"completedMaxAge"}},{"kind":"Field","name":{"kind":"Name","value":"completedMaxCount"}},{"kind":"Field","name":{"kind":"Name","value":"failedMaxAge"}},{"kind":"Field","name":{"kind":"Name","value":"failedMaxCount"}}]}}]}}]}}]} as unknown as DocumentNode<GetQueueJobsQuery, GetQueueJobsQueryVariables>;
|
||||
export const GetQueueMetricsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetQueueMetrics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"timeRange"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QueueMetricsTimeRange"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getQueueMetrics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"queueName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}}},{"kind":"Argument","name":{"kind":"Name","value":"timeRange"},"value":{"kind":"Variable","name":{"kind":"Name","value":"timeRange"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"queueName"}},{"kind":"Field","name":{"kind":"Name","value":"timeRange"}},{"kind":"Field","name":{"kind":"Name","value":"workers"}},{"kind":"Field","name":{"kind":"Name","value":"details"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"failed"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"waiting"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"delayed"}},{"kind":"Field","name":{"kind":"Name","value":"failureRate"}}]}},{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetQueueMetricsQuery, GetQueueMetricsQueryVariables>;
|
||||
export const GetSystemHealthStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSystemHealthStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getSystemHealthStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]} as unknown as DocumentNode<GetSystemHealthStatusQuery, GetSystemHealthStatusQueryVariables>;
|
||||
export const ClearMaintenanceModeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ClearMaintenanceMode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clearMaintenanceMode"}}]}}]} as unknown as DocumentNode<ClearMaintenanceModeMutation, ClearMaintenanceModeMutationVariables>;
|
||||
export const SetMaintenanceModeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetMaintenanceMode"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startAt"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DateTime"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"endAt"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DateTime"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"link"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setMaintenanceMode"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"startAt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startAt"}}},{"kind":"Argument","name":{"kind":"Name","value":"endAt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"endAt"}}},{"kind":"Argument","name":{"kind":"Name","value":"link"},"value":{"kind":"Variable","name":{"kind":"Name","value":"link"}}}]}]}}]} as unknown as DocumentNode<SetMaintenanceModeMutation, SetMaintenanceModeMutationVariables>;
|
||||
export const GetMaintenanceModeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMaintenanceMode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getMaintenanceMode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startAt"}},{"kind":"Field","name":{"kind":"Name","value":"endAt"}},{"kind":"Field","name":{"kind":"Name","value":"link"}}]}}]}}]} as unknown as DocumentNode<GetMaintenanceModeQuery, GetMaintenanceModeQueryVariables>;
|
||||
export const DeleteApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode<DeleteApplicationRegistrationMutation, DeleteApplicationRegistrationMutationVariables>;
|
||||
export const RotateApplicationRegistrationClientSecretDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RotateApplicationRegistrationClientSecret"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rotateApplicationRegistrationClientSecret"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}}]}}]}}]} as unknown as DocumentNode<RotateApplicationRegistrationClientSecretMutation, RotateApplicationRegistrationClientSecretMutationVariables>;
|
||||
export const TransferApplicationRegistrationOwnershipDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TransferApplicationRegistrationOwnership"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"targetWorkspaceSubdomain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transferApplicationRegistrationOwnership"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"targetWorkspaceSubdomain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"targetWorkspaceSubdomain"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<TransferApplicationRegistrationOwnershipMutation, TransferApplicationRegistrationOwnershipMutationVariables>;
|
||||
|
||||
@@ -129,6 +129,7 @@ export type Mutation = {
|
||||
deactivateWorkflowVersion: Scalars['Boolean'];
|
||||
deleteWorkflowVersionEdge: WorkflowVersionStepChanges;
|
||||
deleteWorkflowVersionStep: WorkflowVersionStepChanges;
|
||||
dismissMaintenanceModeBanner: Scalars['Boolean'];
|
||||
dismissReconnectAccountBanner: Scalars['Boolean'];
|
||||
duplicateWorkflow: WorkflowVersionDto;
|
||||
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
|
||||
@@ -249,6 +250,7 @@ export type Query = {
|
||||
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
|
||||
getTimelineThreadsFromOpportunityId: TimelineThreadsWithTotal;
|
||||
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
|
||||
isMaintenanceModeBannerDismissed: Scalars['Boolean'];
|
||||
search: SearchResultConnection;
|
||||
};
|
||||
|
||||
|
||||
+17
-13
@@ -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}>
|
||||
|
||||
+14
-1
@@ -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;
|
||||
};
|
||||
|
||||
+17
-4
@@ -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"
|
||||
|
||||
+2
@@ -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 />
|
||||
|
||||
+2
-1
@@ -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.`
|
||||
|
||||
+2
-1
@@ -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.`
|
||||
|
||||
+2
-1
@@ -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.`
|
||||
|
||||
+2
-1
@@ -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.`
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+71
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
@@ -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}
|
||||
|
||||
+1
@@ -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}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DISMISS_MAINTENANCE_MODE_BANNER = gql`
|
||||
mutation DismissMaintenanceModeBanner {
|
||||
dismissMaintenanceModeBanner
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const IS_MAINTENANCE_MODE_BANNER_DISMISSED = gql`
|
||||
query IsMaintenanceModeBannerDismissed {
|
||||
isMaintenanceModeBannerDismissed
|
||||
}
|
||||
`;
|
||||
+59
@@ -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,
|
||||
};
|
||||
};
|
||||
+4
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+244
@@ -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>
|
||||
);
|
||||
};
|
||||
+30
@@ -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;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CLEAR_MAINTENANCE_MODE = gql`
|
||||
mutation ClearMaintenanceMode {
|
||||
clearMaintenanceMode
|
||||
}
|
||||
`;
|
||||
+11
@@ -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)
|
||||
}
|
||||
`;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_MAINTENANCE_MODE = gql`
|
||||
query GetMaintenanceMode {
|
||||
getMaintenanceMode {
|
||||
startAt
|
||||
endAt
|
||||
link
|
||||
}
|
||||
}
|
||||
`;
|
||||
+13
@@ -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>
|
||||
);
|
||||
};
|
||||
+8
-3
@@ -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();
|
||||
|
||||
|
||||
+8
-3
@@ -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();
|
||||
|
||||
+8
-3
@@ -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,
|
||||
|
||||
+8
-3
@@ -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;
|
||||
|
||||
|
||||
+6
-1
@@ -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({
|
||||
|
||||
+8
-3
@@ -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),
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
type SettingsDataModelObjectAboutFormValues,
|
||||
settingsDataModelObjectAboutFormSchema,
|
||||
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
@@ -26,6 +27,7 @@ export const SettingsNewObject = () => {
|
||||
const navigate = useNavigateSettings();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { createOneObjectMetadataItem } = useCreateOneObjectMetadataItem();
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
|
||||
|
||||
const formConfig = useForm<SettingsDataModelObjectAboutFormValues>({
|
||||
@@ -49,7 +51,7 @@ export const SettingsNewObject = () => {
|
||||
const hasNameConflict = isDefined(conflictingObjectMetadataItem);
|
||||
|
||||
const { isValid, isSubmitting } = formConfig.formState;
|
||||
const canSave = isValid && !isSubmitting && !hasNameConflict;
|
||||
const canSave = isValid && !isSubmitting && !hasNameConflict && !isDDLLocked;
|
||||
|
||||
const handleSave = async (
|
||||
formValues: SettingsDataModelObjectAboutFormValues,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SettingsPath,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
@@ -64,9 +65,12 @@ export const SettingsObjectDetailPage = () => {
|
||||
findObjectMetadataItemByNamePlural(objectNamePlural) ??
|
||||
findObjectMetadataItemByNamePlural(updatedObjectNamePlural);
|
||||
|
||||
const readonly = isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
});
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const readonly =
|
||||
isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
}) || isDDLLocked;
|
||||
|
||||
const activeTabId = useAtomComponentStateValue(
|
||||
activeTabIdComponentState,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useUpdateOneFieldMetadataItem } from '@/object-metadata/hooks/useUpdate
|
||||
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
|
||||
import { formatFieldMetadataItemInput } from '@/object-metadata/utils/formatFieldMetadataItemInput';
|
||||
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
|
||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
@@ -28,6 +29,7 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
|
||||
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
|
||||
import { shouldNavigateBackToMemorizedUrlOnSaveState } from '@/ui/navigation/states/shouldNavigateBackToMemorizedUrlOnSaveState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { AppPath, SettingsPath } from 'twenty-shared/types';
|
||||
@@ -85,9 +87,12 @@ export const SettingsObjectFieldEdit = () => {
|
||||
const objectMetadataItem =
|
||||
findObjectMetadataItemByNamePlural(objectNamePlural);
|
||||
|
||||
const readonly = isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
});
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const readonly =
|
||||
isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
}) || isDDLLocked;
|
||||
|
||||
const {
|
||||
deactivateMetadataField,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem';
|
||||
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
@@ -71,6 +72,7 @@ export const SettingsObjectTable = ({
|
||||
const { t } = useLingui();
|
||||
|
||||
const isAdvancedModeEnabled = useAtomStateValue(isAdvancedModeEnabledState);
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showDeactivated, setShowDeactivated] = useState(true);
|
||||
@@ -246,7 +248,7 @@ export const SettingsObjectTable = ({
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</StyledIconChevronRightContainer>
|
||||
) : (
|
||||
) : isDDLLocked ? null : (
|
||||
<SettingsObjectInactiveMenuDropDown
|
||||
isCustomObject={
|
||||
objectSettingsItem.objectMetadataItem.isCustom
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
|
||||
import { SettingsObjectCoverImage } from '@/settings/data-model/objects/components/SettingsObjectCoverImage';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
@@ -16,19 +18,30 @@ export const SettingsObjects = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { objectMetadataItems } = useFilteredObjectMetadataItems();
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Data model`}
|
||||
actionButton={
|
||||
<UndecoratedLink to={getSettingsPath(SettingsPath.NewObject)}>
|
||||
isDDLLocked ? (
|
||||
<Button
|
||||
Icon={IconPlus}
|
||||
title={t`Add object`}
|
||||
accent="blue"
|
||||
size="small"
|
||||
disabled
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
) : (
|
||||
<UndecoratedLink to={getSettingsPath(SettingsPath.NewObject)}>
|
||||
<Button
|
||||
Icon={IconPlus}
|
||||
title={t`Add object`}
|
||||
accent="blue"
|
||||
size="small"
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
)
|
||||
}
|
||||
links={[
|
||||
{
|
||||
|
||||
+6
-2
@@ -1,3 +1,4 @@
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
|
||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||
@@ -9,6 +10,7 @@ import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fi
|
||||
import { settingsFieldFormSchema } from '@/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
type RelationCreationPayload,
|
||||
SettingsPath,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined, getSettingsPath } from 'twenty-shared/utils';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { type z } from 'zod';
|
||||
@@ -88,11 +90,13 @@ export const SettingsObjectNewFieldConfigure = () => {
|
||||
}
|
||||
}, [activeObjectMetadataItem, navigateApp]);
|
||||
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
if (!isDefined(activeObjectMetadataItem)) return null;
|
||||
|
||||
const { isValid, isSubmitting } = formConfig.formState;
|
||||
|
||||
const canSave = isValid && !isSubmitting;
|
||||
const canSave = isValid && !isSubmitting && !isDDLLocked;
|
||||
|
||||
const handleSave = async (
|
||||
formValues: SettingsDataModelNewFieldFormValues,
|
||||
|
||||
+4
-151
@@ -1,151 +1,4 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
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 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 StyledDatePickerContainer = styled.div`
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
type EventLogDatePickerInputProps = {
|
||||
label: string;
|
||||
value: Date | undefined;
|
||||
onChange: (date: Date | undefined) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export const EventLogDatePickerInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: EventLogDatePickerInputProps) => {
|
||||
const { t } = useLingui();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
listenerId: `event-log-date-picker-${label}`,
|
||||
callback: handleClose,
|
||||
enabled: isOpen,
|
||||
excludedClickOutsideIds: [
|
||||
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 hasValue={isDefined(value)} onClick={() => setIsOpen(true)}>
|
||||
<StyledIconContainer>
|
||||
<IconCalendar size={16} />
|
||||
</StyledIconContainer>
|
||||
{formatDisplayValue(value)}
|
||||
</StyledInput>
|
||||
{isOpen && (
|
||||
<StyledDatePickerContainer>
|
||||
<OverlayContainer>
|
||||
<DateTimePicker
|
||||
instanceId={`event-log-date-picker-${label}`}
|
||||
date={zonedDateTime}
|
||||
onChange={handleDateTimeSelect}
|
||||
onClose={handleDateTimeSelect}
|
||||
onClear={handleClear}
|
||||
clearable
|
||||
/>
|
||||
</OverlayContainer>
|
||||
</StyledDatePickerContainer>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
);
|
||||
};
|
||||
export {
|
||||
SettingsDatePickerInput as EventLogDatePickerInput,
|
||||
type SettingsDatePickerInputProps as EventLogDatePickerInputProps,
|
||||
} from '@/settings/components/SettingsDatePickerInput';
|
||||
|
||||
@@ -58,4 +58,5 @@ export const mockedClientConfig: ClientConfig = {
|
||||
allowRequestsToTwentyIcons: true,
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
isWorkspaceSchemaDDLLocked: false,
|
||||
};
|
||||
|
||||
@@ -497,6 +497,7 @@ export type {
|
||||
AvatarProps,
|
||||
AvatarSize,
|
||||
AvatarType,
|
||||
BannerColor,
|
||||
BannerVariant,
|
||||
CalloutProps,
|
||||
CalloutVariant,
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { DataSource, type Repository } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { addGlobalKeyValuePairUniqueIndexQueries } from 'src/database/typeorm/core/migrations/utils/1774700000000-add-global-key-value-pair-unique-index.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-21:add-global-key-value-pair-unique-index',
|
||||
description:
|
||||
'Deduplicate global keyValuePair rows and add the null/null unique index',
|
||||
})
|
||||
export class AddGlobalKeyValuePairUniqueIndexCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
private hasRunOnce = false;
|
||||
|
||||
private async deduplicateGlobalKeyValuePairs(
|
||||
queryRunner: DataSource['createQueryRunner'] extends () => infer T
|
||||
? T
|
||||
: never,
|
||||
): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "core"."keyValuePair"
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY key
|
||||
ORDER BY "updatedAt" DESC, "createdAt" DESC, id DESC
|
||||
) AS row_number
|
||||
FROM "core"."keyValuePair"
|
||||
WHERE "userId" IS NULL
|
||||
AND "workspaceId" IS NULL
|
||||
) ranked_key_value_pairs
|
||||
WHERE ranked_key_value_pairs.row_number > 1
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
|
||||
protected readonly dataSourceService: DataSourceService,
|
||||
@InjectDataSource()
|
||||
private readonly coreDataSource: DataSource,
|
||||
) {
|
||||
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
if (this.hasRunOnce) {
|
||||
this.logger.log(
|
||||
'Skipping has already been run once AddGlobalKeyValuePairUniqueIndexCommand',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
await this.deduplicateGlobalKeyValuePairs(queryRunner);
|
||||
await addGlobalKeyValuePairUniqueIndexQueries(queryRunner);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
this.logger.log(
|
||||
'Successfully run AddGlobalKeyValuePairUniqueIndexCommand',
|
||||
);
|
||||
this.hasRunOnce = true;
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(
|
||||
`Rolling back AddGlobalKeyValuePairUniqueIndexCommand: ${error.message}`,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
|
||||
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
|
||||
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
|
||||
import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command';
|
||||
@@ -24,6 +25,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
FeatureFlagModule,
|
||||
],
|
||||
providers: [
|
||||
AddGlobalKeyValuePairUniqueIndexCommand,
|
||||
BackfillDatasourceToWorkspaceCommand,
|
||||
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
|
||||
DeduplicateEngineCommandsCommand,
|
||||
@@ -31,6 +33,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
UpdateEditLayoutCommandMenuItemLabelCommand,
|
||||
],
|
||||
exports: [
|
||||
AddGlobalKeyValuePairUniqueIndexCommand,
|
||||
BackfillDatasourceToWorkspaceCommand,
|
||||
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
|
||||
DeduplicateEngineCommandsCommand,
|
||||
|
||||
+3
@@ -12,6 +12,7 @@ import {
|
||||
import { CoreMigrationRunnerService } from 'src/database/commands/core-migration-runner/services/core-migration-runner.service';
|
||||
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
|
||||
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
|
||||
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
|
||||
import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command';
|
||||
import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command';
|
||||
import { IdentifyFieldPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command';
|
||||
@@ -72,6 +73,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
protected readonly makeWorkflowSearchableCommand: MakeWorkflowSearchableCommand,
|
||||
|
||||
// 1.21 Commands
|
||||
protected readonly addGlobalKeyValuePairUniqueIndexCommand: AddGlobalKeyValuePairUniqueIndexCommand,
|
||||
protected readonly backfillDatasourceToWorkspaceCommand: BackfillDatasourceToWorkspaceCommand,
|
||||
protected readonly backfillPageLayoutsAndFieldsWidgetViewFieldsCommand: BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
|
||||
protected readonly deduplicateEngineCommandsCommand: DeduplicateEngineCommandsCommand,
|
||||
@@ -110,6 +112,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
];
|
||||
|
||||
const commands_1210: VersionCommands = [
|
||||
this.addGlobalKeyValuePairUniqueIndexCommand,
|
||||
this.backfillDatasourceToWorkspaceCommand,
|
||||
this.backfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
|
||||
this.deduplicateEngineCommandsCommand,
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
import { addGlobalKeyValuePairUniqueIndexQueries } from 'src/database/typeorm/core/migrations/utils/1774700000000-add-global-key-value-pair-unique-index.util';
|
||||
|
||||
export class AddGlobalKeyValuePairUniqueIndex1774700000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddGlobalKeyValuePairUniqueIndex1774700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const savepointName = 'sp_add_global_key_value_pair_unique_index';
|
||||
|
||||
try {
|
||||
await queryRunner.query(`SAVEPOINT ${savepointName}`);
|
||||
|
||||
await addGlobalKeyValuePairUniqueIndexQueries(queryRunner);
|
||||
|
||||
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
} catch (e) {
|
||||
try {
|
||||
await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
} catch (rollbackError) {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.error(
|
||||
'Failed to rollback to savepoint in AddGlobalKeyValuePairUniqueIndex1774700000000',
|
||||
rollbackError,
|
||||
);
|
||||
throw rollbackError;
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line no-console
|
||||
console.error(
|
||||
'Swallowing AddGlobalKeyValuePairUniqueIndex1774700000000 error',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "core"."IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
export const addGlobalKeyValuePairUniqueIndexQueries = async (
|
||||
queryRunner: QueryRunner,
|
||||
): Promise<void> => {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "core"."IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE" ON "core"."keyValuePair" ("key") WHERE "userId" is NULL AND "workspaceId" is NULL`,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
} from 'src/utils/custom-exception';
|
||||
|
||||
export const AdminPanelExceptionCode = appendCommonExceptionCode({
|
||||
INVALID_MAINTENANCE_MODE_TIME_RANGE: 'INVALID_MAINTENANCE_MODE_TIME_RANGE',
|
||||
} as const);
|
||||
|
||||
const getAdminPanelExceptionUserFriendlyMessage = (
|
||||
code: keyof typeof AdminPanelExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case AdminPanelExceptionCode.INVALID_MAINTENANCE_MODE_TIME_RANGE:
|
||||
return msg`Please choose an end date and time after the start date and time.`;
|
||||
case AdminPanelExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
return msg`Something went wrong. Please try again.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class AdminPanelException extends CustomException<
|
||||
keyof typeof AdminPanelExceptionCode
|
||||
> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: keyof typeof AdminPanelExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ?? getAdminPanelExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/adm
|
||||
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
|
||||
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
@@ -24,7 +25,9 @@ import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-clie
|
||||
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
|
||||
import { UsageModule } from 'src/engine/core-modules/usage/usage.module';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
@@ -44,18 +47,21 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
SecureHttpClientModule,
|
||||
ApplicationRegistrationModule,
|
||||
UsageModule,
|
||||
KeyValuePairModule,
|
||||
UserVarsModule,
|
||||
],
|
||||
providers: [
|
||||
AdminPanelResolver,
|
||||
AdminPanelService,
|
||||
AdminPanelHealthService,
|
||||
AdminPanelQueueService,
|
||||
MaintenanceModeService,
|
||||
DatabaseHealthIndicator,
|
||||
RedisHealthIndicator,
|
||||
WorkerHealthIndicator,
|
||||
ConnectedAccountHealth,
|
||||
AppHealthIndicator,
|
||||
],
|
||||
exports: [AdminPanelService],
|
||||
exports: [AdminPanelService, MaintenanceModeService],
|
||||
})
|
||||
export class AdminPanelModule {}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
|
||||
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
|
||||
@@ -55,9 +56,11 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
import { AdminPanelHealthServiceDataDTO } from './dtos/admin-panel-health-service-data.dto';
|
||||
import { MaintenanceModeDTO } from './dtos/maintenance-mode.dto';
|
||||
import { ModelsDevModelSuggestionDTO } from './dtos/models-dev-model-suggestion.dto';
|
||||
import { ModelsDevProviderSuggestionDTO } from './dtos/models-dev-provider-suggestion.dto';
|
||||
import { QueueMetricsDataDTO } from './dtos/queue-metrics-data.dto';
|
||||
import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@@ -82,6 +85,7 @@ export class AdminPanelResolver {
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly modelsDevCatalogService: ModelsDevCatalogService,
|
||||
private readonly usageAnalyticsService: UsageAnalyticsService,
|
||||
private readonly maintenanceModeService: MaintenanceModeService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {}
|
||||
@@ -551,4 +555,42 @@ export class AdminPanelResolver {
|
||||
label: nameMap.get(item.key),
|
||||
}));
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Query(() => MaintenanceModeDTO, { nullable: true })
|
||||
async getMaintenanceMode(): Promise<MaintenanceModeDTO | null> {
|
||||
const value = await this.maintenanceModeService.getMaintenanceMode();
|
||||
|
||||
if (!isDefined(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startAt: new Date(value.startAt),
|
||||
endAt: new Date(value.endAt),
|
||||
link: value.link,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => Boolean)
|
||||
async setMaintenanceMode(
|
||||
@Args() { startAt, endAt, link }: SetMaintenanceModeInput,
|
||||
): Promise<boolean> {
|
||||
await this.maintenanceModeService.setMaintenanceMode({
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
link,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => Boolean)
|
||||
async clearMaintenanceMode(): Promise<boolean> {
|
||||
await this.maintenanceModeService.clearMaintenanceMode();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Field, GraphQLISODateTime, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('MaintenanceMode')
|
||||
export class MaintenanceModeDTO {
|
||||
@Field(() => GraphQLISODateTime)
|
||||
startAt: Date;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
endAt: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
link?: string;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { ArgsType, Field, GraphQLISODateTime } from '@nestjs/graphql';
|
||||
|
||||
import { IsDate, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@ArgsType()
|
||||
export class SetMaintenanceModeInput {
|
||||
@Field(() => GraphQLISODateTime)
|
||||
@IsNotEmpty()
|
||||
@Type(() => Date)
|
||||
@IsDate()
|
||||
startAt: Date;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
@IsNotEmpty()
|
||||
@Type(() => Date)
|
||||
@IsDate()
|
||||
endAt: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
link?: string;
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
AdminPanelException,
|
||||
AdminPanelExceptionCode,
|
||||
} from 'src/engine/core-modules/admin-panel/admin-panel.exception';
|
||||
import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { KeyValuePairService } from 'src/engine/core-modules/key-value-pair/key-value-pair.service';
|
||||
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
|
||||
|
||||
import {
|
||||
MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
|
||||
type MaintenanceModeBannerKeyValueTypeMap,
|
||||
} from './types/maintenance-mode-banner-key-value.type';
|
||||
|
||||
const MAINTENANCE_MODE_KEY = 'MAINTENANCE_MODE';
|
||||
|
||||
type MaintenanceModeValue = {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
type MaintenanceModeKeyValueTypeMap = {
|
||||
[MAINTENANCE_MODE_KEY]: MaintenanceModeValue;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceModeService {
|
||||
constructor(
|
||||
private readonly keyValuePairService: KeyValuePairService<MaintenanceModeKeyValueTypeMap>,
|
||||
private readonly userVarsService: UserVarsService<MaintenanceModeBannerKeyValueTypeMap>,
|
||||
) {}
|
||||
|
||||
private async clearMaintenanceModeBannerDismissals(): Promise<void> {
|
||||
await this.userVarsService.delete({
|
||||
key: MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
async getMaintenanceMode(): Promise<MaintenanceModeValue | null> {
|
||||
const maintenanceModeKeyValuePairs = await this.keyValuePairService.get({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: MAINTENANCE_MODE_KEY,
|
||||
});
|
||||
|
||||
if (maintenanceModeKeyValuePairs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = (
|
||||
maintenanceModeKeyValuePairs[0] as {
|
||||
value?: MaintenanceModeKeyValueTypeMap[typeof MAINTENANCE_MODE_KEY];
|
||||
}
|
||||
)?.value;
|
||||
|
||||
if (
|
||||
!isDefined(value) ||
|
||||
!isNonEmptyString(value.startAt) ||
|
||||
!isNonEmptyString(value.endAt)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
async setMaintenanceMode(value: MaintenanceModeValue): Promise<void> {
|
||||
if (new Date(value.endAt) <= new Date(value.startAt)) {
|
||||
throw new AdminPanelException(
|
||||
'Maintenance mode end date must be after start date',
|
||||
AdminPanelExceptionCode.INVALID_MAINTENANCE_MODE_TIME_RANGE,
|
||||
);
|
||||
}
|
||||
|
||||
await this.clearMaintenanceModeBannerDismissals();
|
||||
|
||||
await this.keyValuePairService.set({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: MAINTENANCE_MODE_KEY,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
async clearMaintenanceMode(): Promise<void> {
|
||||
await this.clearMaintenanceModeBannerDismissals();
|
||||
|
||||
await this.keyValuePairService.delete({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: MAINTENANCE_MODE_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
async isMaintenanceModeBannerDismissed(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
const isDismissed = await this.userVarsService.get({
|
||||
userId,
|
||||
workspaceId,
|
||||
key: MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
|
||||
});
|
||||
|
||||
return isDismissed === true;
|
||||
}
|
||||
|
||||
async dismissMaintenanceModeBanner(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.userVarsService.set({
|
||||
userId,
|
||||
workspaceId,
|
||||
key: MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export const MAINTENANCE_MODE_BANNER_DISMISSED_KEY =
|
||||
'MAINTENANCE_MODE_BANNER_DISMISSED';
|
||||
|
||||
export type MaintenanceModeBannerKeyValueTypeMap = {
|
||||
[MAINTENANCE_MODE_BANNER_DISMISSED_KEY]: boolean;
|
||||
};
|
||||
+1
@@ -99,6 +99,7 @@ describe('ClientConfigController', () => {
|
||||
allowRequestsToTwentyIcons: true,
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
isWorkspaceSchemaDDLLocked: false,
|
||||
};
|
||||
|
||||
jest
|
||||
|
||||
+24
-1
@@ -1,4 +1,9 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import {
|
||||
Field,
|
||||
GraphQLISODateTime,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
@@ -212,6 +217,18 @@ export class PublicFeatureFlag {
|
||||
metadata: PublicFeatureFlagMetadata;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfigMaintenanceMode {
|
||||
@Field(() => GraphQLISODateTime)
|
||||
startAt: Date;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
endAt: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
link?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfig {
|
||||
@Field(() => String, { nullable: true })
|
||||
@@ -294,4 +311,10 @@ export class ClientConfig {
|
||||
|
||||
@Field(() => Boolean)
|
||||
isClickHouseConfigured: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isWorkspaceSchemaDDLLocked: boolean;
|
||||
|
||||
@Field(() => ClientConfigMaintenanceMode, { nullable: true })
|
||||
maintenance?: ClientConfigMaintenanceMode;
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,14 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
|
||||
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
|
||||
|
||||
import { ClientConfigController } from './client-config.controller';
|
||||
import { ClientConfigResolver } from './client-config.resolver';
|
||||
|
||||
import { ClientConfigService } from './services/client-config.service';
|
||||
|
||||
@Module({
|
||||
imports: [DomainServerConfigModule],
|
||||
imports: [DomainServerConfigModule, AdminPanelModule],
|
||||
controllers: [ClientConfigController],
|
||||
providers: [ClientConfigService],
|
||||
providers: [ClientConfigResolver, ClientConfigService],
|
||||
})
|
||||
export class ClientConfigModule {}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { ClientConfigResolver } from './client-config.resolver';
|
||||
|
||||
describe('ClientConfigResolver', () => {
|
||||
let resolver: ClientConfigResolver;
|
||||
let maintenanceModeService: MaintenanceModeService;
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-id',
|
||||
} as AuthContextUser;
|
||||
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-id',
|
||||
} as WorkspaceEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClientConfigResolver,
|
||||
{
|
||||
provide: MaintenanceModeService,
|
||||
useValue: {
|
||||
dismissMaintenanceModeBanner: jest.fn(),
|
||||
isMaintenanceModeBannerDismissed: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<ClientConfigResolver>(ClientConfigResolver);
|
||||
maintenanceModeService = module.get<MaintenanceModeService>(
|
||||
MaintenanceModeService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return dismissal state from maintenance mode service', async () => {
|
||||
jest
|
||||
.spyOn(maintenanceModeService, 'isMaintenanceModeBannerDismissed')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await resolver.isMaintenanceModeBannerDismissed(
|
||||
mockUser,
|
||||
mockWorkspace,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(
|
||||
maintenanceModeService.isMaintenanceModeBannerDismissed,
|
||||
).toHaveBeenCalledWith(mockUser.id, mockWorkspace.id);
|
||||
});
|
||||
|
||||
it('should persist dismissal through maintenance mode service', async () => {
|
||||
jest
|
||||
.spyOn(maintenanceModeService, 'dismissMaintenanceModeBanner')
|
||||
.mockResolvedValue();
|
||||
|
||||
const result = await resolver.dismissMaintenanceModeBanner(
|
||||
mockUser,
|
||||
mockWorkspace,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(
|
||||
maintenanceModeService.dismissMaintenanceModeBanner,
|
||||
).toHaveBeenCalledWith(mockUser.id, mockWorkspace.id);
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { CoreResolver } from 'src/engine/api/graphql/graphql-config/decorators/core-resolver.decorator';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
@CoreResolver()
|
||||
export class ClientConfigResolver {
|
||||
constructor(
|
||||
private readonly maintenanceModeService: MaintenanceModeService,
|
||||
) {}
|
||||
|
||||
@Query(() => Boolean)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async isMaintenanceModeBannerDismissed(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
return this.maintenanceModeService.isMaintenanceModeBannerDismissed(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async dismissMaintenanceModeBanner(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
await this.maintenanceModeService.dismissMaintenanceModeBanner(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+10
@@ -9,6 +9,7 @@ import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
|
||||
describe('ClientConfigService', () => {
|
||||
let service: ClientConfigService;
|
||||
@@ -39,6 +40,12 @@ describe('ClientConfigService', () => {
|
||||
getModelConfig: jest.fn().mockReturnValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MaintenanceModeService,
|
||||
useValue: {
|
||||
getMaintenanceMode: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -91,6 +98,8 @@ describe('ClientConfigService', () => {
|
||||
CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
|
||||
CLOUDFLARE_API_KEY: undefined,
|
||||
CLOUDFLARE_ZONE_ID: undefined,
|
||||
ALLOW_REQUESTS_TO_TWENTY_ICONS: false,
|
||||
CLICKHOUSE_URL: undefined,
|
||||
};
|
||||
|
||||
return mockValues[key];
|
||||
@@ -159,6 +168,7 @@ describe('ClientConfigService', () => {
|
||||
isGoogleCalendarEnabled: true,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
allowRequestsToTwentyIcons: false,
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
|
||||
+17
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import {
|
||||
type ClientAIModelConfig,
|
||||
type ClientConfig,
|
||||
@@ -33,6 +35,7 @@ export class ClientConfigService {
|
||||
private twentyConfigService: TwentyConfigService,
|
||||
private domainServerConfigService: DomainServerConfigService,
|
||||
private aiModelRegistryService: AiModelRegistryService,
|
||||
private maintenanceModeService: MaintenanceModeService,
|
||||
) {}
|
||||
|
||||
private deriveNativeCapabilities(
|
||||
@@ -237,8 +240,22 @@ export class ClientConfigService {
|
||||
: undefined,
|
||||
isCloudflareIntegrationEnabled: this.isCloudflareIntegrationEnabled(),
|
||||
isClickHouseConfigured: !!this.twentyConfigService.get('CLICKHOUSE_URL'),
|
||||
isWorkspaceSchemaDDLLocked: this.twentyConfigService.get(
|
||||
'WORKSPACE_SCHEMA_DDL_LOCKED',
|
||||
),
|
||||
};
|
||||
|
||||
const maintenanceMode =
|
||||
await this.maintenanceModeService.getMaintenanceMode();
|
||||
|
||||
if (isDefined(maintenanceMode)) {
|
||||
clientConfig.maintenance = {
|
||||
startAt: new Date(maintenanceMode.startAt),
|
||||
endAt: new Date(maintenanceMode.endAt),
|
||||
link: maintenanceMode.link,
|
||||
};
|
||||
}
|
||||
|
||||
return clientConfig;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-13
@@ -1,6 +1,6 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { type I18n, type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import {
|
||||
@@ -8,12 +8,8 @@ import {
|
||||
ErrorCode,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { convertExceptionToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export const generateGraphQLErrorFromError = (
|
||||
error: Error | CustomException,
|
||||
i18n: I18n,
|
||||
) => {
|
||||
export const generateGraphQLErrorFromError = (error: Error, i18n: I18n) => {
|
||||
const graphqlError =
|
||||
error instanceof HttpException
|
||||
? convertExceptionToGraphQLError(error)
|
||||
@@ -21,13 +17,14 @@ export const generateGraphQLErrorFromError = (
|
||||
|
||||
const defaultErrorMessage = msg`An error occurred.`;
|
||||
|
||||
if (error instanceof CustomException) {
|
||||
graphqlError.extensions.userFriendlyMessage = i18n._(
|
||||
error.userFriendlyMessage ?? defaultErrorMessage,
|
||||
);
|
||||
} else {
|
||||
graphqlError.extensions.userFriendlyMessage = i18n._(defaultErrorMessage);
|
||||
}
|
||||
const userFriendlyMessage =
|
||||
'userFriendlyMessage' in error
|
||||
? (error.userFriendlyMessage as MessageDescriptor)
|
||||
: undefined;
|
||||
|
||||
graphqlError.extensions.userFriendlyMessage = i18n._(
|
||||
userFriendlyMessage ?? defaultErrorMessage,
|
||||
);
|
||||
|
||||
return graphqlError;
|
||||
};
|
||||
|
||||
@@ -47,6 +47,14 @@ export enum KeyValuePairType {
|
||||
where: '"workspaceId" is NULL',
|
||||
},
|
||||
)
|
||||
@Index(
|
||||
'IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE',
|
||||
['key'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"userId" is NULL AND "workspaceId" is NULL',
|
||||
},
|
||||
)
|
||||
export class KeyValuePairEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
KeyValuePairEntity,
|
||||
KeyValuePairType,
|
||||
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
|
||||
import { KeyValuePairService } from './key-value-pair.service';
|
||||
|
||||
describe('KeyValuePairService', () => {
|
||||
let service: KeyValuePairService;
|
||||
let keyValuePairRepository: jest.Mocked<Repository<KeyValuePairEntity>>;
|
||||
|
||||
beforeEach(() => {
|
||||
keyValuePairRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
upsert: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as jest.Mocked<Repository<KeyValuePairEntity>>;
|
||||
|
||||
service = new KeyValuePairService(keyValuePairRepository);
|
||||
});
|
||||
|
||||
it('should insert a global null/null key when missing', async () => {
|
||||
await service.set({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
key: 'MAINTENANCE_MODE',
|
||||
value: { startAt: '2026-04-02T10:00:00.000Z' },
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
});
|
||||
|
||||
expect(keyValuePairRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: expect.any(Object),
|
||||
workspaceId: expect.any(Object),
|
||||
key: 'MAINTENANCE_MODE',
|
||||
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' },
|
||||
});
|
||||
expect(keyValuePairRepository.insert).not.toHaveBeenCalled();
|
||||
expect(keyValuePairRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep the existing workspace-null index behavior', async () => {
|
||||
await service.set({
|
||||
userId: 'user-id',
|
||||
workspaceId: null,
|
||||
key: 'USER_SETTING',
|
||||
value: true,
|
||||
type: KeyValuePairType.USER_VARIABLE,
|
||||
});
|
||||
|
||||
expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
|
||||
{
|
||||
userId: 'user-id',
|
||||
workspaceId: null,
|
||||
key: 'USER_SETTING',
|
||||
value: true,
|
||||
type: KeyValuePairType.USER_VARIABLE,
|
||||
},
|
||||
{
|
||||
conflictPaths: ['userId', 'workspaceId', 'key'],
|
||||
indexPredicate: '"workspaceId" is NULL',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+46
-23
@@ -66,40 +66,63 @@ export class KeyValuePairService<
|
||||
},
|
||||
queryRunner?: QueryRunner,
|
||||
) {
|
||||
const normalizedUserId = userId ?? null;
|
||||
const normalizedWorkspaceId = workspaceId ?? null;
|
||||
const hasNullUserAndWorkspace =
|
||||
normalizedUserId === null && normalizedWorkspaceId === null;
|
||||
const keyValuePairRepository = queryRunner
|
||||
? queryRunner.manager.getRepository(KeyValuePairEntity)
|
||||
: this.keyValuePairRepository;
|
||||
|
||||
const upsertData = {
|
||||
userId,
|
||||
workspaceId,
|
||||
userId: normalizedUserId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const conflictPaths = Object.keys(upsertData).filter(
|
||||
(key) =>
|
||||
['userId', 'workspaceId', 'key'].includes(key) &&
|
||||
(conflictPath) =>
|
||||
['userId', 'workspaceId', 'key'].includes(conflictPath) &&
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
upsertData[key] !== undefined,
|
||||
upsertData[conflictPath] !== undefined,
|
||||
);
|
||||
|
||||
const indexPredicate = !userId
|
||||
? '"userId" is NULL'
|
||||
: !workspaceId
|
||||
? '"workspaceId" is NULL'
|
||||
: undefined;
|
||||
const indexPredicate =
|
||||
normalizedUserId === null
|
||||
? '"userId" is NULL'
|
||||
: normalizedWorkspaceId === null
|
||||
? '"workspaceId" is NULL'
|
||||
: undefined;
|
||||
|
||||
if (queryRunner) {
|
||||
await queryRunner.manager
|
||||
.getRepository(KeyValuePairEntity)
|
||||
.upsert(upsertData, {
|
||||
conflictPaths,
|
||||
indexPredicate,
|
||||
});
|
||||
} else {
|
||||
await this.keyValuePairRepository.upsert(upsertData, {
|
||||
conflictPaths,
|
||||
indexPredicate,
|
||||
});
|
||||
}
|
||||
await keyValuePairRepository.upsert(upsertData, {
|
||||
conflictPaths,
|
||||
indexPredicate,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(
|
||||
|
||||
@@ -81,6 +81,16 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
OUTBOUND_HTTP_SAFE_MODE_ENABLED = true;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
|
||||
description:
|
||||
'Lock workspace schema DDL changes (for hot upgrades). Blocks sign-up, workspace deletion, and all metadata schema changes.',
|
||||
isEnvOnly: true,
|
||||
type: ConfigVariableType.BOOLEAN,
|
||||
})
|
||||
@IsOptional()
|
||||
WORKSPACE_SCHEMA_DDL_LOCKED = false;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.TOKENS_DURATION,
|
||||
description: 'Duration for which the email verification token is valid',
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable, CustomError } from 'twenty-shared/utils';
|
||||
|
||||
export const WorkspaceDataSourceExceptionCode = {
|
||||
DDL_LOCKED: 'DDL_LOCKED',
|
||||
} as const;
|
||||
|
||||
const getWorkspaceDataSourceExceptionUserFriendlyMessage = (
|
||||
code: keyof typeof WorkspaceDataSourceExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case WorkspaceDataSourceExceptionCode.DDL_LOCKED:
|
||||
return msg`Workspace schema changes are temporarily locked.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class WorkspaceDataSourceException extends CustomError {
|
||||
code: keyof typeof WorkspaceDataSourceExceptionCode;
|
||||
userFriendlyMessage: MessageDescriptor;
|
||||
|
||||
constructor({
|
||||
message,
|
||||
code,
|
||||
userFriendlyMessage,
|
||||
}: {
|
||||
message: string;
|
||||
code: keyof typeof WorkspaceDataSourceExceptionCode;
|
||||
userFriendlyMessage?: MessageDescriptor;
|
||||
}) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.userFriendlyMessage =
|
||||
userFriendlyMessage ??
|
||||
getWorkspaceDataSourceExceptionUserFriendlyMessage(code);
|
||||
}
|
||||
}
|
||||
+20
@@ -7,12 +7,17 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { type DataSource, type EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import {
|
||||
WorkspaceDataSourceException,
|
||||
WorkspaceDataSourceExceptionCode,
|
||||
} from 'src/engine/workspace-datasource/exceptions/workspace-datasource.exception';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
|
||||
@Injectable()
|
||||
@@ -24,8 +29,19 @@ export class WorkspaceDataSourceService {
|
||||
private readonly coreDataSource: DataSource,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
private assertDDLNotLocked(): void {
|
||||
if (this.twentyConfigService.get('WORKSPACE_SCHEMA_DDL_LOCKED')) {
|
||||
throw new WorkspaceDataSourceException({
|
||||
message:
|
||||
'Workspace schema DDL changes are locked. This is typically set during hot upgrades.',
|
||||
code: WorkspaceDataSourceExceptionCode.DDL_LOCKED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async checkSchemaExists(workspaceId: string) {
|
||||
const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_DATASOURCE_MIGRATED,
|
||||
@@ -57,6 +73,8 @@ export class WorkspaceDataSourceService {
|
||||
* @returns
|
||||
*/
|
||||
public async createWorkspaceDBSchema(workspaceId: string): Promise<string> {
|
||||
this.assertDDLNotLocked();
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
@@ -77,6 +95,8 @@ export class WorkspaceDataSourceService {
|
||||
* @returns
|
||||
*/
|
||||
public async deleteWorkspaceDBSchema(workspaceId: string): Promise<void> {
|
||||
this.assertDDLNotLocked();
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
|
||||
+3
@@ -8,6 +8,7 @@ export const WorkspaceMigrationRunnerExceptionCode = {
|
||||
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||
EXECUTION_FAILED: 'EXECUTION_FAILED',
|
||||
APPLICATION_NOT_FOUND: 'APPLICATION_NOT_FOUND',
|
||||
DDL_LOCKED: 'DDL_LOCKED',
|
||||
} as const;
|
||||
|
||||
const getWorkspaceMigrationRunnerExceptionUserFriendlyMessage = (
|
||||
@@ -20,6 +21,8 @@ const getWorkspaceMigrationRunnerExceptionUserFriendlyMessage = (
|
||||
return msg`Migration execution failed.`;
|
||||
case WorkspaceMigrationRunnerExceptionCode.APPLICATION_NOT_FOUND:
|
||||
return msg`Application not found.`;
|
||||
case WorkspaceMigrationRunnerExceptionCode.DDL_LOCKED:
|
||||
return msg`Workspace schema changes are temporarily locked.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+10
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
@@ -35,6 +36,7 @@ export class WorkspaceMigrationRunnerService {
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
private getLegacyCacheInvalidationPromises({
|
||||
@@ -168,6 +170,14 @@ export class WorkspaceMigrationRunnerService {
|
||||
metadataEvents: MetadataEvent[];
|
||||
hasSchemaMetadataChanged: boolean;
|
||||
}> => {
|
||||
if (this.twentyConfigService.get('WORKSPACE_SCHEMA_DDL_LOCKED')) {
|
||||
throw new WorkspaceMigrationRunnerException({
|
||||
message:
|
||||
'Workspace schema DDL changes are locked. This is typically set during hot upgrades.',
|
||||
code: WorkspaceMigrationRunnerExceptionCode.DDL_LOCKED,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.time('Runner', 'Total execution');
|
||||
this.logger.time('Runner', 'Initial cache retrieval');
|
||||
|
||||
|
||||
@@ -1,43 +1,68 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from '@ui/theme-constants';
|
||||
|
||||
const StyledBanner = styled.div<{ variant?: BannerVariant }>`
|
||||
const StyledBanner = styled.div<{
|
||||
bannerColor: BannerColor;
|
||||
bannerVariant: BannerVariant;
|
||||
}>`
|
||||
align-items: center;
|
||||
backdrop-filter: blur(5px);
|
||||
background: ${({ variant }) =>
|
||||
variant === 'danger'
|
||||
backdrop-filter: ${({ bannerVariant }) =>
|
||||
bannerVariant === 'primary' ? 'blur(5px)' : 'none'};
|
||||
background: ${({ bannerColor, bannerVariant }) => {
|
||||
if (bannerVariant === 'secondary') {
|
||||
return bannerColor === 'danger'
|
||||
? themeCssVariables.background.transparent.danger
|
||||
: themeCssVariables.background.transparent.blue;
|
||||
}
|
||||
return bannerColor === 'danger'
|
||||
? themeCssVariables.color.red
|
||||
: themeCssVariables.color.blue};
|
||||
: themeCssVariables.color.blue;
|
||||
}};
|
||||
box-sizing: border-box;
|
||||
color: ${({ bannerColor, bannerVariant }) => {
|
||||
if (bannerVariant === 'secondary') {
|
||||
return bannerColor === 'danger'
|
||||
? themeCssVariables.color.red
|
||||
: themeCssVariables.color.blue;
|
||||
}
|
||||
return themeCssVariables.font.color.inverted;
|
||||
}};
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]};
|
||||
width: 100%;
|
||||
color: ${themeCssVariables.font.color.inverted};
|
||||
font-family: Inter;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-style: normal;
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
justify-content: center;
|
||||
line-height: 150%;
|
||||
box-sizing: border-box;
|
||||
min-height: 40px;
|
||||
padding: ${themeCssVariables.spacing[2]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export type BannerVariant = 'danger' | 'default';
|
||||
export type BannerColor = 'blue' | 'danger';
|
||||
|
||||
export type BannerVariant = 'primary' | 'secondary';
|
||||
|
||||
type BannerProps = {
|
||||
color?: BannerColor;
|
||||
variant?: BannerVariant;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Banner = ({
|
||||
variant = 'default',
|
||||
color = 'blue',
|
||||
variant = 'primary',
|
||||
className,
|
||||
children,
|
||||
}: BannerProps) => {
|
||||
return (
|
||||
<StyledBanner variant={variant} className={className}>
|
||||
<StyledBanner
|
||||
bannerColor={color}
|
||||
bannerVariant={variant}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</StyledBanner>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export { invalidAvatarUrlsAtomV2 } from './avatar/components/states/invalidAvata
|
||||
export { AVATAR_PROPERTIES_BY_SIZE } from './avatar/constants/AvatarPropertiesBySize';
|
||||
export type { AvatarSize } from './avatar/types/AvatarSize';
|
||||
export type { AvatarType } from './avatar/types/AvatarType';
|
||||
export type { BannerVariant } from './banner/components/Banner';
|
||||
export type { BannerColor, BannerVariant } from './banner/components/Banner';
|
||||
export { Banner } from './banner/components/Banner';
|
||||
export type { SidePanelInformationBannerProps } from './banner/components/SidePanelInformationBanner';
|
||||
export { SidePanelInformationBanner } from './banner/components/SidePanelInformationBanner';
|
||||
|
||||
Reference in New Issue
Block a user