From 2f139bd02cec5bef13642c184f40c74fd3acd03b Mon Sep 17 00:00:00 2001 From: forehalo Date: Tue, 27 May 2025 06:07:26 +0000 Subject: [PATCH] chore(admin): remove useless config diff (#12545) ## Summary by CodeRabbit - **New Features** - Added a GraphQL mutation to validate multiple app configuration updates, returning detailed validation results for each item. - Extended the API schema to support validation feedback, enabling client-side checks before applying changes. - Introduced a detailed, parameterized error message system for configuration validation errors. - Enabled validation of configuration inputs via the admin UI with clear, descriptive error messages. - **Improvements** - Enhanced error reporting with specific, context-rich messages for invalid app configurations. - Simplified admin settings UI by removing the confirmation dialog and streamlining save actions. - Improved clarity and maintainability of validation logic and error handling components. --- .../src/base/config/__tests__/config.spec.ts | 30 +++--- .../backend/server/src/base/config/factory.ts | 22 +++-- packages/backend/server/src/base/error/def.ts | 9 +- .../server/src/base/error/errors.gen.ts | 23 ++++- .../src/core/config/__tests__/service.spec.ts | 7 +- .../server/src/core/config/resolver.ts | 42 +++++++++ .../backend/server/src/core/config/service.ts | 19 +++- .../server/src/data/commands/import.ts | 10 +- packages/backend/server/src/schema.gql | 24 ++++- .../src/graphql/admin/validate-config.gql | 9 ++ packages/common/graphql/src/graphql/index.ts | 14 +++ packages/common/graphql/src/schema.ts | 51 ++++++++++ .../src/modules/settings/config-input-row.tsx | 39 ++------ .../admin/src/modules/settings/config.ts | 37 +++++++- .../src/modules/settings/confirm-changes.tsx | 92 ------------------- .../admin/src/modules/settings/index.tsx | 25 ++--- .../src/modules/settings/use-app-config.ts | 6 +- packages/frontend/i18n/src/i18n.gen.ts | 14 ++- packages/frontend/i18n/src/resources/en.json | 3 +- 19 files changed, 291 insertions(+), 185 deletions(-) create mode 100644 packages/common/graphql/src/graphql/admin/validate-config.gql delete mode 100644 packages/frontend/admin/src/modules/settings/confirm-changes.tsx diff --git a/packages/backend/server/src/base/config/__tests__/config.spec.ts b/packages/backend/server/src/base/config/__tests__/config.spec.ts index cd13a1f8b7..55185b4592 100644 --- a/packages/backend/server/src/base/config/__tests__/config.spec.ts +++ b/packages/backend/server/src/base/config/__tests__/config.spec.ts @@ -1,6 +1,7 @@ import test from 'ava'; import { createModule } from '../../../__tests__/create-module'; +import { InvalidAppConfig } from '../../error'; import { ConfigFactory, ConfigModule } from '..'; import { Config } from '../config'; import { override } from '../register'; @@ -64,30 +65,29 @@ test('should override config', async t => { test('should validate config', t => { const config = module.get(ConfigFactory); - t.notThrows(() => + t.is( config.validate([ { module: 'auth', key: 'passwordRequirements', value: { max: 10, min: 6 }, }, - ]) + ]), + null ); - t.throws( - () => - config.validate([ - { - module: 'auth', - key: 'passwordRequirements', - value: { max: 10, min: 10 }, - }, - ]), + const [error] = config.validate([ { - message: `Invalid config for module [auth] with key [passwordRequirements] -Value: {"max":10,"min":10} -Error: Minimum length of password must be less than maximum length`, - } + module: 'auth', + key: 'passwordRequirements', + value: { max: 10, min: 10 }, + }, + ])!; + + t.true(error instanceof InvalidAppConfig); + t.is( + error.message, + 'Invalid app config for module `auth` with key `passwordRequirements`. Minimum length of password must be less than maximum length.' ); }); diff --git a/packages/backend/server/src/base/config/factory.ts b/packages/backend/server/src/base/config/factory.ts index 952582241c..2feef5ae26 100644 --- a/packages/backend/server/src/base/config/factory.ts +++ b/packages/backend/server/src/base/config/factory.ts @@ -33,13 +33,17 @@ export class ConfigFactory { } validate(updates: Array<{ module: string; key: string; value: any }>) { - const errors: string[] = []; + const errors: InvalidAppConfig[] = []; updates.forEach(update => { const descriptor = APP_CONFIG_DESCRIPTORS[update.module]?.[update.key]; if (!descriptor) { errors.push( - `Invalid config for module [${update.module}] with unknown key [${update.key}]` + new InvalidAppConfig({ + module: update.module, + key: update.key, + hint: `Unknown config [${update.key}]`, + }) ); return; } @@ -47,16 +51,18 @@ export class ConfigFactory { const { success, error } = descriptor.validate(update.value); if (!success) { error.issues.forEach(issue => { - errors.push(`Invalid config for module [${update.module}] with key [${update.key}] -Value: ${JSON.stringify(update.value)} -Error: ${issue.message}`); + errors.push( + new InvalidAppConfig({ + module: update.module, + key: update.key, + hint: issue.message, + }) + ); }); } }); - if (errors.length > 0) { - throw new InvalidAppConfig(errors.join('\n')); - } + return errors.length > 0 ? errors : null; } private loadDefault() { diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 890d581960..ba27d64909 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -877,7 +877,14 @@ export const USER_FRIENDLY_ERRORS = { // app config invalid_app_config: { type: 'invalid_input', - message: 'Invalid app config.', + args: { module: 'string', key: 'string', hint: 'string' }, + message: ({ module, key, hint }) => + `Invalid app config for module \`${module}\` with key \`${key}\`. ${hint}.`, + }, + invalid_app_config_input: { + type: 'invalid_input', + args: { message: 'string' }, + message: ({ message }) => `Invalid app config input: ${message}`, }, // indexer errors diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index eb3720b844..a57749436f 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -1012,10 +1012,26 @@ export class MentionUserOneselfDenied extends UserFriendlyError { super('action_forbidden', 'mention_user_oneself_denied', message); } } +@ObjectType() +class InvalidAppConfigDataType { + @Field() module!: string + @Field() key!: string + @Field() hint!: string +} export class InvalidAppConfig extends UserFriendlyError { - constructor(message?: string) { - super('invalid_input', 'invalid_app_config', message); + constructor(args: InvalidAppConfigDataType, message?: string | ((args: InvalidAppConfigDataType) => string)) { + super('invalid_input', 'invalid_app_config', message, args); + } +} +@ObjectType() +class InvalidAppConfigInputDataType { + @Field() message!: string +} + +export class InvalidAppConfigInput extends UserFriendlyError { + constructor(args: InvalidAppConfigInputDataType, message?: string | ((args: InvalidAppConfigInputDataType) => string)) { + super('invalid_input', 'invalid_app_config_input', message, args); } } @@ -1176,6 +1192,7 @@ export enum ErrorNames { MENTION_USER_DOC_ACCESS_DENIED, MENTION_USER_ONESELF_DENIED, INVALID_APP_CONFIG, + INVALID_APP_CONFIG_INPUT, SEARCH_PROVIDER_NOT_FOUND, INVALID_SEARCH_PROVIDER_REQUEST, INVALID_INDEXER_INPUT @@ -1187,5 +1204,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', types: () => - [GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const, + [GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const, }); diff --git a/packages/backend/server/src/core/config/__tests__/service.spec.ts b/packages/backend/server/src/core/config/__tests__/service.spec.ts index 6947113139..098b69d90c 100644 --- a/packages/backend/server/src/core/config/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/config/__tests__/service.spec.ts @@ -4,6 +4,7 @@ import Sinon from 'sinon'; import { createModule } from '../../../__tests__/create-module'; import { Mockers } from '../../../__tests__/mocks'; +import { InvalidAppConfigInput } from '../../../base'; import { Models } from '../../../models'; import { ServerService } from '../service'; @@ -47,9 +48,7 @@ test('should validate config before update', async t => { }, ]), { - message: `Invalid config for module [server] with key [externalUrl] -Value: "invalid-url@some-domain.com" -Error: Invalid url`, + instanceOf: InvalidAppConfigInput, } ); @@ -64,7 +63,7 @@ Error: Invalid url`, }, ]), { - message: `Invalid config for module [auth] with unknown key [unknown-key]`, + instanceOf: InvalidAppConfigInput, } ); diff --git a/packages/backend/server/src/core/config/resolver.ts b/packages/backend/server/src/core/config/resolver.ts index 05ea8356db..d9838e411a 100644 --- a/packages/backend/server/src/core/config/resolver.ts +++ b/packages/backend/server/src/core/config/resolver.ts @@ -182,6 +182,24 @@ class UpdateAppConfigInput { value!: any; } +@ObjectType() +class AppConfigValidateResult { + @Field() + module!: string; + + @Field() + key!: string; + + @Field(() => GraphQLJSON) + value!: any; + + @Field() + valid!: boolean; + + @Field(() => String, { nullable: true }) + error?: string; +} + @Admin() @Resolver(() => GraphQLJSONObject) export class AppConfigResolver { @@ -204,4 +222,28 @@ export class AppConfigResolver { ): Promise> { return await this.service.updateConfig(me.id, updates); } + + @Mutation(() => [AppConfigValidateResult], { + description: 'validate app configuration', + }) + async validateAppConfig( + @Args('updates', { type: () => [UpdateAppConfigInput] }) + updates: UpdateAppConfigInput[] + ): Promise { + const errors = this.service.validateConfig(updates); + + return updates.map(update => { + const error = errors?.find( + error => + error.data.module === update.module && error.data.key === update.key + ); + return { + module: update.module, + key: update.key, + value: update.value, + valid: !error, + error: error?.data.hint, + }; + }); + } } diff --git a/packages/backend/server/src/core/config/service.ts b/packages/backend/server/src/core/config/service.ts index b8634b0bb0..5229b83eb1 100644 --- a/packages/backend/server/src/core/config/service.ts +++ b/packages/backend/server/src/core/config/service.ts @@ -1,7 +1,12 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { set } from 'lodash-es'; -import { ConfigFactory, EventBus, OnEvent } from '../../base'; +import { + ConfigFactory, + EventBus, + InvalidAppConfigInput, + OnEvent, +} from '../../base'; import { Models } from '../../models'; import { ServerFeature } from './types'; @@ -60,11 +65,21 @@ export class ServerService implements OnApplicationBootstrap { return this.configFactory.clone(); } + validateConfig(updates: Array<{ module: string; key: string; value: any }>) { + return this.configFactory.validate(updates); + } + async updateConfig( user: string, updates: Array<{ module: string; key: string; value: any }> ): Promise> { - this.configFactory.validate(updates); + const errors = this.configFactory.validate(updates); + + if (errors?.length) { + throw new InvalidAppConfigInput({ + message: errors.map(error => error.message).join('\n'), + }); + } const promises = await this.models.appConfig.save( user, diff --git a/packages/backend/server/src/data/commands/import.ts b/packages/backend/server/src/data/commands/import.ts index a3f1fc855c..e6029a56a8 100644 --- a/packages/backend/server/src/data/commands/import.ts +++ b/packages/backend/server/src/data/commands/import.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import { Logger } from '@nestjs/common'; import { Command, CommandRunner } from 'nest-commander'; -import { ConfigFactory } from '../../base'; +import { ConfigFactory, InvalidAppConfigInput } from '../../base'; import { Models } from '../../models'; @Command({ @@ -50,7 +50,13 @@ export class ImportConfigCommand extends CommandRunner { }); }); - this.configFactory.validate(forValidation); + const errors = this.configFactory.validate(forValidation); + + if (errors?.length) { + throw new InvalidAppConfigInput({ + message: errors.map(error => error.message).join('\n '), + }); + } // @ts-expect-error null as user id await this.models.appConfig.save(null, forSaving); diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 7a377efef2..2e5c14d30c 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -71,6 +71,14 @@ type AlreadyInSpaceDataType { spaceId: String! } +type AppConfigValidateResult { + error: String + key: String! + module: String! + valid: Boolean! + value: JSON! +} + type BlobNotFoundDataType { blobId: String! spaceId: String! @@ -523,7 +531,7 @@ type EditorType { name: String! } -union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType +union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType enum ErrorNames { ACCESS_DENIED @@ -589,6 +597,7 @@ enum ErrorNames { HTTP_REQUEST_ERROR INTERNAL_SERVER_ERROR INVALID_APP_CONFIG + INVALID_APP_CONFIG_INPUT INVALID_AUTH_STATE INVALID_CHECKOUT_PARAMETERS INVALID_EMAIL @@ -729,6 +738,16 @@ input ImportUsersInput { users: [CreateUserInput!]! } +type InvalidAppConfigDataType { + hint: String! + key: String! + module: String! +} + +type InvalidAppConfigInputDataType { + message: String! +} + type InvalidEmailDataType { email: String! } @@ -1198,6 +1217,9 @@ type Mutation { """Upload user avatar""" uploadAvatar(avatar: Upload!): UserType! + + """validate app configuration""" + validateAppConfig(updates: [UpdateAppConfigInput!]!): [AppConfigValidateResult!]! verifyEmail(token: String!): Boolean! } diff --git a/packages/common/graphql/src/graphql/admin/validate-config.gql b/packages/common/graphql/src/graphql/admin/validate-config.gql new file mode 100644 index 0000000000..603a9cf315 --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/validate-config.gql @@ -0,0 +1,9 @@ +mutation validateConfig($updates: [UpdateAppConfigInput!]!) { + validateAppConfig(updates: $updates) { + module + key + value + valid + error + } +} \ No newline at end of file diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 1d870b80a1..aed94b8aff 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -246,6 +246,20 @@ export const updateAppConfigMutation = { }`, }; +export const validateConfigMutation = { + id: 'validateConfigMutation' as const, + op: 'validateConfig', + query: `mutation validateConfig($updates: [UpdateAppConfigInput!]!) { + validateAppConfig(updates: $updates) { + module + key + value + valid + error + } +}`, +}; + export const deleteBlobMutation = { id: 'deleteBlobMutation' as const, op: 'deleteBlob', diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 78b723ca23..cb90699500 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -109,6 +109,15 @@ export interface AlreadyInSpaceDataType { spaceId: Scalars['String']['output']; } +export interface AppConfigValidateResult { + __typename?: 'AppConfigValidateResult'; + error: Maybe; + key: Scalars['String']['output']; + module: Scalars['String']['output']; + valid: Scalars['Boolean']['output']; + value: Scalars['JSON']['output']; +} + export interface BlobNotFoundDataType { __typename?: 'BlobNotFoundDataType'; blobId: Scalars['String']['output']; @@ -665,6 +674,8 @@ export type ErrorDataUnion = | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType + | InvalidAppConfigDataType + | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType @@ -762,6 +773,7 @@ export enum ErrorNames { HTTP_REQUEST_ERROR = 'HTTP_REQUEST_ERROR', INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', INVALID_APP_CONFIG = 'INVALID_APP_CONFIG', + INVALID_APP_CONFIG_INPUT = 'INVALID_APP_CONFIG_INPUT', INVALID_AUTH_STATE = 'INVALID_AUTH_STATE', INVALID_CHECKOUT_PARAMETERS = 'INVALID_CHECKOUT_PARAMETERS', INVALID_EMAIL = 'INVALID_EMAIL', @@ -906,6 +918,18 @@ export interface ImportUsersInput { users: Array; } +export interface InvalidAppConfigDataType { + __typename?: 'InvalidAppConfigDataType'; + hint: Scalars['String']['output']; + key: Scalars['String']['output']; + module: Scalars['String']['output']; +} + +export interface InvalidAppConfigInputDataType { + __typename?: 'InvalidAppConfigInputDataType'; + message: Scalars['String']['output']; +} + export interface InvalidEmailDataType { __typename?: 'InvalidEmailDataType'; email: Scalars['String']['output']; @@ -1334,6 +1358,8 @@ export interface Mutation { updateWorkspaceEmbeddingIgnoredDocs: Scalars['Int']['output']; /** Upload user avatar */ uploadAvatar: UserType; + /** validate app configuration */ + validateAppConfig: Array; verifyEmail: Scalars['Boolean']['output']; } @@ -1712,6 +1738,10 @@ export interface MutationUploadAvatarArgs { avatar: Scalars['Upload']['input']; } +export interface MutationValidateAppConfigArgs { + updates: Array; +} + export interface MutationVerifyEmailArgs { token: Scalars['String']['input']; } @@ -2900,6 +2930,22 @@ export type UpdateAppConfigMutation = { updateAppConfig: any; }; +export type ValidateConfigMutationVariables = Exact<{ + updates: Array | UpdateAppConfigInput; +}>; + +export type ValidateConfigMutation = { + __typename?: 'Mutation'; + validateAppConfig: Array<{ + __typename?: 'AppConfigValidateResult'; + module: string; + key: string; + value: Record; + valid: boolean; + error: string | null; + }>; +}; + export type DeleteBlobMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; key: Scalars['String']['input']; @@ -5312,6 +5358,11 @@ export type Mutations = variables: UpdateAppConfigMutationVariables; response: UpdateAppConfigMutation; } + | { + name: 'validateConfigMutation'; + variables: ValidateConfigMutationVariables; + response: ValidateConfigMutation; + } | { name: 'deleteBlobMutation'; variables: DeleteBlobMutationVariables; diff --git a/packages/frontend/admin/src/modules/settings/config-input-row.tsx b/packages/frontend/admin/src/modules/settings/config-input-row.tsx index 6208dc7f8e..3b4cf5d49f 100644 --- a/packages/frontend/admin/src/modules/settings/config-input-row.tsx +++ b/packages/frontend/admin/src/modules/settings/config-input-row.tsx @@ -7,16 +7,16 @@ import { SelectValue, } from '@affine/admin/components/ui/select'; import { Switch } from '@affine/admin/components/ui/switch'; -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { Textarea } from '../../components/ui/textarea'; -import { isEqual } from './utils'; export type ConfigInputProps = { field: string; desc: string; defaultValue: any; - onChange: (key: string, value: any) => void; + onChange: (field: string, value: any) => void; + error?: string; } & ( | { type: 'String' | 'Number' | 'Boolean' | 'JSON'; @@ -33,6 +33,7 @@ const Inputs: Record< defaultValue: any; onChange: (value?: any) => void; options?: string[]; + error?: string; }> > = { Boolean: function SwitchInput({ defaultValue, onChange }) { @@ -114,22 +115,18 @@ export const ConfigRow = ({ type, defaultValue, onChange, + error, ...props }: ConfigInputProps) => { - const [value, setValue] = useState(defaultValue); - - const isValueChanged = !isEqual(value, defaultValue); + const Input = Inputs[type] ?? Inputs.JSON; const onValueChange = useCallback( (value?: any) => { onChange(field, value); - setValue(value); }, [field, onChange] ); - const Input = Inputs[type] ?? Inputs.JSON; - return (
- {isValueChanged && ( -
- - {JSON.stringify(defaultValue)} - {' '} - =>{' '} - - {JSON.stringify(value)} - + {error && ( +
+ {error}
)}
diff --git a/packages/frontend/admin/src/modules/settings/config.ts b/packages/frontend/admin/src/modules/settings/config.ts index 2cc739d9ef..13ed9088dd 100644 --- a/packages/frontend/admin/src/modules/settings/config.ts +++ b/packages/frontend/admin/src/modules/settings/config.ts @@ -34,9 +34,7 @@ type ConfigGroup = { appConfig: AppConfig; }>[]; }; -const IGNORED_MODULES: (keyof AppConfig)[] = [ - 'copilot', // not ready -]; +const IGNORED_MODULES: (keyof AppConfig)[] = []; if (environment.isSelfHosted) { IGNORED_MODULES.push('payment'); @@ -139,6 +137,39 @@ export const KNOWN_CONFIG_GROUPS = [ module: 'oauth', fields: ['providers.google', 'providers.github', 'providers.oidc'], } as ConfigGroup<'oauth'>, + { + name: 'AI', + module: 'copilot', + fields: [ + 'enabled', + 'providers.openai', + 'providers.gemini', + 'providers.perplexity', + 'providers.anthropic', + 'providers.fal', + 'unsplash', + 'exa', + { + key: 'storage', + desc: 'The storage provider for copilot blobs', + sub: 'provider', + type: 'Enum', + options: ['fs', 'aws-s3', 'cloudflare-r2'], + }, + { + key: 'storage', + sub: 'bucket', + type: 'String', + desc: 'The bucket name for copilot blobs storage', + }, + { + key: 'storage', + sub: 'config', + type: 'JSON', + desc: 'The config passed directly to the storage provider(e.g. aws-sdk)', + }, + ], + } as ConfigGroup<'copilot'>, ]; export const UNKNOWN_CONFIG_GROUPS = ALL_CONFIGURABLE_MODULES.filter( diff --git a/packages/frontend/admin/src/modules/settings/confirm-changes.tsx b/packages/frontend/admin/src/modules/settings/confirm-changes.tsx deleted file mode 100644 index ea53be589d..0000000000 --- a/packages/frontend/admin/src/modules/settings/confirm-changes.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Button } from '@affine/admin/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@affine/admin/components/ui/dialog'; -import { useCallback } from 'react'; - -import type { AppConfig } from './config'; - -export const ConfirmChanges = ({ - updates, - open, - onOpenChange, - onConfirm, -}: { - updates: AppConfig; - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; -}) => { - const onClose = useCallback(() => { - onOpenChange(false); - }, [onOpenChange]); - - const modifiedKeys = Object.keys(updates).filter( - key => updates[key].from !== updates[key].to - ); - - return ( - - - - - Save Runtime Configurations ? - - - Are you sure you want to save the following changes? - - - {modifiedKeys.length > 0 ? ( -
-            

{'{'}

- {modifiedKeys.map(key => ( -

- {' '} {key}:{' '} - - {JSON.stringify(updates[key].from)} - - - {JSON.stringify(updates[key].to)} - - , -

- ))} -

{'}'}

-
- ) : ( - 'There is no change.' - )} - -
- - -
-
-
-
- ); -}; diff --git a/packages/frontend/admin/src/modules/settings/index.tsx b/packages/frontend/admin/src/modules/settings/index.tsx index 4e523f2797..3c61dc6295 100644 --- a/packages/frontend/admin/src/modules/settings/index.tsx +++ b/packages/frontend/admin/src/modules/settings/index.tsx @@ -2,7 +2,7 @@ import { Button } from '@affine/admin/components/ui/button'; import { ScrollArea } from '@affine/admin/components/ui/scroll-area'; import { get } from 'lodash-es'; import { CheckIcon } from 'lucide-react'; -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { Header } from '../header'; import { useNav } from '../nav/context'; @@ -12,14 +12,10 @@ import { type AppConfig, } from './config'; import { type ConfigInputProps, ConfigRow } from './config-input-row'; -import { ConfirmChanges } from './confirm-changes'; import { useAppConfig } from './use-app-config'; export function SettingsPage() { const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig(); - const [open, setOpen] = useState(false); - const onOpen = useCallback(() => setOpen(true), [setOpen]); - const disableSave = Object.keys(updates).length === 0; const saveChanges = useCallback(() => { @@ -27,7 +23,6 @@ export function SettingsPage() { return; } save(); - setOpen(false); }, [save, disableSave]); return ( @@ -40,7 +35,7 @@ export function SettingsPage() { size="icon" className="w-7 h-7" variant="ghost" - onClick={onOpen} + onClick={saveChanges} disabled={disableSave} > @@ -52,12 +47,6 @@ export function SettingsPage() { appConfig={appConfig} patchedAppConfig={patchedAppConfig} /> -
); } @@ -94,17 +83,18 @@ const AdminPanel = ({ const descriptor = ALL_CONFIG_DESCRIPTORS[module][field]; desc = descriptor.desc; props = { - type: descriptor.type, - defaultValue: get(appConfig[module], field), field: `${module}/${field}`, desc, - onChange: onUpdate, + type: descriptor.type, options: [], + defaultValue: get(appConfig[module], field), + onChange: onUpdate, }; } else { const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key]; props = { + field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`, desc: field.desc ?? descriptor.desc, type: field.type ?? descriptor.type, // @ts-expect-error for enum type @@ -113,7 +103,6 @@ const AdminPanel = ({ appConfig[module], field.key + (field.sub ? '.' + field.sub : '') ), - field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`, onChange: onUpdate, }; } @@ -128,5 +117,3 @@ const AdminPanel = ({ ); }; - -export { SettingsPage as Component }; diff --git a/packages/frontend/admin/src/modules/settings/use-app-config.ts b/packages/frontend/admin/src/modules/settings/use-app-config.ts index 6db737280a..c9527a14ed 100644 --- a/packages/frontend/admin/src/modules/settings/use-app-config.ts +++ b/packages/frontend/admin/src/modules/settings/use-app-config.ts @@ -25,7 +25,7 @@ export const useAppConfig = () => { query: appConfigQuery, }); - const { trigger } = useMutation({ + const { trigger: saveUpdates } = useMutation({ mutation: updateAppConfigMutation, }); @@ -50,7 +50,7 @@ export const useAppConfig = () => { ); try { - const savedUpdates = await trigger({ + const savedUpdates = await saveUpdates({ updates: updateInputs, }); await mutate(prev => { @@ -69,7 +69,7 @@ export const useAppConfig = () => { }); console.error(e); } - }, [updates, mutate, trigger]); + }, [updates, mutate, saveUpdates]); const update = useCallback( (path: string, value: any) => { diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 4f35b0688a..bd0c665a0c 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8812,9 +8812,19 @@ export function useAFFiNEI18N(): { */ ["error.MENTION_USER_ONESELF_DENIED"](): string; /** - * `Invalid app config.` + * `Invalid app config for module `{{module}}` with key `{{key}}`. {{hint}}.` */ - ["error.INVALID_APP_CONFIG"](): string; + ["error.INVALID_APP_CONFIG"](options: Readonly<{ + module: string; + key: string; + hint: string; + }>): string; + /** + * `Invalid app config input: {{message}}` + */ + ["error.INVALID_APP_CONFIG_INPUT"](options: { + readonly message: string; + }): string; /** * `Search provider not found.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index d472bcb789..cd5605d7cd 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2176,7 +2176,8 @@ "error.NOTIFICATION_NOT_FOUND": "Notification not found.", "error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.", "error.MENTION_USER_ONESELF_DENIED": "You can not mention yourself.", - "error.INVALID_APP_CONFIG": "Invalid app config.", + "error.INVALID_APP_CONFIG": "Invalid app config for module `{{module}}` with key `{{key}}`. {{hint}}.", + "error.INVALID_APP_CONFIG_INPUT": "Invalid app config input: {{message}}", "error.SEARCH_PROVIDER_NOT_FOUND": "Search provider not found.", "error.INVALID_SEARCH_PROVIDER_REQUEST": "Invalid request argument to search provider: {{reason}}", "error.INVALID_INDEXER_INPUT": "Invalid indexer input: {{reason}}"