diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 448dd779f..431b7e560 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -104,6 +104,8 @@ define([ var channel = data.channel; var owners = data.owners || []; var pending_owners = data.pending_owners || []; + var teams = priv.teams; + var teamOwner = data.teamId; var redrawAll = function () {}; @@ -124,6 +126,12 @@ define([ return true; } }); + Object.keys(teams).some(function (id) { + if (teams[id].edPublic === ed) { + f = teams[id]; + f.teamId = id; + } + }); if (ed === edPublic) { f = f || user; if (f.name) { f.edPublic = edPublic; } @@ -155,6 +163,7 @@ define([ var toRemove = sel.map(function (el) { var ed = $(el).attr('data-ed'); if (!ed) { return; } + if (teamOwner && teams[teamOwner] && teams[teamOwner].edPublic === ed) { me = true; } if (ed === edPublic) { me = true; } return ed; }).filter(function (x) { return x; }); @@ -171,7 +180,8 @@ define([ sframeChan.query('Q_SET_PAD_METADATA', { channel: channel, command: pending ? 'RM_PENDING_OWNERS' : 'RM_OWNERS', - value: toRemove + value: toRemove, + teamId: teamOwner }, waitFor(function (err, res) { err = err || (res && res.error); if (err) { @@ -214,6 +224,7 @@ define([ // Add owners column var drawAdd = function () { + var $div = $(h('div.cp-share-column')); var _friends = JSON.parse(JSON.stringify(friends)); Object.keys(_friends).forEach(function (curve) { if (owners.indexOf(_friends[curve].edPublic) !== -1 || @@ -228,16 +239,44 @@ define([ }, function () { //console.log(arguments); }); - $div2 = $(addCol.div); + $div.append(addCol.div); + + if (priv.enableTeams) { + var teamsData = Util.tryParse(JSON.stringify(priv.teams)) || {}; + Object.keys(teamsData).forEach(function (id) { + var t = teamsData[id]; + t.teamId = id; + if (owners.indexOf(t.edPublic) !== -1 || pending_owners.indexOf(t.edPublic) !== -1) { + delete teamsData[id]; + } + }); + var teamsList = UIElements.getUserGrid('Or a team?', { // XXX + common: common, + noFilter: true, + data: teamsData + }, function () {}); + $div.append(teamsList.div); + } + // When clicking on the add button, we get the selected users. var addButton = h('button.no-margin', Messages.owner_addButton); $(addButton).click(function () { // Check selection - var $sel = $div2.find('.cp-usergrid-user.cp-selected'); + var $sel = $div.find('.cp-usergrid-user.cp-selected'); var sel = $sel.toArray(); if (!sel.length) { return; } var toAdd = sel.map(function (el) { - return friends[$(el).attr('data-curve')].edPublic; + var friend = friends[$(el).attr('data-curve')]; + if (!friend) { return; } + return friend.edPublic; + }).filter(function (x) { return x; }); + var toAddTeams = sel.map(function (el) { + var team = teamsData[$(el).attr('data-teamid')]; + if (!team || !team.edPublic) { return; } + return { + edPublic: team.edPublic, + id: $(el).attr('data-teamid') + }; }).filter(function (x) { return x; }); NThen(function (waitFor) { @@ -249,21 +288,58 @@ define([ } })); }).nThen(function (waitFor) { - // Send the command - sframeChan.query('Q_SET_PAD_METADATA', { - channel: channel, - command: 'ADD_PENDING_OWNERS', - value: toAdd - }, waitFor(function (err, res) { - err = err || (res && res.error); - if (err) { - waitFor.abort(); - redrawAll(); - var text = err === "INSUFFICIENT_PERMISSIONS" ? Messages.fm_forbidden - : Messages.error; - return void UI.warn(text); - } - })); + // Add one of our teams as an owner + if (toAddTeams.length) { + // Send the command + sframeChan.query('Q_SET_PAD_METADATA', { + channel: channel, + command: 'ADD_OWNERS', + value: toAddTeams.map(function (obj) { return obj.edPublic; }), + teamId: teamOwner + }, waitFor(function (err, res) { + err = err || (res && res.error); + if (err) { + waitFor.abort(); + redrawAll(); + var text = err === "INSUFFICIENT_PERMISSIONS" ? + Messages.fm_forbidden : Messages.error; + return void UI.warn(text); + } + var isTemplate = priv.isTemplate || data.isTemplate; + toAddTeams.forEach(function (obj) { + sframeChan.query('Q_STORE_IN_TEAM', { + href: data.href || data.rohref, + password: data.password, + path: isTemplate ? ['template'] : undefined, + title: data.title || '', + teamId: obj.id + }, waitFor(function (err) { + if (err) { return void console.error(err); } + console.warn(obj.id); + })); + }); + })); + } + }).nThen(function (waitFor) { + // Offer ownership to a friend + if (toAdd.length) { + // Send the command + sframeChan.query('Q_SET_PAD_METADATA', { + channel: channel, + command: 'ADD_PENDING_OWNERS', + value: toAdd, + teamId: teamOwner + }, waitFor(function (err, res) { + err = err || (res && res.error); + if (err) { + waitFor.abort(); + redrawAll(); + var text = err === "INSUFFICIENT_PERMISSIONS" ? Messages.fm_forbidden + : Messages.error; + return void UI.warn(text); + } + })); + } }).nThen(function (waitFor) { sel.forEach(function (el) { var friend = friends[$(el).attr('data-curve')]; @@ -291,8 +367,8 @@ define([ UI.log(Messages.saved); }); }); - $div2.append(h('p', addButton)); - return $div2; + $div.append(h('p', addButton)); + return $div; }; redrawAll = function (md) { @@ -430,10 +506,10 @@ define([ if (data.href || data.roHref) { parsed = Hash.parsePadUrl(data.href || data.roHref); } - // XXX Teams owner: transfer ownership if (owned && data.roHref && parsed.type !== 'drive' && parsed.hashData.type === 'pad') { var manageOwners = h('button.no-margin', Messages.owner_openModalButton); $(manageOwners).click(function () { + data.teamId = typeof(owned) !== "boolean" ? owned : undefined; var modal = createOwnerModal(common, data); UI.openCustomModal(modal, { wide: true, @@ -665,6 +741,7 @@ define([ UIElements.displayAvatar(common, $(avatar), data.avatar, name); return h('div.cp-usergrid-user'+(data.selected?'.cp-selected':'')+(config.large?'.large':''), { 'data-ed': data.edPublic, + 'data-teamid': data.teamId, 'data-curve': data.curvePublic || '', 'data-name': name.toLowerCase(), 'data-order': i, @@ -1218,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 () { @@ -3710,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/cryptpad-common.js b/www/common/cryptpad-common.js index 3be8ed032..2dbf772dc 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -838,7 +838,6 @@ define([ postMessage('GET_PAD_METADATA', data, cb); }; - // XXX Teams: change the password of a pad owned by the team common.changePadPassword = function (Crypt, Crypto, data, cb) { var href = data.href; var newPassword = data.password; diff --git a/www/common/drive-ui.js b/www/common/drive-ui.js index 813c9e71f..601c2a32d 100644 --- a/www/common/drive-ui.js +++ b/www/common/drive-ui.js @@ -3729,6 +3729,10 @@ define([ data.roHref = base + data.roHref; } + if (currentPath[0] === TEMPLATE) { + data.isTemplate = true; + } + if (manager.isSharedFolder(el)) { delete data.roHref; //data.noPassword = true; 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/async-store.js b/www/common/outer/async-store.js index 21a474c26..535f699a7 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -1552,7 +1552,6 @@ define([ var href, title; - // XXX TEAMOWNER if (!res.some(function (obj) { if (obj.data && Array.isArray(obj.data.owners) && obj.data.owners.indexOf(edPublic) !== -1 && @@ -1612,11 +1611,8 @@ define([ Store.setPadMetadata = function (clientId, data, cb) { if (!data.channel) { return void cb({ error: 'ENOTFOUND'}); } if (!data.command) { return void cb({ error: 'EINVAL' }); } - // XXX TEAMOWNER - // If owned by a team, we should use the team rpc here - // We'll need common-ui-elements to tell us the "owners" value or we can - // call getPadMetadata first - store.rpc.setMetadata(data, function (err, res) { + var s = getStore(data.teamId); + s.rpc.setMetadata(data, function (err, res) { if (err) { return void cb({ error: err }); } if (!Array.isArray(res) || !res.length) { return void cb({}); } cb(res[0]); 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 810e0b3c0..78e0d74ac 100644 --- a/www/common/outer/team.js +++ b/www/common/outer/team.js @@ -15,10 +15,11 @@ define([ '/bower_components/chainpad-netflux/chainpad-netflux.js', '/bower_components/chainpad/chainpad.dist.js', '/bower_components/nthen/index.js', + '/bower_components/saferphore/index.js', '/bower_components/tweetnacl/nacl-fast.min.js', ], function (Util, Hash, Constants, Realtime, ProxyManager, UserObject, SF, Roster, Messaging, - Listmap, Crypto, CpNetflux, ChainPad, nThen) { + Listmap, Crypto, CpNetflux, ChainPad, nThen, Saferphore) { var Team = {}; var Nacl = window.nacl; @@ -73,8 +74,8 @@ define([ var closeTeam = function (ctx, teamId) { var team = ctx.teams[teamId]; if (!team) { return; } - team.listmap.stop(); - team.roster.stop(); + try { team.listmap.stop(); } catch (e) {} + try { team.roster.stop(); } catch (e) {} team.proxy = {}; delete ctx.teams[teamId]; delete ctx.store.proxy.teams[teamId]; @@ -99,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; }; @@ -185,7 +174,6 @@ define([ channel: secret.channel, secret: secret, validateKey: secret.keys.validateKey - // XXX owners: team owner + all admins? }; }; @@ -290,7 +278,9 @@ define([ }; - var openChannel = function (ctx, teamData, id, cb) { + var openChannel = function (ctx, teamData, id, _cb) { + var cb = Util.once(_cb); + var secret = Hash.getSecrets('team', teamData.hash, teamData.password); var crypto = Crypto.createEncryptor(secret.keys); @@ -298,7 +288,34 @@ define([ var roster; var lm; + + // Roster keys + var myKeys = { + curvePublic: ctx.store.proxy.curvePublic, + curvePrivate: ctx.store.proxy.curvePrivate + }; + var rosterData = keys.roster || {}; + var rosterKeys = rosterData.edit ? Crypto.Team.deriveMemberKeys(rosterData.edit, myKeys) + : Crypto.Team.deriveGuestKeys(rosterData.view || ''); + nThen(function (waitFor) { + ctx.store.anon_rpc.send("IS_NEW_CHANNEL", secret.channel, waitFor(function (e, response) { + if (response && response.length && typeof(response[0]) === 'boolean' && response[0]) { + // Channel is empty: remove this team + delete ctx.store.proxy.teams[id]; + waitFor.abort(); + cb({error: 'ENOENT'}); + } + })); + ctx.store.anon_rpc.send("IS_NEW_CHANNEL", rosterKeys.channel, waitFor(function (e, response) { + if (response && response.length && typeof(response[0]) === 'boolean' && response[0]) { + // Channel is empty: remove this team + delete ctx.store.proxy.teams[id]; + waitFor.abort(); + cb({error: 'ENOENT'}); + } + })); + }).nThen(function (waitFor) { // Load the proxy var cfg = { data: {}, @@ -313,17 +330,25 @@ define([ userName: 'team', classic: true }; + cfg.onMetadataUpdate = function () { + var team = ctx.teams[id]; + if (!team) { return; } + ctx.emit('ROSTER_CHANGE', id, team.clients); + }; lm = Listmap.create(cfg); lm.proxy.on('ready', waitFor()); + lm.proxy.on('error', function (info) { + if (info && typeof (info.loaded) !== "undefined" && !info.loaded) { + cb({error:'ECONNECT'}); + } + if (info && info.error) { + if (info.error === "EDELETED" ) { + closeTeam(ctx, id); + } + } + }); // Load the roster - var myKeys = { - curvePublic: ctx.store.proxy.curvePublic, - curvePrivate: ctx.store.proxy.curvePrivate - }; - var rosterData = keys.roster || {}; - var rosterKeys = rosterData.edit ? Crypto.Team.deriveMemberKeys(rosterData.edit, myKeys) - : Crypto.Team.deriveGuestKeys(rosterData.view || ''); Roster.create({ network: ctx.store.network, channel: rosterKeys.channel, @@ -462,10 +487,15 @@ define([ } })); }).nThen(function () { + var id = Util.createRandomInteger(); + config.onMetadataUpdate = function () { + var team = ctx.teams[id]; + 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: { @@ -505,10 +535,91 @@ define([ if (info && typeof (info.loaded) !== "undefined" && !info.loaded) { cb({error:'ECONNECT'}); } + if (info && info.error) { + if (info.error === "EDELETED") { + closeTeam(ctx, id); + } + } }); }); }; + var deleteTeam = function (ctx, data, cId, cb) { + var teamId = data.teamId; + if (!teamId) { return void cb({error: 'EINVAL'}); } + var team = ctx.teams[teamId]; + var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); + if (!team || !teamData) { return void cb ({error: 'ENOENT'}); } + var state = team.roster.getState(); + var curvePublic = Util.find(ctx, ['store', 'proxy', 'curvePublic']); + var me = state.members[curvePublic]; + if (!me || me.role !== "OWNER") { return cb({ error: "EFORBIDDEN"}); } + + var edPublic = Util.find(ctx, ['store', 'proxy', 'edPublic']); + + nThen(function (waitFor) { + ctx.Store.anonRpcMsg(null, { + msg: 'GET_METADATA', + data: teamData.channel + }, waitFor(function (obj) { + // If we can't get owners, abort + if (obj && obj.error) { + waitFor.abort(); + return cb({ error: obj.error}); + } + // Check if we're an owner of the team drive + var metadata = obj[0]; + if (metadata && Array.isArray(metadata.owners) && + metadata.owners.indexOf(edPublic) !== -1) { return; } + // If w'e're not an owner, abort + waitFor.abort(); + cb({error: 'EFORBIDDEN'}); + })); + }).nThen(function (waitFor) { + team.proxy.delete = true; + // Delete the owned pads + var ownedPads = team.manager.getChannelsList('owned'); + var sem = Saferphore.create(10); + ownedPads.forEach(function (c) { + var w = waitFor(); + sem.take(function (give) { + team.rpc.removeOwnedChannel(c, give(function (err) { + if (err) { console.error(err); } + w(); + })); + }); + }); + }).nThen(function (waitFor) { + // Delete the pins log + team.rpc.removePins(waitFor(function (err) { + if (err) { console.error(err); } + console.error(err); + })); + // Delete the roster + var rosterChan = Util.find(teamData, ['keys', 'roster', 'channel']); + ctx.store.rpc.removeOwnedChannel(rosterChan, waitFor(function (err) { + if (err) { console.error(err); } + console.error(err); + })); + // Delete the chat + var chatChan = Util.find(teamData, ['keys', 'chat', 'channel']); + /* + ctx.store.rpc.removeOwnedChannel(chatChan, waitFor(function (err) { + if (err) { console.error(err); } + console.error(err); + })); + */ // XXX + // Delete the team drive + ctx.store.rpc.removeOwnedChannel(teamData.channel, waitFor(function (err) { + if (err) { console.error(err); } + console.error(err); + })); + }).nThen(function () { + cb(); + closeTeam(ctx, teamId); + }); + }; + var joinTeam = function (ctx, data, cId, cb) { var team = data.team; if (!team.hash || !team.channel || !team.password @@ -540,12 +651,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) || []; @@ -584,6 +726,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'}); } @@ -591,6 +886,19 @@ 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 + if (user.role === "OWNER" && data.data.role !== "OWNER") { + revokeOwnership(ctx, teamId, user, function (err) { + if (!err) { return; } + console.error(err); + return void cb({error: err}); + }); + return; + } + var obj = {}; obj[data.curvePublic] = data.data; team.roster.describe(obj, function (err) { @@ -825,6 +1133,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); } @@ -840,6 +1154,9 @@ define([ if (cmd === 'REMOVE_USER') { return void removeUser(ctx, data, clientId, cb); } + if (cmd === 'DELETE_TEAM') { + return void deleteTeam(ctx, data, clientId, cb); + } if (cmd === 'CREATE_TEAM') { return void createTeam(ctx, data, clientId, cb); } diff --git a/www/common/proxy-manager.js b/www/common/proxy-manager.js index ca914b57a..3908727c2 100644 --- a/www/common/proxy-manager.js +++ b/www/common/proxy-manager.js @@ -827,7 +827,7 @@ define([ // Don't push duplicates if (result.indexOf(data.channel) !== -1) { return; } // Return owned pads - if (_ownedByMe(Env, data.owners)) { + if (_ownedByMe(Env, data.owners) && data.owners.length === 1) { result.push(data.channel); } }; diff --git a/www/common/translations/messages.de.json b/www/common/translations/messages.de.json index 7aabe4605..b0a44067e 100644 --- a/www/common/translations/messages.de.json +++ b/www/common/translations/messages.de.json @@ -1197,5 +1197,9 @@ "team_nameHint": "Name des Teams festlegen", "team_avatarTitle": "Teamavatar", "team_avatarHint": "Maximale Größe ist 500 KB (png, jpg, jpeg, gif)", - "team_infoContent": "Jedes Team hat eigene CryptDrives, Speicherplatzbegrenzungen, Chats und Mitgliederlisten. Eigentümer können das gesamte Team löschen. Admins können Mitglieder einladen oder entfernen. Mitglieder können das Team verlassen." + "team_infoContent": "Jedes Team hat eigene CryptDrives, Speicherplatzbegrenzungen, Chats und Mitgliederlisten. Eigentümer können das gesamte Team löschen. Admins können Mitglieder einladen oder entfernen. Mitglieder können das Team verlassen.", + "team_maxOwner": "Jeder Benutzer kann nur Eigentümer eines Teams sein.", + "team_maxTeams": "Jeder Benutzer kann nur Mitglied von {0} Teams sein.", + "team_listTitle": "Deine Teams", + "team_listSlot": "Verfügbare Teamplätze" } diff --git a/www/common/translations/messages.fr.json b/www/common/translations/messages.fr.json index ea32345ad..561572adc 100644 --- a/www/common/translations/messages.fr.json +++ b/www/common/translations/messages.fr.json @@ -40,7 +40,7 @@ "error": "Erreur", "saved": "Enregistré", "synced": "Tout est enregistré", - "deleted": "Pad supprimé de votre CryptDrive", + "deleted": "Supprimé", "deletedFromServer": "Pad supprimé du serveur", "mustLogin": "Vous devez être enregistré pour avoir accès à cette page", "disabledApp": "Cette application a été désactivée. Pour plus d'information, veuillez contacter l'administrateur de ce CryptPad.", @@ -1201,5 +1201,16 @@ "team_maxOwner": "Chaque compte utilisateur ne peut être propriétaire que d'une seule équipe.", "team_maxTeams": "Chaque compte utilisateur ne peut être membre que de {0} équipes.", "team_listTitle": "Vos équipes", - "team_listSlot": "Emplacement d'équipe disponible" + "team_listSlot": "Emplacement d'équipe disponible", + "owner_addTeamText": "...ou à une équipe", + "owner_team_add": "{0} souhaite que vous soyez propriétaire de l'équipe {1}. Acceptez-vous ?", + "team_rosterPromoteOwner": "Proposer d'être propriétaire", + "team_ownerConfirm": "Les co-propriétaires seront en mesure de modifier ou supprimer l'équipe et pourront supprimer vos droits de propriétaire. Continuer ?", + "team_kickConfirm": "{0} sera informé que vous l'avez expulsé de l'équipe. Êtes-vous sûr ?", + "sent": "Message envoyé", + "team_pending": "Invité", + "team_deleteTitle": "Suppression de l'équipe", + "team_deleteHint": "Supprimer l'équipe et tous les documents dont elle est exclusivement propriétaire.", + "team_deleteButton": "Supprimer", + "team_deleteConfirm": "Vous êtes sur le point de supprimer les données d'une équipe entière. Cette action peut impacter l'accès à leur données pour d'autres membres de l'équipe. La suppression est irréversible. Êtes-vous sûr de vouloir continuer ?" } diff --git a/www/common/translations/messages.json b/www/common/translations/messages.json index ed753d9d5..ed85b6752 100644 --- a/www/common/translations/messages.json +++ b/www/common/translations/messages.json @@ -42,7 +42,7 @@ "error": "Error", "saved": "Saved", "synced": "Everything is saved", - "deleted": "Pad deleted from your CryptDrive", + "deleted": "Deleted", "deletedFromServer": "Pad deleted from the server", "mustLogin": "You must be logged in to access this page", "disabledApp": "This application has been disabled. Contact the administrator of this CryptPad for more information.", @@ -1201,5 +1201,16 @@ "team_maxOwner": "Each user account is restricted to owning a single team.", "team_maxTeams": "Each user account can only be a member of {0} teams.", "team_listTitle": "Your teams", - "team_listSlot": "Available team slot" + "team_listSlot": "Available team slot", + "owner_addTeamText": "...or a team", + "owner_team_add": "{0} wants you to be an owner of the team {1}. Do you accept?", + "team_rosterPromoteOwner": "Offer ownership", + "team_ownerConfirm": "Co-owners can modify or delete the team and remove you as an owner. Are you sure?", + "team_kickConfirm": "{0} will know that you removed them from the team. Are you sure?", + "sent": "Message sent", + "team_pending": "Invited", + "team_deleteTitle": "Team deletion", + "team_deleteHint": "Delete the team and all documents owned exclusively by the team.", + "team_deleteButton": "Delete", + "team_deleteConfirm": "You are about to delete all of an entire team's data. This may impact other team members access to their data. This cannot be undone. Are you sure you want to proceed?" } diff --git a/www/teams/inner.js b/www/teams/inner.js index e943dbf94..8f858a751 100644 --- a/www/teams/inner.js +++ b/www/teams/inner.js @@ -115,7 +115,8 @@ define([ ], 'admin': [ 'cp-team-name', - 'cp-team-avatar' + 'cp-team-avatar', + 'cp-team-delete', ], }; @@ -332,7 +333,7 @@ define([ var isOwner = Object.keys(privateData.teams || {}).some(function (id) { return privateData.teams[id].owner; - }); + }) && !privateData.devMode; // XXX if (Object.keys(privateData.teams || {}).length >= 3 || isOwner) { content.push(h('div.alert.alert-warning', { role:'alert' @@ -429,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 @@ -437,6 +441,29 @@ 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 promoteOwner = h('span.fa.fa-angle-double-up', { + title: "Offer ownership" // XXX + }); + $(promoteOwner).click(function () { + $(promoteOwner).hide(); + UI.confirm("Are you sure???", function (yes) { // XXX + if (!yes) { return; } + 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(promoteOwner); + } // 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', { @@ -466,7 +493,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 }); @@ -513,7 +541,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); }); @@ -718,6 +746,40 @@ define([ }); }, true); + makeBlock('delete', function (common, cb) { // XXX makeBlock keys + var deleteTeam = h('button.btn.btn-danger', Messages.team_delete || "DELETE"); // XXX + var $ok = $('', {'class': 'fa fa-check', title: Messages.saved}).hide(); + var $spinner = $('', {'class': 'fa fa-spinner fa-pulse'}).hide(); + + var deleting = false; + $(deleteTeam).click(function () { + if (deleting) { return; } + UI.confirm("Are you sure", function (yes) { // XXX + if (!yes) { return; } + if (deleting) { return; } + deleting = true; + $spinner.show(); + APP.module.execCommand("DELETE_TEAM", { + teamId: APP.team + }, function (obj) { + $spinner.hide(); + deleting = false; + if (obj && obj.error) { + return void UI.warn(obj.error); + } + $ok.show(); + UI.log('DELETED'); // XXX + }); + }); + }); + + cb([ + deleteTeam, + $ok[0], + $spinner[0] + ]); + }, true); + var main = function () { var common; var readOnly; diff --git a/www/teams/main.js b/www/teams/main.js index 5c66c573e..d37de141c 100644 --- a/www/teams/main.js +++ b/www/teams/main.js @@ -36,31 +36,7 @@ define([ }; window.addEventListener('message', onMsg); }).nThen(function (/*waitFor*/) { - var teamId; // XXX - var afterSecrets = function (Cryptpad, Utils, secret, cb) { - return void cb(); - /* - var hash = window.location.hash.slice(1); - if (hash && Utils.LocalStore.isLoggedIn()) { - return; // XXX How to add a shared folder? - // Add a shared folder! - Cryptpad.addSharedFolder(teamId, secret, function (id) { - window.CryptPad_newSharedFolder = id; - cb(); - }); - return; - } else if (hash) { - var id = Utils.Util.createRandomInteger(); - window.CryptPad_newSharedFolder = id; - var data = { - href: Utils.Hash.getRelativeHref(window.location.href), - password: secret.password - }; - return void Cryptpad.loadSharedFolder(id, data, cb); - } - cb(); - */ - }; + var teamId; var addRpc = function (sframeChan, Cryptpad) { sframeChan.on('Q_SET_TEAM', function (data, cb) { teamId = data; @@ -72,11 +48,6 @@ define([ data.teamId = teamId; Cryptpad.userObjectCommand(data, cb); }); - // XXX no drive restore in teams? you could restore old keys... - /*sframeChan.on('Q_DRIVE_RESTORE', function (data, cb) { - data.teamId = teamId; - Cryptpad.restoreDrive(data, cb); - });*/ sframeChan.on('Q_DRIVE_GETOBJECT', function (data, cb) { if (!teamId) { return void cb({error: 'EINVAL'}); } if (data && data.sharedFolder) { @@ -109,7 +80,6 @@ define([ }); }; SFCommonO.start({ - afterSecrets: afterSecrets, noHash: true, noRealtime: true, //driveEvents: true,