mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
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:
@@ -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.'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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<DeepPartial<AppConfig>> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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(
|
||||
user,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
mutation validateConfig($updates: [UpdateAppConfigInput!]!) {
|
||||
validateAppConfig(updates: $updates) {
|
||||
module
|
||||
key
|
||||
value
|
||||
valid
|
||||
error
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -109,6 +109,15 @@ export interface AlreadyInSpaceDataType {
|
||||
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 {
|
||||
__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<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 {
|
||||
__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<AppConfigValidateResult>;
|
||||
verifyEmail: Scalars['Boolean']['output'];
|
||||
}
|
||||
|
||||
@@ -1712,6 +1738,10 @@ export interface MutationUploadAvatarArgs {
|
||||
avatar: Scalars['Upload']['input'];
|
||||
}
|
||||
|
||||
export interface MutationValidateAppConfigArgs {
|
||||
updates: Array<UpdateAppConfigInput>;
|
||||
}
|
||||
|
||||
export interface MutationVerifyEmailArgs {
|
||||
token: Scalars['String']['input'];
|
||||
}
|
||||
@@ -2900,6 +2930,22 @@ export type UpdateAppConfigMutation = {
|
||||
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<{
|
||||
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;
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`flex justify-between flex-grow space-y-[10px]
|
||||
@@ -140,28 +137,12 @@ export const ConfigRow = ({
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
onChange={onValueChange}
|
||||
error={error}
|
||||
{...props}
|
||||
/>
|
||||
{isValueChanged && (
|
||||
<div className="absolute bottom-[-25px] text-sm right-0 break-words">
|
||||
<span
|
||||
className="line-through"
|
||||
style={{
|
||||
color: 'rgba(198, 34, 34, 1)',
|
||||
backgroundColor: 'rgba(254, 213, 213, 1)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(defaultValue)}
|
||||
</span>{' '}
|
||||
=>{' '}
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(20, 147, 67, 1)',
|
||||
backgroundColor: 'rgba(225, 250, 177, 1)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(value)}
|
||||
</span>
|
||||
{error && (
|
||||
<div className="absolute bottom-[-25px] text-sm right-0 break-words text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,9 +34,7 @@ type ConfigGroup<T extends AppConfigModule> = {
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
>
|
||||
<CheckIcon size={20} />
|
||||
@@ -52,12 +47,6 @@ export function SettingsPage() {
|
||||
appConfig={appConfig}
|
||||
patchedAppConfig={patchedAppConfig}
|
||||
/>
|
||||
<ConfirmChanges
|
||||
updates={updates}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onConfirm={saveChanges}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = ({
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export { SettingsPage as Component };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.`
|
||||
*/
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
Reference in New Issue
Block a user