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 { 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.'
);
});

View File

@@ -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() {

View File

@@ -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

View File

@@ -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,
});

View File

@@ -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,
}
);

View File

@@ -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,
};
});
}
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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!
}

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

View File

@@ -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;

View File

@@ -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>{' '}
=&gt;{' '}
<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>

View File

@@ -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(

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 { 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 };

View File

@@ -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) => {

View File

@@ -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.`
*/

View File

@@ -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}}"