mirror of
https://github.com/cryptpad/cryptpad.git
synced 2026-09-12 19:49:59 +05:00
Merge pull request #1801 from cryptpad/plugin-decrees
Add and remove admins from the UI
This commit is contained in:
commit
1a651e3ff1
@ -135,6 +135,24 @@ define(req, function(AppConfig, Default, Language) {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
// XXX
|
||||
Messages.admin_cat_admins = "Administrators";
|
||||
Messages.admin_admin = "Admin";
|
||||
Messages.admin_listAdminsTitle = "Current administrators";
|
||||
Messages.admin_listAdminsHint = "View and remove administrators";
|
||||
Messages.admin_addAdminsTitle = "Add administrators";
|
||||
Messages.admin_addAdminsHint = "Add administrators from their public key or from your contacts list";
|
||||
Messages.admin_addAdminsAdd = "Promote a contact to admin";
|
||||
Messages.admin_addKeyLabel = "Add an admin using their public key";
|
||||
|
||||
Messages.admin_listName = "Admin name";
|
||||
Messages.admin_listKey = "Admin key";
|
||||
Messages.admin_listAction = "Remove admin rights";
|
||||
|
||||
Messages.admin_listHardcoded = "Admin added into config.js. Can only be removed by editing the config file.";
|
||||
Messages.admin_listConfirm = "Are you sure you want to remove the admin rights of this user?";
|
||||
|
||||
return Messages;
|
||||
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
@import (reference) "/customize/src/less2/include/colortheme-all.less";
|
||||
@import (reference) "/customize/src/less2/include/leftside-menu.less";
|
||||
@import (reference) "/customize/src/less2/include/browser.less";
|
||||
@import (reference) "/customize/src/less2/include/variables.less";
|
||||
|
||||
@sidebar_block-width: 25rem;;
|
||||
@sidebar_base-margin: 0.5rem;
|
||||
@ -121,6 +122,12 @@
|
||||
flex-flow: column;
|
||||
align-items: baseline;
|
||||
}
|
||||
.cp-usergrid-container {
|
||||
margin-bottom: 0px !important;
|
||||
.cp-usergrid-grid {
|
||||
margin-bottom: -3px;
|
||||
}
|
||||
}
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@ -128,6 +135,9 @@
|
||||
font-family: inherit;
|
||||
max-width: @sidebar_block-width;
|
||||
}
|
||||
input:invalid {
|
||||
border: 1px solid red;
|
||||
}
|
||||
[type="color"] {
|
||||
width: @sidebar_block-width/5;
|
||||
padding: 3px;
|
||||
@ -214,7 +224,7 @@
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-left: 0px;
|
||||
height: 40px;
|
||||
height: @variables_input-height;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
@ -236,6 +246,12 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.cp-sidebarlayout-description-item {
|
||||
display: block;
|
||||
color: @cp_sidebar-hint;
|
||||
margin-top: @sidebar_base-margin;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
label.noTitle {
|
||||
display: inline-flex;
|
||||
.fa {
|
||||
|
||||
@ -18,9 +18,11 @@ const MFA = require("../storage/mfa");
|
||||
const ArchiveAccount = require('../archive-account');
|
||||
const { Worker } = require('node:worker_threads');
|
||||
const Fse = require("fs-extra");
|
||||
|
||||
const Fs = require("fs");
|
||||
|
||||
const config = require("../load-config");
|
||||
const Keys = require("../keys");
|
||||
|
||||
var Admin = module.exports;
|
||||
|
||||
var getFileDescriptorCount = function (Env, server, cb) {
|
||||
@ -468,7 +470,29 @@ var setLastEviction = function (Env, Server, cb, data, unsafeKey) {
|
||||
};
|
||||
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', ['INSTANCE_STATUS], console.log)
|
||||
const getAdminsData = (Env) => {
|
||||
return Env.adminsData.map(str => {
|
||||
// str is either a full public key or just the ed part
|
||||
const edPublic = Keys.canonicalize(str);
|
||||
const hardcoded = Array.isArray(config?.adminKeys) &&
|
||||
config.adminKeys.some(key => {
|
||||
return Keys.canonicalize(key) === edPublic;
|
||||
});
|
||||
if (str.length === 44) {
|
||||
return { edPublic, first: true, hardcoded };
|
||||
}
|
||||
let name;
|
||||
try {
|
||||
const parsed = Keys.parseUser(str);
|
||||
name = parsed.user;
|
||||
} catch (e) {}
|
||||
return {
|
||||
edPublic, hardcoded, name
|
||||
};
|
||||
});
|
||||
};
|
||||
var instanceStatus = function (Env, Server, cb) {
|
||||
|
||||
cb(void 0, {
|
||||
|
||||
appsToDisable: Env.appsToDisable,
|
||||
@ -514,6 +538,8 @@ var instanceStatus = function (Env, Server, cb) {
|
||||
instanceName: Env.instanceName,
|
||||
instanceNotice: Env.instanceNotice,
|
||||
enforceMFA: Env.enforceMFA,
|
||||
|
||||
admins: getAdminsData(Env)
|
||||
});
|
||||
};
|
||||
|
||||
@ -1138,6 +1164,19 @@ Admin.command = function (Env, safeKey, data, _cb, Server) {
|
||||
|
||||
var command = commands[data[0]];
|
||||
|
||||
Object.keys(Env.plugins || {}).forEach(name => {
|
||||
let plugin = Env.plugins[name];
|
||||
if (!plugin.addAdminCommands) { return; }
|
||||
try {
|
||||
let c = plugin.addAdminCommands(Env);
|
||||
Object.keys(c || {}).forEach(cmd => {
|
||||
if (typeof(c[cmd]) !== "function") { return; }
|
||||
if (commands[cmd]) { return; }
|
||||
commands[cmd] = c[cmd];
|
||||
});
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
if (typeof(command) === 'function') {
|
||||
return void command(Env, Server, cb, data, unsafeKey);
|
||||
}
|
||||
|
||||
140
lib/decrees-core.js
Normal file
140
lib/decrees-core.js
Normal file
@ -0,0 +1,140 @@
|
||||
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
var Decrees = module.exports;
|
||||
var Util = require("./common-util");
|
||||
var Fs = require("fs");
|
||||
var Path = require("path");
|
||||
var readFileBin = require("./stream-file").readFileBin;
|
||||
var Schedule = require("./schedule");
|
||||
var Fse = require("fs-extra");
|
||||
var nThen = require("nthen");
|
||||
|
||||
|
||||
const Utils = Decrees.Utils = {};
|
||||
var isString = (str) => {
|
||||
return typeof(str) === "string";
|
||||
};
|
||||
var isInteger = function (n) {
|
||||
return !(typeof(n) !== 'number' || isNaN(n) || (n % 1) !== 0);
|
||||
};
|
||||
Utils.args_isBoolean = function (args) {
|
||||
return !(!Array.isArray(args) || typeof(args[0]) !== 'boolean');
|
||||
};
|
||||
Utils.args_isString = function (args) {
|
||||
return !(!Array.isArray(args) || !isString(args[0]));
|
||||
};
|
||||
Utils.args_isInteger = function (args) {
|
||||
return !(!Array.isArray(args) || !isInteger(args[0]));
|
||||
};
|
||||
Utils.args_isPositiveInteger = function (args) {
|
||||
return Array.isArray(args) && isInteger(args[0]) && args[0] > 0;
|
||||
};
|
||||
|
||||
|
||||
Decrees.create = (name, commands) => {
|
||||
// [<command>, <args>, <author>, <time>]
|
||||
const handleCommand = function (Env, line) {
|
||||
var command = line[0];
|
||||
var args = line[1];
|
||||
|
||||
if (typeof(commands[command]) !== 'function') {
|
||||
throw new Error("DECREE_UNSUPPORTED_COMMAND");
|
||||
}
|
||||
|
||||
var outcome = commands[command](Env, args);
|
||||
if (outcome) {
|
||||
// trigger Env change event...
|
||||
Env.envUpdated.fire();
|
||||
}
|
||||
return outcome;
|
||||
};
|
||||
|
||||
const createLineHandler = function (Env) {
|
||||
var Log = Env.Log;
|
||||
|
||||
var index = -1;
|
||||
|
||||
return function (err, line) {
|
||||
index++;
|
||||
if (err) {
|
||||
// Log the error and bail out
|
||||
return void Log.error("DECREE_LINE_ERR", {
|
||||
error: err.message,
|
||||
index: index,
|
||||
line: line,
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(line)) {
|
||||
try {
|
||||
return void handleCommand(Env, line);
|
||||
} catch (err2) {
|
||||
return void Log.error("DECREE_COMMAND_ERR", {
|
||||
error: err2.message,
|
||||
index: index,
|
||||
line: line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Log.error("DECREE_HANDLER_WEIRD_LINE", {
|
||||
line: line,
|
||||
index: index,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const load = function (Env, _cb) {
|
||||
Env.scheduleDecree = Env.scheduleDecree || Schedule();
|
||||
|
||||
var cb = Util.once(Util.mkAsync(function (err) {
|
||||
if (err && err.code !== 'ENOENT') {
|
||||
return void _cb(err);
|
||||
}
|
||||
_cb();
|
||||
}));
|
||||
|
||||
Env.scheduleDecree.blocking('', function (unblock) {
|
||||
var done = Util.once(Util.both(cb, unblock));
|
||||
nThen(function (w) {
|
||||
// ensure that the path to the decree log exists
|
||||
Fse.mkdirp(Env.paths.decree, w(function (err) {
|
||||
if (!err) { return; }
|
||||
w.abort();
|
||||
done(err);
|
||||
}));
|
||||
}).nThen(function () {
|
||||
var decreeName = Path.join(Env.paths.decree, name);
|
||||
var stream = Fs.createReadStream(decreeName, {start: 0});
|
||||
var handler = createLineHandler(Env);
|
||||
readFileBin(stream, function (msgObj, next) {
|
||||
var text = msgObj.buff.toString('utf8');
|
||||
try {
|
||||
handler(void 0, JSON.parse(text));
|
||||
} catch (err) {
|
||||
handler(err, text);
|
||||
}
|
||||
next();
|
||||
}, function (err) {
|
||||
done(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const write = function (Env, decree, _cb) {
|
||||
var path = Path.join(Env.paths.decree, name);
|
||||
Env.scheduleDecree.ordered('', function (next) {
|
||||
var cb = Util.both(Util.mkAsync(_cb), next);
|
||||
Fs.appendFile(path, JSON.stringify(decree) + '\n', cb);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
handleCommand,
|
||||
load,
|
||||
write
|
||||
};
|
||||
};
|
||||
363
lib/decrees.js
363
lib/decrees.js
@ -1,9 +1,91 @@
|
||||
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
|
||||
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
var Decrees = module.exports;
|
||||
var Core = require("./commands/core");
|
||||
const Core = require("./commands/core");
|
||||
const DecreesCore = require("./decrees-core");
|
||||
const config = require('./load-config');
|
||||
|
||||
const Quota = require("./commands/quota");
|
||||
const Keys = require("./keys");
|
||||
|
||||
const DECREE_NAME = 'decree.ndjson';
|
||||
|
||||
const {
|
||||
args_isBoolean,
|
||||
args_isString,
|
||||
args_isInteger,
|
||||
args_isPositiveInteger
|
||||
} = DecreesCore.Utils;
|
||||
|
||||
// Toggles a simple boolean
|
||||
const makeBooleanSetter = function (attr) {
|
||||
return function (Env, args) {
|
||||
if (!args_isBoolean(args)) {
|
||||
throw new Error('INVALID_ARGS');
|
||||
}
|
||||
var bool = args[0];
|
||||
if (bool === Env[attr]) { return false; }
|
||||
Env[attr] = bool;
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const default_validator = function () { return true; };
|
||||
const makeGenericSetter = function (attr, validator) {
|
||||
validator = validator || default_validator;
|
||||
return function (Env, args) {
|
||||
if (!validator(args)) {
|
||||
throw new Error("INVALID_ARGS");
|
||||
}
|
||||
var value = args[0];
|
||||
if (value === Env[attr]) { return false; }
|
||||
Env[attr] = value;
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const makeIntegerSetter = function (attr) {
|
||||
return makeGenericSetter(attr, args_isInteger);
|
||||
};
|
||||
|
||||
const makeTranslation = function (attr) {
|
||||
return function (Env, args) {
|
||||
if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); }
|
||||
var value = args[0];
|
||||
var state = Env[attr];
|
||||
|
||||
if (typeof(value) === 'string') {
|
||||
if (state.default === value) { return false; }
|
||||
state.default = value;
|
||||
return true;
|
||||
}
|
||||
if (value && typeof(value) === 'object') {
|
||||
var changed = false;
|
||||
Object.keys(value).forEach(function (lang) {
|
||||
if (state[lang] === value[lang]) { return; }
|
||||
state[lang] = value[lang];
|
||||
changed = true;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
/* commands have a simple API:
|
||||
|
||||
* they receive the global Env and the arguments to be applied
|
||||
* if the arguments are invalid the operation will not be applied
|
||||
* the command throws
|
||||
* if the arguments are valid but do not result in a change, the operation is redundant.
|
||||
* return false
|
||||
* if the arguments are valid and will result in a change, the operation should be applied
|
||||
* apply it
|
||||
* return true to indicate that it was applied
|
||||
|
||||
*/
|
||||
const commands = {};
|
||||
|
||||
/* Admin decrees which modify global server state
|
||||
|
||||
@ -75,33 +157,26 @@ RM_ADMIN_KEY
|
||||
|
||||
*/
|
||||
|
||||
var commands = {};
|
||||
/* commands have a simple API:
|
||||
|
||||
* they receive the global Env and the arguments to be applied
|
||||
* if the arguments are invalid the operation will not be applied
|
||||
* the command throws
|
||||
* if the arguments are valid but do not result in a change, the operation is redundant.
|
||||
* return false
|
||||
* if the arguments are valid and will result in a change, the operation should be applied
|
||||
* apply it
|
||||
* return true to indicate that it was applied
|
||||
|
||||
*/
|
||||
|
||||
var args_isBoolean = function (args) {
|
||||
return !(!Array.isArray(args) || typeof(args[0]) !== 'boolean');
|
||||
// Maintenance: Empty string or an object with a start and end time
|
||||
const isNumber = function (value) {
|
||||
return typeof(value) === "number" && !isNaN(value);
|
||||
};
|
||||
const args_isMaintenance = function (args) {
|
||||
return Array.isArray(args) && args[0] &&
|
||||
(args[0] === "" || (isNumber(args[0].end) && isNumber(args[0].start)));
|
||||
};
|
||||
|
||||
// Toggles a simple boolean
|
||||
var makeBooleanSetter = function (attr) {
|
||||
// we anticipate that we'll add language-specific surveys in the future
|
||||
// whenever that happens we can relax validation a bit to support more formats
|
||||
const makeBroadcastSetter = function (attr, validation) {
|
||||
return function (Env, args) {
|
||||
if (!args_isBoolean(args)) {
|
||||
if ((validation && !validation(args)) && !args_isString(args)) {
|
||||
throw new Error('INVALID_ARGS');
|
||||
}
|
||||
var bool = args[0];
|
||||
if (bool === Env[attr]) { return false; }
|
||||
Env[attr] = bool;
|
||||
var str = args[0];
|
||||
if (str === Env[attr]) { return false; }
|
||||
Env[attr] = str;
|
||||
Env.broadcastCache = {};
|
||||
return true;
|
||||
};
|
||||
};
|
||||
@ -139,49 +214,6 @@ commands.REMOVE_DONATE_BUTTON = makeBooleanSetter('removeDonateButton');
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['BLOCK_DAILY_CHECK', [true]]], console.log)
|
||||
commands.BLOCK_DAILY_CHECK = makeBooleanSetter('blockDailyCheck');
|
||||
|
||||
/*
|
||||
var isNonNegativeNumber = function (n) {
|
||||
return !(typeof(n) !== 'number' || isNaN(n) || n < 0);
|
||||
};
|
||||
*/
|
||||
|
||||
var default_validator = function () { return true; };
|
||||
var makeGenericSetter = function (attr, validator) {
|
||||
validator = validator || default_validator;
|
||||
return function (Env, args) {
|
||||
if (!validator(args)) {
|
||||
throw new Error("INVALID_ARGS");
|
||||
}
|
||||
var value = args[0];
|
||||
if (value === Env[attr]) { return false; }
|
||||
Env[attr] = value;
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
var isString = (str) => {
|
||||
return typeof(str) === "string";
|
||||
};
|
||||
var isInteger = function (n) {
|
||||
return !(typeof(n) !== 'number' || isNaN(n) || (n % 1) !== 0);
|
||||
};
|
||||
|
||||
var args_isString = function (args) {
|
||||
return !(!Array.isArray(args) || !isString(args[0]));
|
||||
};
|
||||
|
||||
var args_isInteger = function (args) {
|
||||
return !(!Array.isArray(args) || !isInteger(args[0]));
|
||||
};
|
||||
|
||||
var makeIntegerSetter = function (attr) {
|
||||
return makeGenericSetter(attr, args_isInteger);
|
||||
};
|
||||
|
||||
var arg_isPositiveInteger = function (args) {
|
||||
return Array.isArray(args) && isInteger(args[0]) && args[0] > 0;
|
||||
};
|
||||
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LOGO_MIME', ['image/png']]], console.log)
|
||||
commands.SET_LOGO_MIME = makeGenericSetter('logoMimeType', args_isString);
|
||||
|
||||
@ -192,7 +224,7 @@ commands.SET_ACCENT_COLOR = makeGenericSetter('accentColor', args_isString);
|
||||
commands.ENABLE_PROFILING = makeBooleanSetter('enableProfiling');
|
||||
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_PROFILING_WINDOW', [10000]]], console.log)
|
||||
commands.SET_PROFILING_WINDOW = makeGenericSetter('profilingWindow', arg_isPositiveInteger);
|
||||
commands.SET_PROFILING_WINDOW = makeGenericSetter('profilingWindow', args_isPositiveInteger);
|
||||
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAX_UPLOAD_SIZE', [50 * 1024 * 1024]]], console.log)
|
||||
commands.SET_MAX_UPLOAD_SIZE = makeIntegerSetter('maxUploadSize');
|
||||
@ -246,30 +278,6 @@ commands.SET_SUPPORT_KEYS = function (Env, args) {
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_PURPOSE', ["development"]]], console.log)
|
||||
commands.SET_INSTANCE_PURPOSE = makeGenericSetter('instancePurpose', args_isString);
|
||||
|
||||
var makeTranslation = function (attr) {
|
||||
return function (Env, args) {
|
||||
if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); }
|
||||
var value = args[0];
|
||||
var state = Env[attr];
|
||||
|
||||
if (typeof(value) === 'string') {
|
||||
if (state.default === value) { return false; }
|
||||
state.default = value;
|
||||
return true;
|
||||
}
|
||||
if (value && typeof(value) === 'object') {
|
||||
var changed = false;
|
||||
Object.keys(value).forEach(function (lang) {
|
||||
if (state[lang] === value[lang]) { return; }
|
||||
state[lang] = value[lang];
|
||||
changed = true;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_JURISDICTION', ['France']]], console.log)
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_JURISDICTION', [{default:'France',de:'Frankreich'}]]], console.log)
|
||||
commands.SET_INSTANCE_JURISDICTION = makeTranslation('instanceJurisdiction');
|
||||
@ -286,30 +294,6 @@ commands.SET_INSTANCE_DESCRIPTION = makeTranslation('instanceDescription');
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_NOTICE', [{default:'Our hosting costs have increased during the pandemic. Please consider donating!',fr:'Nos coûts d'hébergement ont augmenté pendant la pandémie. Veuillez envisager de faire un don !']]], console.log)
|
||||
commands.SET_INSTANCE_NOTICE = makeTranslation('instanceNotice');
|
||||
|
||||
// Maintenance: Empty string or an object with a start and end time
|
||||
var isNumber = function (value) {
|
||||
return typeof(value) === "number" && !isNaN(value);
|
||||
};
|
||||
var args_isMaintenance = function (args) {
|
||||
return Array.isArray(args) && args[0] &&
|
||||
(args[0] === "" || (isNumber(args[0].end) && isNumber(args[0].start)));
|
||||
};
|
||||
|
||||
// we anticipate that we'll add language-specific surveys in the future
|
||||
// whenever that happens we can relax validation a bit to support more formats
|
||||
var makeBroadcastSetter = function (attr, validation) {
|
||||
return function (Env, args) {
|
||||
if ((validation && !validation(args)) && !args_isString(args)) {
|
||||
throw new Error('INVALID_ARGS');
|
||||
}
|
||||
var str = args[0];
|
||||
if (str === Env[attr]) { return false; }
|
||||
Env[attr] = str;
|
||||
Env.broadcastCache = {};
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LAST_BROADCAST_HASH', [hash]]], console.log)
|
||||
commands.SET_LAST_BROADCAST_HASH = makeBroadcastSetter('lastBroadcastHash');
|
||||
|
||||
@ -320,10 +304,6 @@ commands.SET_SURVEY_URL = makeBroadcastSetter('surveyURL');
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAINTENANCE', [""]]], console.log)
|
||||
commands.SET_MAINTENANCE = makeBroadcastSetter('maintenance', args_isMaintenance);
|
||||
|
||||
var Quota = require("./commands/quota");
|
||||
var Keys = require("./keys");
|
||||
var Util = require("./common-util");
|
||||
|
||||
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_QUOTA', ['[user@box:3000/VzeS4vP1DF+tXGuq1i50DKYuBL+09Yqy8kGxoUKRzhA=]', { limit: 2 * 1024 * 1024 * 1024, plan: 'buddy', note: "you're welcome" } ] ] ], console.log)
|
||||
commands.SET_QUOTA = function (Env, args) {
|
||||
if (!Array.isArray(args) || args.length !== 2) {
|
||||
@ -387,11 +367,45 @@ commands.ADD_ADMIN_KEY = function (Env, args) {
|
||||
|
||||
Env.admins = Env.admins || [];
|
||||
|
||||
|
||||
var key = Keys.canonicalize(args[0]);
|
||||
if (!key) { throw new Error("INVALID_KEY"); }
|
||||
Env.admins.push(key);
|
||||
|
||||
if (Env.admins.includes(key)) { // Nothing to change
|
||||
return false;
|
||||
}
|
||||
|
||||
Env.admins.push(key);
|
||||
Env.adminsData.push(args[0]);
|
||||
return true;
|
||||
};
|
||||
commands.RM_ADMIN_KEY = function (Env, args) {
|
||||
if (!Array.isArray(args) || args.length !== 1 || !args[0]) {
|
||||
throw new Error("INVALID_ARGS");
|
||||
}
|
||||
|
||||
const key = Keys.canonicalize(args[0]);
|
||||
if (!key) { throw new Error("INVALID_KEY"); }
|
||||
|
||||
Env.admins = Env.admins || [];
|
||||
if (!Env.admins.includes(key)) { // Nothing to change
|
||||
return false;
|
||||
}
|
||||
|
||||
// NOTE prevent removing config.js hardcoded admin keys
|
||||
if (Array.isArray(config?.adminKeys) && config.adminKeys.includes(key)) {
|
||||
throw new Error("CANT_REMOVE_CONFIG");
|
||||
}
|
||||
|
||||
let idx = Env.admins.indexOf(key);
|
||||
if (idx < 0) { return false; } // should never happen
|
||||
|
||||
if (Env.admins.length === 1) { throw new Error("CANT_REMOVE_LAST_ADMIN"); }
|
||||
Env.admins.splice(idx, 1);
|
||||
Env.adminsData = Env.adminsData.filter(str => {
|
||||
const ed = Keys.canonicalize(str);
|
||||
if (!ed) { return true; }
|
||||
return ed !== key;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
@ -414,107 +428,6 @@ commands.SET_BEARER_SECRET = function (Env, args) {
|
||||
return true;
|
||||
};
|
||||
|
||||
// [<command>, <args>, <author>, <time>]
|
||||
var handleCommand = Decrees.handleCommand = function (Env, line) {
|
||||
var command = line[0];
|
||||
var args = line[1];
|
||||
|
||||
if (typeof(commands[command]) !== 'function') {
|
||||
throw new Error("DECREE_UNSUPPORTED_COMMAND");
|
||||
}
|
||||
module.exports = DecreesCore.create(DECREE_NAME, commands);
|
||||
|
||||
var outcome = commands[command](Env, args);
|
||||
if (outcome) {
|
||||
// trigger Env change event...
|
||||
Env.envUpdated.fire();
|
||||
}
|
||||
return outcome;
|
||||
};
|
||||
|
||||
Decrees.createLineHandler = function (Env) {
|
||||
var Log = Env.Log;
|
||||
|
||||
var index = -1;
|
||||
|
||||
return function (err, line) {
|
||||
index++;
|
||||
if (err) {
|
||||
// Log the error and bail out
|
||||
return void Log.error("DECREE_LINE_ERR", {
|
||||
error: err.message,
|
||||
index: index,
|
||||
line: line,
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(line)) {
|
||||
try {
|
||||
return void handleCommand(Env, line);
|
||||
} catch (err2) {
|
||||
return void Log.error("DECREE_COMMAND_ERR", {
|
||||
error: err2.message,
|
||||
index: index,
|
||||
line: line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Log.error("DECREE_HANDLER_WEIRD_LINE", {
|
||||
line: line,
|
||||
index: index,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
var Fs = require("fs");
|
||||
var Path = require("path");
|
||||
var readFileBin = require("./stream-file").readFileBin;
|
||||
var Schedule = require("./schedule");
|
||||
var Fse = require("fs-extra");
|
||||
var nThen = require("nthen");
|
||||
|
||||
Decrees.load = function (Env, _cb) {
|
||||
Env.scheduleDecree = Env.scheduleDecree || Schedule();
|
||||
|
||||
var cb = Util.once(Util.mkAsync(function (err) {
|
||||
if (err && err.code !== 'ENOENT') {
|
||||
return void _cb(err);
|
||||
}
|
||||
_cb();
|
||||
}));
|
||||
|
||||
Env.scheduleDecree.blocking('', function (unblock) {
|
||||
var done = Util.once(Util.both(cb, unblock));
|
||||
nThen(function (w) {
|
||||
// ensure that the path to the decree log exists
|
||||
Fse.mkdirp(Env.paths.decree, w(function (err) {
|
||||
if (!err) { return; }
|
||||
w.abort();
|
||||
done(err);
|
||||
}));
|
||||
}).nThen(function () {
|
||||
var decreeName = Path.join(Env.paths.decree, 'decree.ndjson');
|
||||
var stream = Fs.createReadStream(decreeName, {start: 0});
|
||||
var handler = Decrees.createLineHandler(Env);
|
||||
readFileBin(stream, function (msgObj, next) {
|
||||
var text = msgObj.buff.toString('utf8');
|
||||
try {
|
||||
handler(void 0, JSON.parse(text));
|
||||
} catch (err) {
|
||||
handler(err, text);
|
||||
}
|
||||
next();
|
||||
}, function (err) {
|
||||
done(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Decrees.write = function (Env, decree, _cb) {
|
||||
var path = Path.join(Env.paths.decree, 'decree.ndjson');
|
||||
Env.scheduleDecree.ordered('', function (next) {
|
||||
var cb = Util.both(Util.mkAsync(_cb), next);
|
||||
Fs.appendFile(path, JSON.stringify(decree) + '\n', cb);
|
||||
});
|
||||
};
|
||||
|
||||
@ -239,7 +239,7 @@ module.exports.create = function (config) {
|
||||
evictionReport: {},
|
||||
commandTimers: {},
|
||||
|
||||
sso: config.sso,
|
||||
sso: plugins?.SSO?.config || {},
|
||||
enforceMFA: config.enforceMFA,
|
||||
|
||||
onlyOffice: {
|
||||
@ -372,6 +372,7 @@ module.exports.create = function (config) {
|
||||
Core.DEFAULT_LIMIT;
|
||||
|
||||
try {
|
||||
Env.adminsData = (config.adminKeys || []).slice();
|
||||
Env.admins = (config.adminKeys || []).map(function (k) {
|
||||
try {
|
||||
return Keys.canonicalize(k);
|
||||
|
||||
@ -584,14 +584,15 @@ var makeRouteCache = function (template, cacheName) {
|
||||
};
|
||||
};
|
||||
|
||||
const ssoList = Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) &&
|
||||
Env.sso.list.map(function (obj) { return obj.name; }) || [];
|
||||
const ssoCfg = (SSOUtils && ssoList.length) ? {
|
||||
force: (Env.sso && Env.sso.enforced && 1) || 0,
|
||||
password: (Env.sso && Env.sso.cpPassword && (Env.sso.forceCpPassword ? 2 : 1)) || 0,
|
||||
list: ssoList
|
||||
} : false;
|
||||
var serveConfig = makeRouteCache(function () {
|
||||
const ssoList = Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) &&
|
||||
Env.sso.list.map(function (obj) { return obj.name; }) || [];
|
||||
const ssoCfg = (SSOUtils && ssoList.length) ? {
|
||||
force: (Env.sso && Env.sso.enforced && 1) || 0,
|
||||
password: (Env.sso && Env.sso.cpPassword && (Env.sso.forceCpPassword ? 2 : 1)) || 0,
|
||||
list: ssoList
|
||||
} : false;
|
||||
|
||||
return [
|
||||
'define(function(){',
|
||||
'return ' + JSON.stringify({
|
||||
|
||||
@ -46,11 +46,4 @@ if (!isPositiveNumber(config.premiumUploadSize) || config.premiumUploadSize < co
|
||||
delete config.premiumUploadSize;
|
||||
}
|
||||
|
||||
config.sso = {};
|
||||
try {
|
||||
config.sso = require("../config/sso");
|
||||
} catch (e) {
|
||||
//console.log("SSO config not found");
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@ -45,7 +45,7 @@
|
||||
"notp": "^2.0.3",
|
||||
"nthen": "0.1.8",
|
||||
"open-sans-fontface": "^1.4.0",
|
||||
"openid-client": "^5.4.2",
|
||||
"openid-client": "^5.7.0",
|
||||
"pako": "^2.1.0",
|
||||
"prompt-confirm": "^2.0.4",
|
||||
"pull-stream": "^3.6.1",
|
||||
@ -2980,9 +2980,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.5",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz",
|
||||
"integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==",
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
@ -3727,11 +3727,11 @@
|
||||
"integrity": "sha512-d1VXrt1qPScsZnDHbZTOf1SmUnanr3KQgQM6+ye6KoFgrLo8a8mkX/J/ZJ2+w7vf0sCC02lRia5SAiaz0JPEog=="
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz",
|
||||
"integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==",
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||
"dependencies": {
|
||||
"jose": "^4.15.1",
|
||||
"jose": "^4.15.9",
|
||||
"lru-cache": "^6.0.0",
|
||||
"object-hash": "^2.2.0",
|
||||
"oidc-token-hash": "^5.0.3"
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
"notp": "^2.0.3",
|
||||
"nthen": "0.1.8",
|
||||
"open-sans-fontface": "^1.4.0",
|
||||
"openid-client": "^5.4.2",
|
||||
"openid-client": "^5.7.1",
|
||||
"pako": "^2.1.0",
|
||||
"prompt-confirm": "^2.0.4",
|
||||
"pull-stream": "^3.6.1",
|
||||
|
||||
@ -61,6 +61,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
&[data-item="add-admins"] {
|
||||
.cp-sidebar-form:not(:last-child) {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,7 +67,7 @@ define([
|
||||
'description',
|
||||
'email',
|
||||
'jurisdiction',
|
||||
'flush-cache'
|
||||
'flush-cache',
|
||||
]
|
||||
},
|
||||
'customize': { // Msg.admin_cat_customize
|
||||
@ -77,6 +77,13 @@ define([
|
||||
'color',
|
||||
]
|
||||
},
|
||||
'admins': {
|
||||
icon: 'fa fa-users',
|
||||
content: [
|
||||
'list-admins',
|
||||
'add-admins'
|
||||
]
|
||||
},
|
||||
'broadcast' : { // Msg.admin_cat_broadcast
|
||||
icon: 'fa fa-bullhorn',
|
||||
content : [
|
||||
@ -94,7 +101,7 @@ define([
|
||||
]
|
||||
},
|
||||
'apps': { // Msg.admin_cat_apps
|
||||
icon: 'fa fa-wrench',
|
||||
icon: 'fa fa-wrench',
|
||||
content: [
|
||||
'apps',
|
||||
]
|
||||
@ -226,6 +233,193 @@ define([
|
||||
cb(button);
|
||||
});
|
||||
|
||||
const evRefreshAdmins = Util.mkEvent();
|
||||
sidebar.addItem('list-admins', cb => {
|
||||
const removeAdmin = (edPublic, _cb) => {
|
||||
const cb = Util.mkAsync(_cb);
|
||||
sFrameChan.query('Q_ADMIN_RPC', {
|
||||
cmd: 'ADMIN_DECREE',
|
||||
data: ['RM_ADMIN_KEY', [edPublic]]
|
||||
}, function (e, response) {
|
||||
if (e || response.error) {
|
||||
UI.warn(Messages.error);
|
||||
console.error(e, response);
|
||||
return void cb('ERROR');
|
||||
}
|
||||
if (typeof(cb) === "function") { cb(); }
|
||||
});
|
||||
};
|
||||
|
||||
const header = [
|
||||
Messages.admin_listName,
|
||||
Messages.admin_listKey,
|
||||
Messages.admin_listAction
|
||||
];
|
||||
var list = blocks.table(header, []);
|
||||
list.setAttribute('id', 'cp-admin-table');
|
||||
let div = blocks.block([list]);
|
||||
div.setAttribute('id', 'cp-admin-table-container');
|
||||
|
||||
const refreshTable = () => {
|
||||
const admins = APP.instanceStatus.admins || [];
|
||||
const newRows = admins.map(obj => {
|
||||
let { name, edPublic, hardcoded } = obj;
|
||||
name = name || Messages.admin_admin;
|
||||
let button = blocks.button('danger','fa-ban', Messages.admin_usersRemove);
|
||||
let $b = $(button);
|
||||
Util.onClickEnter($b, () => {
|
||||
$b.prop('disabled', 'disabled');
|
||||
UI.confirm(Messages.admin_listConfirm, yes => {
|
||||
if (!yes) { return $b.prop('disabled', false); }
|
||||
removeAdmin(edPublic, err => {
|
||||
$b.prop('disabled', false);
|
||||
if (err) { return; }
|
||||
APP.updateStatus(function () {
|
||||
flushCache();
|
||||
evRefreshAdmins.fire();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
let note = Messages.admin_listHardcoded;
|
||||
let action = hardcoded ? note : button;
|
||||
|
||||
return [name, edPublic, action];
|
||||
});
|
||||
list.updateContent(newRows);
|
||||
};
|
||||
refreshTable();
|
||||
evRefreshAdmins.reg(() => {
|
||||
refreshTable();
|
||||
});
|
||||
|
||||
cb(div);
|
||||
});
|
||||
sidebar.addItem('add-admins', cb => {
|
||||
const content = blocks.block();
|
||||
const metadataMgr = common.getMetadataMgr();
|
||||
const privateData = metadataMgr.getPrivateData();
|
||||
const $div = $(content);
|
||||
|
||||
const addAdmin = (data, _cb) => {
|
||||
const cb = Util.mkAsync(_cb);
|
||||
const { ed, name } = data;
|
||||
const key = Hash.getPublicSigningKeyString(privateData.origin, name, ed);
|
||||
sFrameChan.query('Q_ADMIN_RPC', {
|
||||
cmd: 'ADMIN_DECREE',
|
||||
data: ['ADD_ADMIN_KEY', [key]]
|
||||
}, function (e, response) {
|
||||
if (e || response.error) {
|
||||
UI.warn(Messages.error);
|
||||
console.error(e, response);
|
||||
return void cb('ERROR');
|
||||
}
|
||||
if (typeof(cb) === "function") { cb(); }
|
||||
flushCache();
|
||||
});
|
||||
};
|
||||
|
||||
const keyInput = blocks.input({
|
||||
placeholder: Messages.admin_accountMetadataPlaceholder
|
||||
});
|
||||
const keyLabel = blocks.labelledInput(Messages.admin_addKeyLabel, keyInput);
|
||||
const keyButton = blocks.button('primary', 'fa-plus', Messages.tag_add);
|
||||
const keyForm = blocks.form([keyLabel], blocks.nav([keyButton]));
|
||||
const $keyInput = $(keyInput).on('input', () => {
|
||||
let val = $keyInput.val().trim();
|
||||
if (!val) {
|
||||
keyInput.setCustomValidity('');
|
||||
return;
|
||||
}
|
||||
let key = Keys.canonicalize(val);
|
||||
|
||||
if (keyInput.setCustomValidity) {
|
||||
if (!key) {
|
||||
const msg = Messages.admin_invalKey;
|
||||
keyInput.setCustomValidity(msg);
|
||||
} else {
|
||||
keyInput.setCustomValidity('');
|
||||
}
|
||||
}
|
||||
});
|
||||
const $keyBtn = $(keyButton);
|
||||
Util.onClickEnter($keyBtn, () => {
|
||||
let val = $keyInput.val().trim();
|
||||
let key = Keys.canonicalize(val);
|
||||
if (!key) { return; }
|
||||
// We have a valid key
|
||||
let name = Messages.admin_admin;
|
||||
try {
|
||||
let parsed = Keys.parseUser(val);
|
||||
name = parsed.user;
|
||||
} catch (e) {}
|
||||
$keyBtn.prop('disabled', 'disabled');
|
||||
addAdmin({ ed:key, name }, (err) => {
|
||||
$keyBtn.prop('disabled', false);
|
||||
if (!err) { $keyInput.val(''); }
|
||||
// refresh
|
||||
APP.updateStatus(function () {
|
||||
evRefreshAdmins.fire();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const drawContacts = () => {
|
||||
$div.empty();
|
||||
const members = {};
|
||||
const admins = APP.instanceStatus.admins || [];
|
||||
admins.forEach(obj => {
|
||||
const { edPublic, name, hardcoded, first } = obj;
|
||||
members[edPublic] = { name, hardcoded, first };
|
||||
});
|
||||
|
||||
// Remove admins from contacts list
|
||||
const friends = Util.clone(common.getFriends(false));
|
||||
Object.keys(friends).forEach((curve) => {
|
||||
const ed = friends[curve]?.edPublic;
|
||||
if (members[ed]) { delete friends[curve]; }
|
||||
});
|
||||
let contactsGrid = UIElements.getUserGrid(Messages.admin_addAdminsAdd, {
|
||||
common: common,
|
||||
list: true,
|
||||
large: true,
|
||||
data: friends
|
||||
}, function () {});
|
||||
let addBtn = blocks.button('primary', 'fa-plus', Messages.tag_add);
|
||||
Util.onClickEnter($(addBtn), () => {
|
||||
var $sel = $(contactsGrid.div).find('.cp-usergrid-user.cp-selected');
|
||||
nThen((waitFor) => {
|
||||
$sel.each((i, el) => {
|
||||
const $el = $(el);
|
||||
let ed = $el.attr('data-ed');
|
||||
let name = $el.attr('data-name');
|
||||
if (!ed || !name) {
|
||||
console.error('Missing data on selected user', el);
|
||||
return void UI.warn(Messages.error);
|
||||
}
|
||||
addAdmin({ed, name}, waitFor());
|
||||
});
|
||||
}).nThen(() => {
|
||||
APP.updateStatus(function () {
|
||||
evRefreshAdmins.fire();
|
||||
drawContacts();
|
||||
});
|
||||
});
|
||||
});
|
||||
evRefreshAdmins.reg(() => {
|
||||
drawContacts();
|
||||
});
|
||||
|
||||
const list = blocks.form([
|
||||
//currentList.div,
|
||||
contactsGrid.div,
|
||||
], blocks.nav([addBtn]));
|
||||
$div.append([keyForm, list]);
|
||||
};
|
||||
drawContacts();
|
||||
cb(content);
|
||||
});
|
||||
|
||||
var isHex = s => !/[^0-9a-f]/.test(s);
|
||||
|
||||
var sframeCommand = function (command, data, cb) {
|
||||
@ -3928,7 +4122,7 @@ define([
|
||||
|
||||
// EXTENSION_POINT:ADMIN_ITEM
|
||||
let utils = {
|
||||
h, Util, Hash, UIElements
|
||||
$, h, Util, Hash, UIElements, UI, APP
|
||||
};
|
||||
common.getExtensionsSync('ADMIN_ITEM').forEach(ext => {
|
||||
if (!ext || !ext.id || typeof(ext.getContent) !== "function") {
|
||||
@ -3952,13 +4146,19 @@ define([
|
||||
sidebar.makeLeftside(categories);
|
||||
};
|
||||
var updateStatus = APP.updateStatus = function (cb) {
|
||||
sFrameChan.query('Q_ADMIN_RPC', {
|
||||
cmd: 'INSTANCE_STATUS',
|
||||
}, function (e, data) {
|
||||
if (e) { console.error(e); return void cb(e); }
|
||||
if (!Array.isArray(data)) { return void cb('EINVAL'); }
|
||||
APP.instanceStatus = data[0];
|
||||
console.log("Status", APP.instanceStatus);
|
||||
nThen(w => {
|
||||
sFrameChan.query('Q_ADMIN_RPC', {
|
||||
cmd: 'INSTANCE_STATUS',
|
||||
}, w(function (e, data) {
|
||||
if (e) { console.error(e); return void cb(e); }
|
||||
if (!Array.isArray(data)) { return void cb('EINVAL'); }
|
||||
APP.instanceStatus = data[0];
|
||||
console.log("Status", APP.instanceStatus);
|
||||
}));
|
||||
require([`/api/config?${+new Date()}`], w(ApiConfig => {
|
||||
APP.instanceConfig = ApiConfig;
|
||||
}));
|
||||
}).nThen(() => {
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
@ -156,6 +156,35 @@ define([
|
||||
return box;
|
||||
};
|
||||
|
||||
// opts.values = { key1:label1, key2:label2 }
|
||||
blocks.radio = (key, state, opts, onChange) => {
|
||||
if (!opts?.values) {
|
||||
return void console.error('NO_VALUES');
|
||||
}
|
||||
let all = Object.keys(opts.values).map(k => {
|
||||
let v = opts.values[k];
|
||||
let r = UI.createRadio(
|
||||
`cp-${app}-${key}`,
|
||||
`cp-${app}-${key}-${k}`,
|
||||
v, state === k, {
|
||||
input: { value: k },
|
||||
label: { class: 'noTitle' }
|
||||
}
|
||||
);
|
||||
if (typeof(onChange) === "function"){
|
||||
$(r).find('input').on('change', function() {
|
||||
onChange(k);
|
||||
});
|
||||
}
|
||||
return r;
|
||||
});
|
||||
let block = h('div.cp-sidebar-flex-block', all);
|
||||
if (opts && opts.spinner) {
|
||||
block.spinner = UI.makeSpinner($(block));
|
||||
}
|
||||
return block;
|
||||
};
|
||||
|
||||
blocks.table = function (header, entries) {
|
||||
const table = h('table.cp-sidebar-table');
|
||||
if (header) {
|
||||
@ -255,6 +284,13 @@ define([
|
||||
return box;
|
||||
};
|
||||
|
||||
blocks.hintItem = (hint, item) => {
|
||||
return blocks.form([
|
||||
h('span.cp-sidebarlayout-description-item', hint),
|
||||
item
|
||||
]);
|
||||
};
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user