chore(admin): remove useless config diff (#12545)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
forehalo
2025-05-27 06:07:26 +00:00
parent eed95366c9
commit 2f139bd02c
19 changed files with 291 additions and 185 deletions

View File

@@ -1,6 +1,7 @@
import test from 'ava'; import test from 'ava';
import { createModule } from '../../../__tests__/create-module'; import { createModule } from '../../../__tests__/create-module';
import { InvalidAppConfig } from '../../error';
import { ConfigFactory, ConfigModule } from '..'; import { ConfigFactory, ConfigModule } from '..';
import { Config } from '../config'; import { Config } from '../config';
import { override } from '../register'; import { override } from '../register';
@@ -64,30 +65,29 @@ test('should override config', async t => {
test('should validate config', t => { test('should validate config', t => {
const config = module.get(ConfigFactory); const config = module.get(ConfigFactory);
t.notThrows(() => t.is(
config.validate([ config.validate([
{ {
module: 'auth', module: 'auth',
key: 'passwordRequirements', key: 'passwordRequirements',
value: { max: 10, min: 6 }, value: { max: 10, min: 6 },
}, },
]) ]),
null
); );
t.throws( const [error] = config.validate([
() =>
config.validate([
{
module: 'auth',
key: 'passwordRequirements',
value: { max: 10, min: 10 },
},
]),
{ {
message: `Invalid config for module [auth] with key [passwordRequirements] module: 'auth',
Value: {"max":10,"min":10} key: 'passwordRequirements',
Error: Minimum length of password must be less than maximum length`, 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.'
); );
}); });

View File

@@ -33,13 +33,17 @@ export class ConfigFactory {
} }
validate(updates: Array<{ module: string; key: string; value: any }>) { validate(updates: Array<{ module: string; key: string; value: any }>) {
const errors: string[] = []; const errors: InvalidAppConfig[] = [];
updates.forEach(update => { updates.forEach(update => {
const descriptor = APP_CONFIG_DESCRIPTORS[update.module]?.[update.key]; const descriptor = APP_CONFIG_DESCRIPTORS[update.module]?.[update.key];
if (!descriptor) { if (!descriptor) {
errors.push( 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; return;
} }
@@ -47,16 +51,18 @@ export class ConfigFactory {
const { success, error } = descriptor.validate(update.value); const { success, error } = descriptor.validate(update.value);
if (!success) { if (!success) {
error.issues.forEach(issue => { error.issues.forEach(issue => {
errors.push(`Invalid config for module [${update.module}] with key [${update.key}] errors.push(
Value: ${JSON.stringify(update.value)} new InvalidAppConfig({
Error: ${issue.message}`); module: update.module,
key: update.key,
hint: issue.message,
})
);
}); });
} }
}); });
if (errors.length > 0) { return errors.length > 0 ? errors : null;
throw new InvalidAppConfig(errors.join('\n'));
}
} }
private loadDefault() { private loadDefault() {

View File

@@ -877,7 +877,14 @@ export const USER_FRIENDLY_ERRORS = {
// app config // app config
invalid_app_config: { invalid_app_config: {
type: 'invalid_input', 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 // indexer errors

View File

@@ -1012,10 +1012,26 @@ export class MentionUserOneselfDenied extends UserFriendlyError {
super('action_forbidden', 'mention_user_oneself_denied', message); 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 { export class InvalidAppConfig extends UserFriendlyError {
constructor(message?: string) { constructor(args: InvalidAppConfigDataType, message?: string | ((args: InvalidAppConfigDataType) => string)) {
super('invalid_input', 'invalid_app_config', message); 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_DOC_ACCESS_DENIED,
MENTION_USER_ONESELF_DENIED, MENTION_USER_ONESELF_DENIED,
INVALID_APP_CONFIG, INVALID_APP_CONFIG,
INVALID_APP_CONFIG_INPUT,
SEARCH_PROVIDER_NOT_FOUND, SEARCH_PROVIDER_NOT_FOUND,
INVALID_SEARCH_PROVIDER_REQUEST, INVALID_SEARCH_PROVIDER_REQUEST,
INVALID_INDEXER_INPUT INVALID_INDEXER_INPUT
@@ -1187,5 +1204,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({ export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion', name: 'ErrorDataUnion',
types: () => 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,
}); });

View File

@@ -4,6 +4,7 @@ import Sinon from 'sinon';
import { createModule } from '../../../__tests__/create-module'; import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks'; import { Mockers } from '../../../__tests__/mocks';
import { InvalidAppConfigInput } from '../../../base';
import { Models } from '../../../models'; import { Models } from '../../../models';
import { ServerService } from '../service'; 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] instanceOf: InvalidAppConfigInput,
Value: "invalid-url@some-domain.com"
Error: Invalid url`,
} }
); );
@@ -64,7 +63,7 @@ Error: Invalid url`,
}, },
]), ]),
{ {
message: `Invalid config for module [auth] with unknown key [unknown-key]`, instanceOf: InvalidAppConfigInput,
} }
); );

View File

@@ -182,6 +182,24 @@ class UpdateAppConfigInput {
value!: any; 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() @Admin()
@Resolver(() => GraphQLJSONObject) @Resolver(() => GraphQLJSONObject)
export class AppConfigResolver { export class AppConfigResolver {
@@ -204,4 +222,28 @@ export class AppConfigResolver {
): Promise<DeepPartial<AppConfig>> { ): Promise<DeepPartial<AppConfig>> {
return await this.service.updateConfig(me.id, updates); return await this.service.updateConfig(me.id, updates);
} }
@Mutation(() => [AppConfigValidateResult], {
description: 'validate app configuration',
})
async validateAppConfig(
@Args('updates', { type: () => [UpdateAppConfigInput] })
updates: UpdateAppConfigInput[]
): Promise<AppConfigValidateResult[]> {
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,
};
});
}
} }

View File

@@ -1,7 +1,12 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { set } from 'lodash-es'; import { set } from 'lodash-es';
import { ConfigFactory, EventBus, OnEvent } from '../../base'; import {
ConfigFactory,
EventBus,
InvalidAppConfigInput,
OnEvent,
} from '../../base';
import { Models } from '../../models'; import { Models } from '../../models';
import { ServerFeature } from './types'; import { ServerFeature } from './types';
@@ -60,11 +65,21 @@ export class ServerService implements OnApplicationBootstrap {
return this.configFactory.clone(); return this.configFactory.clone();
} }
validateConfig(updates: Array<{ module: string; key: string; value: any }>) {
return this.configFactory.validate(updates);
}
async updateConfig( async updateConfig(
user: string, user: string,
updates: Array<{ module: string; key: string; value: any }> updates: Array<{ module: string; key: string; value: any }>
): Promise<DeepPartial<AppConfig>> { ): Promise<DeepPartial<AppConfig>> {
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( const promises = await this.models.appConfig.save(
user, user,

View File

@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Command, CommandRunner } from 'nest-commander'; import { Command, CommandRunner } from 'nest-commander';
import { ConfigFactory } from '../../base'; import { ConfigFactory, InvalidAppConfigInput } from '../../base';
import { Models } from '../../models'; import { Models } from '../../models';
@Command({ @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 // @ts-expect-error null as user id
await this.models.appConfig.save(null, forSaving); await this.models.appConfig.save(null, forSaving);

View File

@@ -71,6 +71,14 @@ type AlreadyInSpaceDataType {
spaceId: String! spaceId: String!
} }
type AppConfigValidateResult {
error: String
key: String!
module: String!
valid: Boolean!
value: JSON!
}
type BlobNotFoundDataType { type BlobNotFoundDataType {
blobId: String! blobId: String!
spaceId: String! spaceId: String!
@@ -523,7 +531,7 @@ type EditorType {
name: String! 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 { enum ErrorNames {
ACCESS_DENIED ACCESS_DENIED
@@ -589,6 +597,7 @@ 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_AUTH_STATE INVALID_AUTH_STATE
INVALID_CHECKOUT_PARAMETERS INVALID_CHECKOUT_PARAMETERS
INVALID_EMAIL INVALID_EMAIL
@@ -729,6 +738,16 @@ input ImportUsersInput {
users: [CreateUserInput!]! users: [CreateUserInput!]!
} }
type InvalidAppConfigDataType {
hint: String!
key: String!
module: String!
}
type InvalidAppConfigInputDataType {
message: String!
}
type InvalidEmailDataType { type InvalidEmailDataType {
email: String! email: String!
} }
@@ -1198,6 +1217,9 @@ type Mutation {
"""Upload user avatar""" """Upload user avatar"""
uploadAvatar(avatar: Upload!): UserType! uploadAvatar(avatar: Upload!): UserType!
"""validate app configuration"""
validateAppConfig(updates: [UpdateAppConfigInput!]!): [AppConfigValidateResult!]!
verifyEmail(token: String!): Boolean! verifyEmail(token: String!): Boolean!
} }

View File

@@ -0,0 +1,9 @@
mutation validateConfig($updates: [UpdateAppConfigInput!]!) {
validateAppConfig(updates: $updates) {
module
key
value
valid
error
}
}

View File

@@ -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 = { export const deleteBlobMutation = {
id: 'deleteBlobMutation' as const, id: 'deleteBlobMutation' as const,
op: 'deleteBlob', op: 'deleteBlob',

View File

@@ -109,6 +109,15 @@ export interface AlreadyInSpaceDataType {
spaceId: Scalars['String']['output']; spaceId: Scalars['String']['output'];
} }
export interface AppConfigValidateResult {
__typename?: 'AppConfigValidateResult';
error: Maybe<Scalars['String']['output']>;
key: Scalars['String']['output'];
module: Scalars['String']['output'];
valid: Scalars['Boolean']['output'];
value: Scalars['JSON']['output'];
}
export interface BlobNotFoundDataType { export interface BlobNotFoundDataType {
__typename?: 'BlobNotFoundDataType'; __typename?: 'BlobNotFoundDataType';
blobId: Scalars['String']['output']; blobId: Scalars['String']['output'];
@@ -665,6 +674,8 @@ export type ErrorDataUnion =
| ExpectToUpdateDocUserRoleDataType | ExpectToUpdateDocUserRoleDataType
| GraphqlBadRequestDataType | GraphqlBadRequestDataType
| HttpRequestErrorDataType | HttpRequestErrorDataType
| InvalidAppConfigDataType
| InvalidAppConfigInputDataType
| InvalidEmailDataType | InvalidEmailDataType
| InvalidHistoryTimestampDataType | InvalidHistoryTimestampDataType
| InvalidIndexerInputDataType | InvalidIndexerInputDataType
@@ -762,6 +773,7 @@ export enum ErrorNames {
HTTP_REQUEST_ERROR = 'HTTP_REQUEST_ERROR', HTTP_REQUEST_ERROR = 'HTTP_REQUEST_ERROR',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
INVALID_APP_CONFIG = 'INVALID_APP_CONFIG', INVALID_APP_CONFIG = 'INVALID_APP_CONFIG',
INVALID_APP_CONFIG_INPUT = 'INVALID_APP_CONFIG_INPUT',
INVALID_AUTH_STATE = 'INVALID_AUTH_STATE', INVALID_AUTH_STATE = 'INVALID_AUTH_STATE',
INVALID_CHECKOUT_PARAMETERS = 'INVALID_CHECKOUT_PARAMETERS', INVALID_CHECKOUT_PARAMETERS = 'INVALID_CHECKOUT_PARAMETERS',
INVALID_EMAIL = 'INVALID_EMAIL', INVALID_EMAIL = 'INVALID_EMAIL',
@@ -906,6 +918,18 @@ export interface ImportUsersInput {
users: Array<CreateUserInput>; users: Array<CreateUserInput>;
} }
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 { export interface InvalidEmailDataType {
__typename?: 'InvalidEmailDataType'; __typename?: 'InvalidEmailDataType';
email: Scalars['String']['output']; email: Scalars['String']['output'];
@@ -1334,6 +1358,8 @@ export interface Mutation {
updateWorkspaceEmbeddingIgnoredDocs: Scalars['Int']['output']; updateWorkspaceEmbeddingIgnoredDocs: Scalars['Int']['output'];
/** Upload user avatar */ /** Upload user avatar */
uploadAvatar: UserType; uploadAvatar: UserType;
/** validate app configuration */
validateAppConfig: Array<AppConfigValidateResult>;
verifyEmail: Scalars['Boolean']['output']; verifyEmail: Scalars['Boolean']['output'];
} }
@@ -1712,6 +1738,10 @@ export interface MutationUploadAvatarArgs {
avatar: Scalars['Upload']['input']; avatar: Scalars['Upload']['input'];
} }
export interface MutationValidateAppConfigArgs {
updates: Array<UpdateAppConfigInput>;
}
export interface MutationVerifyEmailArgs { export interface MutationVerifyEmailArgs {
token: Scalars['String']['input']; token: Scalars['String']['input'];
} }
@@ -2900,6 +2930,22 @@ export type UpdateAppConfigMutation = {
updateAppConfig: any; updateAppConfig: any;
}; };
export type ValidateConfigMutationVariables = Exact<{
updates: Array<UpdateAppConfigInput> | UpdateAppConfigInput;
}>;
export type ValidateConfigMutation = {
__typename?: 'Mutation';
validateAppConfig: Array<{
__typename?: 'AppConfigValidateResult';
module: string;
key: string;
value: Record<string, string>;
valid: boolean;
error: string | null;
}>;
};
export type DeleteBlobMutationVariables = Exact<{ export type DeleteBlobMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
key: Scalars['String']['input']; key: Scalars['String']['input'];
@@ -5312,6 +5358,11 @@ export type Mutations =
variables: UpdateAppConfigMutationVariables; variables: UpdateAppConfigMutationVariables;
response: UpdateAppConfigMutation; response: UpdateAppConfigMutation;
} }
| {
name: 'validateConfigMutation';
variables: ValidateConfigMutationVariables;
response: ValidateConfigMutation;
}
| { | {
name: 'deleteBlobMutation'; name: 'deleteBlobMutation';
variables: DeleteBlobMutationVariables; variables: DeleteBlobMutationVariables;

View File

@@ -7,16 +7,16 @@ import {
SelectValue, SelectValue,
} from '@affine/admin/components/ui/select'; } from '@affine/admin/components/ui/select';
import { Switch } from '@affine/admin/components/ui/switch'; import { Switch } from '@affine/admin/components/ui/switch';
import { useCallback, useState } from 'react'; import { useCallback } from 'react';
import { Textarea } from '../../components/ui/textarea'; import { Textarea } from '../../components/ui/textarea';
import { isEqual } from './utils';
export type ConfigInputProps = { export type ConfigInputProps = {
field: string; field: string;
desc: string; desc: string;
defaultValue: any; defaultValue: any;
onChange: (key: string, value: any) => void; onChange: (field: string, value: any) => void;
error?: string;
} & ( } & (
| { | {
type: 'String' | 'Number' | 'Boolean' | 'JSON'; type: 'String' | 'Number' | 'Boolean' | 'JSON';
@@ -33,6 +33,7 @@ const Inputs: Record<
defaultValue: any; defaultValue: any;
onChange: (value?: any) => void; onChange: (value?: any) => void;
options?: string[]; options?: string[];
error?: string;
}> }>
> = { > = {
Boolean: function SwitchInput({ defaultValue, onChange }) { Boolean: function SwitchInput({ defaultValue, onChange }) {
@@ -114,22 +115,18 @@ export const ConfigRow = ({
type, type,
defaultValue, defaultValue,
onChange, onChange,
error,
...props ...props
}: ConfigInputProps) => { }: ConfigInputProps) => {
const [value, setValue] = useState(defaultValue); const Input = Inputs[type] ?? Inputs.JSON;
const isValueChanged = !isEqual(value, defaultValue);
const onValueChange = useCallback( const onValueChange = useCallback(
(value?: any) => { (value?: any) => {
onChange(field, value); onChange(field, value);
setValue(value);
}, },
[field, onChange] [field, onChange]
); );
const Input = Inputs[type] ?? Inputs.JSON;
return ( return (
<div <div
className={`flex justify-between flex-grow space-y-[10px] className={`flex justify-between flex-grow space-y-[10px]
@@ -140,28 +137,12 @@ export const ConfigRow = ({
<Input <Input
defaultValue={defaultValue} defaultValue={defaultValue}
onChange={onValueChange} onChange={onValueChange}
error={error}
{...props} {...props}
/> />
{isValueChanged && ( {error && (
<div className="absolute bottom-[-25px] text-sm right-0 break-words"> <div className="absolute bottom-[-25px] text-sm right-0 break-words text-red-500">
<span {error}
className="line-through"
style={{
color: 'rgba(198, 34, 34, 1)',
backgroundColor: 'rgba(254, 213, 213, 1)',
}}
>
{JSON.stringify(defaultValue)}
</span>{' '}
=&gt;{' '}
<span
style={{
color: 'rgba(20, 147, 67, 1)',
backgroundColor: 'rgba(225, 250, 177, 1)',
}}
>
{JSON.stringify(value)}
</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -34,9 +34,7 @@ type ConfigGroup<T extends AppConfigModule> = {
appConfig: AppConfig; appConfig: AppConfig;
}>[]; }>[];
}; };
const IGNORED_MODULES: (keyof AppConfig)[] = [ const IGNORED_MODULES: (keyof AppConfig)[] = [];
'copilot', // not ready
];
if (environment.isSelfHosted) { if (environment.isSelfHosted) {
IGNORED_MODULES.push('payment'); IGNORED_MODULES.push('payment');
@@ -139,6 +137,39 @@ export const KNOWN_CONFIG_GROUPS = [
module: 'oauth', module: 'oauth',
fields: ['providers.google', 'providers.github', 'providers.oidc'], fields: ['providers.google', 'providers.github', 'providers.oidc'],
} as ConfigGroup<'oauth'>, } 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( export const UNKNOWN_CONFIG_GROUPS = ALL_CONFIGURABLE_MODULES.filter(

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">
Save Runtime Configurations ?
</DialogTitle>
<DialogDescription className="leading-6">
Are you sure you want to save the following changes?
</DialogDescription>
</DialogHeader>
{modifiedKeys.length > 0 ? (
<pre className="flex flex-col text-sm bg-zinc-100 gap-1 min-h-[64px] rounded-md p-[12px_16px_16px_12px] mt-2 overflow-auto">
<p>{'{'}</p>
{modifiedKeys.map(key => (
<p key={key}>
{' '} {key}:{' '}
<span
className="mr-2 line-through "
style={{
color: 'rgba(198, 34, 34, 1)',
backgroundColor: 'rgba(254, 213, 213, 1)',
}}
>
{JSON.stringify(updates[key].from)}
</span>
<span
style={{
color: 'rgba(20, 147, 67, 1)',
backgroundColor: 'rgba(225, 250, 177, 1)',
}}
>
{JSON.stringify(updates[key].to)}
</span>
,
</p>
))}
<p>{'}'}</p>
</pre>
) : (
'There is no change.'
)}
<DialogFooter className="mt-6">
<div className="flex justify-end items-center w-full gap-2">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={modifiedKeys.length === 0}
>
<span>Save</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -2,7 +2,7 @@ import { Button } from '@affine/admin/components/ui/button';
import { ScrollArea } from '@affine/admin/components/ui/scroll-area'; import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import { get } from 'lodash-es'; import { get } from 'lodash-es';
import { CheckIcon } from 'lucide-react'; import { CheckIcon } from 'lucide-react';
import { useCallback, useState } from 'react'; import { useCallback } from 'react';
import { Header } from '../header'; import { Header } from '../header';
import { useNav } from '../nav/context'; import { useNav } from '../nav/context';
@@ -12,14 +12,10 @@ import {
type AppConfig, type AppConfig,
} from './config'; } from './config';
import { type ConfigInputProps, ConfigRow } from './config-input-row'; import { type ConfigInputProps, ConfigRow } from './config-input-row';
import { ConfirmChanges } from './confirm-changes';
import { useAppConfig } from './use-app-config'; import { useAppConfig } from './use-app-config';
export function SettingsPage() { export function SettingsPage() {
const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig(); 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 disableSave = Object.keys(updates).length === 0;
const saveChanges = useCallback(() => { const saveChanges = useCallback(() => {
@@ -27,7 +23,6 @@ export function SettingsPage() {
return; return;
} }
save(); save();
setOpen(false);
}, [save, disableSave]); }, [save, disableSave]);
return ( return (
@@ -40,7 +35,7 @@ export function SettingsPage() {
size="icon" size="icon"
className="w-7 h-7" className="w-7 h-7"
variant="ghost" variant="ghost"
onClick={onOpen} onClick={saveChanges}
disabled={disableSave} disabled={disableSave}
> >
<CheckIcon size={20} /> <CheckIcon size={20} />
@@ -52,12 +47,6 @@ export function SettingsPage() {
appConfig={appConfig} appConfig={appConfig}
patchedAppConfig={patchedAppConfig} patchedAppConfig={patchedAppConfig}
/> />
<ConfirmChanges
updates={updates}
open={open}
onOpenChange={setOpen}
onConfirm={saveChanges}
/>
</div> </div>
); );
} }
@@ -94,17 +83,18 @@ const AdminPanel = ({
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field]; const descriptor = ALL_CONFIG_DESCRIPTORS[module][field];
desc = descriptor.desc; desc = descriptor.desc;
props = { props = {
type: descriptor.type,
defaultValue: get(appConfig[module], field),
field: `${module}/${field}`, field: `${module}/${field}`,
desc, desc,
onChange: onUpdate, type: descriptor.type,
options: [], options: [],
defaultValue: get(appConfig[module], field),
onChange: onUpdate,
}; };
} else { } else {
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key]; const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key];
props = { props = {
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
desc: field.desc ?? descriptor.desc, desc: field.desc ?? descriptor.desc,
type: field.type ?? descriptor.type, type: field.type ?? descriptor.type,
// @ts-expect-error for enum type // @ts-expect-error for enum type
@@ -113,7 +103,6 @@ const AdminPanel = ({
appConfig[module], appConfig[module],
field.key + (field.sub ? '.' + field.sub : '') field.key + (field.sub ? '.' + field.sub : '')
), ),
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
onChange: onUpdate, onChange: onUpdate,
}; };
} }
@@ -128,5 +117,3 @@ const AdminPanel = ({
</ScrollArea> </ScrollArea>
); );
}; };
export { SettingsPage as Component };

View File

@@ -25,7 +25,7 @@ export const useAppConfig = () => {
query: appConfigQuery, query: appConfigQuery,
}); });
const { trigger } = useMutation({ const { trigger: saveUpdates } = useMutation({
mutation: updateAppConfigMutation, mutation: updateAppConfigMutation,
}); });
@@ -50,7 +50,7 @@ export const useAppConfig = () => {
); );
try { try {
const savedUpdates = await trigger({ const savedUpdates = await saveUpdates({
updates: updateInputs, updates: updateInputs,
}); });
await mutate(prev => { await mutate(prev => {
@@ -69,7 +69,7 @@ export const useAppConfig = () => {
}); });
console.error(e); console.error(e);
} }
}, [updates, mutate, trigger]); }, [updates, mutate, saveUpdates]);
const update = useCallback( const update = useCallback(
(path: string, value: any) => { (path: string, value: any) => {

View File

@@ -8812,9 +8812,19 @@ export function useAFFiNEI18N(): {
*/ */
["error.MENTION_USER_ONESELF_DENIED"](): string; ["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.` * `Search provider not found.`
*/ */

View File

@@ -2176,7 +2176,8 @@
"error.NOTIFICATION_NOT_FOUND": "Notification not found.", "error.NOTIFICATION_NOT_FOUND": "Notification not found.",
"error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.", "error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.",
"error.MENTION_USER_ONESELF_DENIED": "You can not mention yourself.", "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.SEARCH_PROVIDER_NOT_FOUND": "Search provider not found.",
"error.INVALID_SEARCH_PROVIDER_REQUEST": "Invalid request argument to search provider: {{reason}}", "error.INVALID_SEARCH_PROVIDER_REQUEST": "Invalid request argument to search provider: {{reason}}",
"error.INVALID_INDEXER_INPUT": "Invalid indexer input: {{reason}}" "error.INVALID_INDEXER_INPUT": "Invalid indexer input: {{reason}}"