mirror of
https://github.com/inventree/InvenTree.git
synced 2025-09-13 22:21:37 +00:00
[Refactor] Notification plugins (#9735)
* Refactor notification concept - Notifications handled by plugins * Cleanup * Only send email if template provided in context * Logic cleanup * Fix log_error call * Refactor error logging - Ensure plugin slug is correctly attached - Consistent format - Logic fixes * More robust plugin lookup * Refactor calls to tringger_notification * Tweak for build stock notification * Low stock notification refactor - Actually *use* the notification system - Fix for email template * Check stock only when build is issued * Updated documentation * Add PluginUserSetting class - Allows plugins to define per-user settings * Add API endpoints for PluginUserSetting model * Placeholder for user-plugin-settings page * Refactoring frontend code * Placeholder panel * Adds user interface for changing user-specific plugin settings * Tweaks * Remove old model * Update documentation * Playwright tests * Update API version * Fix unit test * Fix removed arg * Fixes for email notifications - Track status of sending notifications - Add helper "activate" method for plugin class - Update unit tests * Fix barcode tests * More unit test fixes * Test fixes * Fix for settings models with extra fields * Enhance unit test * Remove old test file * Check for null target_fnc * Improve DB query efficiency - Provide a flat list of active keys to plugin.is_active - Prevents DB fetching (in certain circumstances) - Add registry.active_plugins() method * Bump query limit up for test - In practice, this API endpoint is ~10 queries * Handle potential errors * Increase query limit for API test * Increase query limit for some tests * Bump API version * Tweak unit test * Tweak unit test * Increased allowed queries * fix user plugin settings * Fix for unit test * Update debug msg * Tweak API * Fix endpoint * Remove "active plugin keys" code * Restore previous behaviour * Fix unit tests * Tweak unit test * Update src/backend/InvenTree/build/tasks.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/InvenTree/plugin/base/integration/NotificationMixin.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Func updates * Format * Add notification settings * Refactor plugin settings groups * Fix func type * Adjust message * Additional unit tests * Additional playwright tests * Additional playwright test --------- Co-authored-by: Matthias Mair <code@mjmair.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -208,6 +208,7 @@ export enum ApiEndpoints {
|
||||
// Plugin API endpoints
|
||||
plugin_list = 'plugins/',
|
||||
plugin_setting_list = 'plugins/:plugin/settings/',
|
||||
plugin_user_setting_list = 'plugins/:plugin/user-settings/',
|
||||
plugin_registry_status = 'plugins/status/',
|
||||
plugin_install = 'plugins/install/',
|
||||
plugin_reload = 'plugins/reload/',
|
||||
|
@@ -2,9 +2,16 @@ import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { Setting, SettingsStateProps } from '@lib/types/Settings';
|
||||
@@ -25,12 +32,21 @@ import { SettingItem } from './SettingItem';
|
||||
export function SettingList({
|
||||
settingsState,
|
||||
keys,
|
||||
onChange
|
||||
onChange,
|
||||
onLoaded
|
||||
}: Readonly<{
|
||||
settingsState: SettingsStateProps;
|
||||
keys?: string[];
|
||||
onChange?: () => void;
|
||||
onLoaded?: (settings: SettingsStateProps) => void;
|
||||
}>) {
|
||||
useEffect(() => {
|
||||
if (settingsState.loaded) {
|
||||
// Call the onLoaded callback if provided
|
||||
onLoaded?.(settingsState);
|
||||
}
|
||||
}, [settingsState.loaded, settingsState.settings]);
|
||||
|
||||
const api = useApi();
|
||||
|
||||
const allKeys = useMemo(
|
||||
@@ -189,14 +205,39 @@ export function GlobalSettingList({ keys }: Readonly<{ keys: string[] }>) {
|
||||
}
|
||||
|
||||
export function PluginSettingList({
|
||||
pluginKey
|
||||
}: Readonly<{ pluginKey: string }>) {
|
||||
pluginKey,
|
||||
onLoaded
|
||||
}: Readonly<{
|
||||
pluginKey: string;
|
||||
onLoaded?: (settings: SettingsStateProps) => void;
|
||||
}>) {
|
||||
const pluginSettingsStore = useRef(
|
||||
createPluginSettingsState({ plugin: pluginKey })
|
||||
createPluginSettingsState({
|
||||
plugin: pluginKey,
|
||||
endpoint: ApiEndpoints.plugin_setting_list
|
||||
})
|
||||
).current;
|
||||
const pluginSettings = useStore(pluginSettingsStore);
|
||||
|
||||
return <SettingList settingsState={pluginSettings} />;
|
||||
return <SettingList settingsState={pluginSettings} onLoaded={onLoaded} />;
|
||||
}
|
||||
|
||||
export function PluginUserSettingList({
|
||||
pluginKey,
|
||||
onLoaded
|
||||
}: Readonly<{
|
||||
pluginKey: string;
|
||||
onLoaded?: (settings: SettingsStateProps) => void;
|
||||
}>) {
|
||||
const pluginUserSettingsState = useRef(
|
||||
createPluginSettingsState({
|
||||
plugin: pluginKey,
|
||||
endpoint: ApiEndpoints.plugin_user_setting_list
|
||||
})
|
||||
).current;
|
||||
const pluginUserSettings = useStore(pluginUserSettingsState);
|
||||
|
||||
return <SettingList settingsState={pluginUserSettings} onLoaded={onLoaded} />;
|
||||
}
|
||||
|
||||
export function MachineSettingList({
|
||||
|
117
src/frontend/src/pages/Index/Settings/PluginSettingsGroup.tsx
Normal file
117
src/frontend/src/pages/Index/Settings/PluginSettingsGroup.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ApiEndpoints } from '@lib/index';
|
||||
import type { SettingsStateProps } from '@lib/types/Settings';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Accordion, Alert, Group, Stack, Text } from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
PluginSettingList,
|
||||
PluginUserSettingList
|
||||
} from '../../../components/settings/SettingList';
|
||||
import { useInstance } from '../../../hooks/UseInstance';
|
||||
|
||||
function PluginSettingGroupItem({
|
||||
global,
|
||||
pluginKey,
|
||||
pluginName,
|
||||
pluginDescription
|
||||
}: {
|
||||
global: boolean;
|
||||
pluginKey: string;
|
||||
pluginName: string;
|
||||
pluginDescription?: string;
|
||||
}) {
|
||||
// Hide the accordion item if there are no settings for this plugin
|
||||
const [count, setCount] = useState<number>(0);
|
||||
|
||||
// Callback once the plugin settings have been loaded
|
||||
const onLoaded = useCallback(
|
||||
(settings: SettingsStateProps) => {
|
||||
setCount(settings.settings?.length || 0);
|
||||
},
|
||||
[pluginKey]
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion.Item
|
||||
key={`plugin-${pluginKey}`}
|
||||
value={pluginKey}
|
||||
hidden={count === 0}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group>
|
||||
<Text size='lg'>{pluginName}</Text>
|
||||
{pluginDescription && <Text size='sm'>{pluginDescription}</Text>}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{global ? (
|
||||
<PluginSettingList pluginKey={pluginKey} onLoaded={onLoaded} />
|
||||
) : (
|
||||
<PluginUserSettingList pluginKey={pluginKey} onLoaded={onLoaded} />
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an accordion of user-specific plugin settings
|
||||
* - Each element in the accordion corresponds to a plugin
|
||||
* - Each plugin can have multiple settings
|
||||
* - If a plugin has no settings, it will not be displayed
|
||||
*/
|
||||
export default function PluginSettingsGroup({
|
||||
mixin,
|
||||
message,
|
||||
global
|
||||
}: {
|
||||
global: boolean;
|
||||
message?: string;
|
||||
mixin?: string;
|
||||
}) {
|
||||
const mixins: string = useMemo(() => {
|
||||
const mixinList: string[] = ['settings'];
|
||||
|
||||
if (mixin) {
|
||||
mixinList.push(mixin);
|
||||
}
|
||||
|
||||
return mixinList.join(',');
|
||||
}, [mixin]);
|
||||
|
||||
// All *active* plugins which require settings
|
||||
const activePlugins = useInstance({
|
||||
endpoint: ApiEndpoints.plugin_list,
|
||||
params: {
|
||||
active: true,
|
||||
mixin: mixins
|
||||
},
|
||||
hasPrimaryKey: false,
|
||||
defaultValue: []
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<Alert color='blue' icon={<IconInfoCircle />}>
|
||||
<Text>
|
||||
{message ??
|
||||
t`The settings below are specific to each available plugin`}
|
||||
</Text>
|
||||
</Alert>
|
||||
<Accordion multiple>
|
||||
{activePlugins.instance?.map((plugin: any) => {
|
||||
return (
|
||||
<PluginSettingGroupItem
|
||||
global={global}
|
||||
key={plugin.key}
|
||||
pluginKey={plugin.key}
|
||||
pluginName={plugin.meta?.human_name ?? plugin.name}
|
||||
pluginDescription={plugin?.meta?.description}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -1,13 +1,13 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconBellCog,
|
||||
IconCategory,
|
||||
IconCurrencyDollar,
|
||||
IconFileAnalytics,
|
||||
IconFingerprint,
|
||||
IconInfoCircle,
|
||||
IconPackages,
|
||||
IconPlugConnected,
|
||||
IconQrcode,
|
||||
IconServerCog,
|
||||
IconShoppingCart,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
IconTruckDelivery,
|
||||
IconTruckReturn
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import PermissionDenied from '../../../components/errors/PermissionDenied';
|
||||
@@ -25,9 +25,14 @@ import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import type { PanelType } from '../../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../functions/loading';
|
||||
import { useServerApiState } from '../../../states/ServerApiState';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
|
||||
const PluginSettingsGroup = Loadable(
|
||||
lazy(() => import('./PluginSettingsGroup'))
|
||||
);
|
||||
|
||||
/**
|
||||
* System settings page
|
||||
*/
|
||||
@@ -113,15 +118,11 @@ export default function SystemSettings() {
|
||||
label: t`Notifications`,
|
||||
icon: <IconBellCog />,
|
||||
content: (
|
||||
<Stack>
|
||||
<Alert
|
||||
color='teal'
|
||||
title={t`This panel is a placeholder.`}
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
<Text c='gray'>This panel has not yet been implemented</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
<PluginSettingsGroup
|
||||
mixin='notification'
|
||||
global={true}
|
||||
message={t`The settings below are specific to each available notification method`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -305,6 +306,12 @@ export default function SystemSettings() {
|
||||
]}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'plugins',
|
||||
label: t`Plugin Settings`,
|
||||
icon: <IconPlugConnected />,
|
||||
content: <PluginSettingsGroup global={true} />
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
@@ -5,10 +5,11 @@ import {
|
||||
IconDeviceDesktop,
|
||||
IconFileAnalytics,
|
||||
IconLock,
|
||||
IconPlugConnected,
|
||||
IconSearch,
|
||||
IconUserCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import PageTitle from '../../../components/nav/PageTitle';
|
||||
@@ -16,10 +17,15 @@ import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import type { PanelType } from '../../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||
import { UserSettingList } from '../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../functions/loading';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { SecurityContent } from './AccountSettings/SecurityContent';
|
||||
import { AccountContent } from './AccountSettings/UserPanel';
|
||||
|
||||
const PluginSettingsGroup = Loadable(
|
||||
lazy(() => import('./PluginSettingsGroup'))
|
||||
);
|
||||
|
||||
/**
|
||||
* User settings page
|
||||
*/
|
||||
@@ -97,7 +103,13 @@ export default function UserSettings() {
|
||||
name: 'notifications',
|
||||
label: t`Notifications`,
|
||||
icon: <IconBellCog />,
|
||||
content: <UserSettingList keys={['NOTIFICATION_ERROR_REPORT']} />
|
||||
content: (
|
||||
<PluginSettingsGroup
|
||||
mixin='notification'
|
||||
global={false}
|
||||
message={t`The settings below are specific to each available notification method`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'reporting',
|
||||
@@ -108,6 +120,12 @@ export default function UserSettings() {
|
||||
keys={['REPORT_INLINE', 'LABEL_INLINE', 'LABEL_DEFAULT_PRINTER']}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'plugins',
|
||||
label: t`Plugin Settings`,
|
||||
icon: <IconPlugConnected />,
|
||||
content: <PluginSettingsGroup global={false} />
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
@@ -123,10 +123,12 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
|
||||
*/
|
||||
interface CreatePluginSettingStateProps {
|
||||
plugin: string;
|
||||
endpoint: ApiEndpoints;
|
||||
}
|
||||
|
||||
export const createPluginSettingsState = ({
|
||||
plugin
|
||||
plugin,
|
||||
endpoint
|
||||
}: CreatePluginSettingStateProps) => {
|
||||
const pathParams: PathParams = { plugin };
|
||||
|
||||
@@ -135,7 +137,7 @@ export const createPluginSettingsState = ({
|
||||
lookup: {},
|
||||
loaded: false,
|
||||
isError: false,
|
||||
endpoint: ApiEndpoints.plugin_setting_list,
|
||||
endpoint: endpoint,
|
||||
pathParams,
|
||||
fetchSettings: async () => {
|
||||
let success = true;
|
||||
@@ -155,7 +157,7 @@ export const createPluginSettingsState = ({
|
||||
});
|
||||
|
||||
await api
|
||||
.get(apiUrl(ApiEndpoints.plugin_setting_list, undefined, { plugin }))
|
||||
.get(apiUrl(endpoint, undefined, { plugin }))
|
||||
.then((response) => {
|
||||
const settings = response.data;
|
||||
set({
|
||||
@@ -166,7 +168,9 @@ export const createPluginSettingsState = ({
|
||||
});
|
||||
})
|
||||
.catch((_error) => {
|
||||
console.error(`Error fetching plugin settings for plugin ${plugin}`);
|
||||
console.error(
|
||||
`ERR: Could not fetch plugin settings for plugin ${plugin}`
|
||||
);
|
||||
success = false;
|
||||
set({
|
||||
loaded: false,
|
||||
@@ -186,7 +190,6 @@ export const createPluginSettingsState = ({
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
console.log('fetching plugin settings for', plugin);
|
||||
store.getState().fetchSettings();
|
||||
}, [plugin]);
|
||||
|
||||
|
@@ -65,6 +65,47 @@ test('Plugins - Settings', async ({ browser, request }) => {
|
||||
await page.getByText('Mouser Electronics').click();
|
||||
});
|
||||
|
||||
test('Plugins - User Settings', async ({ browser, request }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// Ensure that the SampleIntegration plugin is enabled
|
||||
await setPluginState({
|
||||
request,
|
||||
plugin: 'sample',
|
||||
state: true
|
||||
});
|
||||
|
||||
// Navigate to user settings
|
||||
await navigate(page, 'settings/user/');
|
||||
await loadTab(page, 'Plugin Settings');
|
||||
|
||||
// User settings for the "Sample Plugin" should be visible
|
||||
await page.getByRole('button', { name: 'Sample Plugin' }).click();
|
||||
|
||||
await page.getByText('User Setting 1').waitFor();
|
||||
await page.getByText('User Setting 2').waitFor();
|
||||
await page.getByText('User Setting 3').waitFor();
|
||||
|
||||
// Check for expected setting options
|
||||
await page.getByLabel('edit-setting-USER_SETTING_3').click();
|
||||
|
||||
const val = await page.getByLabel('choice-field-value').inputValue();
|
||||
|
||||
await page.getByLabel('choice-field-value').click();
|
||||
|
||||
await page.getByRole('option', { name: 'Choice X' }).waitFor();
|
||||
await page.getByRole('option', { name: 'Choice Y' }).waitFor();
|
||||
await page.getByRole('option', { name: 'Choice Z' }).waitFor();
|
||||
|
||||
// Change the value of USER_SETTING_3
|
||||
await page
|
||||
.getByRole('option', { name: val == 'Choice X' ? 'Choice Z' : 'Choice X' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page.getByText('Setting USER_SETTING_3 updated successfully').waitFor();
|
||||
});
|
||||
|
||||
// Test base plugin functionality
|
||||
test('Plugins - Functionality', async ({ browser }) => {
|
||||
// Navigate and select the plugin
|
||||
|
@@ -2,7 +2,7 @@ import { expect, test } from './baseFixtures.js';
|
||||
import { apiUrl } from './defaults.js';
|
||||
import { getRowFromCell, loadTab, navigate } from './helpers.js';
|
||||
import { doCachedLogin } from './login.js';
|
||||
import { setSettingState } from './settings.js';
|
||||
import { setPluginState, setSettingState } from './settings.js';
|
||||
|
||||
/**
|
||||
* Adjust language and color settings
|
||||
@@ -80,6 +80,97 @@ test('Settings - User theme', async ({ browser }) => {
|
||||
await page.getByLabel('#228be6').click();
|
||||
});
|
||||
|
||||
test('Settings - User', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
username: 'allaccess',
|
||||
password: 'nolimits',
|
||||
url: 'settings/user/'
|
||||
});
|
||||
|
||||
await loadTab(page, 'Account');
|
||||
await page.getByText('Account Details').waitFor();
|
||||
await page.getByText('Profile Details').waitFor();
|
||||
|
||||
await loadTab(page, 'Security');
|
||||
await page.getByRole('button', { name: 'Single Sign On' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Access Tokens' }).waitFor();
|
||||
|
||||
await loadTab(page, 'Display Options');
|
||||
await page
|
||||
.getByText('The navbar position is fixed to the top of the screen')
|
||||
.waitFor();
|
||||
await page.getByText('Escape Key Closes Forms').waitFor();
|
||||
|
||||
await loadTab(page, 'Search');
|
||||
await page.getByText('Whole Word Search').waitFor();
|
||||
await page.getByText('Hide Unavailable Stock Items').waitFor();
|
||||
|
||||
await loadTab(page, 'Notifications');
|
||||
await page
|
||||
.getByRole('button', { name: 'InvenTree Email Notifications' })
|
||||
.waitFor();
|
||||
|
||||
await loadTab(page, 'Reporting');
|
||||
await page.getByText('Inline report display').waitFor();
|
||||
|
||||
await loadTab(page, 'Plugin Settings');
|
||||
await page
|
||||
.getByRole('button', { name: 'InvenTree Email Notifications' })
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Settings - Global', async ({ browser, request }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
username: 'steven',
|
||||
password: 'wizardstaff',
|
||||
url: 'settings/system/'
|
||||
});
|
||||
|
||||
// Ensure the "slack" notification plugin is enabled
|
||||
// This is to ensure it is visible in the "notification" settings tab
|
||||
await setPluginState({
|
||||
request,
|
||||
plugin: 'inventree-slack-notification',
|
||||
state: true
|
||||
});
|
||||
|
||||
await loadTab(page, 'Server');
|
||||
await loadTab(page, 'Authentication');
|
||||
await loadTab(page, 'Barcodes');
|
||||
await loadTab(page, 'Pricing');
|
||||
await loadTab(page, 'Parts');
|
||||
await loadTab(page, 'Stock');
|
||||
|
||||
await loadTab(page, 'Notifications');
|
||||
await page
|
||||
.getByText(
|
||||
'The settings below are specific to each available notification method'
|
||||
)
|
||||
.waitFor();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'InvenTree Slack Notifications' })
|
||||
.click();
|
||||
await page.getByText('Slack incoming webhook url').waitFor();
|
||||
await page
|
||||
.getByText('URL that is used to send messages to a slack channel')
|
||||
.waitFor();
|
||||
|
||||
await loadTab(page, 'Plugin Settings');
|
||||
await page
|
||||
.getByText('The settings below are specific to each available plugin')
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole('button', { name: 'InvenTree Barcodes Provides' })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole('button', { name: 'InvenTree PDF label printer' })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole('button', { name: 'InvenTree Slack Notifications' })
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Settings - Admin', async ({ browser }) => {
|
||||
// Note here we login with admin access
|
||||
const page = await doCachedLogin(browser, {
|
||||
|
Reference in New Issue
Block a user