From 84abeead9b8cf91bfe7639593c134fed88ac4511 Mon Sep 17 00:00:00 2001 From: Junyi Date: Fri, 27 Feb 2026 11:46:34 +0800 Subject: [PATCH 1/3] chore(acl): sanitize association values (#8688) * chore: acl.sanitizeAssociationValues * refactor(acl): simplify apis * refactor(acl): move sanitize logic back to plugin-acl * fix(acl): fix build error * fix(acl): fix dependencies * fix(acl): fix cycling import --------- Co-authored-by: xilesun <2013xile@gmail.com> --- packages/core/acl/package.json | 7 +- packages/core/acl/src/__tests__/acl.test.ts | 1 + packages/core/acl/src/acl.ts | 182 +- .../middlewares/check-association-operate.ts | 20 +- .../check-change-with-association.ts | 739 +-- .../@nocobase/plugin-acl/src/server/server.ts | 16 +- .../plugin-ai-gigachat/src/client/index.tsx | 2 +- .../plugin-ai-gigachat/src/server/plugin.ts | 12 +- .../plugins/@nocobase/plugin-ai/package.json | 1 + .../manager/ai-context-datasource-manager.ts | 5 +- .../schemaComponents/Steps/Steps.tsx | 2 +- .../client/__tests__/tree-component.test.tsx | 3 +- .../src/client/demos/component-basic.tsx | 4 +- .../demos/component-defaultExpandAll-true.tsx | 4 +- .../src/client/demos/component-fieldNames.tsx | 4 +- .../src/client/demos/component-no-data.tsx | 4 +- .../client/demos/component-remote-search.tsx | 4 +- .../demos/component-searchable-false.tsx | 4 +- .../fixtures/createSingleItemInitializer.ts | 8 +- .../client/demos/fixtures/schemaViewer.tsx | 16 +- .../src/client/demos/initializer.tsx | 19 +- .../src/client/demos/schema-basic.tsx | 10 +- .../demos/schema-defaultExpandAll-true.tsx | 13 +- .../src/client/demos/schema-fieldNames.tsx | 15 +- .../src/client/demos/schema-full-height.tsx | 33 +- .../src/client/demos/schema-height.tsx | 35 +- .../client/demos/schema-searchable-false.tsx | 11 +- .../src/client/demos/settings.tsx | 2 +- .../plugin-block-tree/src/client/index.tsx | 18 +- .../src/client/settings/items/expandAll.ts | 4 +- .../src/client/settings/items/recordsCount.ts | 4 +- .../src/client/settings/items/searchable.ts | 4 +- .../src/client/settings/items/titleField.ts | 8 +- .../components/DatabaseServerSelect.tsx | 3 +- .../client/components/RemoteTableSelect.tsx | 6 +- .../src/client/components/UnSupportFields.tsx | 2 +- .../plugin-collection-fdw/src/index.ts | 2 +- .../src/client/AddVariableButton.tsx | 10 +- .../src/client/EditBadge.tsx | 21 +- .../src/client/index.tsx | 78 +- .../src/client/useCustomVariablesOptions.tsx | 95 +- .../src/server/__tests__/parseFilter.test.ts | 46 +- .../src/server/index.ts | 22 +- .../src/server/parseFilter.ts | 2 +- .../src/client/echarts/transform.ts | 6 +- .../src/client/index.tsx | 2 +- .../src/server/plugin.ts | 14 +- .../src/server/actions/query.ts | 4 +- .../src/client/__e2e__/templates.ts | 4793 ++++++++++------- .../src/client/flows.tsx | 11 +- 50 files changed, 3792 insertions(+), 2539 deletions(-) diff --git a/packages/core/acl/package.json b/packages/core/acl/package.json index 8c9fd0d1fa7..6aef5ce00a4 100644 --- a/packages/core/acl/package.json +++ b/packages/core/acl/package.json @@ -6,10 +6,13 @@ "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/resourcer": "2.0.6", - "@nocobase/utils": "2.0.6", "minimatch": "^5.1.1" }, + "peerDependencies": { + "@nocobase/database": "2.0.6", + "@nocobase/resourcer": "2.0.6", + "@nocobase/utils": "2.0.6" + }, "repository": { "type": "git", "url": "git+https://github.com/nocobase/nocobase.git", diff --git a/packages/core/acl/src/__tests__/acl.test.ts b/packages/core/acl/src/__tests__/acl.test.ts index d3abb096b15..54dc2f5c72b 100644 --- a/packages/core/acl/src/__tests__/acl.test.ts +++ b/packages/core/acl/src/__tests__/acl.test.ts @@ -403,6 +403,7 @@ describe('acl', () => { resourceName: 'test', actionName: 'create', }, + permission: {}, throw: () => {}, }); const ctx1 = newConext() as Context; diff --git a/packages/core/acl/src/acl.ts b/packages/core/acl/src/acl.ts index fbd4a08f8d6..3ad7b4bf704 100644 --- a/packages/core/acl/src/acl.ts +++ b/packages/core/acl/src/acl.ts @@ -20,6 +20,7 @@ import { NoPermissionError } from './errors/no-permission-error'; import FixedParamsManager, { Merger, GeneralMerger } from './fixed-params-manager'; import SnippetManager, { SnippetOptions } from './snippet-manager'; import { mergeAclActionParams, removeEmptyParams } from './utils'; +import Database from '@nocobase/database'; export interface CanResult { role: string; @@ -54,6 +55,14 @@ export interface ListenerContext { type Listener = (ctx: ListenerContext) => void; +export type UserProvider = (args: { fields: string[] }) => Promise; + +export interface ParseJsonTemplateOptions { + timezone?: string; + state?: any; + userProvider?: UserProvider; +} + interface CanArgs { role?: string; resource: string; @@ -357,31 +366,6 @@ export class ACL extends EventEmitter { } } - /** - * @internal - */ - async parseJsonTemplate(json: any, ctx: any) { - if (json.filter) { - ctx.logger?.info?.('parseJsonTemplate.raw', JSON.parse(JSON.stringify(json.filter))); - const timezone = ctx?.get?.('x-timezone'); - const state = JSON.parse(JSON.stringify(ctx.state)); - const filter = await parseFilter(json.filter, { - timezone, - now: new Date().toISOString(), - vars: { - ctx: { - state, - }, - $user: getUser(ctx), - $nRole: () => state.currentRole, - }, - }); - json.filter = filter; - ctx.logger?.info?.('parseJsonTemplate.parsed', filter); - } - return json; - } - middleware() { const acl = this; @@ -470,37 +454,6 @@ export class ACL extends EventEmitter { this.snippetManager.register(snippet); } - /** - * @internal - */ - filterParams(ctx, resourceName, params) { - if (params?.filter?.createdById) { - const collection = ctx.db.getCollection(resourceName); - if (!collection || !collection.getField('createdById')) { - throw new NoPermissionError('createdById field not found'); - } - } - - // 检查 $or 条件中的 createdById - if (params?.filter?.$or?.length) { - const checkCreatedById = (items) => { - return items.some( - (x) => - 'createdById' in x || x.$or?.some((y) => 'createdById' in y) || x.$and?.some((y) => 'createdById' in y), - ); - }; - - if (checkCreatedById(params.filter.$or)) { - const collection = ctx.db.getCollection(resourceName); - if (!collection || !collection.getField('createdById')) { - throw new NoPermissionError('createdById field not found'); - } - } - } - - return params; - } - protected addCoreMiddleware() { const acl = this; @@ -524,8 +477,18 @@ export class ACL extends EventEmitter { try { if (params && resourcerAction.mergeParams) { - const filteredParams = acl.filterParams(ctx, resourceName, params); - const parsedParams = await acl.parseJsonTemplate(filteredParams, ctx); + const db = ctx.database ?? ctx.db; + const collection = db?.getCollection?.(resourceName); + checkFilterParams(collection, params?.filter); + const parsedFilter = await parseJsonTemplate(params.filter, { + state: ctx.state, + timezone: getTimezone(ctx), + userProvider: createUserProvider({ + db: ctx.db, + currentUser: ctx.state?.currentUser, + }), + }); + const parsedParams = params.filter ? { ...params, filter: parsedFilter ?? params.filter } : params; ctx.permission.parsedParams = parsedParams; ctx.log?.debug && ctx.log.debug('acl parsedParams', parsedParams); @@ -587,25 +550,108 @@ export class ACL extends EventEmitter { } } -function getUser(ctx) { - const dataSource = ctx.app.dataSourceManager.dataSources.get('main'); - const db = dataSource.collectionManager.db; +function getTimezone(ctx: any) { + return ctx?.request?.get?.('x-timezone') ?? ctx?.request?.header?.['x-timezone'] ?? ctx?.req?.headers?.['x-timezone']; +} + +export function createUserProvider(options: { + db?: Database; + dataSourceManager?: any; + currentUser?: any; +}): UserProvider { + const db = options.db ?? options.dataSourceManager?.dataSources?.get?.('main')?.collectionManager?.db; + const currentUser = options.currentUser; return async ({ fields }) => { - const userFields = fields.filter((f) => f && db.getFieldByPath('users.' + f)); - ctx.logger?.info('filter-parse: ', { userFields }); - if (!ctx.state.currentUser) { + if (!db) { return; } + if (!currentUser) { + return; + } + const userFields = fields.filter((f) => f && db.getFieldByPath('users.' + f)); if (!userFields.length) { return; } const user = await db.getRepository('users').findOne({ - filterByTk: ctx.state.currentUser.id, + filterByTk: currentUser.id, fields: userFields, }); - ctx.logger?.info('filter-parse: ', { - $user: user?.toJSON(), - }); return user; }; } + +function containsCreatedByIdFilter(input: any, seen = new Set()): boolean { + if (!input) { + return false; + } + + if (Array.isArray(input)) { + return input.some((item) => containsCreatedByIdFilter(item, seen)); + } + + if (!lodash.isPlainObject(input)) { + return false; + } + + if (seen.has(input)) { + return false; + } + seen.add(input); + + for (const [key, value] of Object.entries(input)) { + if (isCreatedByIdKey(key)) { + return true; + } + + if (containsCreatedByIdFilter(value, seen)) { + return true; + } + } + + return false; +} + +function isCreatedByIdKey(key: string): boolean { + return key === 'createdById' || key.startsWith('createdById.') || key.startsWith('createdById$'); +} + +/** + * @internal + */ +export async function parseJsonTemplate(filter: any, options: ParseJsonTemplateOptions) { + if (!filter) { + return filter; + } + + const timezone = options?.timezone; + const state = JSON.parse(JSON.stringify(options?.state || {})); + const parsedFilter = await parseFilter(filter, { + timezone, + now: new Date().toISOString(), + vars: { + ctx: { + state, + }, + $user: options?.userProvider || (async () => undefined), + $nRole: () => state.currentRole, + }, + }); + return parsedFilter; +} + +/** + * @internal + */ +export function checkFilterParams(collection, filter) { + if (!filter) { + return; + } + + if (!containsCreatedByIdFilter(filter)) { + return; + } + + if (!collection || !collection.getField('createdById')) { + throw new NoPermissionError('createdById field not found'); + } +} diff --git a/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/check-association-operate.ts b/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/check-association-operate.ts index 4eea30a9ffa..4ff8116b3c8 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/check-association-operate.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/check-association-operate.ts @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { ACL, NoPermissionError } from '@nocobase/acl'; +import { ACL, NoPermissionError, checkFilterParams, createUserProvider, parseJsonTemplate } from '@nocobase/acl'; import { Context, Next } from '@nocobase/actions'; export async function checkAssociationOperate(ctx: Context, next: Next) { @@ -38,12 +38,22 @@ export async function checkAssociationOperate(ctx: Context, next: Next) { } if (params.filter) { try { - const filteredParams = ctx.acl.filterParams(ctx, resource, params); - const parsedParams = await ctx.acl.parseJsonTemplate(filteredParams, ctx); - const repo = ctx.db.getRepository(resource); + const timezone = + ctx.request?.get?.('x-timezone') ?? ctx.request?.header?.['x-timezone'] ?? ctx.req?.headers?.['x-timezone']; + const collection = ctx.database?.getCollection?.(resource); + checkFilterParams(collection, params.filter); + const parsedFilter = await parseJsonTemplate(params.filter, { + state: ctx.state, + timezone: timezone as string, + userProvider: createUserProvider({ + db: ctx.db, + currentUser: ctx.state?.currentUser, + }), + }); + const repo = ctx.database.getRepository(resource); const record = await repo.findOne({ filterByTk: sourceId, - filter: parsedParams.filter, + filter: parsedFilter ?? params.filter, }); if (!record) { ctx.throw(403, 'No permissions'); diff --git a/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/check-change-with-association.ts b/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/check-change-with-association.ts index dc49ad3eb51..12a2edd2129 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/check-change-with-association.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/check-change-with-association.ts @@ -7,19 +7,312 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { ACL, NoPermissionError } from '@nocobase/acl'; +import { + ACL, + CanResult, + NoPermissionError, + ParseJsonTemplateOptions, + UserProvider, + checkFilterParams, + createUserProvider, + parseJsonTemplate, +} from '@nocobase/acl'; import { Context, Next } from '@nocobase/actions'; -import { Database } from '@nocobase/database'; +import { Collection } from '@nocobase/database'; import _ from 'lodash'; +type ProcessValuesOptions = { + values: Record | Record[]; + updateAssociationValues: string[]; + aclParams: any; + collection: Collection; + lastFieldPath?: string; + protectedKeys?: string[]; + can?: (options: Omit[0], 'role'>) => CanResult | null; + parseOptions?: ParseJsonTemplateOptions; +}; + type AllowedRecordKeysResult = { allowedKeys: Set; missingKeys: Set; }; -/** - * Pick only record key fields from given value(s). - */ +export type SanitizeAssociationValuesOptions = { + acl?: ACL; + resourceName: string; + actionName: string; + values: any; + updateAssociationValues?: string[]; + protectedKeys?: string[]; + aclParams?: any; + roles?: string[]; + currentRole?: string; + currentUser?: any; + collection?: Collection; + db?: any; + database?: any; + timezone?: string; + userProvider?: UserProvider; +}; + +export async function sanitizeAssociationValues(options: SanitizeAssociationValuesOptions) { + const { + acl, + resourceName, + actionName, + values, + updateAssociationValues = [], + protectedKeys = [], + aclParams, + } = options; + + if (_.isEmpty(values)) { + return values; + } + + const collection = options.collection ?? (options.database ?? options.db)?.getCollection?.(resourceName); + if (!collection) { + return values; + } + + const params = aclParams ?? (acl ? (acl as any).fixedParamsManager?.getParams(resourceName, actionName) : undefined); + const roles = options.roles; + const can = (canOptions: Omit[0], 'role'>) => + acl?.can({ roles: roles?.length ? roles : ['anonymous'], ...canOptions }) ?? null; + + const parseOptions: ParseJsonTemplateOptions = { + timezone: options.timezone, + userProvider: options.userProvider, + state: { + currentRole: options.currentRole, + currentRoles: options.roles, + currentUser: options.currentUser, + }, + }; + + return await processValues({ + values, + updateAssociationValues, + aclParams: params, + collection, + lastFieldPath: '', + protectedKeys, + can, + parseOptions, + }); +} + +export const checkChangesWithAssociation = async (ctx: Context, next: Next) => { + const timezone = (ctx.request?.get?.('x-timezone') ?? + ctx.request?.header?.['x-timezone'] ?? + ctx.req?.headers?.['x-timezone']) as string; + const { resourceName, actionName } = ctx.action; + if (!['create', 'firstOrCreate', 'updateOrCreate', 'update'].includes(actionName)) { + return next(); + } + if (ctx.permission?.skip) { + return next(); + } + const roles = ctx.state.currentRoles; + if (roles.includes('root')) { + return next(); + } + const acl = ctx.acl; + for (const role of roles) { + const aclRole = acl.getRole(role); + if (aclRole.snippetAllowed(`${resourceName}:${actionName}`)) { + return next(); + } + } + + const params = ctx.action.params || {}; + const rawValues = params.values; + if (_.isEmpty(rawValues)) { + return next(); + } + + const protectedKeys = ['firstOrCreate', 'updateOrCreate'].includes(actionName) ? params.filterKeys || [] : []; + const collection = (ctx.database ?? ctx.db)?.getCollection?.(resourceName); + const processed = await sanitizeAssociationValues({ + acl, + collection, + resourceName, + actionName, + values: rawValues, + updateAssociationValues: params.updateAssociationValues || [], + protectedKeys, + roles, + currentRole: ctx.state.currentRole, + currentUser: ctx.state.currentUser, + aclParams: ctx.permission?.can?.params, + timezone, + userProvider: createUserProvider({ + dataSourceManager: ctx.app?.dataSourceManager, + currentUser: ctx.state?.currentUser, + }), + }); + ctx.action.params.values = processed; + await next(); +}; + +async function processValues(options: ProcessValuesOptions) { + const { + values, + updateAssociationValues, + aclParams, + collection, + lastFieldPath = '', + protectedKeys = [], + can, + parseOptions, + } = options; + if (Array.isArray(values)) { + const result = []; + + for (const item of values) { + if (!_.isPlainObject(item)) { + result.push(item); + continue; + } + + const processed = await processValues({ + values: item, + updateAssociationValues, + aclParams, + collection, + lastFieldPath, + protectedKeys, + can, + parseOptions, + }); + + if (processed !== null && processed !== undefined) { + result.push(processed); + } + } + + return result; + } + + if (!values || !_.isPlainObject(values)) { + return values; + } + + if (!collection) { + return values; + } + + let v = values; + if (aclParams?.whitelist) { + const combined = _.uniq([...aclParams.whitelist, ...protectedKeys]); + v = _.pick(values, combined); + } + + for (const [fieldName, fieldValue] of Object.entries(v)) { + if (protectedKeys.includes(fieldName)) { + continue; + } + + const field = collection.getField(fieldName); + const isAssociation = + field && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'belongsToArray'].includes(field.type); + + if (!isAssociation) { + continue; + } + + const targetCollection = collection.db.getCollection(field.target); + if (!targetCollection) { + delete v[fieldName]; + continue; + } + + const fieldPath = lastFieldPath ? `${lastFieldPath}.${fieldName}` : fieldName; + const recordKey = field.type === 'hasOne' ? targetCollection.model.primaryKeyAttribute : field.targetKey; + + const canUpdateAssociation = updateAssociationValues.includes(fieldPath); + + if (!canUpdateAssociation) { + const normalized = normalizeAssociationValue(fieldValue, recordKey); + + if (normalized === undefined && !protectedKeys.includes(fieldName)) { + delete v[fieldName]; + } else { + v[fieldName] = normalized; + } + + continue; + } + + const createParams = can?.({ + resource: field.target, + action: 'create', + }); + + const updateParams = can?.({ + resource: field.target, + action: 'update', + }); + + if (Array.isArray(fieldValue)) { + const processed = []; + let allowedRecordKeys: Set | undefined; + let existingRecordKeys: Set | undefined; + + if (updateParams) { + const allowedResult = await collectAllowedRecordKeys( + fieldValue, + recordKey, + updateParams?.params?.filter, + targetCollection, + parseOptions, + ); + allowedRecordKeys = allowedResult?.allowedKeys; + if (createParams && allowedResult?.missingKeys?.size) { + existingRecordKeys = await collectExistingRecordKeys(recordKey, targetCollection, allowedResult.missingKeys); + } + } + + for (const item of fieldValue) { + const r = await processAssociationChild({ + value: item, + recordKey, + updateAssociationValues, + createParams, + updateParams, + target: targetCollection, + fieldPath, + allowedRecordKeys, + existingRecordKeys, + can, + parseOptions, + }); + if (r !== null && r !== undefined) { + processed.push(r); + } + } + + v[fieldName] = processed; + continue; + } + + const r = await processAssociationChild({ + value: fieldValue, + recordKey, + updateAssociationValues, + createParams, + updateParams, + target: targetCollection, + fieldPath, + can, + parseOptions, + }); + v[fieldName] = r; + } + + return v; +} + function normalizeAssociationValue( value: any, recordKey: string, @@ -32,44 +325,36 @@ function normalizeAssociationValue( .map((v) => (typeof v === 'number' || typeof v === 'string' ? v : v[recordKey])) .filter((v) => v !== null && v !== undefined); return result.length > 0 ? result : undefined; - } else { - return typeof value === 'number' || typeof value === 'string' ? value : value[recordKey]; } -} - -async function resolveScopeFilter(ctx: Context, target: string, params?: any) { - if (!params) { - return {}; - } - - const filteredParams = ctx.acl.filterParams(ctx, target, params); - const parsedParams = await ctx.acl.parseJsonTemplate(filteredParams, ctx); - return parsedParams.filter || {}; + return typeof value === 'number' || typeof value === 'string' ? value : value[recordKey]; } async function collectAllowedRecordKeys( - ctx: Context, items: any[], recordKey: string, - updateParams: any, - target: string, + filter: any, + collection: Collection, + parseOptions?: ParseJsonTemplateOptions, ): Promise { - const repo = ctx.database.getRepository(target); - if (!repo) { - return undefined; + if (!collection) { + return; } + const { repository } = collection; + const keys = items .map((item) => (_.isPlainObject(item) ? item[recordKey] : undefined)) .filter((key) => key !== undefined && key !== null); if (!keys.length) { - return undefined; + return; } try { - const scopedFilter = await resolveScopeFilter(ctx, target, updateParams?.params); - const records = await repo.find({ + checkFilterParams(collection, filter); + + const scopedFilter = filter ? await parseJsonTemplate(filter, parseOptions) : {}; + const records = await repository.find({ filter: { ...scopedFilter, [`${recordKey}.$in`]: keys, @@ -98,13 +383,12 @@ async function collectAllowedRecordKeys( } async function collectExistingRecordKeys( - ctx: Context, recordKey: string, - target: string, + collection: Collection, keys: Iterable, ): Promise> { - const repo = ctx.database.getRepository(target); - if (!repo) { + const { repository } = collection; + if (!repository) { return new Set(); } @@ -113,7 +397,7 @@ async function collectExistingRecordKeys( return new Set(); } - const records = await repo.find({ + const records = await repository.find({ filter: { [`${recordKey}.$in`]: keyList, }, @@ -129,17 +413,12 @@ async function collectExistingRecordKeys( return existingKeys; } -async function recordExistsWithoutScope( - ctx: Context, - target: string, - recordKey: string, - keyValue: any, -): Promise { - const repo = ctx.database.getRepository(target); - if (!repo) { +async function recordExistsWithoutScope(collection: Collection, recordKey: string, keyValue: any): Promise { + const { repository } = collection; + if (!repository) { return false; } - const record = await repo.findOne({ + const record = await repository.findOne({ filter: { [recordKey]: keyValue, }, @@ -147,33 +426,50 @@ async function recordExistsWithoutScope( return Boolean(record); } -/** - * Process nested association values recursively if creation is allowed. - */ -async function processAssociationChild( - ctx: Context, - value: Record, - recordKey: string, - updateAssociationValues: string[], - createParams: any, - updateParams: any, - target: string, - fieldPath: string, - allowedRecordKeys?: Set, - existingRecordKeys?: Set, -): Promise | string | number | null> { +type ProcessAssociationChildOptions = { + value: Record; + recordKey: string; + updateAssociationValues: string[]; + createParams: any; + updateParams: any; + target: Collection; + fieldPath: string; + allowedRecordKeys?: Set; + existingRecordKeys?: Set; + can?: (options: Omit[0], 'role'>) => CanResult | null; + parseOptions?: ParseJsonTemplateOptions; +}; + +async function processAssociationChild(options: ProcessAssociationChildOptions) { + const { + value, + recordKey, + updateAssociationValues, + createParams, + updateParams, + target, + fieldPath, + allowedRecordKeys, + existingRecordKeys, + can, + parseOptions, + } = options; const keyValue = value?.[recordKey]; const fallbackToCreate = async () => { if (!createParams) { return keyValue; } - ctx.log.debug(`Association record missing, fallback to create`, { - fieldPath, - value, - target, + return await processValues({ + values: value, + updateAssociationValues, + aclParams: createParams.params, + collection: target, + lastFieldPath: fieldPath, + protectedKeys: [], + can, + parseOptions, }); - return await processValues(ctx, value, updateAssociationValues, createParams.params, target, fieldPath, []); }; const tryFallbackToCreate = async ( @@ -184,293 +480,86 @@ async function processAssociationChild( return undefined; } const recordExists = - typeof knownExists === 'boolean' ? knownExists : await recordExistsWithoutScope(ctx, target, recordKey, keyValue); + typeof knownExists === 'boolean' ? knownExists : await recordExistsWithoutScope(target, recordKey, keyValue); if (!recordExists) { - ctx.log.debug(reason, { - fieldPath, - value, - createParams, - updateParams, - }); return await fallbackToCreate(); } return undefined; }; - // Case 1: Existing record → potential update if (keyValue !== undefined && keyValue !== null) { if (!updateParams) { - // No update permission, try create const created = await tryFallbackToCreate(`No permission to update association, try create not exist record`); if (created !== undefined) { return created; } - ctx.log.debug(`No permission to update association`, { fieldPath, value, updateParams }); return keyValue; - } else { - const repo = ctx.database.getRepository(target); - if (!repo) { - ctx.log.debug(`Repository not found for association target`, { fieldPath, target }); - return keyValue; - } - try { - if (allowedRecordKeys) { - if (!allowedRecordKeys.has(keyValue)) { - const created = await tryFallbackToCreate( - `No permission to update association due to scope, try create not exist record`, - existingRecordKeys ? existingRecordKeys.has(keyValue) : undefined, - ); - if (created !== undefined) { - return created; - } - ctx.log.debug(`No permission to update association due to scope`, { fieldPath, value, updateParams }); - return keyValue; + } + const { repository } = target; + if (!repository) { + return keyValue; + } + try { + if (allowedRecordKeys) { + if (!allowedRecordKeys.has(keyValue)) { + const created = await tryFallbackToCreate( + `No permission to update association due to scope, try create not exist record`, + existingRecordKeys ? existingRecordKeys.has(keyValue) : undefined, + ); + if (created !== undefined) { + return created; + } + return keyValue; + } + } else { + checkFilterParams(target, updateParams.params?.filter); + const filter = await parseJsonTemplate(updateParams.params?.filter, parseOptions); + const record = await repository.findOne({ + filter: { + ...filter, + [recordKey]: keyValue, + }, + }); + if (!record) { + const created = await tryFallbackToCreate( + `No permission to update association due to scope, try create not exist record`, + ); + if (created !== undefined) { + return created; } - } else { - const filter = await resolveScopeFilter(ctx, target, updateParams.params); - const record = await repo.findOne({ - filter: { - ...filter, - [recordKey]: keyValue, - }, - }); - if (!record) { - const created = await tryFallbackToCreate( - `No permission to update association due to scope, try create not exist record`, - ); - if (created !== undefined) { - return created; - } - ctx.log.debug(`No permission to update association due to scope`, { fieldPath, value, updateParams }); - return keyValue; - } - } - return await processValues(ctx, value, updateAssociationValues, updateParams.params, target, fieldPath, []); - } catch (e) { - if (e instanceof NoPermissionError) { return keyValue; } - throw e; } + return await processValues({ + values: value, + updateAssociationValues, + aclParams: updateParams.params, + collection: target, + lastFieldPath: fieldPath, + protectedKeys: [], + can, + parseOptions, + }); + } catch (e) { + if (e instanceof NoPermissionError) { + return keyValue; + } + throw e; } } - // Case 2: New record → potential create if (createParams) { - return await processValues(ctx, value, updateAssociationValues, createParams.params, target, fieldPath, []); + return await processValues({ + values: value, + updateAssociationValues, + aclParams: createParams.params, + collection: target, + lastFieldPath: fieldPath, + protectedKeys: [], + can, + parseOptions, + }); } - // Case 3: Neither create nor update is allowed - ctx.log.debug(`No permission to create association`, { fieldPath, value, createParams }); return null; } - -/** - * Recursively process values based on ACL and association rules. - */ -async function processValues( - ctx: Context, - values: Record | Record[], - updateAssociationValues: string[], - aclParams: any, - collectionName: string, - lastFieldPath = '', - protectedKeys: string[] = [], -) { - // Case: array of items → process each item - if (Array.isArray(values)) { - const result = []; - - for (const item of values) { - if (!_.isPlainObject(item)) { - // Non-object items are returned as-is - result.push(item); - continue; - } - - const processed = await processValues( - ctx, - item, - updateAssociationValues, - aclParams, - collectionName, - lastFieldPath, - protectedKeys, - ); - - if (processed !== null && processed !== undefined) { - result.push(processed); - } - } - - return result; - } - - if (!values || !_.isPlainObject(values)) { - return values; - } - - const db: Database = ctx.database; - const collection = db.getCollection(collectionName); - - if (!collection) { - return values; - } - - // Whitelist: protectedKeys must never be removed - if (aclParams?.whitelist) { - const combined = _.uniq([...aclParams.whitelist, ...protectedKeys]); - values = _.pick(values, combined); - } - - for (const [fieldName, fieldValue] of Object.entries(values)) { - // Skip protected fields - if (protectedKeys.includes(fieldName)) { - continue; - } - - const field = collection.getField(fieldName); - const isAssociation = - field && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'belongsToArray'].includes(field.type); - - if (!isAssociation) { - continue; - } - - const targetCollection = db.getCollection(field.target); - if (!targetCollection) { - delete values[fieldName]; - continue; - } - - const fieldPath = lastFieldPath ? `${lastFieldPath}.${fieldName}` : fieldName; - const recordKey = field.type === 'hasOne' ? targetCollection.model.primaryKeyAttribute : field.targetKey; - - const canUpdateAssociation = updateAssociationValues.includes(fieldPath); - - // Association cannot update → only keep key(s) - if (!canUpdateAssociation) { - const normalized = normalizeAssociationValue(fieldValue, recordKey); - - if (normalized === undefined && !protectedKeys.includes(fieldName)) { - delete values[fieldName]; - } else { - values[fieldName] = normalized; - } - - ctx.log.debug(`Not allow to update association, only keep keys`, { - fieldPath, - fieldValue, - updateAssociationValues, - recordKey, - normalizedValue: values[fieldName], - }); - continue; - } - - // Allowed: process create/update rules - const createParams = ctx.can({ - roles: ctx.state.currentRoles, - resource: field.target, - action: 'create', - }); - - const updateParams = ctx.can({ - roles: ctx.state.currentRoles, - resource: field.target, - action: 'update', - }); - - // Multi - if (Array.isArray(fieldValue)) { - const processed = []; - let allowedRecordKeys: Set | undefined; - let existingRecordKeys: Set | undefined; - - if (updateParams) { - const allowedResult = await collectAllowedRecordKeys(ctx, fieldValue, recordKey, updateParams, field.target); - allowedRecordKeys = allowedResult?.allowedKeys; - if (createParams && allowedResult?.missingKeys?.size) { - existingRecordKeys = await collectExistingRecordKeys(ctx, recordKey, field.target, allowedResult.missingKeys); - } - } - - for (const item of fieldValue) { - const r = await processAssociationChild( - ctx, - item, - recordKey, - updateAssociationValues, - createParams, - updateParams, - field.target, - fieldPath, - allowedRecordKeys, - existingRecordKeys, - ); - if (r !== null && r !== undefined) { - processed.push(r); - } - } - - values[fieldName] = processed; - continue; - } - - // Single - const r = await processAssociationChild( - ctx, - fieldValue, - recordKey, - updateAssociationValues, - createParams, - updateParams, - field.target, - fieldPath, - ); - values[fieldName] = r; - } - - return values; -} - -export const checkChangesWithAssociation = async (ctx: Context, next: Next) => { - const { resourceName, actionName } = ctx.action; - if (!['create', 'firstOrCreate', 'updateOrCreate', 'update'].includes(actionName)) { - return next(); - } - if (ctx.permission?.skip) { - return next(); - } - const roles = ctx.state.currentRoles; - if (roles.includes('root')) { - return next(); - } - const acl: ACL = ctx.acl; - for (const role of roles) { - const aclRole = acl.getRole(role); - if (aclRole.snippetAllowed(`${resourceName}:${actionName}`)) { - return next(); - } - } - - const params = ctx.action.params || {}; - const rawValues = params.values; - if (_.isEmpty(rawValues)) { - return next(); - } - - const protectedKeys = ['firstOrCreate', 'updateOrCreate'].includes(actionName) ? params.filterKeys || [] : []; - const aclParams = ctx.permission.can?.params || ctx.acl.fixedParamsManager.getParams(resourceName, actionName); - const processed = await processValues( - ctx, - rawValues, - params.updateAssociationValues || [], - aclParams, - resourceName, - '', - protectedKeys, - ); - ctx.action.params.values = processed; - await next(); -}; diff --git a/packages/plugins/@nocobase/plugin-acl/src/server/server.ts b/packages/plugins/@nocobase/plugin-acl/src/server/server.ts index c722f259a68..e16e98164c5 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/server/server.ts @@ -24,13 +24,25 @@ import { RoleResourceActionModel } from './model/RoleResourceActionModel'; import { RoleResourceModel } from './model/RoleResourceModel'; import { setSystemRoleMode } from './actions/union-role'; import { checkAssociationOperate } from './middlewares/check-association-operate'; -import { checkChangesWithAssociation } from './middlewares/check-change-with-association'; +import { + SanitizeAssociationValuesOptions, + checkChangesWithAssociation, + sanitizeAssociationValues, +} from './middlewares/check-change-with-association'; +import type { ACL } from '@nocobase/acl'; export class PluginACLServer extends Plugin { get acl() { return this.app.acl; } + async sanitizeAssociationValues(options: SanitizeAssociationValuesOptions & { acl?: ACL }) { + return sanitizeAssociationValues({ + ...options, + acl: options.acl ?? this.acl, + }); + } + async writeResourceToACL(resourceModel: RoleResourceModel, transaction: Transaction) { await resourceModel.writeToACL({ acl: this.acl, @@ -551,8 +563,6 @@ export class PluginACLServer extends Plugin { return next(); }); - const parseJsonTemplate = this.app.acl.parseJsonTemplate; - this.app.acl.beforeGrantAction(async (ctx) => { const actionName = this.app.acl.resolveActionAlias(ctx.actionName); diff --git a/packages/plugins/@nocobase/plugin-ai-gigachat/src/client/index.tsx b/packages/plugins/@nocobase/plugin-ai-gigachat/src/client/index.tsx index a0c606dcc07..9f27723998b 100644 --- a/packages/plugins/@nocobase/plugin-ai-gigachat/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-ai-gigachat/src/client/index.tsx @@ -7,7 +7,7 @@ export class PluginAIGigaChatClient extends Plugin { // await this.app.pm.add() } - async beforeLoad() { } + async beforeLoad() {} // You can get and modify the app instance here async load() { diff --git a/packages/plugins/@nocobase/plugin-ai-gigachat/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-ai-gigachat/src/server/plugin.ts index aca3f0e332f..a5851520124 100644 --- a/packages/plugins/@nocobase/plugin-ai-gigachat/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-ai-gigachat/src/server/plugin.ts @@ -3,21 +3,21 @@ import PluginAIServer from '@nocobase/plugin-ai'; import { gigaChatProviderOptions } from './llm-providers/gigachat'; export class PluginAIGigaChatServer extends Plugin { - async afterAdd() { } + async afterAdd() {} - async beforeLoad() { } + async beforeLoad() {} async load() { this.aiPlugin.aiManager.registerLLMProvider('gigachat', gigaChatProviderOptions); } - async install() { } + async install() {} - async afterEnable() { } + async afterEnable() {} - async afterDisable() { } + async afterDisable() {} - async remove() { } + async remove() {} private get aiPlugin(): PluginAIServer { return this.app.pm.get('ai'); diff --git a/packages/plugins/@nocobase/plugin-ai/package.json b/packages/plugins/@nocobase/plugin-ai/package.json index 38238b3346f..90fb8107ce2 100644 --- a/packages/plugins/@nocobase/plugin-ai/package.json +++ b/packages/plugins/@nocobase/plugin-ai/package.json @@ -13,6 +13,7 @@ "homepage.ru-RU": "https://docs-ru.nocobase.com/handbook/action-ai", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/action-ai", "peerDependencies": { + "@nocobase/acl": "2.x", "@nocobase/ai": "2.x", "@nocobase/client": "2.x", "@nocobase/flow-engine": "2.x", diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/manager/ai-context-datasource-manager.ts b/packages/plugins/@nocobase/plugin-ai/src/server/manager/ai-context-datasource-manager.ts index a59d13e3f3b..abbb581b223 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/manager/ai-context-datasource-manager.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/manager/ai-context-datasource-manager.ts @@ -12,6 +12,7 @@ import { AIContextDatasource } from '../../collections/ai-context-datasource'; import PluginAIServer from '../plugin'; import { WorkContext, WorkContextResolveStrategy } from '../types'; import { Context } from '@nocobase/actions'; +import { checkFilterParams, parseJsonTemplate } from '@nocobase/acl'; export class AIContextDatasourceManager { constructor(protected plugin: PluginAIServer) {} @@ -76,8 +77,8 @@ export class AIContextDatasourceManager { }; } - const filteredParams = ds.acl.filterParams(ctx, collectionName, can.params); - const parsedParams = filteredParams ? await ds.acl.parseJsonTemplate(filteredParams, ctx) : {}; + checkFilterParams(collection, can.params?.filter); + const parsedParams = can.params ? await parseJsonTemplate(can.params, ctx) : {}; if (parsedParams.appends && options.fields) { for (const queryField of options.fields) { diff --git a/packages/plugins/@nocobase/plugin-block-multi-step-form/src/client/StepsForm/schemaComponents/Steps/Steps.tsx b/packages/plugins/@nocobase/plugin-block-multi-step-form/src/client/StepsForm/schemaComponents/Steps/Steps.tsx index 615cc01bb13..d88b0f52c7a 100644 --- a/packages/plugins/@nocobase/plugin-block-multi-step-form/src/client/StepsForm/schemaComponents/Steps/Steps.tsx +++ b/packages/plugins/@nocobase/plugin-block-multi-step-form/src/client/StepsForm/schemaComponents/Steps/Steps.tsx @@ -34,7 +34,7 @@ export function Steps(props: StepProps) { }, }, 'x-content': x.title, - }, + }, }, }} /> diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/__tests__/tree-component.test.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/__tests__/tree-component.test.tsx index 915e1cefb5d..215b22a28a1 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/__tests__/tree-component.test.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/__tests__/tree-component.test.tsx @@ -31,6 +31,5 @@ describe('TreeComponent', () => { // expect(document.querySelectorAll('.ant-tree-treenode-switcher-open').length).toBe(2); // expect(document.querySelector('.ant-tree-node-selected > span')).toHaveStyle('color: rgb(22, 119, 255);'); - }) + }); }); - diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-basic.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-basic.tsx index cf203541f95..9a84e833998 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-basic.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-basic.tsx @@ -3,9 +3,7 @@ import { Tree } from '@nocobase/plugin-block-tree/client'; import { getMockData } from './fixtures/getMockData'; const App: React.FC = () => { - return ( - - ); + return ; }; export default App; diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-defaultExpandAll-true.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-defaultExpandAll-true.tsx index 17d167da23e..538baf26e31 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-defaultExpandAll-true.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-defaultExpandAll-true.tsx @@ -3,9 +3,7 @@ import { Tree } from '@nocobase/plugin-block-tree/client'; import { getMockData } from './fixtures/getMockData'; const App: React.FC = () => { - return ( - - ); + return ; }; export default App; diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-fieldNames.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-fieldNames.tsx index c638af9e4b7..51dd7ec0420 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-fieldNames.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-fieldNames.tsx @@ -36,9 +36,7 @@ const generateData = (_level: number, _preKey?: React.Key, _tns?: TreeNode[]) => generateData(z); const App: React.FC = () => { - return ( - - ); + return ; }; export default App; diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-no-data.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-no-data.tsx index ee4317d8cfb..eb232345171 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-no-data.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-no-data.tsx @@ -2,9 +2,7 @@ import React from 'react'; import { Tree } from '@nocobase/plugin-block-tree/client'; const App: React.FC = () => { - return ( - - ); + return ; }; export default App; diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-remote-search.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-remote-search.tsx index 220b605e33b..fa34ac14517 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-remote-search.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-remote-search.tsx @@ -17,9 +17,7 @@ const App: React.FC = () => { }, 1000); } - return ( - - ); + return ; }; export default App; diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-searchable-false.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-searchable-false.tsx index 1168ad3515d..be87f9cde7e 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-searchable-false.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/component-searchable-false.tsx @@ -3,9 +3,7 @@ import { Tree } from '@nocobase/plugin-block-tree/client'; import { getMockData } from './fixtures/getMockData'; const App: React.FC = () => { - return ( - - ); + return ; }; export default App; diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/fixtures/createSingleItemInitializer.ts b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/fixtures/createSingleItemInitializer.ts index 53cb93686aa..03b1b5d905a 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/fixtures/createSingleItemInitializer.ts +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/fixtures/createSingleItemInitializer.ts @@ -1,5 +1,5 @@ -import { Grid, SchemaInitializerItemType } from "@nocobase/client"; -import { SchemaInitializer } from "@nocobase/client"; +import { Grid, SchemaInitializerItemType } from '@nocobase/client'; +import { SchemaInitializer } from '@nocobase/client'; export function createSingleItemInitializer(initializerItem: SchemaInitializerItemType) { return new SchemaInitializer({ @@ -10,8 +10,6 @@ export function createSingleItemInitializer(initializerItem: SchemaInitializerIt style: { marginLeft: 8, }, - items: [ - initializerItem - ] + items: [initializerItem], }); } diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/fixtures/schemaViewer.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/fixtures/schemaViewer.tsx index 7d4a7d58ea5..2bd61711b6b 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/fixtures/schemaViewer.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/fixtures/schemaViewer.tsx @@ -6,10 +6,12 @@ import { useFieldSchema, ISchema } from '@formily/react'; function ShowSchema({ children, schemaKey }) { const filedSchema = useFieldSchema(); const key = schemaKey ? `properties.schema.${schemaKey}` : `properties.schema`; - return <> -
{JSON.stringify(_.get(filedSchema.toJSON(), key), null, 2)}
- {children} - + return ( + <> +
{JSON.stringify(_.get(filedSchema.toJSON(), key), null, 2)}
+ {children} + + ); } export function schemaViewer(schema: ISchema, schemaKey?: string) { @@ -18,10 +20,10 @@ export function schemaViewer(schema: ISchema, schemaKey?: string) { name: 'schema-viewer', 'x-component': ShowSchema, 'x-component-props': { - schemaKey + schemaKey, }, properties: { - schema + schema, }, - } + }; } diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/initializer.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/initializer.tsx index 5a6428dd4a4..541e00c6c19 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/initializer.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/initializer.tsx @@ -11,19 +11,22 @@ import { schemaViewer } from './fixtures/schemaViewer'; const initializer = createSingleItemInitializer(treeInitializerItem); const Demo = () => { - return ; + return ( + + ); }; class DemoPlugin extends Plugin { async load() { this.app.schemaInitializerManager.add(initializer); - this.app.router.add('root', { path: '/', Component: Demo }) + this.app.router.add('root', { path: '/', Component: Demo }); } } diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-basic.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-basic.tsx index 621465727ef..6ca82b24b5c 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-basic.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-basic.tsx @@ -6,18 +6,22 @@ import TreePlugin from '@nocobase/plugin-block-tree/client'; import { getTreeSchema } from '../schema'; const Demo = () => { - return ; + return ( + + ); }; class DemoPlugin extends Plugin { async load() { - this.app.router.add('root', { path: '/', Component: Demo }) + this.app.router.add('root', { path: '/', Component: Demo }); } } const app = mockApp({ plugins: [TreePlugin, DemoPlugin], - delayResponse: 100 + delayResponse: 100, }); export default app.getRootComponent(); diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-defaultExpandAll-true.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-defaultExpandAll-true.tsx index 103282321ad..365d3715ac8 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-defaultExpandAll-true.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-defaultExpandAll-true.tsx @@ -6,18 +6,25 @@ import TreePlugin from '@nocobase/plugin-block-tree/client'; import { getTreeSchema } from '../schema'; const Demo = () => { - return ; + return ( + + ); }; class DemoPlugin extends Plugin { async load() { - this.app.router.add('root', { path: '/', Component: Demo }) + this.app.router.add('root', { path: '/', Component: Demo }); } } const app = mockApp({ plugins: [TreePlugin, DemoPlugin], - delayResponse: 100 + delayResponse: 100, }); export default app.getRootComponent(); diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-fieldNames.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-fieldNames.tsx index ee998192ecc..6da46d6f9f3 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-fieldNames.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-fieldNames.tsx @@ -6,18 +6,27 @@ import TreePlugin from '@nocobase/plugin-block-tree/client'; import { getTreeSchema } from '../schema'; const Demo = () => { - return ; + return ( + + ); }; class DemoPlugin extends Plugin { async load() { - this.app.router.add('root', { path: '/', Component: Demo }) + this.app.router.add('root', { path: '/', Component: Demo }); } } const app = mockApp({ plugins: [TreePlugin, DemoPlugin], - delayResponse: 100 + delayResponse: 100, }); export default app.getRootComponent(); diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-full-height.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-full-height.tsx index da508ddf3e4..99ba86b2e9d 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-full-height.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-full-height.tsx @@ -6,30 +6,35 @@ import TreePlugin from '@nocobase/plugin-block-tree/client'; import { getTreeSchema } from '../schema'; const Demo = () => { - return ; + }} + /> + ); }; class DemoPlugin extends Plugin { async load() { - this.app.router.add('root', { path: '/', Component: Demo }) + this.app.router.add('root', { path: '/', Component: Demo }); } } const app = mockApp({ plugins: [TreePlugin, DemoPlugin], - delayResponse: 100 + delayResponse: 100, }); export default app.getRootComponent(); diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-height.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-height.tsx index b1257f22708..6ee500b5cb2 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-height.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-height.tsx @@ -6,31 +6,36 @@ import TreePlugin from '@nocobase/plugin-block-tree/client'; import { getTreeSchema } from '../schema'; const Demo = () => { - return ; + }} + /> + ); }; class DemoPlugin extends Plugin { async load() { - this.app.router.add('root', { path: '/', Component: Demo }) + this.app.router.add('root', { path: '/', Component: Demo }); } } const app = mockApp({ plugins: [TreePlugin, DemoPlugin], - delayResponse: 100 + delayResponse: 100, }); export default app.getRootComponent(); diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-searchable-false.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-searchable-false.tsx index b01e804ba3c..3f3640292f1 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-searchable-false.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/schema-searchable-false.tsx @@ -6,12 +6,19 @@ import TreePlugin from '@nocobase/plugin-block-tree/client'; import { getTreeSchema } from '../schema'; const Demo = () => { - return ; + return ( + + ); }; class DemoPlugin extends Plugin { async load() { - this.app.router.add('root', { path: '/', Component: Demo }) + this.app.router.add('root', { path: '/', Component: Demo }); } } diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/settings.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/settings.tsx index 4a16b4be0e5..eb134c7afd3 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/settings.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/demos/settings.tsx @@ -12,7 +12,7 @@ const Demo = () => { class DemoPlugin extends Plugin { async load() { - this.app.router.add('root', { path: '/', Component: Demo }) + this.app.router.add('root', { path: '/', Component: Demo }); } } diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/index.tsx b/packages/plugins/@nocobase/plugin-block-tree/src/client/index.tsx index 322ef87262d..eaff2fa594c 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/index.tsx @@ -20,9 +20,21 @@ export class PluginBlockTreeClient extends Plugin { this.app.schemaSettingsManager.add(treeSettings); this.app.addScopes({ useTreeProps }); - this.app.schemaInitializerManager.addItem('page:addBlock', `filterBlocks.${treeInitializerItem.name}`, treeInitializerItem); - this.app.schemaInitializerManager.addItem('popup:common:addBlock', `filterBlocks.${treeInitializerItem.name}`, treeInitializerItem); - this.app.schemaInitializerManager.addItem('popup:tableSelector:addBlock', `filterBlocks.${treeInitializerItem.name}`, treeInitializerItem); + this.app.schemaInitializerManager.addItem( + 'page:addBlock', + `filterBlocks.${treeInitializerItem.name}`, + treeInitializerItem, + ); + this.app.schemaInitializerManager.addItem( + 'popup:common:addBlock', + `filterBlocks.${treeInitializerItem.name}`, + treeInitializerItem, + ); + this.app.schemaInitializerManager.addItem( + 'popup:tableSelector:addBlock', + `filterBlocks.${treeInitializerItem.name}`, + treeInitializerItem, + ); } } diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/expandAll.ts b/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/expandAll.ts index 004e0085d47..f9508914869 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/expandAll.ts +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/expandAll.ts @@ -7,8 +7,8 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { createSwitchSettingsItem } from "@nocobase/client"; -import { generateNTemplate } from "../../locale"; +import { createSwitchSettingsItem } from '@nocobase/client'; +import { generateNTemplate } from '../../locale'; export const expandAllSchemaSettingsItem = createSwitchSettingsItem({ title: generateNTemplate('Expand all'), diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/recordsCount.ts b/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/recordsCount.ts index d8deb3fdc19..37d442c4ac3 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/recordsCount.ts +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/recordsCount.ts @@ -7,8 +7,8 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { createSelectSchemaSettingsItem } from "@nocobase/client"; -import { generateNTemplate } from "../../locale"; +import { createSelectSchemaSettingsItem } from '@nocobase/client'; +import { generateNTemplate } from '../../locale'; export const recordsCountFieldSchemaSettingsItem = createSelectSchemaSettingsItem({ name: 'recordsCount', diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/searchable.ts b/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/searchable.ts index 1ea15f85869..b3a1f0be4b5 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/searchable.ts +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/searchable.ts @@ -7,8 +7,8 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { createSwitchSettingsItem } from "@nocobase/client"; -import { generateNTemplate } from "../../locale"; +import { createSwitchSettingsItem } from '@nocobase/client'; +import { generateNTemplate } from '../../locale'; export const searchableSchemaSettingsItem = createSwitchSettingsItem({ name: 'searchable', diff --git a/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/titleField.ts b/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/titleField.ts index b639c3d8e99..4bea5e8c643 100644 --- a/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/titleField.ts +++ b/packages/plugins/@nocobase/plugin-block-tree/src/client/settings/items/titleField.ts @@ -7,9 +7,9 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { SelectProps, createSelectSchemaSettingsItem, useCollection, useCompile } from "@nocobase/client"; -import { useCollectionKey } from "../../schema"; -import { generateCommonTemplate } from "../../locale"; +import { SelectProps, createSelectSchemaSettingsItem, useCollection, useCompile } from '@nocobase/client'; +import { useCollectionKey } from '../../schema'; +import { generateCommonTemplate } from '../../locale'; function useOptions(): SelectProps['options'] { const collection = useCollection(); @@ -17,7 +17,7 @@ function useOptions(): SelectProps['options'] { const compile = useCompile(); return collection .getFields((field) => collection.isTitleField(field)) - .map(field => ({ label: field.uiSchema?.title ? compile(field.uiSchema.title) : field.name, value: field.name })); + .map((field) => ({ label: field.uiSchema?.title ? compile(field.uiSchema.title) : field.name, value: field.name })); } export const titleFieldSchemaSettingsItem = createSelectSchemaSettingsItem({ diff --git a/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/DatabaseServerSelect.tsx b/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/DatabaseServerSelect.tsx index a52d8af5c3a..a1e15c12b7a 100644 --- a/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/DatabaseServerSelect.tsx +++ b/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/DatabaseServerSelect.tsx @@ -156,7 +156,8 @@ export const DatabaseServerSelectProvider = (props) => { .then(({ data }) => { initialOptions.current = data?.data; setOptions(data?.data); - }); + }) + .catch(console.error); }; useEffect(() => { try { diff --git a/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/RemoteTableSelect.tsx b/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/RemoteTableSelect.tsx index 7b8f88e1006..436eee4c71f 100644 --- a/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/RemoteTableSelect.tsx +++ b/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/RemoteTableSelect.tsx @@ -12,7 +12,7 @@ import { Select, Spin, Empty } from 'antd'; import { useForm } from '@formily/react'; import { useAPIClient } from '@nocobase/client'; -export const RemoteTableSelect = memo((props:any) => { +export const RemoteTableSelect = memo((props: any) => { const { remoteServerName, onChange, disabled } = props; const [options, setOptions] = useState([]); const [value, setValue] = useState(props.value); @@ -34,6 +34,10 @@ export const RemoteTableSelect = memo((props:any) => { ); setValue(props.value); setLoading(false); + }) + .catch((error) => { + console.error(error); + setLoading(false); }); } else { setOptions([]); diff --git a/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/UnSupportFields.tsx b/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/UnSupportFields.tsx index e80f848b506..5b121d81a85 100644 --- a/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/UnSupportFields.tsx +++ b/packages/plugins/@nocobase/plugin-collection-fdw/src/client/components/UnSupportFields.tsx @@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next'; import { useRecord } from '@nocobase/client'; import { NAMESPACE } from '../../locale'; -export const UnSupportFields = ({dataSource}) => { +export const UnSupportFields = ({ dataSource }) => { const { t } = useTranslation(); const columns = [ { diff --git a/packages/plugins/@nocobase/plugin-collection-fdw/src/index.ts b/packages/plugins/@nocobase/plugin-collection-fdw/src/index.ts index f53218658fa..50d2ca525cc 100644 --- a/packages/plugins/@nocobase/plugin-collection-fdw/src/index.ts +++ b/packages/plugins/@nocobase/plugin-collection-fdw/src/index.ts @@ -7,4 +7,4 @@ * For more information, see */ -export { default } from "./server"; +export { default } from './server'; diff --git a/packages/plugins/@nocobase/plugin-custom-variables/src/client/AddVariableButton.tsx b/packages/plugins/@nocobase/plugin-custom-variables/src/client/AddVariableButton.tsx index 9e1fe00fd6a..c8d33d99377 100644 --- a/packages/plugins/@nocobase/plugin-custom-variables/src/client/AddVariableButton.tsx +++ b/packages/plugins/@nocobase/plugin-custom-variables/src/client/AddVariableButton.tsx @@ -1,5 +1,5 @@ -import React, { createContext, FC } from "react"; -import { useSchemaInitializerRender, useToken } from "@nocobase/client"; +import React, { createContext, FC } from 'react'; +import { useSchemaInitializerRender, useToken } from '@nocobase/client'; const AddVariableButtonContext = createContext<{ onSuccess: () => void }>({ onSuccess: () => {}, @@ -10,12 +10,12 @@ export const AddVariableButton: FC<{ onSuccess?: () => void }> = (props) => { const { render } = useSchemaInitializerRender('customVariables:addVariable'); return ( - { }) }}> + {}) }}> {render({ style: { borderRadius: token.borderRadius } })} ); -} +}; export const useAddVariableButtonProps = () => { return React.useContext(AddVariableButtonContext); -} +}; diff --git a/packages/plugins/@nocobase/plugin-custom-variables/src/client/EditBadge.tsx b/packages/plugins/@nocobase/plugin-custom-variables/src/client/EditBadge.tsx index ab2354a5699..b0aab4e9950 100644 --- a/packages/plugins/@nocobase/plugin-custom-variables/src/client/EditBadge.tsx +++ b/packages/plugins/@nocobase/plugin-custom-variables/src/client/EditBadge.tsx @@ -14,6 +14,11 @@ import { useTranslation } from 'react-i18next'; import { SchemaSettingsModalItem } from '@nocobase/client'; import { NAMESPACE } from '../locale'; +const BadgeVariableTextArea = (props) => { + const variables = useVariableOptions({} as any); + return ; +}; + const EditBadge: FC = () => { const { t } = useTranslation(NAMESPACE); const currentRoute = useCurrentRoute(); @@ -30,11 +35,15 @@ const EditBadge: FC = () => { 'x-decorator-props': { tooltip: t('You can enter numbers, text, variables, aggregation variables, expressions, etc.'), }, - 'x-component': (props) => { - const variables = useVariableOptions({} as any); - return ; - }, - description: {t('Syntax references: ')}Formula.js, + 'x-component': BadgeVariableTextArea, + description: ( + + {t('Syntax references: ')} + + Formula.js + + + ), }, color: { title: t('Background color'), @@ -94,7 +103,7 @@ const EditBadge: FC = () => { badge: { ...currentRoute.options?.badge, ...badge, - count: (badge.count == null || badge.count === '') ? undefined : badge.count, + count: badge.count == null || badge.count === '' ? undefined : badge.count, }, }, }); diff --git a/packages/plugins/@nocobase/plugin-custom-variables/src/client/index.tsx b/packages/plugins/@nocobase/plugin-custom-variables/src/client/index.tsx index 4a35c9cacaf..695cffc3794 100644 --- a/packages/plugins/@nocobase/plugin-custom-variables/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-custom-variables/src/client/index.tsx @@ -7,7 +7,14 @@ * For more information, see */ -import { isVariable, Plugin, useAPIClient, useApp, useLocalVariablesWithoutCustomVariable, useVariables } from '@nocobase/client'; +import { + isVariable, + Plugin, + useAPIClient, + useApp, + useLocalVariablesWithoutCustomVariable, + useVariables, +} from '@nocobase/client'; import { flatten } from '@nocobase/utils/client'; import React, { useCallback, useEffect, useMemo } from 'react'; import { AddVariableButton } from './AddVariableButton'; @@ -45,7 +52,7 @@ class PluginCustomVariablesClient extends Plugin { const { t } = useTranslation(NAMESPACE); const option = useMemo(() => { return { - label: t("Custom Variables"), + label: t('Custom Variables'), value: '$customVariables', children: [ ...options, @@ -53,7 +60,7 @@ class PluginCustomVariablesClient extends Plugin { label: , disabled: true, value: 'none', - } + }, ], }; }, [options, refresh]); @@ -78,40 +85,45 @@ class PluginCustomVariablesClient extends Plugin { setRefreshId((id) => id + 1); }, []); - const getFilterCtx = useCallback(async (filter) => { - const ctx = {}; - flatten(filter, { - breakOn({ key }) { - return key.startsWith('$') && key !== '$and' && key !== '$or'; - }, - transformValue(value) { - if (!isVariable(value)) { - return value; - } - const result = variables?.parseVariable(value, localVariablesWithoutCustomVariable).then(({ value }) => value); - ctx[value] = result; - return result; - }, - }); + const getFilterCtx = useCallback( + async (filter) => { + const ctx = {}; + flatten(filter, { + breakOn({ key }) { + return key.startsWith('$') && key !== '$and' && key !== '$or'; + }, + transformValue(value) { + if (!isVariable(value)) { + return value; + } + const result = variables + ?.parseVariable(value, localVariablesWithoutCustomVariable) + .then(({ value }) => value); + ctx[value] = result; + return result; + }, + }); - const keys = Object.keys(ctx); - const values = await Promise.all(keys.map((key) => ctx[key])); + const keys = Object.keys(ctx); + const values = await Promise.all(keys.map((key) => ctx[key])); - values.forEach((value, index) => { - ctx[keys[index]] = value; - }); + values.forEach((value, index) => { + ctx[keys[index]] = value; + }); - return ctx; - }, [localVariablesWithoutCustomVariable, variables?.parseVariable]); + return ctx; + }, + [localVariablesWithoutCustomVariable, variables?.parseVariable], + ); // Clean up all registered event listeners useEffect(() => { return () => { // Remove all event listeners when component unmounts - eventNamesRef.current.forEach(eventName => { + eventNamesRef.current.forEach((eventName) => { app.eventBus.removeEventListener(eventName, refresh); }); - } + }; }, []); return useMemo(() => { @@ -133,21 +145,25 @@ class PluginCustomVariablesClient extends Plugin { const eventNames = [ `collection:${variable.options.collection}:create`, `collection:${variable.options.collection}:update`, - `collection:${variable.options.collection}:destroy` + `collection:${variable.options.collection}:destroy`, ]; // Remove previously added event listeners first - eventNamesRef.current.forEach(eventName => { + eventNamesRef.current.forEach((eventName) => { app.eventBus.removeEventListener(eventName, refresh); }); // Add new event listeners - eventNames.forEach(eventName => { + eventNames.forEach((eventName) => { eventNamesRef.current.add(eventName); app.eventBus.addEventListener(eventName, refresh); }); - const { data } = await api.request({ url: `customVariables:parse?name=${name}`, method: 'POST', data: { filterCtx } }); + const { data } = await api.request({ + url: `customVariables:parse?name=${name}`, + method: 'POST', + data: { filterCtx }, + }); return data?.data; }; diff --git a/packages/plugins/@nocobase/plugin-custom-variables/src/client/useCustomVariablesOptions.tsx b/packages/plugins/@nocobase/plugin-custom-variables/src/client/useCustomVariablesOptions.tsx index 51d5327422e..f036e597d10 100644 --- a/packages/plugins/@nocobase/plugin-custom-variables/src/client/useCustomVariablesOptions.tsx +++ b/packages/plugins/@nocobase/plugin-custom-variables/src/client/useCustomVariablesOptions.tsx @@ -1,9 +1,9 @@ -import { useAPIClient, useFlag, useToken, useVariableScopeInfo } from "@nocobase/client"; -import React, { useRef } from "react"; -import { useCallback, useEffect, useState } from "react"; -import { Dropdown } from "antd"; -import { DeleteOutlined, EditOutlined, EllipsisOutlined } from "@ant-design/icons"; -import { VariableEditor } from "./variableInitializer"; +import { useAPIClient, useFlag, useToken, useVariableScopeInfo } from '@nocobase/client'; +import React, { useRef } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { Dropdown } from 'antd'; +import { DeleteOutlined, EditOutlined, EllipsisOutlined } from '@ant-design/icons'; +import { VariableEditor } from './variableInitializer'; import { Modal } from 'antd'; import { useTranslation } from 'react-i18next'; import { NAMESPACE } from '../locale'; @@ -25,7 +25,7 @@ const Edit = (props: EditProps) => { const handleClick = (e) => { setVisible(true); - } + }; useEffect(() => { // refresh the variable list when the editor is closed @@ -40,16 +40,12 @@ const Edit = (props: EditProps) => { <>
- {t("Edit")} + {t('Edit')}
- + ); -} +}; interface MoreActionsProps { variable: { @@ -72,14 +68,14 @@ const MoreActions = (props: MoreActionsProps) => { }, { key: 'delete', - label: t("Delete"), + label: t('Delete'), icon: , onClick: async ({ domEvent }) => { Modal.confirm({ - title: t("Delete Variable"), - content: t("Are you sure you want to delete \"{{label}}\" variable?", { label: props.variable.label }), - okText: t("Yes"), - cancelText: t("No"), + title: t('Delete Variable'), + content: t('Are you sure you want to delete "{{label}}" variable?', { label: props.variable.label }), + okText: t('Yes'), + cancelText: t('No'), onOk: async () => { await api.request({ url: `customVariables:destroy`, @@ -87,18 +83,18 @@ const MoreActions = (props: MoreActionsProps) => { params: { filterByTk: props.variable.name }, }); props.refresh(); - } + }, }); }, }, - ] + ]; return (
{ e.stopPropagation(); @@ -109,7 +105,7 @@ const MoreActions = (props: MoreActionsProps) => {
); -} +}; interface VariableLabelProps { value: string; @@ -139,7 +135,7 @@ const VariableLabel = (props: VariableLabelProps) => {