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) {
let tk = options;
if (typeof options === 'object' && options['tk']) {
if (typeof options === 'object' && 'tk' in options) {
tk = options['tk'];
}
return tk;
@ -126,9 +126,14 @@ export abstract class RelationRepository {
if (typeof tk === 'string') {
tk = tk.split(',');
}
if (tk) {
return lodash.castArray(tk);
}
return [];
}
targetKey() {
return this.associationField.targetKey;
}

View File

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

View File

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

View File

@ -7,7 +7,7 @@
* 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 React, { useContext, useEffect, useMemo } from 'react';
import PluginACLClient from '..';
@ -24,10 +24,13 @@ export const Permissions: React.FC<{ active: boolean }> = ({ active }) => {
const [activeKey, setActiveKey] = React.useState('general');
const { role, setRole } = useContext(RolesManagerContext);
const pluginACLClient = usePlugin(PluginACLClient);
const currentUserRole = useACLRoleContext();
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();

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

View File

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

View File

@ -35,12 +35,12 @@ export class PluginDataSourceManagerClient extends Plugin {
async load() {
// 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',
label: t('Data sources'),
children: (
<TabLayout>
<DataSourcePermissionManager role={role} />
<DataSourcePermissionManager role={activeRole} />
</TabLayout>
),
}));

View File

@ -12,7 +12,8 @@
"peerDependencies": {
"@nocobase/client": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x"
"@nocobase/test": "1.x",
"@nocobase/plugin-acl": "1.x"
},
"devDependencies": {
"@ant-design/icons": "5.x",
@ -23,6 +24,12 @@
"@types/react-dom": "17.x",
"antd-mobile": "^5.36.1",
"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],
apis: {
'mobileRoutes:list': {
'mobileRoutes:listAccessible': {
data: [
{
id: 10,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,8 +7,9 @@
* 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 React, { FC } from 'react';
import { useSize } from './sizeContext';
interface DesktopModeContentProps {
@ -28,6 +29,11 @@ export const DesktopModeContent: FC<DesktopModeContentProps> = ({ children }) =>
overflow: 'auto',
padding: 80,
}}
className={css`
.adm-error-block-full-page {
padding-top: 100px;
}
`}
>
<Resizable
style={{

View File

@ -43,11 +43,16 @@ import {
useMobileNavigationBarLink,
} from './pages';
import PluginACLClient from '@nocobase/plugin-acl/client';
import { MenuPermissions, MobileAllRoutesProvider } from './MenuPermissions';
// 导出 JSBridge会挂在到 window 上
import './js-bridge';
import { MobileSettings } from './mobile-blocks/settings-block/MobileSettings';
import { MobileSettingsBlockInitializer } from './mobile-blocks/settings-block/MobileSettingsBlockInitializer';
import { MobileSettingsBlockSchemaSettings } from './mobile-blocks/settings-block/schemaSettings';
// @ts-ignore
import pkg from './../../package.json';
export * from './desktop-mode';
export * from './mobile';
@ -105,6 +110,7 @@ export class PluginMobileClient extends Plugin {
this.addInitializers();
this.addSettings();
this.addScopes();
this.addPermissionsSettingsUI();
this.app.pluginSettingsManager.add('mobile', {
title: generatePluginTranslationTemplate('Mobile'),
@ -240,6 +246,32 @@ export class PluginMobileClient extends Plugin {
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;

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@
import { APIClient, useAPIClient, useRequest } from '@nocobase/client';
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 type { IResource } from '@nocobase/sdk';
@ -88,7 +88,17 @@ function useTitle(activeTabBar: MobileRouteItem) {
}, [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 resource = useMemo(() => api.resource('mobileRoutes'), [api]);
const schemaResource = useMemo(() => api.resource('uiSchemas'), [api]);
@ -96,9 +106,17 @@ export const MobileRoutesProvider = ({ children }) => {
data,
runAsync: refresh,
loading,
} = useRequest<{ data: MobileRouteItem[] }>(() =>
resource.list({ tree: true, sort: 'sort' }).then((res) => res.data),
} = useRequest<{ data: MobileRouteItem[] }>(
() => resource[action]({ tree: true, sort: 'sort' }).then((res) => res.data),
{
manual,
},
);
if (refreshRef) {
refreshRef.current = refresh;
}
const routeList = useMemo(() => data?.data || [], [data]);
const { activeTabBarItem, activeTabItem } = useActiveTabBar(routeList);

View File

@ -21,5 +21,8 @@
"Icon field is required": "Icon field is required",
"Desktop data blocks": "Desktop data 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": "图标必填",
"Desktop data 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',
},
},
{
type: 'belongsToMany',
name: 'roles',
through: 'rolesMobileRoutes',
target: 'roles',
onDelete: 'CASCADE',
},
],
category: [],
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.
*/
import { Model } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
export class PluginMobileServer extends Plugin {
async load() {
this.registerActionHandlers();
this.bindNewMenuToRoles();
this.setACL();
}
setACL() {
this.app.acl.registerSnippet({
name: `ui.${this.name}`,
name: `ui.mobile`,
actions: ['mobileRoutes:create', 'mobileRoutes:update', 'mobileRoutes:destroy'],
});
this.app.acl.registerSnippet({
name: `pm.${this.name}`,
actions: ['mobileRoutes:list'],
name: `pm.mobile`,
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-kanban",
"@nocobase/plugin-logger",
"@nocobase/plugin-mobile",
"@nocobase/plugin-system-settings",
"@nocobase/plugin-ui-schema-storage",
"@nocobase/plugin-user-data-sync",