diff --git a/customize.dist/src/less2/include/modals-ui-elements.less b/customize.dist/src/less2/include/modals-ui-elements.less index 2b52c0cc0..edaf59c47 100644 --- a/customize.dist/src/less2/include/modals-ui-elements.less +++ b/customize.dist/src/less2/include/modals-ui-elements.less @@ -123,6 +123,11 @@ display: flex; align-items: center; margin-bottom: 5px; + &.cp-teams-invite-role { + .cp-radio:not(:first-child) { + margin-left: 10px; + } + } } .cp-teams-invite-message { resize: none; diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 1255f16ae..2be4dcf82 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -352,10 +352,23 @@ define([ }); var linkName, linkPassword, linkMessage, linkError, linkSpinText; - var linkForm, linkSpin, linkResult; + var linkForm, linkSpin, linkResult, linkUses, linkRole; var linkWarning; // Invite from link var dismissButton = h('span.fa.fa-times'); + + var roleViewer = UI.createRadio('cp-team-role', 'cp-team-role-viewer', + Messages.team_viewers, true, { + input: { value: 'VIEWER' }, + }); + var roleMember = UI.createRadio('cp-team-role', 'cp-team-role-member', + Messages.team_members, false, { + input: { value: 'MEMBER' }, + }); + +Messages.team_inviteRole = "Initial role"; // XXX +Messages.team_inviteUses = "Use(s) allowed for this link (0 = no limit)"; // XXX + var linkContent = h('div.cp-share-modal', [ h('p', Messages.team_inviteLinkTitle ), linkError = h('div.alert.alert-danger.cp-teams-invite-alert', {style : 'display: none;'}), @@ -372,7 +385,7 @@ define([ h('div.cp-teams-invite-block', [ h('span', Messages.team_inviteLinkSetPassword), h('a.cp-teams-help.fa.fa-question-circle', { - href: origin + Pages.localizeDocsLink('https://docs.cryptpad.org/en/user_guide/security.html#passwords-for-documents-and-folders'), + href: Pages.localizeDocsLink('https://docs.cryptpad.org/en/user_guide/security.html#passwords-for-documents-and-folders'), target: "_blank", 'data-tippy-placement': "right" }) @@ -387,7 +400,21 @@ define([ linkMessage = h('textarea.cp-teams-invite-message', { placeholder: Messages.team_inviteLinkNoteMsg, rows: 3 - }) + }), + linkRole = h('div.cp-teams-invite-block.cp-teams-invite-role', + h('span', Messages.team_inviteRole), + roleViewer, + roleMember + ), + h('div.cp-teams-invite-block.cp-teams-invite-uses', + linkUses = h('input', { + type: 'number', + min: 0, + max: 999, + value: 1 + }), + h('span', Messages.team_inviteUses) + ), ]), linkSpin = h('div.cp-teams-invite-spinner', { style: 'display: none;' @@ -400,17 +427,18 @@ define([ }, h('textarea', { readonly: 'readonly' })), - linkWarning = h('div.cp-teams-invite-alert.alert.alert-warning.dismissable', { + linkWarning = h('div.cp-teams-invite-alert.alert.alert-warning.dismissable', { // XXX remove warning? style: "display: none;" }, [ h('span.cp-inline-alert-text', Messages.team_inviteLinkWarning), dismissButton ]) ]); + $(linkUses).on('change keyup', function(e) { + if (e.target.value === '') { e.target.value = 0; } + }); $(linkMessage).keydown(function (e) { - if (e.which === 13) { - e.stopPropagation(); - } + if (e.which === 13) { e.stopPropagation(); } }); var localStore = window.cryptpadStore; localStore.get('hide-alert-teamInvite', function (val) { @@ -428,6 +456,12 @@ define([ var $nav = $linkContent.closest('.alertify').find('nav'); $(linkError).text('').hide(); var name = $(linkName).val(); + + var uses = Number($(linkUses).val()); + if (isNaN(uses) || !uses) { uses = -1; } + + var role = $(linkRole).find("input[name='cp-team-role']:checked").val() || 'VIEWER'; + var pw = $(linkPassword).find('input').val(); var msg = $(linkMessage).val(); var hash = Hash.createRandomHash('invite', pw); @@ -461,6 +495,8 @@ define([ hash: hash, teamId: config.teamId, seeds: seeds, + role: role, + uses: uses }, waitFor(function (obj) { if (obj && obj.error) { waitFor.abort(); diff --git a/www/common/outer/invitation.js b/www/common/outer/invitation.js index 603cae3c1..00bce97f3 100644 --- a/www/common/outer/invitation.js +++ b/www/common/outer/invitation.js @@ -1,5 +1,5 @@ (function () { -var factory = function (Util, Cred, Nacl) { +var factory = function (Util, Cred, Nacl, Crypto) { var Invite = {}; var encode64 = Nacl.util.encodeBase64; @@ -49,6 +49,24 @@ var factory = function (Util, Cred, Nacl) { roster.invite(toInvite, cb); }; + // Invite links should only be visible to members or above, so + // we store them in the roster encrypted with a string only available + // to users with edit rights + var decodeUTF8 = Nacl.util.decodeUTF8; + Invite.encryptHash = function (data, seedStr) { + var array = decodeUTF8(seedStr); + var bytes = Nacl.hash(array); + var cryptKey = bytes.subarray(0, 32); + return Crypto.encrypt(data, cryptKey); + }; + Invite.decryptHash = function (encryptedStr, seedStr) { + var array = decodeUTF8(seedStr); + var bytes = Nacl.hash(array); + var cryptKey = bytes.subarray(0, 32); + return Crypto.decrypt(encryptedStr, cryptKey); + }; + + /* INPUTS * password (for scrypt) @@ -84,16 +102,17 @@ var factory = function (Util, Cred, Nacl) { module.exports = factory( require("../common-util"), require("../common-credential.js"), - require("nthen"), - require("tweetnacl/nacl-fast") + require("tweetnacl/nacl-fast"), + require("chainpad-crypto/crypto") ); } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { define([ '/common/common-util.js', '/common/common-credential.js', + '/bower_components/chainpad-crypto/crypto.js', '/bower_components/tweetnacl/nacl-fast.min.js', - ], function (Util, Cred) { - return factory(Util, Cred, window.nacl); + ], function (Util, Cred, Crypto) { + return factory(Util, Cred, window.nacl, Crypto); }); } }()); diff --git a/www/common/outer/roster.js b/www/common/outer/roster.js index 6aa80e5d8..93f0880f4 100644 --- a/www/common/outer/roster.js +++ b/www/common/outer/roster.js @@ -463,9 +463,22 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto, Feedback) if (typeof(members[curve]) !== 'undefined') { throw new Error("MEMBER_ALREADY_PRESENT"); } // copy the new profile from the old one - members[curve] = Util.clone(members[author]); - // and erase the old one - delete members[author]; + var clone = Util.clone(members[author]); + delete clone.remaining; + delete clone.totalUses; + delete clone.inviteChannel; + delete clone.previewChannel; + members[curve] = clone; + + // XXX + var remaining = members[author].remaining || 1; + if (remaining === -1) { return true; } // Infinite uses, keep the link + if (remaining > 1) { // Remove 1 use + members[author].remaining = remaining - 1; + } else { // Disable link + delete members[author]; + } + return true; }; diff --git a/www/common/outer/team.js b/www/common/outer/team.js index 2bd43e1ea..8fb96b097 100644 --- a/www/common/outer/team.js +++ b/www/common/outer/team.js @@ -1038,6 +1038,17 @@ define([ }); } + // Decrypt hash for invite links + Object.keys(members).forEach(function (curve) { + var member = members[curve]; + if (!member.inviteChannel) { return; } + if (!member.hash) { return; } + if (!teamData.hash) { delete member.hash; return; } + try { + member.hash = Invite.decryptHash(member.hash, teamData.hash); + } catch (e) { console.error(e); } + }); + cb(members); }); }; @@ -1580,10 +1591,12 @@ define([ var message = data.message; var name = data.name; - /* - var password = data.password; + //var password = data.password; var hash = data.hash; - */ + var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); + try { + var encryptedHash = Invite.encryptHash(hash, teamData.hash); + } catch (e) { console.error(e); } // derive { channel, cryptKey} for the preview content channel var previewKeys = Invite.derivePreviewKeys(seeds.preview); @@ -1595,6 +1608,10 @@ define([ // and a placeholder in the roster var ephemeralKeys = Invite.generateKeys(); + // Initial role of the invited users + var role = data.role || "VIEWER"; // XXX + var uses = data.uses || 1; + nThen(function (w) { @@ -1652,9 +1669,12 @@ define([ }; putOpts.metadata.validateKey = sign.validateKey; + + + // available only with the link and the content var inviteContent = { - teamData: getInviteData(ctx, teamId, false), + teamData: getInviteData(ctx, teamId, role === "MEMBER"), ephemeral: { edPublic: ephemeralKeys.edPublic, edPrivate: ephemeralKeys.edPrivate, @@ -1692,6 +1712,10 @@ define([ curvePublic: ephemeralKeys.curvePublic, displayName: data.name, pending: true, + remaining: uses, + totalUses: uses, + role: role, + hash: encryptedHash, inviteChannel: inviteKeys.channel, previewChannel: previewKeys.channel, } @@ -1821,20 +1845,22 @@ define([ })); }).nThen(function () { var tempRpc = {}; - initRpc(ctx, tempRpc, inviteContent.ephemeral, function (err) { - if (err) { return; } - var rpc = tempRpc.rpc; - if (rosterState.inviteChannel) { - rpc.removeOwnedChannel(rosterState.inviteChannel, function (err) { - if (err) { console.error(err); } - }); - } - if (rosterState.previewChannel) { - rpc.removeOwnedChannel(rosterState.previewChannel, function (err) { - if (err) { console.error(err); } - }); - } - }); + if (!rosterState.remaining || rosterState.remaining === 1) { + initRpc(ctx, tempRpc, inviteContent.ephemeral, function (err) { + if (err) { return; } + var rpc = tempRpc.rpc; + if (rosterState.inviteChannel) { + rpc.removeOwnedChannel(rosterState.inviteChannel, function (err) { + if (err) { console.error(err); } + }); + } + if (rosterState.previewChannel) { + rpc.removeOwnedChannel(rosterState.previewChannel, function (err) { + if (err) { console.error(err); } + }); + } + }); + } // Add the team to our list and join... joinTeam(ctx, { team: inviteContent.teamData diff --git a/www/teams/app-team.less b/www/teams/app-team.less index c63e56041..54567f66f 100644 --- a/www/teams/app-team.less +++ b/www/teams/app-team.less @@ -285,6 +285,22 @@ } } + .cp-teams-invite-uses { // XXX + input { + margin-bottom: 0px !important; + margin-right: 10px; + width: 75px !important; + } + + } + .cp-teams-invite-role { + margin-bottom: 15px; + span:first-child { + margin-right: 10px; + } + } + + #cp-teams-roster-dialog { table { width: 100%; diff --git a/www/teams/inner.js b/www/teams/inner.js index 450ac6189..c8c5d443a 100644 --- a/www/teams/inner.js +++ b/www/teams/inner.js @@ -17,6 +17,7 @@ define([ '/customize/application_config.js', '/common/messenger-ui.js', '/common/inner/invitation.js', + '/common/clipboard.js', '/common/make-backup.js', '/customize/messages.js', @@ -43,6 +44,7 @@ define([ AppConfig, MessengerUI, InviteInner, + Clipboard, Backup, Messages) { @@ -714,6 +716,20 @@ define([ title: Messages.team_pendingOwnerTitle }, ' ' + Messages.team_pendingOwner)); } + if (data.pending && data.inviteChannel && data.remaining === -1) { // Invite link + Messages.team_linkUsesInfinite = "(infinite uses)"; // XXX DB done + $(name).append(h('em', ' ' + Messages.team_linkUsesInfinite)); + } else if (data.pending && data.inviteChannel) { + Messages.team_linkUses = "({0}/{1} remaining)"; // XXX DB done + $(name).append(h('em', ' ' + Messages._getKey('team_linkUses', [ + data.remaining || 1, + data.totalUses || 1 + ]))); + } + if (data.pending && data.inviteChannel) { + var r = data.role === "MEMBER" ? Messages.team_members : Messages.team_viewers; + $(name).append(h('em', ' (' + r + ')')); + } // Status var status = h('span.cp-team-member-status'+(data.online ? '.online' : '')); // Actions @@ -817,6 +833,23 @@ define([ actions, status, ]; + if (data.inviteChannel) { + if (data.hash) { + var copy = h('span.fa.fa-copy'); + $(copy).click(function () { + var privateData = common.getMetadataMgr().getPrivateData(); + var origin = privateData.origin; + var href = origin + Hash.hashToHref(data.hash, 'teams'); + var success = Clipboard.copy(href); + if (success) { UI.log(Messages.shareSuccess); } + }).prependTo(actions); + } + content = [ + avatar, + name, + actions + ]; + } var div = h('div.cp-team-roster-member', content); if (data.profile) { $(div).dblclick(function (e) { @@ -872,7 +905,7 @@ define([ if (!roster[k].pending) { return; } if (!roster[k].inviteChannel) { return; } roster[k].curvePublic = k; - return roster[k].role === "VIEWER" || !roster[k].role; + return roster[k].role === "MEMBER" || roster[k].role === "VIEWER" || !roster[k].role; }).map(function (k) { return makeMember(common, roster[k], me); }); @@ -1349,7 +1382,7 @@ define([ Messages._getKey('team_inviteFromMsg', [Util.fixHTML(getDisplayName(json.author.displayName)), Util.fixHTML(json.teamName)]))); - if (typeof(json.message) === 'string') { + if (typeof(json.message) === 'string' && json.message) { var message = h('div.cp-teams-invite-message'); json.message.split('\n').forEach(line => { if (line.trim()) { @@ -1524,12 +1557,12 @@ define([ $div.empty().append(content); }); } - var $divLink = $('div.cp-team-link').empty(); + /*var $divLink = $('div.cp-team-link').empty(); if ($divLink.length) { refreshLink(common, function (content) { $divLink.append(content); }); - } + }*/ var $divCreate = $('div.cp-team-create'); if ($divCreate.length) { refreshCreate(common, function (content) {