nocobase/packages/core/client/src/acl/ACLProvider.tsx
Zeke Zhang 97333d0c06
refactor(Menu): optimize menu interface (#5955)
* feat: define desktopRoutes collection

* feat: convert routes to schema

* feat: support to add new route

* feat: support to delete routes

* feat: adaptor Hidden option

* feat: adaptor Edit option

* fix: fix incomplete menu display issue

* feat: support to insert

* feat: adjust permission configuration page interface

* feat: add listAccessible action

* feat: routes table

* feat: edit button

* style: optimize style

* chore: add confirm text

* fix: delete corresponding schema when removing routes to ensure data consistency

* chore: add field type

* feat: create a tab when creating a page

* fix: fix tabs issue

* fix: avoid undefined error

* fix: should hide menu when it's hideInMenu is true

* fix: should refresh menu when updating it

* chore: optimize params

* fix: fix tab when adding child

* chore: should display empty

* fix: fix subapp path

* fix: fix activeKey of tab

* chore: add translation

* chore: prevent menu collapse after adding new menu item

* refactor: rename useDesktopRoutes to useNocoBaseRoutes

* fix: fix tab path

* fix: fix draging issue

* feat: support to set Hidden for Tab

* feat: implement move

* fix: draging

* chore: add migration

* fix: fix migration

* chore: fix build

* chore: fix e2e test

* fix: fix menu creation position issue

* fix: migration

* chore: add translation

* fix: fix some bugs

* fix: fix 'Move to'

* fix: compile Route name in permission management page

* fix: fix table selection issue

* chore: add comment

* fix: hidden

* fix: fix mobile route path

* fix: do not select parent node when selecting child nodes

* fix(mobile): hide menu

* fix(mobile): path

* fix(mobile): fix schema

* fix(mobile): compile tab title

* fix: fix permission configuration page selection error

* fix: fix selection issues

* fix(migration): transform old permission configuration to new permission configuration

* chore: translate fields title

* feat: support localization

* fix: fix pagination

* chore: fix build

* fix: change aclSnippet

* chore: fix unit tests

* fix: fix error

* chore: fix unit tests of server

* chore(migration): update version of migration

* chore: fix e2e tests

* chore: fix build error

* chore: make e2e tests pass

* chore: fix migration error

* fix: show ellipsis when text overflows

* fix: show ellipsis when text overflows

* chore: change 'Access' to 'View'

* fix: should use sort field to sort

* fix: fix tab drag and drop issue

* refactor: rename unnamed tab label to 'Unnamed'

* fix: fix draging issue

* refactor: add 'enableTabs' field

* refactor: optimize route fields

* refactor: optimize migration

* fix: set enableTabs to false when creating page

* refactor: change empty tab name to 'Unnamed'

* fix: fix tab path

* fix: avoid undefined error

* chore(migration): update appVersion

* fix(migration): fix page issue

* chore: fix unit test

* fix(mobile): fix incorrect path

* fix(mobile): fix enableTabs issue

* fix: disable Add child route button when enableTabs is false

* fix: fix embed issue

* fix(migration): add migration for mobile

* chore: update migration

* fix: fix tab title not updating issue

* fix: fix untranslated text issue

* fix: fix routes table style

* fix: fix group issue

* fix(mobile): fix 404 issue

* fix: should hide tabs when creating page

* fix: should translate tabs title

* fix: fix translation issue

* fix(migration): fix appVersion

* fix: fix ACL

* fix: should set paginate to true and filter out hidden items

* fix(migration): fix bug

* refactor(desktopRoutes): add enableHeader and displayTitle

* fix: fix permission issue

* fix(mobile): fix error

* fix(mobile): fix migration error

* fix(migration): compatible with older versions

* fix: make unit tests pass

* chore: ignore some failing test cases

* chore: test

* fix: test
2025-01-24 13:02:38 +08:00

414 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// 注意: 这行必须放到顶部,否则会导致 Data sources 页面报错,原因未知
import { useBlockRequestContext } from '../block-provider/BlockProvider';
import { Field } from '@formily/core';
import { Schema, useField, useFieldSchema } from '@formily/react';
import { omit } from 'lodash';
import React, { createContext, useCallback, useContext, useEffect, useMemo } from 'react';
import { useAPIClient, useRequest } from '../api-client';
import { useAppSpin } from '../application/hooks/useAppSpin';
import { useResourceActionContext } from '../collection-manager/ResourceActionProvider';
import {
CollectionNotAllowViewPlaceholder,
useCollection,
useCollectionManager,
useCollectionRecordData,
useDataBlockProps,
} from '../data-source';
import { useDataSourceKey } from '../data-source/data-source/DataSourceProvider';
import { SchemaComponentOptions, useDesignable } from '../schema-component';
import { useApp } from '../application';
import { NavigateToSigninWithRedirect } from '../user/CurrentUserProvider';
// 注意: 必须要对 useBlockRequestContext 进行引用,否则会导致 Data sources 页面报错,原因未知
useBlockRequestContext;
export const ACLContext = createContext<any>({});
ACLContext.displayName = 'ACLContext';
// TODO: delete thisreplace by `ACLPlugin`
export const ACLProvider = (props) => {
return (
<SchemaComponentOptions
components={{ ACLCollectionFieldProvider, ACLActionProvider, ACLMenuItemProvider, ACLCollectionProvider }}
>
{props.children}
</SchemaComponentOptions>
);
};
const getRouteUrl = (props) => {
if (props?.match) {
return props.match;
}
return props && getRouteUrl(props?.children?.props);
};
export const ACLRolesCheckProvider = (props) => {
const { setDesignable } = useDesignable();
const { render } = useAppSpin();
const api = useAPIClient();
const app = useApp();
const result = useRequest<{
data: {
snippets: string[];
role: string;
resources: string[];
actions: any;
actionAlias: any;
strategy: any;
allowAll: boolean;
};
}>(
{
url: 'roles:check',
},
{
onSuccess(data) {
if (!data?.data?.snippets.includes('ui.*')) {
setDesignable(false);
}
if (data?.data?.role !== api.auth.role) {
api.auth.setRole(data?.data?.role);
}
app.pluginSettingsManager.setAclSnippets(data?.data?.snippets || []);
},
},
);
if (result.loading) {
return render();
}
if (result.error) {
return <NavigateToSigninWithRedirect />;
}
return <ACLContext.Provider value={result}>{props.children}</ACLContext.Provider>;
};
export const useRoleRecheck = () => {
const ctx = useContext(ACLContext);
const { allowAll } = useACLRoleContext();
return () => {
if (allowAll) {
return;
}
ctx.refresh();
};
};
export const useACLContext = () => {
return useContext(ACLContext);
};
export const ACLActionParamsContext = createContext<any>({});
ACLActionParamsContext.displayName = 'ACLActionParamsContext';
export const ACLCustomContext = createContext<any>({});
ACLCustomContext.displayName = 'ACLCustomContext';
const useACLCustomContext = () => {
return useContext(ACLCustomContext);
};
export const useACLRolesCheck = () => {
const ctx = useContext(ACLContext);
const dataSourceName = useDataSourceKey();
const { dataSources: dataSourcesAcl } = ctx?.data?.meta || {};
const data = { ...ctx?.data?.data, ...omit(dataSourcesAcl?.[dataSourceName], 'snippets') };
const getActionAlias = useCallback(
(actionPath: string) => {
const actionName = actionPath.split(':').pop();
return data?.actionAlias?.[actionName] || actionName;
},
[data?.actionAlias],
);
return {
data,
getActionAlias,
inResources: useCallback(
(resourceName: string) => {
return data?.resources?.includes?.(resourceName);
},
[data?.resources],
),
getResourceActionParams: useCallback(
(actionPath: string) => {
const [resourceName] = actionPath.split(':');
const actionAlias = getActionAlias(actionPath);
return data?.actions?.[`${resourceName}:${actionAlias}`] || data?.actions?.[actionPath];
},
[data?.actions, getActionAlias],
),
getStrategyActionParams: useCallback(
(actionPath: string) => {
const actionAlias = getActionAlias(actionPath);
const strategyAction = data?.strategy?.actions?.find((action) => {
const [value] = action.split(':');
return value === actionAlias;
});
return strategyAction ? {} : null;
},
[data?.strategy?.actions, getActionAlias],
),
};
};
const getIgnoreScope = (options: any = {}) => {
const { schema, recordPkValue } = options;
let ignoreScope = false;
if (options.ignoreScope) {
ignoreScope = true;
}
if (schema?.['x-acl-ignore-scope']) {
ignoreScope = true;
}
if (schema?.['x-acl-action-props']?.['skipScopeCheck']) {
ignoreScope = true;
}
if (!recordPkValue) {
ignoreScope = true;
}
return ignoreScope;
};
const useAllowedActions = () => {
const service = useResourceActionContext();
return service?.data?.meta?.allowedActions;
};
const useResourceName = () => {
const service = useResourceActionContext();
const dataBlockProps = useDataBlockProps();
return (
dataBlockProps?.resource ||
dataBlockProps?.association ||
dataBlockProps?.collection ||
service?.defaultRequest?.resource
);
};
export function useACLRoleContext() {
const { data, getActionAlias, inResources, getResourceActionParams, getStrategyActionParams } = useACLRolesCheck();
const allowedActions = useAllowedActions();
const cm = useCollectionManager();
const verifyScope = useCallback(
(actionName: string, recordPkValue: any) => {
const actionAlias = getActionAlias(actionName);
if (!Array.isArray(allowedActions?.[actionAlias])) {
return null;
}
return allowedActions[actionAlias].includes(recordPkValue);
},
[allowedActions, getActionAlias],
);
return {
...data,
parseAction: useCallback(
(actionPath: string, options: any = {}) => {
const [resourceName, actionName] = actionPath?.split(':') || [];
const targetResource = resourceName?.includes('.') && cm.getCollectionField(resourceName)?.target;
if (!getIgnoreScope(options)) {
const r = verifyScope(actionName, options.recordPkValue);
if (r !== null) {
return r ? {} : null;
}
}
if (data?.allowAll) {
return {};
}
if (inResources(targetResource)) {
return getResourceActionParams(`${targetResource}:${actionName}`);
}
if (inResources(resourceName)) {
return getResourceActionParams(actionPath);
}
return getStrategyActionParams(actionPath);
},
[cm, data?.allowAll, getResourceActionParams, getStrategyActionParams, inResources, verifyScope],
),
};
}
/**
* Used to get whether the current user has permission to configure UI
* @returns {allowConfigUI: boolean}
*/
export function useUIConfigurationPermissions(): { allowConfigUI: boolean } {
const { allowAll, snippets } = useACLRoleContext();
return {
allowConfigUI: allowAll || snippets.includes('ui.*'),
};
}
export const ACLCollectionProvider = (props) => {
const { allowAll, parseAction } = useACLRoleContext();
const { allowAll: customAllowAll } = useACLCustomContext();
const app = useApp();
const schema = useFieldSchema();
let actionPath = schema?.['x-acl-action'] || props.actionPath;
const resoureName = schema?.['x-decorator-props']?.['association'] || schema?.['x-decorator-props']?.['collection'];
// 兼容 undefined 的情况
if (actionPath === 'undefined:list' && resoureName && resoureName !== 'undefined') {
actionPath = `${resoureName}:list`;
}
const params = useMemo(() => {
if (!actionPath) {
return null;
}
return parseAction(actionPath, { schema });
}, [parseAction, actionPath, schema]);
if (allowAll || app.disableAcl || customAllowAll) {
return props.children;
}
if (!actionPath) {
return <ACLActionParamsContext.Provider value={{}}>{props.children}</ACLActionParamsContext.Provider>;
}
if (!params) {
return <CollectionNotAllowViewPlaceholder />;
}
const [_, actionName] = actionPath.split(':');
params.actionName = actionName;
return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
};
export const useACLActionParamsContext = () => {
return useContext(ACLActionParamsContext);
};
export const useRecordPkValue = () => {
const collection = useCollection();
const recordData = useCollectionRecordData();
if (!collection) {
return;
}
const primaryKey = collection.getPrimaryKey();
return recordData?.[primaryKey];
};
export const ACLActionProvider = (props) => {
const collection = useCollection();
const recordPkValue = useRecordPkValue();
const resource = useResourceName();
const { parseAction } = useACLRoleContext();
const schema = useFieldSchema();
let actionPath = schema['x-acl-action'];
const editablePath = ['create', 'update', 'destroy', 'importXlsx'];
if (!actionPath && resource && schema['x-action']) {
actionPath = `${resource}:${schema['x-action']}`;
}
if (!actionPath?.includes(':')) {
actionPath = `${resource}:${actionPath}`;
}
const params = useMemo(
() => parseAction(actionPath, { schema, recordPkValue }),
[parseAction, actionPath, schema, recordPkValue],
);
if (!actionPath) {
return <>{props.children}</>;
}
if (!resource) {
return <>{props.children}</>;
}
if (!params) {
return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
}
//视图表无编辑权限时不显示
if (editablePath.includes(actionPath) || editablePath.includes(actionPath?.split(':')[1])) {
if ((collection && collection.template !== 'view') || collection?.writableView) {
return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
}
return null;
}
return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
};
export const useACLFieldWhitelist = () => {
const params = useContext(ACLActionParamsContext);
const whitelist = useMemo(() => {
return []
.concat(params?.whitelist || [])
.concat(params?.fields || [])
.concat(params?.appends || []);
}, [params?.whitelist, params?.fields, params?.appends]);
return {
whitelist,
schemaInWhitelist: useCallback(
(fieldSchema: Schema | any, isSkip?) => {
if (isSkip) {
return true;
}
if (whitelist.length === 0) {
return true;
}
if (!fieldSchema) {
return true;
}
if (!fieldSchema['x-collection-field']) {
return true;
}
const [key1, key2] = fieldSchema['x-collection-field'].split('.');
const [associationField] = fieldSchema['name'].split('.');
return whitelist?.includes(associationField || key2 || key1);
},
[whitelist],
),
};
};
export const ACLCollectionFieldProvider = (props) => {
const fieldSchema = useFieldSchema();
const field = useField<Field>();
const { allowAll } = useACLRoleContext();
const { whitelist } = useACLFieldWhitelist();
const [name] = (fieldSchema.name as string).split('.');
const allowed =
!fieldSchema['x-acl-ignore'] && whitelist.length > 0 && fieldSchema?.['x-collection-field']
? whitelist.includes(name)
: true;
useEffect(() => {
if (!allowed) {
field.required = false;
field.display = 'hidden';
}
}, [allowed, field]);
if (allowAll) {
return <>{props.children}</>;
}
if (!fieldSchema['x-collection-field']) {
return <>{props.children}</>;
}
if (!allowed) {
return null;
}
return <>{props.children}</>;
};
export const ACLMenuItemProvider = (props) => {
// 这里的权限控制已经在后端处理了
return <>{props.children}</>;
};