diff --git a/customize.dist/src/less2/include/support.less b/customize.dist/src/less2/include/support.less index bb861285a..954c22388 100644 --- a/customize.dist/src/less2/include/support.less +++ b/customize.dist/src/less2/include/support.less @@ -91,6 +91,12 @@ .cp-support-message-data { display: none; cursor: default; + position: relative; + .cp-support-copydata { + position: absolute; + right: 0; + margin-right: 0px; + } } } .cp-support-message-time { diff --git a/www/common/inner/sidebar-layout.js b/www/common/inner/sidebar-layout.js index 41b930013..a6253a2f2 100644 --- a/www/common/inner/sidebar-layout.js +++ b/www/common/inner/sidebar-layout.js @@ -50,7 +50,7 @@ define([ icon ? h('i', { 'class': icon }) : undefined, h('span', text) ]); - } + }; blocks.nav = (buttons) => { return h('nav', buttons); }; @@ -221,12 +221,16 @@ define([ var isActive = key === active ? '.cp-leftside-active' : ''; var item = h('li.cp-sidebarlayout-category'+isActive, { 'role': 'menuitem', - 'tabindex': 0 + 'tabindex': 0, + 'data-category': key }, [ icon, Messages[`${app}_cat_${key}`] || key, ]); var $item = $(item).appendTo(container); + category.open = function () { + $item.click(); + }; Util.onClickEnter($item, function () { if (!Array.isArray(category.content) && category.onClick) { @@ -245,6 +249,10 @@ define([ $leftside.append(container); }; + sidebar.openCategory = name => { + $(`.cp-sidebarlayout-category[data-category="${name}"]`).click(); + }; + sidebar.disableItem = (key) => { $(items[key]).remove(); delete items[key]; diff --git a/www/common/outer/mailbox-handlers.js b/www/common/outer/mailbox-handlers.js index 6216ef1d3..b7c9afd36 100644 --- a/www/common/outer/mailbox-handlers.js +++ b/www/common/outer/mailbox-handlers.js @@ -846,15 +846,17 @@ define([ handlers['NEW_TICKET'] = function (ctx, box, data, cb) { var msg = data.msg; var content = msg.content; - content.time = data.time; - var i = 0; - var handle = function () { - var support = Util.find(ctx, ['store', 'modules', 'support']); - if (!support && i++ < 100) { setTimeout(handle, 600); } - if (!support) { return; } - support.addAdminTicket(content, cb); - }; - handle(); + if (!content.time) { content.time = data.time; } + + var support = Util.find(ctx, ['store', 'modules', 'support']); + + // Admin to user + if (content.isAdmin) { + support.addUserTicket(content, cb); + } + + // User to admin + support.addAdminTicket(content, cb); }; var supportNotif, adminSupportNotif; handlers['NOTIF_TICKET'] = function (ctx, box, data, cb) { diff --git a/www/common/outer/mailbox.js b/www/common/outer/mailbox.js index ea5db15ef..74f7a1497 100644 --- a/www/common/outer/mailbox.js +++ b/www/common/outer/mailbox.js @@ -39,6 +39,7 @@ define([ if (res.error) { console.error(res); } }); } + // XXX XXX XXX no need for the "support" mailbox anymore if (!mailboxes['support'] && ctx.loggedIn) { mailboxes.support = { channel: Hash.createChannelId(), diff --git a/www/common/outer/support.js b/www/common/outer/support.js index 7137574a5..cfe0e523b 100644 --- a/www/common/outer/support.js +++ b/www/common/outer/support.js @@ -41,6 +41,7 @@ define([ if (isAdmin) { return ctx.adminRdyEvt.reg(() => { cb(null, { + supportKey: supportKey, myCurve: data.adminCurvePrivate || Util.find(ctx.store.proxy, [ 'mailboxes', 'supportteam', 'keys', 'curvePrivate']), theirPublic: data.curvePublic, @@ -50,8 +51,9 @@ define([ } cb(null, { - theirPublic: data.curvePublic || supportKey, // old tickets may use deprecated key + supportKey: supportKey, myCurve: ctx.store.proxy.curvePrivate, + theirPublic: data.curvePublic || supportKey, // old tickets may use deprecated key notifKey: supportKey }); }); @@ -108,7 +110,7 @@ define([ }); }; - var makeTicket = function (ctx, data, cId, cb) { + var makeTicket = 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({error: 'E_NOT_READY'}); } @@ -117,34 +119,57 @@ define([ var channel = data.channel; var title = data.title; var ticket = data.ticket; - var supportKey, myCurve; + var supportKey, theirPublic, myCurve, notifKey; + var time = +new Date(); nThen((waitFor) => { // Send ticket to the admins and call back - getKeys(ctx, false, data, waitFor((err, obj) => { + getKeys(ctx, isAdmin, data, waitFor((err, obj) => { if (err) { waitFor.abort(); return void cb({error: err}); } - supportKey = obj.theirPublic; + supportKey = obj.supportKey; + theirPublic = obj.theirPublic; myCurve = obj.myCurve; + notifKey = obj.notifKey; })); }).nThen((waitFor) => { // Create ticket mailbox - var keys = Crypto.Curve.deriveKeys(supportKey, myCurve); + var keys = Crypto.Curve.deriveKeys(theirPublic, myCurve); var crypto = Crypto.Curve.createEncryptor(keys); var text = JSON.stringify(ticket); var ciphertext = crypto.encrypt(text); anonRpc.send("WRITE_PRIVATE_MESSAGE", [ channel, ciphertext - ], waitFor((err) => { + ], waitFor((err, res) => { if (err) { waitFor.abort(); return void cb({error: err}); } + time = res && res[0]; })); }).nThen((waitFor) => { - // Store in our worker + if (!isAdmin) { return; } + // ADMIN: Store in chainpad + var doc = ctx.adminDoc.proxy; + var active = doc.tickets.active; + if (active[channel]) { + waitFor.abort(); + return void cb({error: 'EEXISTS'}); + } + active[channel] = { + title: ticket.title, + premium: false, + time: time, + author: data.name, + supportKey: supportKey, // Store current support key + lastAdmin: true, + authorKey: data.curvePublic + }; + }).nThen((waitFor) => { + if (isAdmin) { return; } + // USER: Store in my worker ctx.supportData[channel] = { time: +new Date(), title: title, @@ -152,31 +177,41 @@ define([ }; ctx.Store.onSync(null, waitFor()); }).nThen(() => { - var supportChannel = Hash.getChannelIdFromKey(supportKey); - // XXX create tickt as an admin - // XXX isAdmin + var notifChannel = isAdmin ? data.notifications + : Hash.getChannelIdFromKey(supportKey); // First message to deal with the new ticket (store it in the list) mailbox.sendTo('NEW_TICKET', { title: title, channel: channel, - premium: Util.find(ctx, ['store', 'account', 'plan']) + time, + isAdmin, + supportKey: supportKey, // Store current support key + premium: isAdmin ? '' : Util.find(ctx, ['store', 'account', 'plan']), + user: Util.find(data.ticket, ['sender', 'curvePublic']) ? undefined : { + supportTeam: true + } }, { - channel: supportChannel, - curvePublic: supportKey + channel: notifChannel, + curvePublic: theirPublic }, (obj) => { + console.error(obj); // Don't store the ticket in case of error if (obj && obj.error) { delete ctx.supportData[channel]; } cb(obj); }); - // XXX create tickt as an admin - // XXX isAdmin // Second message is only a notification to warn the user/admins mailbox.sendTo('NOTIF_TICKET', { title: title, channel: channel, + time, + isAdmin, + isNewTicket: true, + user: Util.find(data.ticket, ['sender', 'curvePublic']) ? undefined : { + supportTeam: true + } }, { - channel: supportChannel, - curvePublic: supportKey + channel: notifChannel, + curvePublic: theirPublic }, () => {}); }); }; @@ -403,6 +438,9 @@ define([ cb({tickets: all}); }); }; + var makeMyTicket = function (ctx, data, cId, cb) { + makeTicket(ctx, data, false, cb); + }; var replyMyTicket = function (ctx, data, cId, cb) { replyTicket(ctx, data, false, (err) => { if (err) { return void cb({error: err}); } @@ -479,6 +517,13 @@ define([ }); }; + var makeTicketAdmin = function (ctx, data, cId, cb) { + if (!ctx.adminRdyEvt) { return void cb({ error: 'EFORBIDDEN' }); } + ctx.adminRdyEvt.reg(() => { + makeTicket(ctx, data, true, cb); + }); + }; + var replyTicketAdmin = function (ctx, data, cId, cb) { if (!ctx.adminRdyEvt) { return void cb({ error: 'EFORBIDDEN' }); } let supportKey = data.supportKey; @@ -561,12 +606,12 @@ define([ let supportKey; nThen((waitFor) => { // Send ticket to the admins and call back - getKeys(ctx, false, data, waitFor((err, obj) => { + getKeys(ctx, true, data, waitFor((err, obj) => { if (err) { waitFor.abort(); return void cb(true); } - supportKey = obj.theirPublic; + supportKey = obj.supportKey; })); }).nThen(() => { // random timeout to avoid duplication wiht multiple admins @@ -581,7 +626,7 @@ define([ premium: data.premium, time: data.time, author: data.user && data.user.displayName, - supportKey: supportKey, // Store current support key + supportKey: data.supportKey || supportKey, // Store current support key authorKey: data.user && data.user.curvePublic }; Realtime.whenRealtimeSyncs(ctx.adminDoc.realtime, function () { @@ -629,6 +674,19 @@ define([ cb(exists); }); }; + + var addUserTicket = function (ctx, data, cb) { + if (!ctx.supportData) { return void cb(true); } + let channel = data.channel; + ctx.supportData[channel] = { + time: data.time, + title: data.title, + curvePublic: data.supportKey // Old tickets still use previous keys + }; + ctx.Store.onSync(null, function () { + cb(true); + }); + }; var updateUserTicket = function (ctx, data) { notifyClient(ctx, false, 'UPDATE_TICKET', data.channel); if (data.isClose) { @@ -1013,6 +1071,9 @@ define([ support.checkAdminTicket = function (content, cb) { checkAdminTicket(ctx, content, cb); }; + support.addUserTicket = function (content, cb) { + addUserTicket(ctx, content, cb); + }; support.updateUserTicket = function (content) { updateUserTicket(ctx, content); }; @@ -1021,7 +1082,7 @@ define([ var data = obj.data; // User commands if (cmd === 'MAKE_TICKET') { - return void makeTicket(ctx, data, clientId, cb); + return void makeMyTicket(ctx, data, clientId, cb); } if (cmd === 'GET_MY_TICKETS') { return void getMyTickets(ctx, data, clientId, cb); @@ -1036,6 +1097,9 @@ define([ return void deleteMyTicket(ctx, data, clientId, cb); } // Moderator commands + if (cmd === 'MAKE_TICKET_ADMIN') { + return void makeTicketAdmin(ctx, data, clientId, cb); + } if (cmd === 'LIST_TICKETS_ADMIN') { return void listTicketsAdmin(ctx, data, clientId, cb); } diff --git a/www/moderation/app-moderation.less b/www/moderation/app-moderation.less index 7259d9270..c3a4ffa3f 100644 --- a/www/moderation/app-moderation.less +++ b/www/moderation/app-moderation.less @@ -49,7 +49,28 @@ margin-left: 10px; } } + } + .cp-moderation-userdata { + display: flex; + align-items: center; + input, textarea { + width: 25rem !important; + max-width: 25rem; + } + textarea { + margin: 0; + height: 5rem; + } + .cp-moderation-userdata-inputs { + display: flex; + flex-flow: column; + margin-right: 1rem; + :first-child { + margin-top: 0 !important; + } + } + } } diff --git a/www/moderation/inner.js b/www/moderation/inner.js index 3b60ce756..ed4b5ef15 100644 --- a/www/moderation/inner.js +++ b/www/moderation/inner.js @@ -59,8 +59,17 @@ define([ Messages.support_notificationsTitle = "Disable notifications"; Messages.support_notificationsHint = "Check this option to disable notifications on new or updated ticket"; + Messages.support_openTicketTitle = "Open a ticket for a user"; + Messages.support_openTicketHint = "Create a ticket for a user. They will receive a CryptPad notification to warn them. You can copy their user data from an existing support ticket, using the Copy button in the user data."; + Messages.support_userChannel = "User's notifications channel ID"; + Messages.support_userKey = "User's curvePublic key"; + Messages.support_invalChan = "Invalid notifications channel"; + + Messages.support_pasteUserData = "Paste user data here"; + var andThen = function (common, $container, linkedTicket) { const sidebar = Sidebar.create(common, 'support', $container); + const blocks = sidebar.blocks; // Support panel functions let open = []; @@ -177,7 +186,7 @@ define([ refreshAll(); }); }; - const onMove = function (ticket, channel, data) { + const onMove = function (ticket, channel) { APP.module.execCommand('MOVE_TICKET_ADMIN', { channel: channel, from: type, @@ -253,6 +262,12 @@ define([ 'notifications' ] }, + 'ticket': { + icon: undefined, + content: [ + 'open-ticket' + ] + }, 'refresh': { icon: undefined, onClick: refreshAll @@ -294,6 +309,74 @@ define([ } }); + sidebar.addItem('open-ticket', cb => { + let form = APP.support.makeForm(); + let inputName = blocks.input({type: 'text', readonly: true}); + let inputChan = blocks.input({type: 'text', readonly: true}); + let inputKey = blocks.input({type: 'text', readonly: true}); + let labelName = blocks.labelledInput(Messages.login_username, inputName); + let labelChan = blocks.labelledInput(Messages.support_userChannel, inputChan); + let labelKey = blocks.labelledInput(Messages.support_userKey, inputKey); + + let send = blocks.button('primary', 'fa-paper-plane', Messages.support_formButton); + let nav = blocks.nav([send]); + + let paste = blocks.textArea({ + placeholder: Messages.support_pasteUserData + }); + let inputs = h('div.cp-moderation-userdata-inputs', [ labelName, labelChan, labelKey ]); + let userData = h('div.cp-moderation-userdata', [inputs , paste]); + + + let $paste = $(paste).on('input', () => { + let text = $paste.val().trim(); + let parsed = Util.tryParse(text); + $paste.val(''); + if (!parsed || !parsed.name || !parsed.notifications || !parsed.curvePublic) { + return void UI.warn(Messages.error); + } + $(inputName).val(parsed.name); + $(inputChan).val(parsed.notifications); + $(inputKey).val(parsed.curvePublic); + $paste.hide(); + }); + [inputName, inputChan, inputKey].forEach(input => { + $(input).on('input', () => { $paste.show(); }); + }); + + let $send = $(send); + Util.onClickEnter($send, function () { + let name = $(inputName).val().trim(); + let chan = $(inputChan).val().trim(); + let key = $(inputKey).val().trim(); + let data = APP.support.getFormData(form); + + if (!name) { return void UI.warn(Messages.login_invalUser); } + if (!Hash.isValidChannel(chan)) { return void UI.warn(Messages.support_invalChan); } + if (key.length !== 44) { return void UI.warn(Messages.admin_invalKey); } + + $send.attr('disabled', 'disabled'); + APP.module.execCommand('MAKE_TICKET_ADMIN', { + name: name, + notifications: chan, + curvePublic: key, + channel: Hash.createChannelId(), + title: data.title, + ticket: data + }, function (obj) { + if (obj && obj.error) { + console.error(obj.error); + return void UI.warn(Messages.error); + } + refreshAll(); + sidebar.openCategory('open'); + }); + }); + + let div = blocks.form([userData, form], nav); + cb(div); + }); + sidebar.makeLeftside(categories); }; diff --git a/www/support/inner.js b/www/support/inner.js index a7f77fd15..be3935b26 100644 --- a/www/support/inner.js +++ b/www/support/inner.js @@ -295,8 +295,6 @@ define([ var form = APP.support.makeForm(); - var id = Util.uid(); - $div.find('button').click(function () { var data = APP.support.getFormData(form); APP.supportModule.execCommand('MAKE_TICKET', { @@ -308,7 +306,6 @@ define([ console.error(obj.error); return void UI.warn(Messages.error); } - id = Util.uid(); events.UPDATE_TICKET.fire(); $('.cp-sidebarlayout-category[data-category="tickets"]').click(); }); diff --git a/www/support/ui.js b/www/support/ui.js index e55847c99..6e4bbbeda 100644 --- a/www/support/ui.js +++ b/www/support/ui.js @@ -19,6 +19,7 @@ define([ Messages.support_answerAs = "Answer as {0}"; // XXX Messages.support_movePending = "Move to pending"; Messages.support_moveActive = "Move to active"; + Messages.support_copyUserData = "Copy user data"; var getDebuggingData = function (ctx, data) { var common = ctx.common; @@ -31,12 +32,9 @@ define([ data.sender = { name: user.name, - accountName: privateData.accountName, - drive: privateData.driveChannel, - channel: privateData.support, curvePublic: user.curvePublic, edPublic: privateData.edPublic, - notifications: user.notifications, + notifications: user.notifications }; if (ctx.isAdmin && ctx.anonymous) { @@ -53,6 +51,8 @@ define([ } if (!ctx.isAdmin) { + data.sender.drive = privateData.driveChannel; + data.sender.accountName = privateData.accountName; data.sender.userAgent = Util.find(window, ['navigator', 'userAgent']); data.sender.vendor = Util.find(window, ['navigator', 'vendor']); data.sender.appVersion = Util.find(window, ['navigator', 'appVersion']); @@ -223,7 +223,7 @@ define([ var content = [ h('hr'), category, - catContainer, + !ctx.isAdmin ? catContainer : undefined, notice, //h('br'), h('input.cp-support-form-title' + (title ? '.cp-hidden' : ''), { @@ -328,11 +328,7 @@ define([ // Admin actions let show = h('button.btn.btn-primary.cp-support-expand', Messages.admin_support_open); let $show = $(show); - let url = h('button.btn', { title: Messages.share_linkCopy, }, [ - h('i.fa.fa-link', { - 'aria-hidden': true, - }), - ]); + let url = h('button.btn.fa.fa-link', { title: Messages.share_linkCopy, }); $(url).click(function (e) { e.stopPropagation(); var link = privateData.origin + privateData.pathname + '#' + 'active-' + id; @@ -368,7 +364,7 @@ define([ if (onMove) { let text = onMove.isTicketActive ? Messages.support_movePending : Messages.support_moveActive; - settings = h('button.btn.btn-secondary.fa.fa-hdd-o', { title: text }); + settings = h('button.btn.btn-secondary.fa.fa-archive', { title: text }); Util.onClickEnter($(settings), function () { onMove(ticket, id, content); }); @@ -439,13 +435,26 @@ define([ || (!senderKey && content.sender.accountName === 'support'); // XXX anon key? var fromPremium = Boolean(content.sender.plan || Util.find(content, ['sender', 'quota', 'plan'])); + var copyUser = h('button.btn.btn-secondary.fa.fa-clipboard.cp-support-copydata', { + title: Messages.support_copyUserData + }); + Util.onClickEnter($(copyUser), () => { + let data = JSON.stringify({ + name: content.sender.name, + curvePublic: content.sender.curvePublic, + notifications: content.sender.notifications + }); + Clipboard.copy(data, (err) => { + if (!err) { UI.log(Messages.genericCopySuccess); } + }); + }); var userData = h('div.cp-support-showdata', [ Messages.support_showData, - h('pre.cp-support-message-data', JSON.stringify(content.sender, 0, 2)) + h('pre.cp-support-message-data', [copyUser, JSON.stringify(content.sender, 0, 2)]) ]); $(userData).click(function () { - $(userData).find('pre').toggle(); - }).find('pre').click(function (ev) { + $(userData).find('.cp-support-message-data').toggle(); + }).find('*').click(function (ev) { ev.stopPropagation(); });