From 295a712942a04b3027e0403d5521e01236383041 Mon Sep 17 00:00:00 2001 From: yflory Date: Mon, 30 Sep 2019 15:17:26 +0200 Subject: [PATCH] Transfer team ownership --- www/common/common-ui-elements.js | 128 ++++++++++++++- www/common/notifications.js | 3 + www/common/outer/mailbox-handlers.js | 21 ++- www/common/outer/team.js | 229 +++++++++++++++++++++++++-- www/teams/inner.js | 30 +++- 5 files changed, 389 insertions(+), 22 deletions(-) diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 03b0b534d..431b7e560 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -288,6 +288,7 @@ define([ } })); }).nThen(function (waitFor) { + // Add one of our teams as an owner if (toAddTeams.length) { // Send the command sframeChan.query('Q_SET_PAD_METADATA', { @@ -320,6 +321,7 @@ define([ })); } }).nThen(function (waitFor) { + // Offer ownership to a friend if (toAdd.length) { // Send the command sframeChan.query('Q_SET_PAD_METADATA', { @@ -1293,7 +1295,7 @@ define([ var team = privateData.teams[config.teamId]; if (!team) { return void UI.warn(Messages.error); } - var module = config.module || common.makeUniversal('team', { onEvent: function () {} }); + var module = config.module || common.makeUniversal('team'); var $div; var refreshButton = function () { @@ -3785,6 +3787,130 @@ define([ UI.proposal(div, todo); }; + UIElements.displayAddTeamOwnerModal = function (common, data) { + var priv = common.getMetadataMgr().getPrivateData(); + var user = common.getMetadataMgr().getUserData(); + var sframeChan = common.getSframeChannel(); + var msg = data.content.msg; + + var name = Util.fixHTML(msg.content.user.displayName) || Messages.anonymous; + var title = Util.fixHTML(msg.content.title); + + //var text = Messages._getKey('owner_team_add', [name, title]); // XXX + var text = name + ' wants you to be an owner of the team ' + title; // XXX + + var div = h('div', [ + UI.setHTML(h('p'), text), + ]); + + var answer = function (yes) { + common.mailbox.sendTo("ADD_OWNER_ANSWER", { + teamChannel: msg.content.teamChannel, + title: msg.content.title, + answer: yes, + user: { + displayName: user.name, + avatar: user.avatar, + profile: user.profile, + notifications: user.notifications, + curvePublic: user.curvePublic, + edPublic: priv.edPublic + } + }, { + channel: msg.content.user.notifications, + curvePublic: msg.content.user.curvePublic + }); + common.mailbox.dismiss(data, function (err) { + if (err) { console.log(err); } + }); + }; + var module = common.makeUniversal('team'); + + var addOwner = function (chan, waitFor, cb) { + // Remove yourself from the pending owners + sframeChan.query('Q_SET_PAD_METADATA', { + channel: chan, + command: 'ADD_OWNERS', + value: [priv.edPublic] + }, function (err, res) { + err = err || (res && res.error); + if (!err) { return; } + waitFor.abort(); + cb(err); + }); + }; + var removePending = function (chan, waitFor, cb) { + // Remove yourself from the pending owners + sframeChan.query('Q_SET_PAD_METADATA', { + channel: chan, + command: 'RM_PENDING_OWNERS', + value: [priv.edPublic] + }, waitFor(function (err, res) { + err = err || (res && res.error); + if (!err) { return; } + waitFor.abort(); + cb(err); + })); + }; + var changeAll = function (add, _cb) { + var f = add ? addOwner : removePending; + var cb = Util.once(_cb); + NThen(function (waitFor) { + f(msg.content.teamChannel, waitFor, cb); + f(msg.content.chatChannel, waitFor, cb); + f(msg.content.rosterChannel, waitFor, cb); + }).nThen(function () { cb(); }); + }; + + var todo = function (yes) { + if (yes) { + // ACCEPT + changeAll(true, function (err) { + if (err) { + console.error(err); + var text = err === "INSUFFICIENT_PERMISSIONS" ? Messages.fm_forbidden + : Messages.error; + return void UI.warn(text); + } + UI.log(Messages.saved); + + // Send notification to the sender + answer(true); + + // Mark ourselves as "owner" in our local team data + module.execCommand("ANSWER_OWNERSHIP", { + teamChannel: msg.content.teamChannel, + answer: true + }, function (obj) { + if (obj && obj.error) { console.error(obj.error); } + }); + + // Remove yourself from the pending owners + changeAll(false, function (err) { + if (err) { console.error(err); } + }); + }); + return; + } + + // DECLINE + // Remove yourself from the pending owners + changeAll(false, function (err) { + if (err) { console.error(err); } + // Send notification to the sender + answer(false); + // Set our role back to ADMIN + module.execCommand("ANSWER_OWNERSHIP", { + teamChannel: msg.content.teamChannel, + answer: false + }, function (obj) { + if (obj && obj.error) { console.error(obj.error); } + }); + }); + }; + + UI.proposal(div, todo); + }; UIElements.getVerifiedFriend = function (common, curve, name) { var priv = common.getMetadataMgr().getPrivateData(); diff --git a/www/common/notifications.js b/www/common/notifications.js index c5a94c275..1222e8602 100644 --- a/www/common/notifications.js +++ b/www/common/notifications.js @@ -216,6 +216,9 @@ define([ // if not archived, add handlers if (!content.archived) { content.handler = function () { + if (msg.content.teamChannel) { + return void UIElements.displayAddTeamOwnerModal(common, data); + } UIElements.displayAddOwnerModal(common, data); }; } diff --git a/www/common/outer/mailbox-handlers.js b/www/common/outer/mailbox-handlers.js index d25cb283b..913f094cf 100644 --- a/www/common/outer/mailbox-handlers.js +++ b/www/common/outer/mailbox-handlers.js @@ -270,12 +270,12 @@ define([ var content = msg.content; if (msg.author !== content.user.curvePublic) { return void cb(true); } - if (!content.href || !content.title || !content.channel) { + if (!content.teamChannel && !(content.href && content.title && content.channel)) { console.log('Remove invalid notification'); return void cb(true); } - var channel = content.channel; + var channel = content.channel || content.teamChannel; if (addOwners[channel]) { return void cb(true); } addOwners[channel] = { @@ -286,7 +286,7 @@ define([ cb(false); }; removeHandlers['ADD_OWNER'] = function (ctx, box, data) { - var channel = data.content.channel; + var channel = data.content.channel || data.content.teamChannel; if (addOwners[channel]) { delete addOwners[channel]; } @@ -297,12 +297,23 @@ define([ var content = msg.content; if (msg.author !== content.user.curvePublic) { return void cb(true); } - if (!content.channel) { + if (!content.channel && !content.teamChannel) { console.log('Remove invalid notification'); return void cb(true); } - var channel = content.channel; + var channel = content.channel || content.teamChannel; + + // If our ownership rights for a team have been removed, update the owner flag + if (content.teamChannel) { + var teams = ctx.store.proxy.teams || {}; + Object.keys(teams).some(function (id) { + if (teams[id].channel === channel) { + teams[id].owner = false; + return true; + } + }); + } if (addOwners[channel] && content.pending) { return void cb(false, addOwners[channel]); diff --git a/www/common/outer/team.js b/www/common/outer/team.js index d382a7609..5512b782b 100644 --- a/www/common/outer/team.js +++ b/www/common/outer/team.js @@ -100,18 +100,6 @@ define([ if (membersChannel) { list.push(membersChannel); } if (mailboxChannel) { list.push(mailboxChannel); } - - - // XXX Add the team mailbox - /* - if (store.proxy.mailboxes) { - var mList = Object.keys(store.proxy.mailboxes).map(function (m) { - return store.proxy.mailboxes[m].channel; - }); - list = list.concat(mList); - } - */ - list.sort(); return list; }; @@ -186,7 +174,6 @@ define([ channel: secret.channel, secret: secret, validateKey: secret.keys.validateKey - // XXX owners: team owner + all admins? }; }; @@ -314,6 +301,11 @@ define([ userName: 'team', classic: true }; + cfg.onMetadataUpdate = function (md) { + var team = ctx.teams[id]; + if (!team) { return; } + ctx.emit('ROSTER_CHANGE', id, team.clients); + }; lm = Listmap.create(cfg); lm.proxy.on('ready', waitFor()); @@ -463,10 +455,14 @@ define([ } })); }).nThen(function () { + var id = Util.createRandomInteger(); + config.onMetadataUpdate = function (md) { + if (!team) { return; } + ctx.emit('ROSTER_CHANGE', id, team.clients); + }; var lm = Listmap.create(config); var proxy = lm.proxy; proxy.on('ready', function () { - var id = Util.createRandomInteger(); // Store keys in our drive var keys = { drive: { @@ -617,12 +613,43 @@ define([ var getTeamRoster = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } + var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); + if (!teamData) { return void cb ({error: 'ENOENT'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } var state = team.roster.getState() || {}; var members = state.members || {}; + // Get pending owners + var md = team.listmap.metadata || {}; + if (Array.isArray(md.pending_owners)) { + // Get the members associated to the pending_owners' edPublic and mark them as such + md.pending_owners.forEach(function (ed) { + var member; + Object.keys(members).some(function (curve) { + if (members[curve].edPublic === ed) { + member = members[curve]; + return true; + } + }); + if ((!member || member.role !== 'OWNER') && teamData.owner) { + var removeOwnership = function (chan) { + ctx.Store.setPadMetadata(null, { + channel: chan, + command: 'RM_PENDING_OWNERS', + value: [ed], + }, function () {}); + }; + removeOwnership(teamData.channel); + removeOwnership(Util.find(teamData, ['keys', 'roster', 'channel'])); + removeOwnership(Util.find(teamData, ['keys', 'chat', 'channel'])); + return; + } + member.pendingOwner = true; + }); + } + // Add online status (using messenger data) var chatData = team.getChatData(); var online = ctx.store.messenger.getOnlineList(chatData.channel) || []; @@ -661,6 +688,159 @@ define([ }); }; + var offerOwnership = function (ctx, data, cId, _cb) { + var cb = Util.once(_cb); + var teamId = data.teamId; + if (!teamId) { return void cb({error: 'EINVAL'}); } + var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); + if (!teamData) { return void cb ({error: 'ENOENT'}); } + var team = ctx.teams[teamId]; + if (!team) { return void cb ({error: 'ENOENT'}); } + if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } + if (!data.curvePublic) { return void cb({error: 'MISSING_DATA'}); } + var state = team.roster.getState(); + var user = state.members[data.curvePublic]; + nThen(function (waitFor) { + // Offer ownership to a friend + var onError = function (res) { + var err = res && res.error; + if (err) { + waitFor.abort(); + return void cb({error:err}); + } + }; + var addPendingOwner = function (chan) { + ctx.Store.setPadMetadata(null, { + channel: chan, + command: 'ADD_PENDING_OWNERS', + value: [user.edPublic], + }, waitFor(onError)); + }; + // Team proxy + addPendingOwner(teamData.channel); + // Team roster + addPendingOwner(Util.find(teamData, ['keys', 'roster', 'channel'])); + // Team chat + addPendingOwner(Util.find(teamData, ['keys', 'chat', 'channel'])); + }).nThen(function (waitFor) { + var obj = {}; + obj[user.curvePublic] = { + role: 'OWNER' + }; + team.roster.describe(obj, waitFor(function (err) { + if (err) { console.error(err); } + })); + }).nThen(function (waitFor) { + // Send mailbox to offer ownership + var myData = Messaging.createData(ctx.store.proxy, false); + ctx.store.mailbox.sendTo("ADD_OWNER", { + teamChannel: teamData.channel, + chatChannel: Util.find(teamData, ['keys', 'chat', 'channel']), + rosterChannel: Util.find(teamData, ['keys', 'roster', 'channel']), + title: teamData.metadata.name, + user: myData + }, { + channel: user.notifications, + curvePublic: user.curvePublic + }, waitFor()); + }).nThen(function () { + cb(); + }); + }; + + var revokeOwnership = function (ctx, teamId, user, _cb) { + var cb = Util.once(_cb); + if (!teamId) { return void cb({error: 'EINVAL'}); } + var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); + if (!teamData) { return void cb ({error: 'ENOENT'}); } + var team = ctx.teams[teamId]; + if (!team) { return void cb ({error: 'ENOENT'}); } + var md = team.listmap.metadata || {}; + var isPendingOwner = (md.pending_owners || []).indexOf(user.edPublic) !== -1; + nThen(function (waitFor) { + var cmd = isPendingOwner ? 'RM_PENDING_OWNERS' : 'RM_OWNERS'; + + var onError = function (res) { + var err = res && res.error; + if (err) { + waitFor.abort(); + return void cb(err); + } + }; + var removeOwnership = function (chan) { + ctx.Store.setPadMetadata(null, { + channel: chan, + command: cmd, + value: [user.edPublic], + }, waitFor(onError)); + }; + // Team proxy + removeOwnership(teamData.channel); + // Team roster + removeOwnership(Util.find(teamData, ['keys', 'roster', 'channel'])); + // Team chat + removeOwnership(Util.find(teamData, ['keys', 'chat', 'channel'])); + }).nThen(function (waitFor) { + var obj = {}; + obj[user.curvePublic] = { + role: 'ADMIN', + pendingOwner: false + }; + team.roster.describe(obj, waitFor(function (err) { + if (err) { console.error(err); } + })); + }).nThen(function (waitFor) { + // Send mailbox to offer ownership + var myData = Messaging.createData(ctx.store.proxy, false); + ctx.store.mailbox.sendTo("RM_OWNER", { + teamChannel: teamData.channel, + title: teamData.metadata.name, + pending: isPendingOwner, + user: myData + }, { + channel: user.notifications, + curvePublic: user.curvePublic + }, waitFor()); + }).nThen(function () { + cb(); + }); + }; + + // We've received an offer to be an owner of the team. + // If we accept, we need to set the "owner" flag in our team data + // If we decline, we need to change our role back to "ADMIN" + var answerOwnership = function (ctx, data, cId, cb) { + var myTeams = ctx.store.proxy.teams; + var teamId; + Object.keys(myTeams).forEach(function (id) { + if (myTeams[id].channel === data.teamChannel) { + teamId = id; + return true; + } + }); + if (!teamId) { return void cb({error: 'EINVAL'}); } + var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); + if (!teamData) { return void cb ({error: 'ENOENT'}); } + var team = ctx.teams[teamId]; + if (!team) { return void cb ({error: 'ENOENT'}); } + if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } + var obj = {}; + + // Accept + if (data.answer) { + teamData.owner = true; + return; + } + // Decline + obj[ctx.store.proxy.curvePublic] = { + role: 'ADMIN', + }; + team.roster.describe(obj, function (err) { + if (err) { return void cb({error: err}); } + cb(); + }); + }; + var describeUser = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } @@ -668,6 +848,21 @@ define([ if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } if (!data.curvePublic || !data.data) { return void cb({error: 'MISSING_DATA'}); } + var state = team.roster.getState(); + var user = state.members[data.curvePublic]; + + // It it is an ownership revocation, we have to set it in pad metadata first + console.log(user.role, data.data.role); + if (user.role === "OWNER" && data.data.role !== "OWNER") { + revokeOwnership(ctx, teamId, user, function (err) { + console.error(err); + if (!err) { return; } + waitFor.abort(); + return void cb({error: err}); + }); + return; + } + var obj = {}; obj[data.curvePublic] = data.data; team.roster.describe(obj, function (err) { @@ -902,6 +1097,12 @@ define([ if (cmd === 'SET_TEAM_METADATA') { return void setTeamMetadata(ctx, data, clientId, cb); } + if (cmd === 'OFFER_OWNERSHIP') { + return void offerOwnership(ctx, data, clientId, cb); + } + if (cmd === 'ANSWER_OWNERSHIP') { + return void answerOwnership(ctx, data, clientId, cb); + } if (cmd === 'DESCRIBE_USER') { return void describeUser(ctx, data, clientId, cb); } diff --git a/www/teams/inner.js b/www/teams/inner.js index f7c319bc7..bb01e5e11 100644 --- a/www/teams/inner.js +++ b/www/teams/inner.js @@ -430,6 +430,9 @@ define([ common.displayAvatar($(avatar), data.avatar, data.displayName); // Name var name = h('span.cp-team-member-name', data.displayName); + if (data.pendingOwner) { + $(name).append(h('em', " PENDING")); + } // Status var status = h('span.cp-team-member-status'+(data.online ? '.online' : '')); // Actions @@ -438,6 +441,28 @@ define([ var isMe = me && me.curvePublic === data.curvePublic; var myRole = me ? (ROLES.indexOf(me.role) || 0) : -1; var theirRole = ROLES.indexOf(data.role) || 0; + // If they're an admin and I am an owner, I can promote them to owner + if (!isMe && myRole > theirRole && theirRole === 1 && !data.pending) { + var promote = h('span.fa.fa-angle-double-up', { + title: "Offer ownership" // XXX + }); + $(promote).click(function () { + $(promote).hide(); + UI.confirm("Are you sure???", function (yes) { // XXX + APP.module.execCommand('OFFER_OWNERSHIP', { + teamId: APP.team, + curvePublic: data.curvePublic + }, function (obj) { + if (obj && obj.error) { + console.error(obj.error); + return void UI.warn(Messages.error); + } + UI.log("DONE"); // XXX + }); + }); + }); + $actions.append(promote); + } // If they're a member and I have a higher role than them, I can promote them to admin if (!isMe && myRole > theirRole && theirRole === 0 && !data.pending) { var promote = h('span.fa.fa-angle-double-up', { @@ -467,7 +492,8 @@ define([ $actions.append(demote); } // If I'm not a member and I have an equal or higher role than them, I can remove them - if (!isMe && myRole > 0 && myRole >= theirRole) { + // Note: we can't remove owners, we have to demote them first + if (!isMe && myRole > 0 && myRole >= theirRole && theirRole !== 2) { var remove = h('span.fa.fa-times', { title: Messages.team_rosterKick }); @@ -514,7 +540,7 @@ define([ var me = roster[userData.curvePublic] || {}; var owner = Object.keys(roster).filter(function (k) { if (roster[k].pending) { return; } - return roster[k].role === "OWNER"; + return roster[k].role === "OWNER" || roster[k].pendingOwner; }).map(function (k) { return makeMember(common, roster[k], me); });