Summary: - Add InstallAdmin class to identify users who can manage Grist installation. This is overridable by different Grist flavors (e.g. different in SaaS). It generalizes previous logic used to decide who can control Activation settings (e.g. enable telemetry). - Implement a basic Admin Panel at /admin, and move items previously in the "Support Grist" page into the "Support Grist" section of the Admin Panel. - Replace "Support Grist" menu items with "Admin Panel" and show only to admins. - Add "Support Grist" links to Github sponsorship to user-account menu. - Add "Support Grist" button to top-bar, which - for admins, replaces the previous "Contribute" button and reopens the "Support Grist / opt-in to telemetry" nudge (unchanged) - for everyone else, links to Github sponsorship - in either case, user can dismiss it. Test Plan: Shuffled some test cases between Support Grist and the new Admin Panel, and added some new cases. Reviewers: jarek, paulfitz Reviewed By: jarek, paulfitz Differential Revision: https://phab.getgrist.com/D4194test-server-reset
parent
0c05f4cdc4
commit
e380fcfa90
@ -0,0 +1,270 @@
|
||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import * as version from 'app/common/version';
|
||||
import {buildHomeBanners} from 'app/client/components/Banners';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||
import {SupportGristPage} from 'app/client/ui/SupportGristPage';
|
||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||
import {transition} from 'app/client/ui/transitions';
|
||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {Disposable, dom, DomContents, IDisposableOwner, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('AdminPanel');
|
||||
|
||||
// Translated "Admin Panel" name, made available to other modules.
|
||||
export function getAdminPanelName() {
|
||||
return t("Admin Panel");
|
||||
}
|
||||
|
||||
export class AdminPanel extends Disposable {
|
||||
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const panelOpen = Observable.create(this, false);
|
||||
return pagePanels({
|
||||
leftPanel: {
|
||||
panelWidth: Observable.create(this, 240),
|
||||
panelOpen,
|
||||
hideOpener: true,
|
||||
header: dom.create(AppHeader, this._appModel),
|
||||
content: leftPanelBasic(this._appModel, panelOpen),
|
||||
},
|
||||
headerMain: this._buildMainHeader(),
|
||||
contentTop: buildHomeBanners(this._appModel),
|
||||
contentMain: dom.create(this._buildMainContent.bind(this)),
|
||||
});
|
||||
}
|
||||
|
||||
private _buildMainHeader() {
|
||||
return dom.frag(
|
||||
cssBreadcrumbs({style: 'margin-left: 16px;'},
|
||||
cssLink(
|
||||
urlState().setLinkUrl({}),
|
||||
t('Home'),
|
||||
),
|
||||
separator(' / '),
|
||||
dom('span', getAdminPanelName()),
|
||||
),
|
||||
createTopBarHome(this._appModel),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildMainContent(owner: IDisposableOwner) {
|
||||
return cssPageContainer(
|
||||
dom.cls('clipboard'),
|
||||
{tabIndex: "-1"},
|
||||
cssSection(
|
||||
cssSectionTitle(t('Support Grist')),
|
||||
this._buildItem(owner, {
|
||||
id: 'telemetry',
|
||||
name: t('Telemetry'),
|
||||
description: t('Help us make Grist better'),
|
||||
value: maybeSwitchToggle(this._supportGrist.getTelemetryOptInObservable()),
|
||||
expandedContent: this._supportGrist.buildTelemetrySection(),
|
||||
}),
|
||||
this._buildItem(owner, {
|
||||
id: 'sponsor',
|
||||
name: t('Sponsor'),
|
||||
description: t('Support Grist Labs on GitHub'),
|
||||
value: this._supportGrist.buildSponsorshipSmallButton(),
|
||||
expandedContent: this._supportGrist.buildSponsorshipSection(),
|
||||
}),
|
||||
),
|
||||
cssSection(
|
||||
cssSectionTitle(t('Version')),
|
||||
this._buildItem(owner, {
|
||||
id: 'version',
|
||||
name: t('Current'),
|
||||
description: t('Current version of Grist'),
|
||||
value: cssValueLabel(`Version ${version.version}`),
|
||||
}),
|
||||
),
|
||||
testId('admin-panel'),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildItem(owner: IDisposableOwner, options: {
|
||||
id: string,
|
||||
name: DomContents,
|
||||
description: DomContents,
|
||||
value: DomContents,
|
||||
expandedContent?: DomContents,
|
||||
}) {
|
||||
const itemContent = [
|
||||
cssItemName(options.name, testId(`admin-panel-item-name-${options.id}`)),
|
||||
cssItemDescription(options.description),
|
||||
cssItemValue(options.value,
|
||||
testId(`admin-panel-item-value-${options.id}`),
|
||||
dom.on('click', ev => ev.stopPropagation())),
|
||||
];
|
||||
if (options.expandedContent) {
|
||||
const isCollapsed = Observable.create(owner, true);
|
||||
return cssItem(
|
||||
cssItemShort(
|
||||
dom.domComputed(isCollapsed, (c) => cssCollapseIcon(c ? 'Expand' : 'Collapse')),
|
||||
itemContent,
|
||||
cssItemShort.cls('-expandable'),
|
||||
dom.on('click', () => isCollapsed.set(!isCollapsed.get())),
|
||||
),
|
||||
cssExpandedContentWrap(
|
||||
transition(isCollapsed, {
|
||||
prepare(elem, close) { elem.style.maxHeight = close ? elem.scrollHeight + 'px' : '0'; },
|
||||
run(elem, close) { elem.style.maxHeight = close ? '0' : elem.scrollHeight + 'px'; },
|
||||
finish(elem, close) { elem.style.maxHeight = close ? '0' : 'unset'; },
|
||||
}),
|
||||
cssExpandedContent(
|
||||
options.expandedContent,
|
||||
),
|
||||
),
|
||||
testId(`admin-panel-item-${options.id}`),
|
||||
);
|
||||
} else {
|
||||
return cssItem(
|
||||
cssItemShort(itemContent),
|
||||
testId(`admin-panel-item-${options.id}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maybeSwitchToggle(value: Observable<boolean|null>): DomContents {
|
||||
return dom('div.widget_switch',
|
||||
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
|
||||
dom.hide((use) => use(value) === null),
|
||||
dom.cls('switch_on', (use) => use(value) || false),
|
||||
dom.cls('switch_transition', true),
|
||||
dom.on('click', () => value.set(!value.get())),
|
||||
dom('div.switch_slider'),
|
||||
dom('div.switch_circle'),
|
||||
);
|
||||
}
|
||||
|
||||
const cssPageContainer = styled('div', `
|
||||
overflow: auto;
|
||||
padding: 40px;
|
||||
font-size: ${vars.introFontSize};
|
||||
color: ${theme.text};
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
padding: 0px;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSection = styled('div', `
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 16px auto;
|
||||
border: 1px solid ${theme.widgetBorder};
|
||||
border-radius: 4px;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
width: auto;
|
||||
padding: 12px;
|
||||
margin: 8px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSectionTitle = styled('div', `
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
margin-bottom: 16px;
|
||||
font-size: ${vars.headerControlFontSize};
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
`);
|
||||
|
||||
const cssItem = styled('div', `
|
||||
margin-top: 8px;
|
||||
`);
|
||||
|
||||
const cssItemShort = styled('div', `
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
margin: 0 -8px;
|
||||
border-radius: 4px;
|
||||
&-expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
&-expandable:hover {
|
||||
background-color: ${theme.lightHover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssItemName = styled('div', `
|
||||
width: 112px;
|
||||
font-weight: bold;
|
||||
font-size: ${vars.largeFontSize};
|
||||
&:first-child {
|
||||
margin-left: 28px;
|
||||
}
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
width: calc(100% - 28px);
|
||||
}
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssItemDescription = styled('div', `
|
||||
margin-right: auto;
|
||||
`);
|
||||
|
||||
const cssItemValue = styled('div', `
|
||||
flex: none;
|
||||
margin: -16px;
|
||||
padding: 16px;
|
||||
cursor: auto;
|
||||
`);
|
||||
|
||||
const cssCollapseIcon = styled(icon, `
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
margin-left: -4px;
|
||||
--icon-color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssExpandedContentWrap = styled('div', `
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
`);
|
||||
|
||||
const cssExpandedContent = styled('div', `
|
||||
margin-left: 24px;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid ${theme.widgetBorder};
|
||||
.${cssItem.className}:last-child & {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssValueLabel = styled('div', `
|
||||
padding: 4px 8px;
|
||||
color: ${theme.text};
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
border-radius: ${vars.controlBorderRadius};
|
||||
`);
|
@ -0,0 +1,52 @@
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import {appSettings} from 'app/server/lib/AppSettings';
|
||||
import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||
import {User} from 'app/gen-server/entity/User';
|
||||
import express from 'express';
|
||||
|
||||
/**
|
||||
* Class implementing the logic to determine whether a user is authorized to manage the Grist
|
||||
* installation.
|
||||
*/
|
||||
export abstract class InstallAdmin {
|
||||
|
||||
// Returns true if user is authorized to manage the Grist installation.
|
||||
public abstract isAdminUser(user: User): Promise<boolean>;
|
||||
|
||||
// Returns true if req is authenticated (contains a user) and the user is authorized to manage
|
||||
// the Grist installation. This should not fail, only return true or false.
|
||||
public async isAdminReq(req: express.Request): Promise<boolean> {
|
||||
const user = (req as RequestWithLogin).user;
|
||||
return user ? this.isAdminUser(user) : false;
|
||||
}
|
||||
|
||||
// Returns middleware that fails unless the request includes an authenticated user and this user
|
||||
// is authorized to manage the Grist installation.
|
||||
public getMiddlewareRequireAdmin(): express.RequestHandler {
|
||||
return this._requireAdmin.bind(this);
|
||||
}
|
||||
|
||||
private async _requireAdmin(req: express.Request, resp: express.Response, next: express.NextFunction) {
|
||||
try {
|
||||
// getUser() will fail with 401 if user is not present.
|
||||
if (!await this.isAdminUser(getUser(req))) {
|
||||
throw new ApiError('Access denied', 403);
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Considers the user whose email matches GRIST_DEFAULT_EMAIL env var, if given, to be the
|
||||
// installation admin. If not given, then there is no admin.
|
||||
export class SimpleInstallAdmin extends InstallAdmin {
|
||||
private _installAdminEmail = appSettings.section('access').flag('installAdminEmail').readString({
|
||||
envVar: 'GRIST_DEFAULT_EMAIL',
|
||||
});
|
||||
|
||||
public override async isAdminUser(user: User): Promise<boolean> {
|
||||
return this._installAdminEmail ? (user.loginEmail === this._installAdminEmail) : false;
|
||||
}
|
||||
}
|
@ -0,0 +1,195 @@
|
||||
import {TelemetryLevel} from 'app/common/Telemetry';
|
||||
import {assert, driver, Key, WebElement} from 'mocha-webdriver';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
|
||||
describe('AdminPanel', function() {
|
||||
this.timeout(30000);
|
||||
setupTestSuite();
|
||||
|
||||
let oldEnv: testUtils.EnvironmentSnapshot;
|
||||
let session: gu.Session;
|
||||
|
||||
afterEach(() => gu.checkForErrors());
|
||||
|
||||
before(async function() {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core';
|
||||
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
|
||||
await server.restart(true);
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
oldEnv.restore();
|
||||
await server.restart(true);
|
||||
});
|
||||
|
||||
it('should not be shown to non-managers', async function() {
|
||||
session = await gu.session().user('user2').personalSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
|
||||
await gu.openAccountMenu();
|
||||
assert.equal(await driver.find('.test-usermenu-admin-panel').isPresent(), false);
|
||||
await driver.sendKeys(Key.ESCAPE);
|
||||
assert.equal(await driver.find('.test-dm-admin-panel').isPresent(), false);
|
||||
|
||||
// Try loading the URL directly.
|
||||
await driver.get(`${server.getHost()}/admin`);
|
||||
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
|
||||
assert.equal(await driver.find('.test-admin-panel').isPresent(), false);
|
||||
});
|
||||
|
||||
it('should be shown to managers', async function() {
|
||||
session = await gu.session().personalSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
assert.equal(await driver.find('.test-dm-admin-panel').isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-dm-admin-panel').getAttribute('href'), /\/admin$/);
|
||||
await gu.openAccountMenu();
|
||||
assert.equal(await driver.find('.test-usermenu-admin-panel').isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-usermenu-admin-panel').getAttribute('href'), /\/admin$/);
|
||||
await driver.find('.test-usermenu-admin-panel').click();
|
||||
assert.equal(await waitForAdminPanel().isDisplayed(), true);
|
||||
});
|
||||
|
||||
it('should include support-grist section', async function() {
|
||||
assert.match(await driver.find('.test-admin-panel-item-sponsor').getText(), /Support Grist Labs on GitHub/);
|
||||
await withExpandedItem('sponsor', async () => {
|
||||
const button = await driver.find('.test-support-grist-page-sponsorship-section');
|
||||
assert.equal(await button.isDisplayed(), true);
|
||||
assert.match(await button.getText(), /You can support Grist open-source/);
|
||||
});
|
||||
});
|
||||
|
||||
it('supports opting in to telemetry from the page', async function() {
|
||||
await assertTelemetryLevel('off');
|
||||
|
||||
let toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
|
||||
await withExpandedItem('telemetry', async () => {
|
||||
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
|
||||
await driver.findContentWait(
|
||||
'.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000).click();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
|
||||
assert.equal(
|
||||
await driver.find('.test-support-grist-page-telemetry-section-message').getText(),
|
||||
'You have opted in to telemetry. Thank you! 🙏'
|
||||
);
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
});
|
||||
|
||||
// Check it's still on after collapsing.
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
|
||||
// Reload the page and check that the Grist config indicates telemetry is set to "limited".
|
||||
await driver.navigate().refresh();
|
||||
await waitForAdminPanel();
|
||||
toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
await toggleItem('telemetry');
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
|
||||
assert.equal(
|
||||
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
|
||||
'You have opted in to telemetry. Thank you! 🙏'
|
||||
);
|
||||
await assertTelemetryLevel('limited');
|
||||
});
|
||||
|
||||
it('supports opting out of telemetry from the page', async function() {
|
||||
await driver.findContent('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/).click();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
|
||||
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
|
||||
let toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
|
||||
// Reload the page and check that the Grist config indicates telemetry is set to "off".
|
||||
await driver.navigate().refresh();
|
||||
await waitForAdminPanel();
|
||||
await toggleItem('telemetry');
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
|
||||
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
|
||||
await assertTelemetryLevel('off');
|
||||
toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
});
|
||||
|
||||
it('supports toggling telemetry from the toggle in the top line', async function() {
|
||||
const toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
await toggle.click();
|
||||
await gu.waitForServer();
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
assert.match(await driver.find('.test-support-grist-page-telemetry-section-message').getText(),
|
||||
/You have opted in/);
|
||||
await toggle.click();
|
||||
await gu.waitForServer();
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
await withExpandedItem('telemetry', async () => {
|
||||
assert.equal(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent(), false);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows telemetry opt-in status even when set via environment variable', async function() {
|
||||
// Set the telemetry level to "limited" via environment variable and restart the server.
|
||||
process.env.GRIST_TELEMETRY_LEVEL = 'limited';
|
||||
await server.restart();
|
||||
|
||||
// Check that the Support Grist page reports telemetry is enabled.
|
||||
await driver.get(`${server.getHost()}/admin`);
|
||||
await waitForAdminPanel();
|
||||
const toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
await toggleItem('telemetry');
|
||||
assert.equal(
|
||||
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
|
||||
'You have opted in to telemetry. Thank you! 🙏'
|
||||
);
|
||||
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
|
||||
/Opt out of Telemetry/).isPresent());
|
||||
|
||||
// Now set the telemetry level to "off" and restart the server.
|
||||
process.env.GRIST_TELEMETRY_LEVEL = 'off';
|
||||
await server.restart();
|
||||
|
||||
// Check that the Support Grist page reports telemetry is disabled.
|
||||
await driver.get(`${server.getHost()}/admin`);
|
||||
await waitForAdminPanel();
|
||||
await toggleItem('telemetry');
|
||||
assert.equal(
|
||||
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
|
||||
'You have opted out of telemetry.'
|
||||
);
|
||||
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
|
||||
/Opt in to Telemetry/).isPresent());
|
||||
});
|
||||
|
||||
it('should show version', async function() {
|
||||
await driver.get(`${server.getHost()}/admin`);
|
||||
await waitForAdminPanel();
|
||||
assert.equal(await driver.find('.test-admin-panel-item-version').isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-admin-panel-item-value-version').getText(), /^Version \d+\./);
|
||||
});
|
||||
});
|
||||
|
||||
async function assertTelemetryLevel(level: TelemetryLevel) {
|
||||
const telemetryLevel = await driver.executeScript('return window.gristConfig.telemetry?.telemetryLevel');
|
||||
assert.equal(telemetryLevel, level);
|
||||
}
|
||||
|
||||
async function toggleItem(itemId: string) {
|
||||
const header = await driver.find(`.test-admin-panel-item-name-${itemId}`);
|
||||
await header.click();
|
||||
await driver.sleep(500); // Time to expand or collapse.
|
||||
return header;
|
||||
}
|
||||
|
||||
async function withExpandedItem(itemId: string, callback: () => Promise<void>) {
|
||||
const header = await toggleItem(itemId);
|
||||
await callback();
|
||||
await header.click();
|
||||
await driver.sleep(500); // Time to collapse.
|
||||
}
|
||||
|
||||
const isSwitchOn = (switchElem: WebElement) => switchElem.matches('[class*=switch_on]');
|
||||
const waitForAdminPanel = () => driver.findWait('.test-admin-panel', 2000);
|
Loading…
Reference in new issue