diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 68e688c5a1..f8d63bb272 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -708,6 +708,8 @@ module.exports = ProjectController = { anonymous, anonymousAccessToken: req._anonymousAccessToken, isTokenMember, + isRestrictedTokenMember: + isTokenMember === true && privilegeLevel === 'readOnly', languages: Settings.languages, editorThemes: THEME_LIST, maxDocLength: Settings.max_doc_length, @@ -828,6 +830,10 @@ module.exports = ProjectController = { tokens: project.tokens, isV1Project: false } + if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) { + model.owner_ref = null + model.lastUpdatedBy = null + } return model }, diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 11e548e613..a25112e88b 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -91,7 +91,8 @@ block content include ./editor/history .ui-layout-east - include ./editor/chat + if !isRestrictedTokenMember + include ./editor/chat include ./editor/hotkeys @@ -140,6 +141,7 @@ block requirejs window.brandVariation = data.brandVariation; window.anonymousAccessToken = "#{anonymousAccessToken}"; window.isTokenMember = #{!!isTokenMember}; + window.isRestrictedTokenMember = #{!!isRestrictedTokenMember}; window.maxDocLength = #{maxDocLength}; window.trackChangesState = data.trackChangesState; window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)}; diff --git a/services/web/app/views/project/editor/header.pug b/services/web/app/views/project/editor/header.pug index e3d0725eed..9c4563f76f 100644 --- a/services/web/app/views/project/editor/header.pug +++ b/services/web/app/views/project/editor/header.pug @@ -83,7 +83,7 @@ header.toolbar.toolbar-header.toolbar-with-labels( popover-trigger="mouseenter" ng-click="gotoUser(user)" ) {{ userInitial(user) }} - + span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4") span.online-user.online-user-multi( dropdown-toggle, @@ -101,16 +101,17 @@ header.toolbar.toolbar-header.toolbar-with-labels( ) {{ user.name.slice(0,1) }} | {{ user.name }} - a.btn.btn-full-height( - href, - ng-if="project.features.trackChangesVisible", - ng-class="{ active: ui.reviewPanelOpen && ui.view !== 'history' }" - ng-disabled="ui.view === 'history'" - ng-click="toggleReviewPanel()" - ) - i.review-icon - p.toolbar-label - | #{translate("review")} + if !isRestrictedTokenMember + a.btn.btn-full-height( + href, + ng-if="project.features.trackChangesVisible", + ng-class="{ active: ui.reviewPanelOpen && ui.view !== 'history' }" + ng-disabled="ui.view === 'history'" + ng-click="toggleReviewPanel()" + ) + i.review-icon + p.toolbar-label + | #{translate("review")} a.btn.btn-full-height( href @@ -122,24 +123,25 @@ header.toolbar.toolbar-header.toolbar-with-labels( != moduleIncludes('publish:button', locals) - a.btn.btn-full-height( - href, - ng-click="toggleHistory();", - ng-class="{ active: (ui.view == 'history') }", - ) - i.fa.fa-fw.fa-history - p.toolbar-label #{translate("history")} - a.btn.btn-full-height( - href, - ng-class="{ active: ui.chatOpen }", - ng-click="toggleChat();", - ng-controller="ChatButtonController", - ng-show="!anonymous", - ) - i.fa.fa-fw.fa-comment( - ng-class="{ 'bounce': unreadMessages > 0 }" + if !isRestrictedTokenMember + a.btn.btn-full-height( + href, + ng-click="toggleHistory();", + ng-class="{ active: (ui.view == 'history') }", ) - span.label.label-info( - ng-show="unreadMessages > 0" - ) {{ unreadMessages }} - p.toolbar-label #{translate("chat")} + i.fa.fa-fw.fa-history + p.toolbar-label #{translate("history")} + a.btn.btn-full-height( + href, + ng-class="{ active: ui.chatOpen }", + ng-click="toggleChat();", + ng-controller="ChatButtonController", + ng-show="!anonymous", + ) + i.fa.fa-fw.fa-comment( + ng-class="{ 'bounce': unreadMessages > 0 }" + ) + span.label.label-info( + ng-show="unreadMessages > 0" + ) {{ unreadMessages }} + p.toolbar-label #{translate("chat")} diff --git a/services/web/app/views/project/editor/share.pug b/services/web/app/views/project/editor/share.pug index 633c406280..fee79e8bdb 100644 --- a/services/web/app/views/project/editor/share.pug +++ b/services/web/app/views/project/editor/share.pug @@ -8,181 +8,191 @@ script(type='text/ng-template', id='shareProjectModalTemplate') h3 #{translate("share_project")} .modal-body.modal-body-share .container-fluid - //- Private (with token-access available) - .row.public-access-level(ng-show="isAdmin && project.publicAccesLevel == 'private'") - .col-xs-12.text-center - | #{translate('link_sharing_is_off')} - |    - a( - href - ng-click="makeTokenBased()" - ) #{translate('turn_on_link_sharing')} - span    - a( - href="/learn/how-to/What_is_Link_Sharing%3F" - target="_blank" - ) - i.fa.fa-question-circle( - tooltip=translate('learn_more_about_link_sharing') - ) - //- Token-based access - .row.public-access-level(ng-show="isAdmin && project.publicAccesLevel == 'tokenBased'") - .col-xs-12.text-center - strong - | #{translate('link_sharing_is_on')}. + if isRestrictedTokenMember + //- Token-based access + .row.public-access-level + .col-xs-12.access-token-display-area + div.access-token-wrapper + strong #{translate('anyone_with_link_can_view')} + pre.access-token {{ readOnlyTokenLink }} + + if !isRestrictedTokenMember + //- Private (with token-access available) + .row.public-access-level(ng-show="isAdmin && project.publicAccesLevel == 'private'") + .col-xs-12.text-center + | #{translate('link_sharing_is_off')} |    - a( - href - ng-click="makePrivate()" - ) #{translate('turn_off_link_sharing')} - span    - a( - href="/learn/how-to/What_is_Link_Sharing%3F" - target="_blank" - ) - i.fa.fa-question-circle( - tooltip=translate('learn_more_about_link_sharing') - ) - - .col-xs-12.access-token-display-area - div.access-token-wrapper - strong #{translate('anyone_with_link_can_edit')} - pre.access-token(ng-show="readAndWriteTokenLink") {{ readAndWriteTokenLink }} - pre.access-token(ng-hide="readAndWriteTokenLink") #{translate('loading')}... - div.access-token-wrapper - strong #{translate('anyone_with_link_can_view')} - pre.access-token(ng-show="readOnlyTokenLink") {{ readOnlyTokenLink }} - pre.access-token(ng-hide="readOnlyTokenLink") #{translate('loading')}... - - //- legacy public-access - .row.public-access-level(ng-show="isAdmin && (project.publicAccesLevel == 'readAndWrite' || project.publicAccesLevel == 'readOnly')") - .col-xs-12.text-center - strong(ng-if="project.publicAccesLevel == 'readAndWrite'") #{translate("this_project_is_public")} - strong(ng-if="project.publicAccesLevel == 'readOnly'") #{translate("this_project_is_public_read_only")} - |    - a( - href - ng-click="makePrivate()" - ) #{translate("make_private")} - - .row.project-member - .col-xs-8 {{ project.owner.email }} - .text-left( - ng-class="{'col-xs-3': project.members.length > 0, 'col-xs-4': project.members.length == 0}" - ) #{translate("owner")} - .row.project-member(ng-repeat="member in project.members") - .col-xs-8 {{ member.email }} - .col-xs-3.text-left - span(ng-show="member.privileges == 'readAndWrite'") #{translate("can_edit")} - span(ng-show="member.privileges == 'readOnly'") #{translate("read_only")} - .col-xs-1(ng-show="isAdmin") - a( - href - tooltip=translate('remove_collaborator') - tooltip-placement="bottom" - ng-click="removeMember(member)" - ) - i.fa.fa-times - .row.project-invite(ng-repeat="invite in project.invites") - .col-xs-8 {{ invite.email }}  - div.small - | #{translate("invite_not_accepted")}.  - button.btn.btn-inline-link( - ng-show="isAdmin", - ng-click="resendInvite(invite, $event)" - ) #{translate("resend")} - .col-xs-3.text-left - // todo: get invite privileges - span(ng-show="invite.privileges == 'readAndWrite'") #{translate("can_edit")} - span(ng-show="invite.privileges == 'readOnly'") #{translate("read_only")} - .col-xs-1(ng-show="isAdmin") - a( - href - tooltip=translate('revoke_invite') - tooltip-placement="bottom" - ng-click="revokeInvite(invite)" - ) - i.fa.fa-times - .row.invite-controls(ng-show="isAdmin") - form(ng-show="canAddCollaborators") - .small #{translate("share_with_your_collabs")} - .form-group - tags-input( - template="shareTagTemplate" - placeholder=settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, ...' - ng-model="inputs.contacts" - focus-on="open" - display-property="display" - add-on-paste="true" - add-on-enter="false" - replace-spaces-with-dashes="false" - type="email" - ) - auto-complete( - source="filterAutocompleteUsers($query)" - template="shareAutocompleteTemplate" - display-property="email" - min-length="0" - ) - .form-group - .pull-right - select.privileges.form-control( - ng-model="inputs.privileges" - name="privileges" - ) - option(value="readAndWrite") #{translate("can_edit")} - option(value="readOnly") #{translate("read_only")} - |    - //- We have to use mousedown here since click has issues with the - //- blur handler in tags-input sometimes changing its height and - //- moving this button, preventing the click registering. - button.btn.btn-info( - type="submit" - ng-mousedown="addMembers()" - ng-keyup="$event.keyCode == 13 ? addMembers() : null" - ) #{translate("share")} - div(ng-hide="canAddCollaborators") - p.text-center #{translate("need_to_upgrade_for_more_collabs")}. Also: - .row - .col-md-8.col-md-offset-2 - ul.list-unstyled - li - i.fa.fa-check   - | #{translate("unlimited_projects")} - - li - i.fa.fa-check   - | #{translate("collabs_per_proj", {collabcount:'Multiple'})} - - li - i.fa.fa-check   - | #{translate("full_doc_history")} - - li - i.fa.fa-check   - | #{translate("sync_to_dropbox")} - - li - i.fa.fa-check   - | #{translate("sync_to_github")} - - li - i.fa.fa-check   - |#{translate("compile_larger_projects")} - - p.text-center.row-spaced-thin(ng-controller="FreeTrialModalController") - a.btn.btn-success( + a( href - ng-class="buttonClass" - ng-click="startFreeTrial('projectMembers')" - ) #{translate("start_free_trial")} + ng-click="makeTokenBased()" + ) #{translate('turn_on_link_sharing')} + span    + a( + href="/learn/how-to/What_is_Link_Sharing%3F" + target="_blank" + ) + i.fa.fa-question-circle( + tooltip=translate('learn_more_about_link_sharing') + ) - p.small(ng-show="startedFreeTrial") - | #{translate("refresh_page_after_starting_free_trial")} - .row.public-access-level.public-access-level--notice(ng-show="!isAdmin") - .col-xs-12.text-center(ng-show="project.publicAccesLevel == 'private'") #{translate("to_add_more_collaborators")} - .col-xs-12.text-center(ng-show="project.publicAccesLevel == 'tokenBased'") #{translate("to_change_access_permissions")} + //- Token-based access + .row.public-access-level(ng-show="isAdmin && project.publicAccesLevel == 'tokenBased'") + .col-xs-12.text-center + strong + | #{translate('link_sharing_is_on')}. + |    + a( + href + ng-click="makePrivate()" + ) #{translate('turn_off_link_sharing')} + span    + a( + href="/learn/how-to/What_is_Link_Sharing%3F" + target="_blank" + ) + i.fa.fa-question-circle( + tooltip=translate('learn_more_about_link_sharing') + ) + + .col-xs-12.access-token-display-area + div.access-token-wrapper + strong #{translate('anyone_with_link_can_edit')} + pre.access-token(ng-show="readAndWriteTokenLink") {{ readAndWriteTokenLink }} + pre.access-token(ng-hide="readAndWriteTokenLink") #{translate('loading')}... + div.access-token-wrapper + strong #{translate('anyone_with_link_can_view')} + pre.access-token(ng-show="readOnlyTokenLink") {{ readOnlyTokenLink }} + pre.access-token(ng-hide="readOnlyTokenLink") #{translate('loading')}... + + //- legacy public-access + .row.public-access-level(ng-show="isAdmin && (project.publicAccesLevel == 'readAndWrite' || project.publicAccesLevel == 'readOnly')") + .col-xs-12.text-center + strong(ng-if="project.publicAccesLevel == 'readAndWrite'") #{translate("this_project_is_public")} + strong(ng-if="project.publicAccesLevel == 'readOnly'") #{translate("this_project_is_public_read_only")} + |    + a( + href + ng-click="makePrivate()" + ) #{translate("make_private")} + + .row.project-member + .col-xs-8 {{ project.owner.email }} + .text-left( + ng-class="{'col-xs-3': project.members.length > 0, 'col-xs-4': project.members.length == 0}" + ) #{translate("owner")} + .row.project-member(ng-repeat="member in project.members") + .col-xs-8 {{ member.email }} + .col-xs-3.text-left + span(ng-show="member.privileges == 'readAndWrite'") #{translate("can_edit")} + span(ng-show="member.privileges == 'readOnly'") #{translate("read_only")} + .col-xs-1(ng-show="isAdmin") + a( + href + tooltip=translate('remove_collaborator') + tooltip-placement="bottom" + ng-click="removeMember(member)" + ) + i.fa.fa-times + .row.project-invite(ng-repeat="invite in project.invites") + .col-xs-8 {{ invite.email }}  + div.small + | #{translate("invite_not_accepted")}.  + button.btn.btn-inline-link( + ng-show="isAdmin", + ng-click="resendInvite(invite, $event)" + ) #{translate("resend")} + .col-xs-3.text-left + // todo: get invite privileges + span(ng-show="invite.privileges == 'readAndWrite'") #{translate("can_edit")} + span(ng-show="invite.privileges == 'readOnly'") #{translate("read_only")} + .col-xs-1(ng-show="isAdmin") + a( + href + tooltip=translate('revoke_invite') + tooltip-placement="bottom" + ng-click="revokeInvite(invite)" + ) + i.fa.fa-times + .row.invite-controls(ng-show="isAdmin") + form(ng-show="canAddCollaborators") + .small #{translate("share_with_your_collabs")} + .form-group + tags-input( + template="shareTagTemplate" + placeholder=settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, ...' + ng-model="inputs.contacts" + focus-on="open" + display-property="display" + add-on-paste="true" + add-on-enter="false" + replace-spaces-with-dashes="false" + type="email" + ) + auto-complete( + source="filterAutocompleteUsers($query)" + template="shareAutocompleteTemplate" + display-property="email" + min-length="0" + ) + .form-group + .pull-right + select.privileges.form-control( + ng-model="inputs.privileges" + name="privileges" + ) + option(value="readAndWrite") #{translate("can_edit")} + option(value="readOnly") #{translate("read_only")} + |    + //- We have to use mousedown here since click has issues with the + //- blur handler in tags-input sometimes changing its height and + //- moving this button, preventing the click registering. + button.btn.btn-info( + type="submit" + ng-mousedown="addMembers()" + ng-keyup="$event.keyCode == 13 ? addMembers() : null" + ) #{translate("share")} + div(ng-hide="canAddCollaborators") + p.text-center #{translate("need_to_upgrade_for_more_collabs")}. Also: + .row + .col-md-8.col-md-offset-2 + ul.list-unstyled + li + i.fa.fa-check   + | #{translate("unlimited_projects")} + + li + i.fa.fa-check   + | #{translate("collabs_per_proj", {collabcount:'Multiple'})} + + li + i.fa.fa-check   + | #{translate("full_doc_history")} + + li + i.fa.fa-check   + | #{translate("sync_to_dropbox")} + + li + i.fa.fa-check   + | #{translate("sync_to_github")} + + li + i.fa.fa-check   + |#{translate("compile_larger_projects")} + + p.text-center.row-spaced-thin(ng-controller="FreeTrialModalController") + a.btn.btn-success( + href + ng-class="buttonClass" + ng-click="startFreeTrial('projectMembers')" + ) #{translate("start_free_trial")} + + p.small(ng-show="startedFreeTrial") + | #{translate("refresh_page_after_starting_free_trial")} + .row.public-access-level.public-access-level--notice(ng-show="!isAdmin") + .col-xs-12.text-center(ng-show="project.publicAccesLevel == 'private'") #{translate("to_add_more_collaborators")} + .col-xs-12.text-center(ng-show="project.publicAccesLevel == 'tokenBased'") #{translate("to_change_access_permissions")} .modal-footer.modal-footer-share .modal-footer-left i.fa.fa-refresh.fa-spin(ng-show="state.inflight") @@ -207,9 +217,9 @@ script(type="text/ng-template", id="shareTagTemplate") .tag-template span(ng-if="data.type") i.fa.fa-fw(ng-class="{'fa-user': data.type != 'group', 'fa-group': data.type == 'group'}") - | + | span {{$getDisplayText()}} - | + | a(href, ng-click="$removeTag()").remove-button i.fa.fa-fw.fa-close @@ -217,10 +227,10 @@ script(type="text/ng-template", id="shareAutocompleteTemplate") .autocomplete-template div(ng-if="data.type == 'user'") i.fa.fa-fw.fa-user - | + | span(ng-bind-html="$highlight(data.display)") div(ng-if="data.type == 'group'") i.fa.fa-fw.fa-group - | + | span(ng-bind-html="$highlight(data.name)") span.subdued.small(ng-show="data.member_count") ({{ data.member_count }} members) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index 1d324bf3d0..bcf7d08bce 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -35,7 +35,7 @@ td.project-list-table-name-cell(ng-if-start="!project.isV1Project") ) × td.project-list-table-owner-cell - span.owner {{getOwnerName(project)}} + span.owner(ng-if='project.owner') {{getOwnerName(project)}} |   i.fa.fa-question-circle.small( ng-if="hasGenericOwnerName()" diff --git a/services/web/public/src/ide.js b/services/web/public/src/ide.js index 08ca4d6528..ba8a42e4ec 100644 --- a/services/web/public/src/ide.js +++ b/services/web/public/src/ide.js @@ -125,6 +125,7 @@ define([ $scope.settings = window.userSettings $scope.anonymous = window.anonymous $scope.isTokenMember = window.isTokenMember + $scope.isRestrictedTokenMember = window.isRestrictedTokenMember $scope.cobranding = { isProjectCobranded: CobrandingDataService.isProjectCobranded(), diff --git a/services/web/public/src/main/project-list/project-list.js b/services/web/public/src/main/project-list/project-list.js index d63b7f3593..3cc4ef4291 100644 --- a/services/web/public/src/main/project-list/project-list.js +++ b/services/web/public/src/main/project-list/project-list.js @@ -759,7 +759,8 @@ define(['base', 'main/project-list/services/project-list'], function(App) { $scope.getUserName = ProjectListService.getUserName - $scope.isOwner = () => window.user_id === $scope.project.owner._id + $scope.isOwner = () => + $scope.project.owner && window.user_id === $scope.project.owner._id $scope.$watch('project.selected', function(value) { if (value != null) { diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index 661ed2ecc3..a5c6d05a7f 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -804,6 +804,30 @@ describe('ProjectController', function() { return this.ProjectController.loadEditor(this.req, this.res) }) + it('should add isRestrictedTokenMember', function(done) { + this.res.render = (pageName, opts) => { + opts.isRestrictedTokenMember.should.exist + opts.isRestrictedTokenMember.should.equal(false) + return done() + } + return this.ProjectController.loadEditor(this.req, this.res) + }) + + it('should set isRestrictedTokenMember to true under the right conditions', function(done) { + this.CollaboratorsHandler.userIsTokenMember.callsArgWith(2, null, true) + this.AuthorizationManager.getPrivilegeLevelForProject.callsArgWith( + 3, + null, + 'readOnly' + ) + this.res.render = (pageName, opts) => { + opts.isRestrictedTokenMember.should.exist + opts.isRestrictedTokenMember.should.equal(true) + return done() + } + return this.ProjectController.loadEditor(this.req, this.res) + }) + it('should render the closed page if the editor is closed', function(done) { this.settings.editorIsOpen = false this.res.render = (pageName, opts) => { @@ -996,6 +1020,83 @@ describe('ProjectController', function() { }) }) + describe('_buildProjectViewModel', function() { + beforeEach(function() { + this.ProjectHelper.isArchived.returns(false) + this.project = { + _id: 'abcd', + name: 'netsenits', + lastUpdated: 1, + lastUpdatedBy: 2, + publicAccesLevel: 'private', + archived: false, + owner_ref: 'defg', + tokens: { + readAndWrite: '1abcd', + readAndWritePrefix: '1', + readOnly: 'neiotsranteoia' + } + } + }) + + it('should produce a model of the project', function() { + const result = this.ProjectController._buildProjectViewModel( + this.project, + 'readAndWrite', + 'owner', + this.user._id + ) + expect(result).to.exist + expect(result).to.be.object + expect(result).to.deep.equal({ + id: 'abcd', + name: 'netsenits', + lastUpdated: 1, + lastUpdatedBy: 2, + publicAccessLevel: 'private', + accessLevel: 'readAndWrite', + source: 'owner', + archived: false, + owner_ref: 'defg', + tokens: { + readAndWrite: '1abcd', + readAndWritePrefix: '1', + readOnly: 'neiotsranteoia' + }, + isV1Project: false + }) + }) + + describe('when token-read-only access', function() { + it('should redact the owner and last-updated data', function() { + const result = this.ProjectController._buildProjectViewModel( + this.project, + 'readOnly', + 'token', + this.user._id + ) + expect(result).to.exist + expect(result).to.be.object + expect(result).to.deep.equal({ + id: 'abcd', + name: 'netsenits', + lastUpdated: 1, + lastUpdatedBy: null, + publicAccessLevel: 'private', + accessLevel: 'readOnly', + source: 'token', + archived: false, + owner_ref: null, + tokens: { + readAndWrite: '1abcd', + readAndWritePrefix: '1', + readOnly: 'neiotsranteoia' + }, + isV1Project: false + }) + }) + }) + }) describe('_isInPercentageRollout', function() { before(function() { return (this.ids = [