feat(acl): add support for configuring mobile menu permissions (#5174)

* refactor(plugin-acl): extensible support for role permissions configuration UI

* feat: complete the configuration UI

* feat: complete the backend section

* chore: update unit tests

* chore: add translation

* chore: change 'Menu' to 'Desktop menu'

* refactor: use 'extendCollection' instead of 'this.db.extendCollection'

* chore: update acl e2e test

* test: add e2e tests

* fix: should refresh data when changing tab

* fix(menu): should hide children when children only have one

* feat: show tip when no pages find

* feat(tabBar): supports left and right swiping

* refactor: improve code

* chore: make e2e test pass

* chore: add migration

* fix: should use tk instead of values

* chore: nothing

* fix: improve

* refactor: rename mobileMenuUiSchemas to mobileRoutes

* refactor: add onDelete

* fix: change snippet to 'pm.mobile' from 'pm.mobile.roles'

* refactor: extract nested loop to outside

* refactor: use db.on('mobileRoutes:afterCreate')

* refactor: simplify code logic

* chore: fix build

* fix: improve code

* chore: fix build

* feat: hide menu configuration UI when no permission

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Zeke Zhang 2024-09-21 23:04:26 +08:00 committed by GitHub
parent c83b578905
commit ffc2982380
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 753 additions and 94 deletions

View File

@ -115,7 +115,7 @@ export abstract class RelationRepository {
convertTk(options: any) { convertTk(options: any) {
let tk = options; let tk = options;
if (typeof options === 'object' && options['tk']) { if (typeof options === 'object' && 'tk' in options) {
tk = options['tk']; tk = options['tk'];
} }
return tk; return tk;
@ -126,7 +126,12 @@ export abstract class RelationRepository {
if (typeof tk === 'string') { if (typeof tk === 'string') {
tk = tk.split(','); tk = tk.split(',');
} }
return lodash.castArray(tk);
if (tk) {
return lodash.castArray(tk);
}
return [];
} }
targetKey() { targetKey() {

View File

@ -9,6 +9,7 @@
import { TabsProps } from 'antd/es/tabs/index'; import { TabsProps } from 'antd/es/tabs/index';
import React from 'react'; import React from 'react';
import { TFunction } from 'react-i18next';
import { GeneralPermissions } from './permissions/GeneralPermissions'; import { GeneralPermissions } from './permissions/GeneralPermissions';
import { MenuItemsProvider } from './permissions/MenuItemsProvider'; import { MenuItemsProvider } from './permissions/MenuItemsProvider';
import { MenuPermissions } from './permissions/MenuPermissions'; import { MenuPermissions } from './permissions/MenuPermissions';
@ -22,11 +23,15 @@ interface PermissionsTabsProps {
/** /**
* the currently selected role * the currently selected role
*/ */
role: Role; activeRole: null | Role;
/**
* the current user's role
*/
currentUserRole: null | Role;
/** /**
* translation function * translation function
*/ */
t: (key: string) => string; t: TFunction;
/** /**
* used to constrain the size of the container in the Tab * used to constrain the size of the container in the Tab
*/ */
@ -53,7 +58,7 @@ export class ACLSettingsUI {
}), }),
({ activeKey, t, TabLayout }) => ({ ({ activeKey, t, TabLayout }) => ({
key: 'menu', key: 'menu',
label: t('Menu'), label: t('Desktop menu'),
children: ( children: (
<TabLayout> <TabLayout>
<MenuItemsProvider> <MenuItemsProvider>

View File

@ -37,7 +37,7 @@ test('menu permission ', async ({ page, mockPage, mockRole, updateRole }) => {
.locator('span') .locator('span')
.nth(1) .nth(1)
.click(); .click();
await page.getByRole('tab').getByText('Menu').click(); await page.getByRole('tab').getByText('Desktop menu').click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({ await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({
checked: true, checked: true,
@ -57,7 +57,7 @@ test('menu permission ', async ({ page, mockPage, mockRole, updateRole }) => {
.locator('span') .locator('span')
.nth(1) .nth(1)
.click(); .click();
await page.getByRole('tab').getByText('Menu').click(); await page.getByRole('tab').getByText('Desktop menu').click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({ await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({
checked: false, checked: false,

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { useAPIClient, usePlugin, useRequest } from '@nocobase/client'; import { useACLRoleContext, useAPIClient, usePlugin, useRequest } from '@nocobase/client';
import { Tabs } from 'antd'; import { Tabs } from 'antd';
import React, { useContext, useEffect, useMemo } from 'react'; import React, { useContext, useEffect, useMemo } from 'react';
import PluginACLClient from '..'; import PluginACLClient from '..';
@ -24,10 +24,13 @@ export const Permissions: React.FC<{ active: boolean }> = ({ active }) => {
const [activeKey, setActiveKey] = React.useState('general'); const [activeKey, setActiveKey] = React.useState('general');
const { role, setRole } = useContext(RolesManagerContext); const { role, setRole } = useContext(RolesManagerContext);
const pluginACLClient = usePlugin(PluginACLClient); const pluginACLClient = usePlugin(PluginACLClient);
const currentUserRole = useACLRoleContext();
const items = useMemo( const items = useMemo(
() => pluginACLClient.settingsUI.getPermissionsTabs({ t, activeKey, TabLayout, role }), () =>
[activeKey, pluginACLClient.settingsUI, role, t], pluginACLClient.settingsUI
.getPermissionsTabs({ t, activeKey, TabLayout, activeRole: role, currentUserRole })
.filter(Boolean),
[activeKey, pluginACLClient.settingsUI, role, t, currentUserRole],
); );
const api = useAPIClient(); const api = useAPIClient();

View File

@ -2,5 +2,6 @@
"The current user has no roles. Please try another account.": "The current user has no roles. Please try another account.", "The current user has no roles. Please try another account.": "The current user has no roles. Please try another account.",
"The user role does not exist. Please try signing in again": "The user role does not exist. Please try signing in again", "The user role does not exist. Please try signing in again": "The user role does not exist. Please try signing in again",
"New role": "New role", "New role": "New role",
"Permissions": "Permissions" "Permissions": "Permissions",
"Desktop menu": "Desktop menu"
} }

View File

@ -1,4 +1,5 @@
{ {
"The current user has no roles. Please try another account.": "현재 사용자에게 역할이 없습니다. 다른 계정을 시도해주세요.", "The current user has no roles. Please try another account.": "현재 사용자에게 역할이 없습니다. 다른 계정을 시도해주세요.",
"The user role does not exist. Please try signing in again": "사용자 역할이 존재하지 않습니다. 다시 로그인을 시도해주세요." "The user role does not exist. Please try signing in again": "사용자 역할이 존재하지 않습니다. 다시 로그인을 시도해주세요.",
"Desktop menu": "데스크톱 메뉴"
} }

View File

@ -5,7 +5,7 @@
"Permissions": "权限", "Permissions": "权限",
"Roles & Permissions": "角色和权限", "Roles & Permissions": "角色和权限",
"General": "通用", "General": "通用",
"Menu": "菜单", "Desktop menu": "桌面端菜单",
"Plugin settings": "插件设置", "Plugin settings": "插件设置",
"Data sources": "数据源" "Data sources": "数据源"
} }

View File

@ -12,7 +12,8 @@
"@nocobase/client": "1.x", "@nocobase/client": "1.x",
"@nocobase/plugin-acl": "1.x", "@nocobase/plugin-acl": "1.x",
"@nocobase/server": "1.x", "@nocobase/server": "1.x",
"@nocobase/test": "1.x" "@nocobase/test": "1.x",
"@nocobase/plugin-acl": "1.x"
}, },
"keywords": [ "keywords": [
"Data model tools" "Data model tools"

View File

@ -35,12 +35,12 @@ export class PluginDataSourceManagerClient extends Plugin {
async load() { async load() {
// register a configuration item in the Users & Permissions management page // register a configuration item in the Users & Permissions management page
this.app.pm.get(PluginACLClient).settingsUI.addPermissionsTab(({ t, TabLayout, role }) => ({ this.app.pm.get(PluginACLClient).settingsUI.addPermissionsTab(({ t, TabLayout, activeRole }) => ({
key: 'dataSource', key: 'dataSource',
label: t('Data sources'), label: t('Data sources'),
children: ( children: (
<TabLayout> <TabLayout>
<DataSourcePermissionManager role={role} /> <DataSourcePermissionManager role={activeRole} />
</TabLayout> </TabLayout>
), ),
})); }));

View File

@ -12,7 +12,8 @@
"peerDependencies": { "peerDependencies": {
"@nocobase/client": "1.x", "@nocobase/client": "1.x",
"@nocobase/server": "1.x", "@nocobase/server": "1.x",
"@nocobase/test": "1.x" "@nocobase/test": "1.x",
"@nocobase/plugin-acl": "1.x"
}, },
"devDependencies": { "devDependencies": {
"@ant-design/icons": "5.x", "@ant-design/icons": "5.x",
@ -23,6 +24,12 @@
"@types/react-dom": "17.x", "@types/react-dom": "17.x",
"antd-mobile": "^5.36.1", "antd-mobile": "^5.36.1",
"re-resizable": "6.6.0", "re-resizable": "6.6.0",
"react-device-detect": "2.2.3" "react-device-detect": "2.2.3",
"@emotion/css": "11.x",
"ahooks": "3.x",
"lodash": "4.x",
"@formily/core": "2.x",
"antd": "5.x",
"react-i18next": "11.x"
} }
} }

View File

@ -0,0 +1,278 @@
/**
* 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.
*/
import { css } from '@emotion/css';
import { createForm, Form, onFormValuesChange } from '@formily/core';
import { uid } from '@formily/shared';
import { SchemaComponent, useAPIClient, useRequest } from '@nocobase/client';
import { RolesManagerContext } from '@nocobase/plugin-acl/client';
import { useMemoizedFn } from 'ahooks';
import { Checkbox, message, Table } from 'antd';
import _, { uniq } from 'lodash';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MobileRoutesProvider, useMobileRoutes } from './mobile-providers';
interface MenuItem {
title: string;
id: number;
children?: MenuItem[];
parent?: MenuItem;
}
const style = css`
.ant-table-cell {
> .ant-space-horizontal {
.ant-space-item-split:has(+ .ant-space-item:empty) {
display: none;
}
}
}
`;
const translateTitle = (menus: any[], t) => {
return menus.map((menu) => {
const title = t(menu.title);
if (menu.children) {
return {
...menu,
title,
children: translateTitle(menu.children, t),
};
}
return {
...menu,
title,
};
});
};
const findIDList = (items) => {
if (!Array.isArray(items)) {
return [];
}
const IDList = [];
for (const item of items) {
IDList.push(item.id);
if (item.hideChildren && !_.isNil(item.firstTabId)) {
IDList.push(item.firstTabId);
}
IDList.push(...findIDList(item.children));
}
return IDList;
};
const toItems = (items, parent?: MenuItem): MenuItem[] => {
if (!Array.isArray(items)) {
return [];
}
return items.map((item) => {
const children = toItems(item.children, item);
const hideChildren = children.length <= 1;
return {
title: item.title,
id: item.id,
children: hideChildren ? null : children,
hideChildren,
firstTabId: children[0]?.id,
parent,
};
});
};
export const MenuPermissions: React.FC<{
active: boolean;
}> = ({ active }) => {
const { routeList } = useMobileRoutes();
const items = toItems(routeList);
const { role, setRole } = useContext(RolesManagerContext);
const api = useAPIClient();
const { t } = useTranslation();
const allIDList = findIDList(items);
const [IDList, setIDList] = useState([]);
const { loading, refresh } = useRequest(
{
resource: 'roles.mobileRoutes',
resourceOf: role.name,
action: 'list',
params: {
paginate: false,
},
},
{
ready: !!role && active,
refreshDeps: [role?.name],
onSuccess(data) {
setIDList(data?.data?.map((item) => item['id']) || []);
},
},
);
const resource = api.resource('roles.mobileRoutes', role.name);
const allChecked = allIDList.length === IDList.length;
const handleChange = async (checked, menuItem) => {
if (checked) {
let newIDList = IDList.filter((id) => id !== menuItem.id);
const shouldRemove = [menuItem.id];
if (menuItem.parent) {
const selectedChildren = menuItem.parent.children.filter((item) => newIDList.includes(item.id));
if (selectedChildren.length === 0) {
newIDList = newIDList.filter((id) => id !== menuItem.parent.id);
shouldRemove.push(menuItem.parent.id);
}
}
if (menuItem.children) {
newIDList = newIDList.filter((id) => !menuItem.children.map((item) => item.id).includes(id));
shouldRemove.push(...menuItem.children.map((item) => item.id));
}
if (menuItem.hideChildren && !_.isNil(menuItem.firstTabId)) {
shouldRemove.push(menuItem.firstTabId);
newIDList = newIDList.filter((id) => id !== menuItem.firstTabId);
}
setIDList(newIDList);
await resource.remove({
values: shouldRemove,
});
} else {
const newIDList = [...IDList, menuItem.id];
const shouldAdd = [menuItem.id];
if (menuItem.parent) {
if (!newIDList.includes(menuItem.parent.id)) {
newIDList.push(menuItem.parent.id);
shouldAdd.push(menuItem.parent.id);
}
}
if (menuItem.children) {
const childrenIDList = menuItem.children.map((item) => item.id);
newIDList.push(...childrenIDList);
shouldAdd.push(...childrenIDList);
}
if (menuItem.hideChildren && !_.isNil(menuItem.firstTabId)) {
shouldAdd.push(menuItem.firstTabId);
newIDList.push(menuItem.firstTabId);
}
setIDList(uniq(newIDList));
await resource.add({
values: shouldAdd,
});
}
message.success(t('Saved successfully'));
};
const update = useMemoizedFn(async (form: Form) => {
await api.resource('roles').update({
filterByTk: role.name,
values: form.values,
});
setRole({ ...role, ...form.values });
message.success(t('Saved successfully'));
});
const form = useMemo(() => {
return createForm({
values: role,
effects() {
onFormValuesChange(async (form) => {
await update(form);
});
},
});
}, [role, update]);
return (
<>
<SchemaComponent
schema={{
type: 'void',
name: uid(),
'x-component': 'FormV2',
'x-component-props': {
form,
},
properties: {
allowNewMobileMenu: {
title: t('Menu permissions'),
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': t('New menu items are allowed to be accessed by default.'),
},
},
}}
/>
<Table
className={style}
loading={loading}
rowKey={'id'}
pagination={false}
expandable={{
defaultExpandAllRows: true,
}}
columns={[
{
dataIndex: 'title',
title: t('Menu item title'),
},
{
dataIndex: 'accessible',
title: (
<>
<Checkbox
checked={allChecked}
onChange={async (value) => {
if (allChecked) {
await resource.set({
values: [],
});
} else {
await resource.set({
values: allIDList,
});
}
refresh();
message.success(t('Saved successfully'));
}}
/>{' '}
{t('Accessible')}
</>
),
render: (_, schema) => {
const checked = IDList.includes(schema.id);
return <Checkbox checked={checked} onChange={() => handleChange(checked, schema)} />;
},
},
]}
dataSource={translateTitle(items, t)}
/>
</>
);
};
export const MobileAllRoutesProvider: React.FC<{ active: boolean }> = ({ children, active }) => {
const refreshRef = React.useRef(() => {});
useEffect(() => {
if (active) {
refreshRef.current?.();
}
}, [active]);
return (
<MobileRoutesProvider action="list" refreshRef={refreshRef} manual>
{children}
</MobileRoutesProvider>
);
};

View File

@ -0,0 +1,35 @@
/**
* 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.
*/
import { useDesignable } from '@nocobase/client';
import { ErrorBlock } from 'antd-mobile';
import _ from 'lodash';
import React, { FC } from 'react';
import { isMobile } from 'react-device-detect';
import { usePluginTranslation } from './locale';
import { useMobileRoutes } from './mobile-providers/context';
export const ShowTipWhenNoPages: FC = ({ children }) => {
const { designable } = useDesignable();
const { routeList } = useMobileRoutes();
const { t } = usePluginTranslation();
if ((!designable || isMobile) && _.isEmpty(routeList)) {
return (
<ErrorBlock
status="empty"
fullPage
title={t('No accessible pages found')}
description={t('This might be due to permission configuration issues')}
/>
);
}
return <>{children}</>;
};

View File

@ -0,0 +1,118 @@
/**
* 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.
*/
import { expect, Page, test } from '@nocobase/test/e2e';
const createNewPage = async (title: string, page: Page) => {
await page.getByTestId('schema-initializer-MobileTabBar').hover();
await page.getByRole('menuitem', { name: 'Page' }).click();
await page.getByLabel('block-item-Input-Title').getByRole('textbox').fill(title);
await page.getByRole('button', { name: 'Select icon' }).click();
await page.getByRole('tooltip').getByLabel('account-book').locator('svg').click();
await page.getByLabel('action-Action-Submit').click();
};
const createNewTab = async (title: string, page: Page) => {
await page.getByLabel('action-Action-undefined').click();
await page.getByLabel('block-item-Input-Title').getByRole('textbox').fill(title);
await page.getByLabel('action-Action-Submit').click();
};
const deletePage = async (title: string, page: Page) => {
await page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: title }).hover();
await page.getByTestId('mobile-tab-bar').getByRole('button', { name: 'designer-schema-settings-' }).hover();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
};
test.describe('mobile permissions', () => {
test('menu permission ', async ({ page }) => {
await page.goto('/m');
// by default, the user's role is root, which has all permissions
// create a new page
await createNewPage('root', page);
// expect: the new page should be visible
await expect(page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'root' })).toBeVisible();
// change the user's role to admin
await page.evaluate(() => {
window.localStorage.setItem('NOCOBASE_ROLE', 'admin');
});
await page.reload();
await createNewPage('admin', page);
await page.getByLabel('block-item-MobilePageProvider').hover();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').hover();
await page.getByRole('menuitem', { name: 'Display tabs' }).getByRole('switch').check();
// create a new tab
await createNewTab('tab456', page);
await expect(page.getByRole('tab', { name: 'tab456', exact: true })).toBeVisible();
// change tab title 'Unnamed' to 'tab123'
await page.getByRole('tab', { name: 'Unnamed', exact: true }).hover();
await page.getByTestId('mobile-page-tabs-Unnamed').getByLabel('designer-schema-settings-').hover();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('block-item-Input-Title').getByRole('textbox').fill('tab123');
await page.getByRole('button', { name: 'Submit' }).click();
// expect: the new page should be visible
await expect(page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'admin' })).toBeVisible();
// change the user's role to admin, and then change the menu permission
await page.evaluate(() => {
window.localStorage.setItem('NOCOBASE_ROLE', 'admin');
});
await page.reload();
await page.goto('/admin/settings/users-permissions/roles');
await page.getByRole('tab', { name: 'Mobile menu' }).click();
await page.getByRole('row', { name: 'Collapse row admin' }).getByLabel('', { exact: true }).uncheck();
// the children of the admin tabs should be unchecked
await expect(page.getByRole('row', { name: 'tab123' }).getByLabel('', { exact: true })).toBeChecked({
checked: false,
});
await expect(page.getByRole('row', { name: 'tab456' }).getByLabel('', { exact: true })).toBeChecked({
checked: false,
});
// the admin tab should be checked when the child is checked
await page.getByRole('row', { name: 'tab123' }).getByLabel('', { exact: true }).check();
await expect(page.getByRole('row', { name: 'Collapse row admin' }).getByLabel('', { exact: true })).toBeChecked({
checked: true,
});
// the admin tab should be unchecked when the children is all unchecked
await page.getByRole('row', { name: 'tab123' }).getByLabel('', { exact: true }).uncheck();
await expect(page.getByRole('row', { name: 'Collapse row admin' }).getByLabel('', { exact: true })).toBeChecked({
checked: false,
});
// to the mobile, the admin page should be hidden
await page.goto('/m');
await expect(page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'admin' })).not.toBeVisible();
// go back to the configuration page, and check one child of the admin
await page.goto('/admin/settings/users-permissions/roles');
await page.getByRole('tab', { name: 'Mobile menu' }).click();
await page.getByRole('row', { name: 'tab123' }).getByLabel('', { exact: true }).check();
// to the mobile, the admin page should be visible, and the tab123 should be visible, and the tab456 should be hidden
await page.goto('/m');
await expect(page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'admin' })).toBeVisible();
await page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'admin' }).click();
await expect(page.getByRole('tab', { name: 'tab123', exact: true })).toBeVisible();
await expect(page.getByRole('tab', { name: 'tab456', exact: true })).not.toBeVisible();
// delete the pages
await deletePage('root', page);
await deletePage('admin', page);
});
});

View File

@ -28,7 +28,7 @@ const app = mockApp({
}, },
plugins: [DemoPlugin], plugins: [DemoPlugin],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: 10, id: 10,

View File

@ -1,12 +1,12 @@
import React from 'react';
import { Plugin } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import { mockApp } from '@nocobase/client/demo-utils'; import { mockApp } from '@nocobase/client/demo-utils';
import { import {
MobileTitleProvider,
useMobileTitle,
MobileRoutesProvider, MobileRoutesProvider,
MobileTitleProvider,
useMobileRoutes, useMobileRoutes,
useMobileTitle,
} from '@nocobase/plugin-mobile/client'; } from '@nocobase/plugin-mobile/client';
import React from 'react';
const InnerPage = () => { const InnerPage = () => {
const { routeList } = useMobileRoutes(); const { routeList } = useMobileRoutes();
@ -43,39 +43,37 @@ const app = mockApp({
}, },
plugins: [DemoPlugin], plugins: [DemoPlugin],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
"id": 10, id: 10,
"createdAt": "2024-07-08T13:22:33.763Z", createdAt: '2024-07-08T13:22:33.763Z',
"updatedAt": "2024-07-08T13:22:33.763Z", updatedAt: '2024-07-08T13:22:33.763Z',
"parentId": null, parentId: null,
"title": "Test1", title: 'Test1',
"icon": "AppstoreOutlined", icon: 'AppstoreOutlined',
"schemaUid": "test", schemaUid: 'test',
"type": "page", type: 'page',
"options": null, options: null,
"sort": 1, sort: 1,
"createdById": 1, createdById: 1,
"updatedById": 1, updatedById: 1,
}, },
{ {
"id": 13, id: 13,
"createdAt": "2024-07-08T13:23:01.929Z", createdAt: '2024-07-08T13:23:01.929Z',
"updatedAt": "2024-07-08T13:23:12.433Z", updatedAt: '2024-07-08T13:23:12.433Z',
"parentId": null, parentId: null,
"title": "Test2", title: 'Test2',
"icon": "aliwangwangoutlined", icon: 'aliwangwangoutlined',
"schemaUid": null, schemaUid: null,
"type": "link", type: 'link',
"options": { options: {
"schemaUid": null, schemaUid: null,
"url": "https://github.com", url: 'https://github.com',
"params": [ params: [{}],
{} },
] },
}
}
], ],
}, },
}, },

View File

@ -38,7 +38,7 @@ const app = mockApp({
'applicationPlugins:update': { 'applicationPlugins:update': {
data: {}, data: {},
}, },
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: 10, id: 10,

View File

@ -38,7 +38,7 @@ const app = mockApp({
'applicationPlugins:update': { 'applicationPlugins:update': {
data: {}, data: {},
}, },
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: 10, id: 10,

View File

@ -43,7 +43,7 @@ const app = mockApp({
'applicationPlugins:update': { 'applicationPlugins:update': {
data: {}, data: {},
}, },
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: 10, id: 10,

View File

@ -48,7 +48,7 @@ const app = mockApp({
schemaSettings: [mobileTabBarLinkSettings], schemaSettings: [mobileTabBarLinkSettings],
designable: true, designable: true,
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [], data: [],
}, },
'mobileRoutes:update': { 'mobileRoutes:update': {

View File

@ -46,7 +46,7 @@ const app = mockApp({
schemaSettings: [mobileTabBarPageSettings], schemaSettings: [mobileTabBarPageSettings],
designable: true, designable: true,
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [], data: [],
}, },
'mobileRoutes:update': { 'mobileRoutes:update': {

View File

@ -45,7 +45,7 @@ const app = mockApp({
}, },
designable: true, designable: true,
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: 1, id: 1,

View File

@ -28,7 +28,7 @@ const app = mockApp({
}, },
plugins: [DemoPlugin], plugins: [DemoPlugin],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: 28, id: 28,

View File

@ -2,8 +2,8 @@
* iframe: true * iframe: true
*/ */
import { Plugin } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import PluginMobileClient, { Mobile } from '@nocobase/plugin-mobile/client';
import { mockApp } from '@nocobase/client/demo-utils'; import { mockApp } from '@nocobase/client/demo-utils';
import PluginMobileClient, { Mobile } from '@nocobase/plugin-mobile/client';
import React from 'react'; import React from 'react';
class DemoPlugin extends Plugin { class DemoPlugin extends Plugin {
@ -40,7 +40,7 @@ const app = mockApp({
DemoPlugin, DemoPlugin,
], ],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: '1', id: '1',

View File

@ -25,7 +25,7 @@ const app = mockApp({
}, },
plugins: [DemoPlugin], plugins: [DemoPlugin],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [], data: [],
}, },
}, },

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Plugin } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import { MobileNotFoundPage, MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client'; import { MobileNotFoundPage, MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client';
import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils'; import { mockApp } from '@nocobase/client/demo-utils';
@ -40,7 +40,7 @@ const app = mockApp({
}, },
plugins: [DemoPlugin], plugins: [DemoPlugin],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [], data: [],
}, },
'uiSchemas:getJsonSchema/tab1': { 'uiSchemas:getJsonSchema/tab1': {

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Plugin } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import { MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client'; import { MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client';
import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils'; import { mockApp } from '@nocobase/client/demo-utils';
@ -37,7 +37,7 @@ const app = mockApp({
}, },
plugins: [DemoPlugin], plugins: [DemoPlugin],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [], data: [],
}, },
'uiSchemas:getJsonSchema/tab1': { 'uiSchemas:getJsonSchema/tab1': {

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Plugin } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import { MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client'; import { MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client';
import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils'; import { mockApp } from '@nocobase/client/demo-utils';
@ -31,7 +31,7 @@ const app = mockApp({
}, },
plugins: [DemoPlugin], plugins: [DemoPlugin],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: 28, id: 28,

View File

@ -1,7 +1,5 @@
import React from 'react'; import { Plugin } from '@nocobase/client';
import { useLocation } from 'react-router-dom';
import { mockApp } from '@nocobase/client/demo-utils'; import { mockApp } from '@nocobase/client/demo-utils';
import { SchemaComponent, Plugin } from '@nocobase/client';
import { import {
MobilePageNavigationBar, MobilePageNavigationBar,
MobilePageProvider, MobilePageProvider,
@ -9,6 +7,8 @@ import {
MobileRoutesProvider, MobileRoutesProvider,
MobileTitleProvider, MobileTitleProvider,
} from '@nocobase/plugin-mobile/client'; } from '@nocobase/plugin-mobile/client';
import React from 'react';
import { useLocation } from 'react-router-dom';
const Demo = () => { const Demo = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
@ -51,7 +51,7 @@ const app = mockApp({
}, },
plugins: [DemoPlugin], plugins: [DemoPlugin],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: 1, id: 1,

View File

@ -1,7 +1,5 @@
import React from 'react'; import { Plugin } from '@nocobase/client';
import { useLocation } from 'react-router-dom';
import { mockApp } from '@nocobase/client/demo-utils'; import { mockApp } from '@nocobase/client/demo-utils';
import { SchemaComponent, Plugin } from '@nocobase/client';
import { import {
MobilePageNavigationBar, MobilePageNavigationBar,
MobilePageProvider, MobilePageProvider,
@ -9,6 +7,8 @@ import {
MobileRoutesProvider, MobileRoutesProvider,
MobileTitleProvider, MobileTitleProvider,
} from '@nocobase/plugin-mobile/client'; } from '@nocobase/plugin-mobile/client';
import React from 'react';
import { useLocation } from 'react-router-dom';
const Demo = () => { const Demo = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
@ -52,7 +52,7 @@ const app = mockApp({
}, },
plugins: [DemoPlugin], plugins: [DemoPlugin],
apis: { apis: {
'mobileRoutes:list': { 'mobileRoutes:listAccessible': {
data: [ data: [
{ {
id: 1, id: 1,

View File

@ -7,8 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React, { FC } from 'react'; import { css } from '@emotion/css';
import { Resizable } from 're-resizable'; import { Resizable } from 're-resizable';
import React, { FC } from 'react';
import { useSize } from './sizeContext'; import { useSize } from './sizeContext';
interface DesktopModeContentProps { interface DesktopModeContentProps {
@ -28,6 +29,11 @@ export const DesktopModeContent: FC<DesktopModeContentProps> = ({ children }) =>
overflow: 'auto', overflow: 'auto',
padding: 80, padding: 80,
}} }}
className={css`
.adm-error-block-full-page {
padding-top: 100px;
}
`}
> >
<Resizable <Resizable
style={{ style={{

View File

@ -43,11 +43,16 @@ import {
useMobileNavigationBarLink, useMobileNavigationBarLink,
} from './pages'; } from './pages';
import PluginACLClient from '@nocobase/plugin-acl/client';
import { MenuPermissions, MobileAllRoutesProvider } from './MenuPermissions';
// 导出 JSBridge会挂在到 window 上 // 导出 JSBridge会挂在到 window 上
import './js-bridge'; import './js-bridge';
import { MobileSettings } from './mobile-blocks/settings-block/MobileSettings'; import { MobileSettings } from './mobile-blocks/settings-block/MobileSettings';
import { MobileSettingsBlockInitializer } from './mobile-blocks/settings-block/MobileSettingsBlockInitializer'; import { MobileSettingsBlockInitializer } from './mobile-blocks/settings-block/MobileSettingsBlockInitializer';
import { MobileSettingsBlockSchemaSettings } from './mobile-blocks/settings-block/schemaSettings'; import { MobileSettingsBlockSchemaSettings } from './mobile-blocks/settings-block/schemaSettings';
// @ts-ignore
import pkg from './../../package.json';
export * from './desktop-mode'; export * from './desktop-mode';
export * from './mobile'; export * from './mobile';
@ -105,6 +110,7 @@ export class PluginMobileClient extends Plugin {
this.addInitializers(); this.addInitializers();
this.addSettings(); this.addSettings();
this.addScopes(); this.addScopes();
this.addPermissionsSettingsUI();
this.app.pluginSettingsManager.add('mobile', { this.app.pluginSettingsManager.add('mobile', {
title: generatePluginTranslationTemplate('Mobile'), title: generatePluginTranslationTemplate('Mobile'),
@ -240,6 +246,32 @@ export class PluginMobileClient extends Plugin {
getRouterComponent() { getRouterComponent() {
return this.mobileRouter.getRouterComponent(); return this.mobileRouter.getRouterComponent();
} }
addPermissionsSettingsUI() {
this.app.pm.get(PluginACLClient)?.settingsUI.addPermissionsTab(({ t, TabLayout, activeKey, currentUserRole }) => {
if (
currentUserRole &&
((!currentUserRole.snippets.includes('pm.mobile') && !currentUserRole.snippets.includes('pm.*')) ||
currentUserRole.snippets.includes('!pm.mobile'))
) {
return null;
}
return {
key: 'mobile-menu',
label: t('Mobile menu', {
ns: pkg.name,
}),
children: (
<TabLayout>
<MobileAllRoutesProvider active={activeKey === 'mobile-menu'}>
<MenuPermissions active={activeKey === 'mobile-menu'} />
</MobileAllRoutesProvider>
</TabLayout>
),
};
});
}
} }
export default PluginMobileClient; export default PluginMobileClient;

View File

@ -7,9 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { useTranslation } from 'react-i18next';
// @ts-ignore // @ts-ignore
import pkg from './../../package.json'; import pkg from './../../package.json';
import { useTranslation } from 'react-i18next';
export function usePluginTranslation() { export function usePluginTranslation() {
return useTranslation([pkg.name, 'client'], { nsMode: 'fallback' }); return useTranslation([pkg.name, 'client'], { nsMode: 'fallback' });

View File

@ -69,9 +69,9 @@ export const MobileTabBar: FC<MobileTabBarProps> & {
styles.mobileTabBarList, styles.mobileTabBarList,
css({ css({
maxWidth: designable ? 'calc(100% - 58px)' : '100%', maxWidth: designable ? 'calc(100% - 58px)' : '100%',
'.nb-block-item': { // '.nb-block-item': {
maxWidth: `${100 / routeList.length}%`, // maxWidth: `${100 / routeList.length}%`,
}, // },
}), }),
)} )}
> >
@ -82,7 +82,6 @@ export const MobileTabBar: FC<MobileTabBarProps> & {
</DndContext> </DndContext>
<MobileTabBarInitializer /> <MobileTabBarInitializer />
</div> </div>
<SafeArea position="bottom" /> <SafeArea position="bottom" />
</div> </div>
); );

View File

@ -33,6 +33,7 @@ export const useStyles = createStyles(() => ({
justifyContent: 'space-around', justifyContent: 'space-around',
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
overflowX: 'auto',
'.adm-tab-bar-item': { '.adm-tab-bar-item': {
maxWidth: '100%', maxWidth: '100%',
'.adm-tab-bar-item-title': { '.adm-tab-bar-item-title': {

View File

@ -8,7 +8,7 @@
*/ */
import React, { FC, useEffect } from 'react'; import React, { FC, useEffect } from 'react';
import { ShowTipWhenNoPages } from '../ShowTipWhenNoPages';
import { MobileRoutesProvider, MobileTitleProvider } from './context'; import { MobileRoutesProvider, MobileTitleProvider } from './context';
export interface MobileProvidersProps { export interface MobileProvidersProps {
@ -23,7 +23,9 @@ export const MobileProviders: FC<MobileProvidersProps> = ({ children }) => {
return ( return (
<MobileTitleProvider> <MobileTitleProvider>
<MobileRoutesProvider>{children}</MobileRoutesProvider> <MobileRoutesProvider>
<ShowTipWhenNoPages>{children}</ShowTipWhenNoPages>
</MobileRoutesProvider>
</MobileTitleProvider> </MobileTitleProvider>
); );
}; };

View File

@ -9,7 +9,7 @@
import { APIClient, useAPIClient, useRequest } from '@nocobase/client'; import { APIClient, useAPIClient, useRequest } from '@nocobase/client';
import { Spin } from 'antd'; import { Spin } from 'antd';
import React, { createContext, useContext, useEffect, useMemo } from 'react'; import React, { createContext, FC, useContext, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import type { IResource } from '@nocobase/sdk'; import type { IResource } from '@nocobase/sdk';
@ -88,7 +88,17 @@ function useTitle(activeTabBar: MobileRouteItem) {
}, [activeTabBar, context]); }, [activeTabBar, context]);
} }
export const MobileRoutesProvider = ({ children }) => { export const MobileRoutesProvider: FC<{
/**
* list: return all route data, and only administrators can access;
* listAccessible: return the route data that the current user can access;
*
* @default 'listAccessible'
*/
action?: 'list' | 'listAccessible';
refreshRef?: any;
manual?: boolean;
}> = ({ children, refreshRef, manual, action = 'listAccessible' }) => {
const api = useAPIClient(); const api = useAPIClient();
const resource = useMemo(() => api.resource('mobileRoutes'), [api]); const resource = useMemo(() => api.resource('mobileRoutes'), [api]);
const schemaResource = useMemo(() => api.resource('uiSchemas'), [api]); const schemaResource = useMemo(() => api.resource('uiSchemas'), [api]);
@ -96,9 +106,17 @@ export const MobileRoutesProvider = ({ children }) => {
data, data,
runAsync: refresh, runAsync: refresh,
loading, loading,
} = useRequest<{ data: MobileRouteItem[] }>(() => } = useRequest<{ data: MobileRouteItem[] }>(
resource.list({ tree: true, sort: 'sort' }).then((res) => res.data), () => resource[action]({ tree: true, sort: 'sort' }).then((res) => res.data),
{
manual,
},
); );
if (refreshRef) {
refreshRef.current = refresh;
}
const routeList = useMemo(() => data?.data || [], [data]); const routeList = useMemo(() => data?.data || [], [data]);
const { activeTabBarItem, activeTabItem } = useActiveTabBar(routeList); const { activeTabBarItem, activeTabItem } = useActiveTabBar(routeList);

View File

@ -21,5 +21,8 @@
"Icon field is required": "Icon field is required", "Icon field is required": "Icon field is required",
"Desktop data blocks": "Desktop data blocks", "Desktop data blocks": "Desktop data blocks",
"Other desktop blocks": "Other desktop blocks", "Other desktop blocks": "Other desktop blocks",
"Settings": "Settings" "Settings": "Settings",
"Mobile menu": "Mobile menu",
"No accessible pages found": "No accessible pages found",
"This might be due to permission configuration issues": "This might be due to permission configuration issues"
} }

View File

@ -22,5 +22,8 @@
"Icon field is required": "图标必填", "Icon field is required": "图标必填",
"Desktop data blocks": "桌面端数据区块", "Desktop data blocks": "桌面端数据区块",
"Other desktop blocks": "其他桌面端区块", "Other desktop blocks": "其他桌面端区块",
"Settings": "设置" "Settings": "设置",
"Mobile menu": "移动端菜单",
"No accessible pages found": "没有找到你可以访问的页面",
"This might be due to permission configuration issues": "这可能是权限配置的问题"
} }

View File

@ -0,0 +1,27 @@
/**
* 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.
*/
import { extendCollection } from '@nocobase/database';
export default extendCollection({
name: 'roles',
fields: [
{
type: 'belongsToMany',
name: 'mobileRoutes',
target: 'mobileRoutes',
through: 'rolesMobileRoutes',
onDelete: 'CASCADE',
},
{
type: 'boolean',
name: 'allowNewMobileMenu',
},
],
});

View File

@ -294,6 +294,13 @@ export default defineCollection({
title: 'sort', title: 'sort',
}, },
}, },
{
type: 'belongsToMany',
name: 'roles',
through: 'rolesMobileRoutes',
target: 'roles',
onDelete: 'CASCADE',
},
], ],
category: [], category: [],
logging: true, logging: true,

View File

@ -0,0 +1,46 @@
/**
* 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.
*/
/* istanbul ignore file -- @preserve */
import { Migration } from '@nocobase/server';
export default class extends Migration {
appVersion = '<1.4.0-beta';
async up() {
await this.db.sequelize.transaction(async (transaction) => {
const mobileRoutes = await this.db.getRepository('mobileRoutes').find({
sort: 'sort',
transaction,
});
const roles = await this.db.getRepository('roles').find({
filter: {
'allowNewMobileMenu.$isFalsy': true,
},
transaction,
});
const mobileRouteIds = mobileRoutes.map((item) => item.get('id'));
for (const role of roles) {
// 如果是 false不处理
if (role.allowNewMobileMenu === false) {
continue;
}
role.allowNewMobileMenu = true;
await role.save({ transaction });
await this.db.getRepository<any>('roles.mobileRoutes', role.get('name')).add({
tk: mobileRouteIds,
transaction,
});
}
});
}
async down() {}
}

View File

@ -7,21 +7,83 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { Model } from '@nocobase/database';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
export class PluginMobileServer extends Plugin { export class PluginMobileServer extends Plugin {
async load() { async load() {
this.registerActionHandlers();
this.bindNewMenuToRoles();
this.setACL();
}
setACL() {
this.app.acl.registerSnippet({ this.app.acl.registerSnippet({
name: `ui.${this.name}`, name: `ui.mobile`,
actions: ['mobileRoutes:create', 'mobileRoutes:update', 'mobileRoutes:destroy'], actions: ['mobileRoutes:create', 'mobileRoutes:update', 'mobileRoutes:destroy'],
}); });
this.app.acl.registerSnippet({ this.app.acl.registerSnippet({
name: `pm.${this.name}`, name: `pm.mobile`,
actions: ['mobileRoutes:list'], actions: ['mobileRoutes:list', 'roles.mobileRoutes:*'],
}); });
this.app.acl.allow('mobileRoutes', 'list', 'loggedIn'); this.app.acl.allow('mobileRoutes', 'listAccessible', 'loggedIn');
}
/**
* used to implement: roles with permission (allowNewMobileMenu is true) can directly access the newly created menu
*/
bindNewMenuToRoles() {
this.app.db.on('roles.beforeCreate', async (instance: Model) => {
instance.set('allowNewMobileMenu', ['admin', 'member'].includes(instance.name));
});
this.app.db.on('mobileRoutes.afterCreate', async (instance: Model, { transaction }) => {
const addNewMenuRoles = await this.app.db.getRepository('roles').find({
filter: {
allowNewMobileMenu: true,
},
transaction,
});
// @ts-ignore
await this.app.db.getRepository('mobileRoutes.roles', instance.id).add({
tk: addNewMenuRoles.map((role) => role.name),
transaction,
});
});
}
registerActionHandlers() {
this.app.resourceManager.registerActionHandler('mobileRoutes:listAccessible', async (ctx, next) => {
const mobileRoutesRepository = ctx.db.getRepository('mobileRoutes');
const rolesRepository = ctx.db.getRepository('roles');
if (ctx.state.currentRole === 'root') {
ctx.body = await mobileRoutesRepository.find({
tree: true,
...ctx.query,
});
return await next();
}
const role = await rolesRepository.findOne({
filterByTk: ctx.state.currentRole,
appends: ['mobileRoutes'],
});
const mobileRoutesId = role.get('mobileRoutes').map((item) => item.id);
ctx.body = await mobileRoutesRepository.find({
tree: true,
...ctx.query,
filter: {
id: mobileRoutesId,
},
});
await next();
});
} }
} }

View File

@ -102,6 +102,7 @@
"@nocobase/plugin-gantt", "@nocobase/plugin-gantt",
"@nocobase/plugin-kanban", "@nocobase/plugin-kanban",
"@nocobase/plugin-logger", "@nocobase/plugin-logger",
"@nocobase/plugin-mobile",
"@nocobase/plugin-system-settings", "@nocobase/plugin-system-settings",
"@nocobase/plugin-ui-schema-storage", "@nocobase/plugin-ui-schema-storage",
"@nocobase/plugin-user-data-sync", "@nocobase/plugin-user-data-sync",