New support: moderator view

This commit is contained in:
yflory 2024-02-15 18:37:08 +01:00
parent 6493f3a42e
commit 8e24b8b3f2
8 changed files with 390 additions and 140 deletions

View file

@ -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;

View file

@ -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();

View file

@ -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); }

View file

@ -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) {

View file

@ -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);
}

View file

@ -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;
}
}
}
}

View file

@ -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();
});

View file

@ -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);
};