From 8e24b8b3f2b327f66fdd3526504fff49a4f3bb26 Mon Sep 17 00:00:00 2001 From: yflory Date: Thu, 15 Feb 2024 18:37:08 +0100 Subject: [PATCH] New support: moderator view --- customize.dist/src/less2/include/support.less | 10 + www/common/outer/async-store.js | 4 +- www/common/outer/mailbox-handlers.js | 2 + www/common/outer/mailbox.js | 3 +- www/common/outer/support.js | 332 +++++++++++------- www/moderation/app-moderation.less | 33 +- www/moderation/inner.js | 83 ++++- www/support/ui.js | 63 ++++ 8 files changed, 390 insertions(+), 140 deletions(-) diff --git a/customize.dist/src/less2/include/support.less b/customize.dist/src/less2/include/support.less index 5b92fd33d..9ad195f1b 100644 --- a/customize.dist/src/less2/include/support.less +++ b/customize.dist/src/less2/include/support.less @@ -49,6 +49,16 @@ max-width: 90%; margin: 5px auto; border-radius: @variables_radius; + .cp-support-ticket-header { + display: flex; + align-items: center; + justify-content: space-between; + } + &.cp-not-loaded { + .cp-support-list-actions { + display: none; + } + } .cp-support-list-message { background-color: @msg-bg; padding: 5px 5px; diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index c31967098..4a6198f20 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -308,7 +308,7 @@ define([ }); }; - var account = {}; + var account = store.account = {}; Store.getPinnedUsage = function (clientId, data, cb) { var s = getStore(data && data.teamId); @@ -2837,6 +2837,7 @@ define([ loadUniversal(Calendar, 'calendar', waitFor); if (store.modules['team']) { store.modules['team'].onReady(waitFor); } loadUniversal(History, 'history', waitFor); + loadUniversal(Support, 'support', waitFor); }).nThen(function () { var requestLogin = function () { broadcast([], "REQUEST_LOGIN"); @@ -2936,7 +2937,6 @@ define([ broadcast([], "UPDATE_TOKEN", { token: proxy[Constants.tokenKey] }); }); - loadUniversal(Support, 'support'); loadMailbox(); onReadyEvt.fire(); diff --git a/www/common/outer/mailbox-handlers.js b/www/common/outer/mailbox-handlers.js index fa6ffae6d..167bdb044 100644 --- a/www/common/outer/mailbox-handlers.js +++ b/www/common/outer/mailbox-handlers.js @@ -845,7 +845,9 @@ define([ handlers['NEW_TICKET'] = function (ctx, box, data, cb) { var msg = data.msg; var content = msg.content; + content.time = data.time; var i = 0; + console.error(msg); var handle = function () { var support = Util.find(ctx, ['store', 'modules', 'support']); if (!support && i++ < 100) { setTimeout(handle, 600); } diff --git a/www/common/outer/mailbox.js b/www/common/outer/mailbox.js index 7dc106911..120cd33ce 100644 --- a/www/common/outer/mailbox.js +++ b/www/common/outer/mailbox.js @@ -374,7 +374,8 @@ proxy.mailboxes = { // Message should be displayed var message = { msg: msg, - hash: hash + hash: hash, + time: time }; var notify = box.ready; Handlers.add(ctx, box, message, function (dismissed, toDismiss, invalid) { diff --git a/www/common/outer/support.js b/www/common/outer/support.js index 5b0ad7cde..09e8c310e 100644 --- a/www/common/outer/support.js +++ b/www/common/outer/support.js @@ -14,35 +14,53 @@ define([ ], function (Util, Hash, Realtime, nThen, Crypto, Listmap, ChainPad, CpNetflux) { var Support = {}; - var getKeys = function (ctx, cb) { + // UTILS + + var getKeys = function (ctx, isAdmin, data, _cb) { + var cb = Util.mkAsync(_cb); + if (isAdmin && !ctx.adminRdyEvt) { return void cb('EFORBIDDEN'); } require(['/api/config?' + (+new Date())], function (NewConfig) { var supportKey = NewConfig.newSupportMailbox; if (!supportKey) { return void cb('E_NOT_INIT'); } - var myCurve = ctx.store.proxy.curvePrivate; + + // If admin, check key + if (isAdmin && Util.find(ctx.store.proxy, [ + 'mailboxes', 'support2', 'keys', 'curvePublic']) !== supportKey) { + return void cb('EFORBIDDEN'); + } + + if (isAdmin) { + return ctx.adminRdyEvt.reg(() => { + cb(null, { + myCurve: data.adminCurvePrivate || Util.find(ctx.store.proxy, [ + 'mailboxes', 'support2', 'keys', 'curvePrivate']), + theirPublic: data.curvePublic + }); + }); + } + cb(null, { - supportKey, - myCurve + theirPublic: supportKey, + myCurve: ctx.store.proxy.curvePrivate }); }); }; - // Get the content of a mailbox and close it - // Used for support tickets - var getContent = function (ctx, data, _cb) { + // Get the content of a ticket mailbox and close it + var getContent = function (ctx, data, isAdmin, _cb) { var cb = Util.once(Util.both(close, Util.mkAsync(_cb))); - var supportKey, myCurve; + var theirPublic, myCurve; nThen((waitFor) => { - // Send ticket to the admins and call back - getKeys(ctx, waitFor((err, obj) => { + getKeys(ctx, isAdmin, data, waitFor((err, obj) => { if (err) { waitFor.abort(); return void cb({error: err}); } - supportKey = obj.supportKey; + theirPublic = obj.theirPublic; myCurve = obj.myCurve; })); }).nThen((waitFor) => { - var keys = Crypto.Curve.deriveKeys(supportKey, myCurve); + var keys = Crypto.Curve.deriveKeys(theirPublic, myCurve); var crypto = Crypto.Curve.createEncryptor(keys); var cfg = { network: ctx.store.network, @@ -65,8 +83,8 @@ define([ } catch (e) { console.error(e); } + msg.time = time; if (author) { msg.author = author; } - console.log(msg); all.push(msg); }; cfg.onError = cb; @@ -78,107 +96,6 @@ define([ }); }; - var loadAdminDoc = function (ctx, hash, cb) { - var secret = Hash.getSecrets('support', hash); - var listmapConfig = { - data: {}, - channel: secret.channel, - crypto: Crypto.createEncryptor(secret.keys), - userName: 'support', - ChainPad: ChainPad, - classic: true, - network: ctx.store.network, - //Cache: Cache, // XXX XXX XXX - metadata: { - validateKey: secret.keys.validateKey || undefined, - }, - }; - var rt = ctx.adminDoc = Listmap.create(listmapConfig); - // XXX on change, tell current user that support has changed? - rt.onReady = Util.mkEvent(true); - rt.proxy.on('ready', function () { - var doc = rt.proxy; - doc.tickets = doc.tickets || {}; - doc.tickets.active = doc.tickets.active || {}; - doc.tickets.closed = doc.tickets.closed || {}; - rt.onReady.fire(); - cb(); - }); - }; - -/* - -{ - tickets: { - active: {}, - closed: {} - } -} - -*/ - - var addAdminTicket = function (ctx, data, cb) { - console.error(data); - if (!ctx.adminDoc) { - if (ctx.admin) { - return void setTimeout(() => { addAdminTicket(ctx, data, cb); }, 200); - } - // XXX You have an admin mailbox but wrong keys ==> delete the mailbox? - return void cb(false); - } - // Wait for the chainpad to be ready before adding the data - ctx.adminDoc.onReady.reg(() => { - // random timeout to avoid duplication wiht multiple admins - var rdmTo = Math.floor(Math.random() * 2000); // Between 0 and 2000ms - console.warn(rdmTo); - setTimeout(() => { - var doc = ctx.adminDoc.proxy; - console.warn(data.channel, doc.tickets.active); - if (doc.tickets.active[data.channel] || doc.tickets.closed[data.channel]) { - console.warn('already there'); - return void cb(true); } - doc.tickets.active[data.channel] = { - title: data.title, - author: data.user && data.user.curvePublic - }; - Realtime.whenRealtimeSyncs(ctx.adminDoc.realtime, function () { - console.warn('synced'); - cb(true); - }); - }); - }); - }; - var initializeSupportAdmin = function (ctx) { - let proxy = ctx.store.proxy; - let supportKey = Util.find(proxy, ['mailboxes', 'support2', 'keys', 'curvePublic']); - let privateKey = Util.find(proxy, ['mailboxes', 'support2', 'keys', 'curvePrivate']); - nThen((waitFor) => { - getKeys(ctx, waitFor((err, obj) => { - if (err) { return void waitFor.abort(); } - if (obj.supportKey !== supportKey) { - // Deprecated support key: no longer an admin! - ctx.admin = false; - // XXX delete the mailbox? - return void waitFor.abort(); - } - })); - }).nThen((waitFor) => { - ctx.admin = true; - let seed = privateKey.slice(0,24); // XXX better way to get seed? - let hash = Hash.getEditHashFromKeys({ - version: 2, - type: 'support', - keys: { - editKeyStr: seed - } - }); - loadAdminDoc(ctx, hash, waitFor()); - }).nThen(() => { - console.log(ctx.store.mailbox) - }); - - }; - var makeTicket = function (ctx, data, cId, cb) { var mailbox = Util.find(ctx, [ 'store', 'mailbox' ]); var anonRpc = Util.find(ctx, [ 'store', 'anon_rpc' ]); @@ -191,12 +108,12 @@ define([ var supportKey, myCurve; nThen((waitFor) => { // Send ticket to the admins and call back - getKeys(ctx, waitFor((err, obj) => { + getKeys(ctx, false, data, waitFor((err, obj) => { if (err) { waitFor.abort(); return void cb({error: err}); } - supportKey = obj.supportKey; + supportKey = obj.theirPublic; myCurve = obj.myCurve; })); }).nThen((waitFor) => { @@ -216,7 +133,6 @@ define([ })); }).nThen((waitFor) => { // Store in our worker - console.error(channel); ctx.supportData[channel] = { time: +new Date(), title: title, @@ -225,10 +141,10 @@ define([ ctx.Store.onSync(null, waitFor()); }).nThen(() => { var supportChannel = Hash.getChannelIdFromKey(supportKey); - console.error(supportChannel); mailbox.sendTo('NEW_TICKET', { title: title, - channel: channel + channel: channel, + premium: Util.find(ctx, ['store', 'account', 'plan']) }, { channel: supportChannel, curvePublic: supportKey @@ -240,6 +156,41 @@ define([ }); }; + var replyTicket = function (ctx, data, isAdmin, cb) { + var mailbox = Util.find(ctx, [ 'store', 'mailbox' ]); + var anonRpc = Util.find(ctx, [ 'store', 'anon_rpc' ]); + if (!mailbox) { return void cb('E_NOT_READY'); } + if (!anonRpc) { return void cb("anonymous rpc session not ready"); } + var theirPublic, myCurve; + nThen((waitFor) => { + getKeys(ctx, isAdmin, data, waitFor((err, obj) => { + if (err) { + waitFor.abort(); + return void cb({error: err}); + } + theirPublic = obj.theirPublic; + myCurve = obj.myCurve; + })); + }).nThen(() => { + var keys = Crypto.Curve.deriveKeys(theirPublic, myCurve); + var crypto = Crypto.Curve.createEncryptor(keys); + var text = JSON.stringify(data.ticket); + var ciphertext = crypto.encrypt(text); + anonRpc.send("WRITE_PRIVATE_MESSAGE", [ + data.channel, + ciphertext + ], (err) => { + if (err) { + waitFor.abort(); + return void cb(err); + } + cb(); + }); + }); + }; + + // USER COMMANDS + var getMyTickets = function (ctx, data, cId, cb) { var all = []; var n = nThen; @@ -248,7 +199,7 @@ define([ var t = Util.clone(ctx.supportData[ticket]); getContent(ctx, { channel: ticket, - }, waitFor((err, messages) => { + }, false, waitFor((err, messages) => { if (err) { t.error = err; } else { t.messages = messages; } t.id = ticket; @@ -261,6 +212,135 @@ define([ }); }; + // ADMIN COMMANDS + + var listTicketsAdmin = function (ctx, data, cId, cb) { + if (!ctx.adminRdyEvt) { return void cb({ error: 'EFORBIDDEN' }); } + ctx.adminRdyEvt.reg(() => { + var doc = ctx.adminDoc.proxy; + cb(Util.clone(doc.tickets.active)); + }); + }; + + var loadTicketAdmin = function (ctx, data, cId, cb) { + getContent(ctx, data, true, function (err, res) { + if (err) { return void cb({error: err}); } + ctx.adminRdyEvt.reg(() => { + var doc = ctx.adminDoc.proxy; + if (Array.isArray(res) && res.length) { + res.sort((t1, t2) => { return t1.time - t2.time; }); + let last = res[res.length - 1]; + var entry = doc.tickets.active[data.channel]; + if (entry) { entry.time = last.time; } + } + cb(res); + }); + }); + }; + + var replyTicketAdmin = function (ctx, data, cId, cb) { + if (!ctx.adminRdyEvt) { return void cb({ error: 'EFORBIDDEN' }); } + replyTicket(ctx, data, true, (err) => { + if (err) { return void cb({error: err}); } + ctx.adminRdyEvt.reg(() => { + var doc = ctx.adminDoc.proxy; + var entry = doc.tickets.active[data.channel] || doc.tickets.pending[data.channel]; + entry.time = +new Date(); + entry.lastAdmin = true; + }); + cb({sent: true}); + }); + }; + + var addAdminTicket = function (ctx, data, cb) { + // Wait for the chainpad to be ready before adding the data + if (!ctx.adminRdyEvt) { return void cb(false); } // XXX not an admin, delete mailbox? + + ctx.adminRdyEvt.reg(() => { + // random timeout to avoid duplication wiht multiple admins + var rdmTo = Math.floor(Math.random() * 2000); // Between 0 and 2000ms + setTimeout(() => { + var doc = ctx.adminDoc.proxy; + if (doc.tickets.active[data.channel] || doc.tickets.closed[data.channel]) { + return void cb(true); } + doc.tickets.active[data.channel] = { + title: data.title, + premium: data.premium, + time: data.time, + author: data.user && data.user.displayName, + authorKey: data.user && data.user.curvePublic + }; + Realtime.whenRealtimeSyncs(ctx.adminDoc.realtime, function () { + console.warn('synced'); + cb(true); + }); + }); + }); + }; + + // INITIALIZE ADMIN + + var loadAdminDoc = function (ctx, hash, cb) { + var secret = Hash.getSecrets('support', hash); + var listmapConfig = { + data: {}, + channel: secret.channel, + crypto: Crypto.createEncryptor(secret.keys), + userName: 'support', + ChainPad: ChainPad, + classic: true, + network: ctx.store.network, + //Cache: Cache, // XXX XXX XXX + metadata: { + validateKey: secret.keys.validateKey || undefined, + }, + }; + var rt = ctx.adminDoc = Listmap.create(listmapConfig); + // XXX on change, tell current user that support has changed? + rt.proxy.on('ready', function () { + var doc = rt.proxy; + doc.tickets = doc.tickets || {}; + doc.tickets.active = doc.tickets.active || {}; + doc.tickets.closed = doc.tickets.closed || {}; + ctx.adminRdyEvt.fire(); + cb(); + }); + }; + + + var initializeSupportAdmin = function (ctx, waitFor) { + let unlock = waitFor(); + let proxy = ctx.store.proxy; + let supportKey = Util.find(proxy, ['mailboxes', 'support2', 'keys', 'curvePublic']); + let privateKey = Util.find(proxy, ['mailboxes', 'support2', 'keys', 'curvePrivate']); + ctx.adminRdyEvt = Util.mkEvent(true); + nThen((waitFor) => { + getKeys(ctx, false, {}, waitFor((err, obj) => { + setTimeout(unlock); // Unlock loading process + if (err) { return void waitFor.abort(); } + if (obj.theirPublic !== supportKey) { + // Deprecated support key: no longer an admin! + // XXX delete the mailbox? + return void waitFor.abort(); + } + })); + }).nThen((waitFor) => { + let seed = privateKey.slice(0,24); // XXX better way to get seed? + let hash = Hash.getEditHashFromKeys({ + version: 2, + type: 'support', + keys: { + editKeyStr: seed + } + }); + loadAdminDoc(ctx, hash, waitFor()); + }).nThen(() => { + console.log('Support admin loaded') + }); + + }; + + Support.init = function (cfg, waitFor, emit) { var support = {}; @@ -282,8 +362,7 @@ define([ }; if (Util.find(store, ['proxy', 'mailboxes', 'support2'])) { - initializeSupportAdmin(ctx); - + initializeSupportAdmin(ctx, waitFor); } support.ctx = ctx; @@ -302,6 +381,15 @@ define([ if (cmd === 'MAKE_TICKET') { return void makeTicket(ctx, data, clientId, cb); } + if (cmd === 'LIST_TICKETS_ADMIN') { + return void listTicketsAdmin(ctx, data, clientId, cb); + } + if (cmd === 'LOAD_TICKET_ADMIN') { + return void loadTicketAdmin(ctx, data, clientId, cb); + } + if (cmd === 'REPLY_TICKET_ADMIN') { + return void replyTicketAdmin(ctx, data, clientId, cb); + } if (cmd === 'GET_MY_TICKETS') { return void getMyTickets(ctx, data, clientId, cb); } diff --git a/www/moderation/app-moderation.less b/www/moderation/app-moderation.less index 2e6be71f6..7259d9270 100644 --- a/www/moderation/app-moderation.less +++ b/www/moderation/app-moderation.less @@ -26,17 +26,30 @@ display: flex; flex-flow: column; - .cp-sidebarlayout-element { - label:not(.cp-admin-label) { - font-weight: normal !important; - } - input { - max-width: 25rem; - } - nav { - display: flex; - margin-top: 0.5rem; + background: @cp_sidebar-right-bg; + color: @cp_sidebar-right-fg; + + #cp-content-container { + overflow: auto; + } + .cp-support-container { + display: flex; + flex-wrap: wrap; + .cp-support-column { + min-width: 700px; + flex: 1 0 50%; + h1 { + display: flex; + align-items: center; + button { + margin-left: 50px !important; + } + } + .cp-support-count { + margin-left: 10px; + } } } + } diff --git a/www/moderation/inner.js b/www/moderation/inner.js index bbb070d80..28d704e6d 100644 --- a/www/moderation/inner.js +++ b/www/moderation/inner.js @@ -42,7 +42,7 @@ define([ Keys, Support, Clipboard, - Sortify, + Sortify ) { var APP = { @@ -53,11 +53,84 @@ define([ var common; var sFrameChan; -// XXX - var andThen = function () { + var $body = $('#cp-content-container'); + var $container = $(h('div.cp-support-container')).appendTo($body); + var refresh = () => { + APP.module.execCommand('LIST_TICKETS_ADMIN', {}, (tickets) => { + $container.empty(); + var col1 = h('div.cp-support-column', h('h1', [ + h('span', Messages.admin_support_premium), + h('span.cp-support-count'), + ])); + var col2 = h('div.cp-support-column', h('h1', [ + h('span', Messages.admin_support_normal), + h('span.cp-support-count'), + ])); + var col3 = h('div.cp-support-column', h('h1', [ + h('span', Messages.admin_support_answered), + h('span.cp-support-count'), + ])); + $container.append([col1, col2, col3]); + var sortTicket = function (c1, c2) { + return tickets[c2].time - tickets[c1].time; + }; + + const onLoad = function (ticket, channel, data) { + APP.module.execCommand('LOAD_TICKET_ADMIN', { + channel: channel, + curvePublic: data.authorKey + }, function (obj) { + if (!Array.isArray(obj)) { + console.error(obj && obj.error); + return void UI.warn(Messages.error); + } + obj.forEach(function (msg) { + $(ticket).append(APP.support.makeMessage(msg)); + }); + }); + }; + const onClose = function (ticket, channel, data) { + APP.module.execCommand('CLOSE_TICKET_ADMIN', { + channel: channel, + curvePublic: data.authorKey + }, function (obj) { + // XXX TODO + }); + }; + const onReply = function (ticket, channel, data, form, cb) { + // XXX TODO + var formData = APP.support.getFormData(form); + APP.module.execCommand('REPLY_TICKET_ADMIN', { + channel: channel, + curvePublic: data.authorKey, + ticket: formData + }, function (obj) { + if (obj && obj.error) { + console.error(obj && obj.error); + return void UI.warn(Messages.error); + } + $(ticket).find('.cp-support-list-message').remove(); + refresh(); // XXX RE-open this ticket and scroll to? + }); + }; + + + Object.keys(tickets).sort(sortTicket).forEach(function (channel) { + var d = tickets[channel]; + var ticket = APP.support.makeTicketAdmin(channel, d, onLoad, onClose, onReply); + var container; + if (d.lastAdmin) { container = col3; } + else if (d.premium) { container = col1; } + else { container = col2; } + $(container).append(ticket); + }); + console.log(tickets); + }); + }; + refresh(); }; var createToolbar = function () { @@ -81,8 +154,6 @@ define([ APP.$toolbar = $('#cp-toolbar'); sFrameChan = common.getSframeChannel(); sFrameChan.onReady(waitFor()); - }).nThen(function (waitFor) { - if (!common.isAdmin()) { return; } // XXX moderator }).nThen(function (/*waitFor*/) { createToolbar(); var metadataMgr = common.getMetadataMgr(); @@ -96,8 +167,10 @@ define([ APP.privateKey = privateData.supportPrivateKey; APP.origin = privateData.origin; APP.readOnly = privateData.readOnly; + APP.module = common.makeUniversal('support'); APP.support = Support.create(common, true); + andThen(); UI.removeLoadingScreen(); }); diff --git a/www/support/ui.js b/www/support/ui.js index 9c5745902..606186182 100644 --- a/www/support/ui.js +++ b/www/support/ui.js @@ -516,6 +516,66 @@ define([ ]); }; + var makeTicketAdmin = function (ctx, id, content, onShow, onClose, onReply) { + var show = h('button.btn.btn-primary.cp-support-expand', Messages.admin_support_open); + + var answer = h('button.btn.btn-primary.cp-support-answer', Messages.support_answer); + var close = h('button.btn.btn-danger.cp-support-close', Messages.support_close); + var actions = h('div.cp-support-list-actions', [ answer, close ]); + + var isPremium = content.premium ? '.cp-support-premium' : ''; + var name = Util.fixHTML(content.author) || Messages.anonymous; + var ticket = h('div.cp-support-list-ticket.cp-not-loaded'+isPremium, { + 'data-id': id + }, [ + h('div.cp-support-ticket-header', [ + h('span', content.title), + UI.setHTML(h('span'), Messages._getKey('support_from', [name])), + h('span', new Date(content.time).toLocaleString()), + h('span', show), + ]), + actions + ]); + + // Add button handlers + + UI.confirmButton(close, { + classes: 'btn-danger' + }, function() { + $(close).remove(); + onClose(ticket, id, content); + }); + + var $ticket = $(ticket); + $(answer).click(function () { + $ticket.find('.cp-support-form-container').remove(); + $(actions).hide(); + var form = makeForm(ctx, function () { + console.error(form); + onReply(ticket, id, content, form, function () { + $(actions).css('display', ''); + $(form).remove(); + }); + /* + var sent = sendForm(ctx, content.id, form, content.sender); + if (sent) { + $(actions).css('display', ''); + $(form).remove(); + } + */ + }, content.title, true); + $ticket.append(form); + }); + + var $show = $(show); + Util.onClickEnter($show, function () { + $ticket.removeClass('cp-not-loaded'); + $show.remove(); + onShow(ticket, id, content); + }); + return ticket; + }; + var create = function (common, isAdmin, pinUsage, teamsUsage) { var ui = {}; var ctx = { @@ -551,6 +611,9 @@ define([ ui.makeTicket = function ($div, content, onHide) { return makeTicket(ctx, $div, content, onHide); }; + ui.makeTicketAdmin = function (id, content, onShow, onClose, onReply) { + return makeTicketAdmin(ctx, id, content, onShow, onClose, onReply); + }; ui.makeMessage = function (content, hash) { return makeMessage(ctx, content, hash); };