diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js index feab60559b..080dcce37f 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -42,8 +42,10 @@ module.exports = { getProjectsUserIsMemberOf, dangerouslyGetAllProjectsUserIsMemberOf, isUserInvitedMemberOfProject, + isUserInvitedReadWriteMemberOfProject, getPublicShareTokens, userIsTokenMember, + userIsReadWriteTokenMember, getAllInvitedMembers, }, } @@ -139,6 +141,23 @@ async function isUserInvitedMemberOfProject(userId, projectId) { return false } +async function isUserInvitedReadWriteMemberOfProject(userId, projectId) { + if (!userId) { + return false + } + const members = await getMemberIdsWithPrivilegeLevels(projectId) + for (const member of members) { + if ( + member.id.toString() === userId.toString() && + member.source !== Sources.TOKEN && + member.privilegeLevel === PrivilegeLevels.READ_AND_WRITE + ) { + return true + } + } + return false +} + async function getPublicShareTokens(userId, projectId) { const memberInfo = await Project.findOne( { @@ -253,6 +272,21 @@ async function userIsTokenMember(userId, projectId) { return project != null } +async function userIsReadWriteTokenMember(userId, projectId) { + userId = new ObjectId(userId.toString()) + projectId = new ObjectId(projectId.toString()) + const project = await Project.findOne( + { + _id: projectId, + tokenAccessReadAndWrite_refs: userId, + }, + { + _id: 1, + } + ).exec() + return project != null +} + async function _getInvitedMemberCount(projectId) { const members = await getMemberIdsWithPrivilegeLevels(projectId) return members.filter(m => m.source !== Sources.TOKEN).length diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index f74a953b19..ac8d5563cb 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -467,6 +467,36 @@ const _ProjectController = { anonRequestToken ) + const linkSharingChanges = + await SplitTestHandler.promises.getAssignmentForUser( + project.owner_ref, + 'link-sharing-warning' + ) + if (linkSharingChanges?.variant === 'active') { + if (isTokenMember) { + // Check explicitly that the user is in read write token refs, while this could be inferred + // from the privilege level, the privilege level of token members might later be restricted + const isReadWriteTokenMember = + await CollaboratorsGetter.promises.userIsReadWriteTokenMember( + userId, + projectId + ) + if (isReadWriteTokenMember) { + // Check for an edge case where a user is both in read write token access refs but also + // an invited read write member. Ensure they are not redirected to the sharing updates page + // We could also delete the token access ref if the user is already a member of the project + const isInvitedReadWriteMember = + await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject( + userId, + projectId + ) + if (!isInvitedReadWriteMember) { + return res.redirect(`/project/${projectId}/sharing-updates`) + } + } + } + } + let allowedFreeTrial = true if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) { diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessController.js b/services/web/app/src/Features/TokenAccess/TokenAccessController.js index 3735c4e6b4..f9914b6c2a 100644 --- a/services/web/app/src/Features/TokenAccess/TokenAccessController.js +++ b/services/web/app/src/Features/TokenAccess/TokenAccessController.js @@ -15,6 +15,9 @@ const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler') const EditorRealTimeController = require('../Editor/EditorRealTimeController') +const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter') +const ProjectGetter = require('../Project/ProjectGetter') +const AsyncFormHelper = require('../Helpers/AsyncFormHelper') const orderedPrivilegeLevels = [ PrivilegeLevels.NONE, @@ -433,6 +436,116 @@ async function grantTokenAccessReadOnly(req, res, next) { } } +async function ensureUserCanUseSharingUpdatesConsentPage(req, res, next) { + const { Project_id: projectId } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) + const project = await ProjectGetter.promises.getProject(projectId, { + owner_ref: 1, + }) + if (!project) { + throw new Errors.NotFoundError() + } + const linkSharingChanges = + await SplitTestHandler.promises.getAssignmentForUser( + project.owner_ref, + 'link-sharing-warning' + ) + if (linkSharingChanges?.variant !== 'active') { + return AsyncFormHelper.redirect(req, res, `/project/${projectId}`) + } + const isReadWriteTokenMember = + await CollaboratorsGetter.promises.userIsReadWriteTokenMember( + userId, + projectId + ) + if (!isReadWriteTokenMember) { + // If the user is not a read write token member, there are no actions to take + return AsyncFormHelper.redirect(req, res, `/project/${projectId}`) + } + const isReadWriteMember = + await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject( + userId, + projectId + ) + if (isReadWriteMember) { + // If the user is already an invited editor, the actions don't make sense + return AsyncFormHelper.redirect(req, res, `/project/${projectId}`) + } + next() +} + +async function sharingUpdatesConsent(req, res, next) { + const { Project_id: projectId } = req.params + res.render('project/token/sharing-updates', { + projectId, + }) +} + +async function moveReadWriteToCollaborators(req, res, next) { + const { Project_id: projectId } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) + const isInvitedMember = + await CollaboratorsGetter.promises.isUserInvitedMemberOfProject( + userId, + projectId + ) + await ProjectAuditLogHandler.promises.addEntry( + projectId, + 'accept-via-link-sharing', + userId, + req.ip, + { + privileges: 'readAndWrite', + tokenMember: true, + invitedMember: isInvitedMember, + } + ) + if (isInvitedMember) { + // Read only invited viewer who is gaining edit access via link sharing + await TokenAccessHandler.promises.removeReadAndWriteUserFromProject( + userId, + projectId + ) + await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel( + projectId, + userId, + PrivilegeLevels.READ_AND_WRITE + ) + } else { + // Normal case, not invited, joining via link sharing + await TokenAccessHandler.promises.removeReadAndWriteUserFromProject( + userId, + projectId + ) + await CollaboratorsHandler.promises.addUserIdToProject( + projectId, + undefined, + userId, + PrivilegeLevels.READ_AND_WRITE + ) + } + EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', { + members: true, + }) + res.sendStatus(204) +} + +async function moveReadWriteToReadOnly(req, res, next) { + const { Project_id: projectId } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) + await ProjectAuditLogHandler.promises.addEntry( + projectId, + 'readonly-via-sharing-updates', + userId, + req.ip + ) + await TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly( + userId, + projectId + ) + res.sendStatus(204) +} + module.exports = { READ_ONLY_TOKEN_PATTERN: TokenAccessHandler.READ_ONLY_TOKEN_PATTERN, READ_AND_WRITE_TOKEN_PATTERN: TokenAccessHandler.READ_AND_WRITE_TOKEN_PATTERN, @@ -440,4 +553,10 @@ module.exports = { tokenAccessPage: expressify(tokenAccessPage), grantTokenAccessReadOnly: expressify(grantTokenAccessReadOnly), grantTokenAccessReadAndWrite: expressify(grantTokenAccessReadAndWrite), + ensureUserCanUseSharingUpdatesConsentPage: expressify( + ensureUserCanUseSharingUpdatesConsentPage + ), + sharingUpdatesConsent: expressify(sharingUpdatesConsent), + moveReadWriteToCollaborators: expressify(moveReadWriteToCollaborators), + moveReadWriteToReadOnly: expressify(moveReadWriteToReadOnly), } diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js b/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js index 77b866737c..f5f5ef9728 100644 --- a/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js +++ b/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js @@ -187,6 +187,35 @@ const TokenAccessHandler = { ).exec() }, + async removeReadAndWriteUserFromProject(userId, projectId) { + userId = new ObjectId(userId.toString()) + projectId = new ObjectId(projectId.toString()) + + return await Project.updateOne( + { + _id: projectId, + }, + { + $pull: { tokenAccessReadAndWrite_refs: userId }, + } + ).exec() + }, + + async moveReadAndWriteUserToReadOnly(userId, projectId) { + userId = new ObjectId(userId.toString()) + projectId = new ObjectId(projectId.toString()) + + return await Project.updateOne( + { + _id: projectId, + }, + { + $pull: { tokenAccessReadAndWrite_refs: userId }, + $addToSet: { tokenAccessReadOnly_refs: userId }, + } + ).exec() + }, + grantSessionTokenAccess(req, projectId, token) { if (!req.session) { return diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessRouter.js b/services/web/app/src/Features/TokenAccess/TokenAccessRouter.js new file mode 100644 index 0000000000..54dfa11e0f --- /dev/null +++ b/services/web/app/src/Features/TokenAccess/TokenAccessRouter.js @@ -0,0 +1,31 @@ +const AuthenticationController = require('../Authentication/AuthenticationController') +const AuthorizationMiddleware = require('../Authorization/AuthorizationMiddleware') +const TokenAccessController = require('./TokenAccessController') + +module.exports = { + apply(webRouter) { + webRouter.get( + `/project/:Project_id/sharing-updates`, + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage, + TokenAccessController.sharingUpdatesConsent + ) + + webRouter.post( + `/project/:Project_id/sharing-updates/join`, + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage, + TokenAccessController.moveReadWriteToCollaborators + ) + + webRouter.post( + `/project/:Project_id/sharing-updates/view`, + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage, + TokenAccessController.moveReadWriteToReadOnly + ) + }, +} diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 729d8585c1..f1c876d26b 100644 --- a/services/web/app/src/router.js +++ b/services/web/app/src/router.js @@ -51,6 +51,7 @@ const BetaProgramController = require('./Features/BetaProgram/BetaProgramControl const AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter') const MetaController = require('./Features/Metadata/MetaController') const TokenAccessController = require('./Features/TokenAccess/TokenAccessController') +const TokenAccessRouter = require('./Features/TokenAccess/TokenAccessRouter') const Features = require('./infrastructure/Features') const LinkedFilesRouter = require('./Features/LinkedFiles/LinkedFilesRouter') const TemplatesRouter = require('./Features/Templates/TemplatesRouter') @@ -270,6 +271,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter) TemplatesRouter.apply(webRouter) UserMembershipRouter.apply(webRouter) + TokenAccessRouter.apply(webRouter) Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) diff --git a/services/web/app/views/project/token/sharing-updates.pug b/services/web/app/views/project/token/sharing-updates.pug new file mode 100644 index 0000000000..a0afb0c621 --- /dev/null +++ b/services/web/app/views/project/token/sharing-updates.pug @@ -0,0 +1,16 @@ +extends ../../layout-marketing + +block entrypointVar + - entrypoint = 'pages/sharing-updates' + +block vars + - var suppressFooter = true + - var suppressCookieBanner = true + - var suppressSkipToContent = true + +block append meta + meta(name="ol-project_id" data-type="string" content=projectId) + +block content + .content.content-alt#main-content + div#sharing-updates-page diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 1ef95c39e7..8d998621db 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -874,6 +874,7 @@ "off": "", "official": "", "ok": "", + "ok_continue_to_project": "", "ok_join_project": "", "on": "", "on_free_plan_upgrade_to_access_features": "", @@ -1529,6 +1530,7 @@ "update_account_info": "", "update_dropbox_settings": "", "update_your_billing_details": "", + "updates_to_project_sharing": "", "updating": "", "upgrade": "", "upgrade_cc_btn": "", @@ -1594,6 +1596,7 @@ "we_sent_new_code": "", "wed_love_you_to_stay": "", "welcome_to_sl": "", + "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "", "were_performing_maintenance": "", "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "", "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "", @@ -1640,6 +1643,7 @@ "you_are_on_a_paid_plan_contact_support_to_find_out_more": "", "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "", "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "", + "you_can_also_choose_to_view_anonymously_or_leave_the_project": "", "you_can_now_enable_sso": "", "you_can_now_log_in_sso": "", "you_can_only_add_n_people_to_edit_a_project": "", diff --git a/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx b/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx new file mode 100644 index 0000000000..a765972a5e --- /dev/null +++ b/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx @@ -0,0 +1,135 @@ +import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' +import { Trans, useTranslation } from 'react-i18next' +import withErrorBoundary from '@/infrastructure/error-boundary' +import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback' +import { useCallback } from 'react' +import getMeta from '@/utils/meta' +import { postJSON } from '@/infrastructure/fetch-json' +import { debugConsole } from '@/utils/debugging' +import useAsync from '@/shared/hooks/use-async' +import Notification from '@/shared/components/notification' + +function SharingUpdatesRoot() { + const { isReady } = useWaitForI18n() + const { t } = useTranslation() + const { isLoading, isSuccess, isError, runAsync } = useAsync() + const projectId = getMeta('ol-project_id') + + const joinProject = useCallback(() => { + runAsync(postJSON(`/project/${projectId}/sharing-updates/join`)) + .then(() => { + location.assign(`/project/${projectId}`) + }) + .catch(debugConsole.error) + }, [runAsync, projectId]) + + const viewProject = useCallback(() => { + runAsync(postJSON(`/project/${projectId}/sharing-updates/view`)) + .then(() => { + location.assign(`/project/${projectId}`) + }) + .catch(debugConsole.error) + }, [runAsync, projectId]) + + const leaveProject = useCallback(() => { + runAsync(postJSON(`/project/${projectId}/leave`)) + .then(() => { + location.assign('/project') + }) + .catch(debugConsole.error) + }, [runAsync, projectId]) + + if (!isReady) { + return null + } + + return ( +
+
+
+
+
+
+

+ {t('updates_to_project_sharing')} +

+
+
+ +
+
+

+ , + ]} + /> +

+
+
+ +
+
+ +
+
+ + {isError && ( +
+
+ +
+
+ )} + +
+
+

+ + viewProject()} + disabled={isLoading || isSuccess} + />, + // eslint-disable-next-line react/jsx-key +

+
+
+
+
+
+ ) +} + +export default withErrorBoundary( + SharingUpdatesRoot, + GenericErrorBoundaryFallback +) diff --git a/services/web/frontend/js/pages/sharing-updates.tsx b/services/web/frontend/js/pages/sharing-updates.tsx new file mode 100644 index 0000000000..d75069129e --- /dev/null +++ b/services/web/frontend/js/pages/sharing-updates.tsx @@ -0,0 +1,11 @@ +import './../utils/meta' +import './../utils/webpack-public-path' +import './../infrastructure/error-reporter' +import './../i18n' +import ReactDOM from 'react-dom' +import SharingUpdatesRoot from '../features/token-access/components/sharing-updates-root' + +const element = document.getElementById('sharing-updates-page') +if (element) { + ReactDOM.render(, element) +} diff --git a/services/web/frontend/stylesheets/app/invite.less b/services/web/frontend/stylesheets/app/invite.less index d7f4af2cd7..b0b7752853 100644 --- a/services/web/frontend/stylesheets/app/invite.less +++ b/services/web/frontend/stylesheets/app/invite.less @@ -27,3 +27,17 @@ font-size: var(--font-size-07); line-height: var(--line-height-06); } + +.sharing-updates { + font-family: 'Noto Sans', sans-serif; + h1 { + font-family: 'Noto Sans', sans-serif; + // heading-sm + font-size: var(--font-size-05); + line-height: var(--line-height-04); + } + small { + font-size: var(--font-size-02); + line-height: var(--line-height-02); + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index d283bafb13..c9dcbe9206 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1284,6 +1284,7 @@ "off": "Off", "official": "Official", "ok": "OK", + "ok_continue_to_project": "OK, continue to project", "ok_join_project": "OK, join project", "on": "On", "on_free_plan_upgrade_to_access_features": "You are on the __appName__ Free plan. Upgrade to access these <0>Premium Features", @@ -2151,6 +2152,7 @@ "update_account_info": "Update Account Info", "update_dropbox_settings": "Update Dropbox Settings", "update_your_billing_details": "Update Your Billing Details", + "updates_to_project_sharing": "Updates to project sharing", "updating": "Updating", "updating_site": "Updating Site", "upgrade": "Upgrade", @@ -2231,6 +2233,7 @@ "website_status": "Website status", "wed_love_you_to_stay": "We’d love you to stay", "welcome_to_sl": "Welcome to __appName__", + "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "We’re making some <0>changes to project sharing. This means, as someone with edit access, your name and email address will be visible to the project owner and other editors.", "were_performing_maintenance": "We’re performing maintenance on Overleaf and you need to wait a moment. Sorry for any inconvenience. The editor will refresh automatically in __seconds__ seconds.", "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "We’ve recently <0>reduced the compile timeout limit on our free plan, which may have affected this project.", "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "We’ve recently <0>reduced the compile timeout limit on our free plan, which may have affected your project.", @@ -2292,6 +2295,7 @@ "you_are_on_a_paid_plan_contact_support_to_find_out_more": "You’re on an __appName__ Paid plan. <0>Contact support to find out more.", "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "You are on our <0>__planName__ plan as a <1>confirmed member of <1>__institutionName__", "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__ plan as a <1>member of the group subscription <1>__groupName__ administered by <1>__adminEmail__", + "you_can_also_choose_to_view_anonymously_or_leave_the_project": "You can also choose to <0>view anonymously (you will lose edit access) or <1>leave the project.", "you_can_now_enable_sso": "You can now enable SSO on your Group settings page.", "you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features.", "you_can_only_add_n_people_to_edit_a_project": "You can only add X people to edit a project with you on your current plan. Upgrade to add more.", diff --git a/services/web/test/acceptance/src/TokenAccessTests.js b/services/web/test/acceptance/src/TokenAccessTests.js index 4bf308bfff..65adf25bbb 100644 --- a/services/web/test/acceptance/src/TokenAccessTests.js +++ b/services/web/test/acceptance/src/TokenAccessTests.js @@ -5,6 +5,8 @@ const request = require('./helpers/request') const settings = require('@overleaf/settings') const { db } = require('../../../app/src/infrastructure/mongodb') const expectErrorResponse = require('./helpers/expectErrorResponse') +const SplitTestHandler = require('../../../app/src/Features/SplitTests/SplitTestHandler') +const sinon = require('sinon') const tryEditorAccess = (user, projectId, test, callback) => async.series( @@ -237,6 +239,47 @@ const tryFetchProjectTokens = (user, projectId, callback) => { ) } +const trySharingUpdatesPage = (user, projectId, test, callback) => + user.request.get( + `/project/${projectId}/sharing-updates`, + (error, response, body) => { + if (error != null) { + return callback(error) + } + test(response, body, projectId) + callback() + } + ) + +const trySharingUpdatesJoin = (user, projectId, test, callback) => + user.request.post( + `/project/${projectId}/sharing-updates/join`, + (error, response, body) => { + if (error != null) { + return callback(error) + } + test(response, body, projectId) + callback() + } + ) + +const trySharingUpdatesView = (user, projectId, test, callback) => + user.request.post( + `/project/${projectId}/sharing-updates/view`, + (error, response, body) => { + if (error != null) { + return callback(error) + } + test(response, body, projectId) + callback() + } + ) + +const expectRedirectToProject = (response, body, projectId) => { + expect(response.statusCode).to.equal(302) + expect(response.headers.location).to.equal(`/project/${projectId}`) +} + describe('TokenAccess', function () { beforeEach(function (done) { this.timeout(90000) @@ -1782,4 +1825,388 @@ describe('TokenAccess', function () { ) }) }) + + describe('link sharing changes', function () { + beforeEach(function () { + this.getAssignmentForUser = sinon.stub( + SplitTestHandler.promises, + 'getAssignmentForUser' + ) + this.getAssignmentForUser.resolves({ variant: 'default' }) + }) + + afterEach(function () { + this.getAssignmentForUser.restore() + }) + + describe('not a member of the project', function () { + beforeEach(function (done) { + this.projectName = `token-link-sharing-changes${Math.random()}` + this.owner.createProject(this.projectName, (err, projectId) => { + if (err != null) { + return done(err) + } + this.projectId = projectId + this.owner.makeTokenBased(this.projectId, err => { + if (err != null) { + return done(err) + } + this.owner.getProject(this.projectId, (err, project) => { + if (err != null) { + return done(err) + } + this.tokens = project.tokens + done() + }) + }) + }) + }) + + it('should deny access', function (done) { + async.series( + [ + cb => { + trySharingUpdatesPage( + this.other1, + this.projectId, + expectErrorResponse.restricted.html, + cb + ) + }, + cb => { + trySharingUpdatesJoin( + this.other1, + this.projectId, + expectErrorResponse.restricted.html, + cb + ) + }, + cb => { + trySharingUpdatesView( + this.other1, + this.projectId, + expectErrorResponse.restricted.html, + cb + ) + }, + ], + done + ) + }) + }) + + describe('read and write token member of project', function () { + beforeEach(function (done) { + this.projectName = `token-link-sharing-changes${Math.random()}` + this.owner.createProject(this.projectName, (err, projectId) => { + if (err != null) { + return done(err) + } + this.projectId = projectId + this.owner.makeTokenBased(this.projectId, err => { + if (err != null) { + return done(err) + } + this.owner.getProject(this.projectId, (err, project) => { + if (err != null) { + return done(err) + } + this.tokens = project.tokens + // must do token accept before split test enabled + // otherwise would be automatically added to named collaborators + tryReadAndWriteTokenAccept( + this.other1, + this.tokens.readAndWrite, + (response, body) => { + expect(response.statusCode).to.equal(200) + }, + (response, body) => { + expect(response.statusCode).to.equal(200) + expect(body.redirect).to.equal(`/project/${this.projectId}`) + expect(body.tokenAccessGranted).to.equal('readAndWrite') + }, + done + ) + }) + }) + }) + }) + + describe('link sharing changes test not active', function () { + it('should redirect to project, same permissions as before', function (done) { + async.series( + [ + cb => { + trySharingUpdatesPage( + this.other1, + this.projectId, + expectRedirectToProject, + cb + ) + }, + cb => { + trySharingUpdatesJoin( + this.other1, + this.projectId, + expectRedirectToProject, + cb + ) + }, + cb => { + trySharingUpdatesView( + this.other1, + this.projectId, + expectRedirectToProject, + cb + ) + }, + cb => { + tryContentAccess( + this.other1, + this.projectId, + (response, body) => { + expect(body.privilegeLevel).to.equal('readAndWrite') + expect(body.isRestrictedUser).to.equal(false) + expect(body.isTokenMember).to.equal(true) + expect(body.isInvitedMember).to.equal(false) + expect(body.project.owner).to.have.all.keys( + '_id', + 'email', + 'first_name', + 'last_name', + 'privileges', + 'signUpDate' + ) + }, + cb + ) + }, + cb => { + tryEditorAccess( + this.other1, + this.projectId, + (response, body) => { + expect(response.statusCode).to.equal(200) + }, + cb + ) + }, + ], + done + ) + }) + }) + + describe('link sharing changes test is active', function () { + beforeEach(function () { + this.getAssignmentForUser.resolves({ variant: 'active' }) + }) + it('should show sharing updates page', function (done) { + trySharingUpdatesPage( + this.other1, + this.projectId, + (response, body) => { + expect(response.statusCode).to.equal(200) + }, + done + ) + }) + + it('should allow join to named collaborator', function (done) { + async.series( + [ + cb => { + trySharingUpdatesJoin( + this.other1, + this.projectId, + (response, body) => { + expect(response.statusCode).to.equal(204) + }, + cb + ) + }, + cb => { + tryContentAccess( + this.other1, + this.projectId, + (response, body) => { + expect(body.privilegeLevel).to.equal('readAndWrite') + expect(body.isRestrictedUser).to.equal(false) + expect(body.isTokenMember).to.equal(false) + expect(body.isInvitedMember).to.equal(true) // now collaborator + expect(body.project.owner).to.have.all.keys( + '_id', + 'email', + 'first_name', + 'last_name', + 'privileges', + 'signUpDate' + ) + }, + cb + ) + }, + cb => { + tryEditorAccess( + this.other1, + this.projectId, + (response, body) => { + expect(response.statusCode).to.equal(200) + }, + cb + ) + }, + ], + done + ) + }) + + it('should allow move to anonymous viewer', function (done) { + async.series( + [ + cb => { + trySharingUpdatesView( + this.other1, + this.projectId, + (response, body) => { + expect(response.statusCode).to.equal(204) + }, + cb + ) + }, + cb => { + tryContentAccess( + this.other1, + this.projectId, + (response, body) => { + expect(body.privilegeLevel).to.equal('readOnly') + expect(body.isRestrictedUser).to.equal(true) + expect(body.isTokenMember).to.equal(true) + expect(body.isInvitedMember).to.equal(false) + expect(body.project.owner).to.have.keys('_id') + }, + cb + ) + }, + cb => { + tryEditorAccess( + this.other1, + this.projectId, + (response, body) => { + expect(response.statusCode).to.equal(200) + }, + cb + ) + }, + ], + done + ) + }) + }) + }) + + describe('read-only token member of project', function () { + beforeEach(function (done) { + this.projectName = `token-link-sharing-changes${Math.random()}` + this.owner.createProject(this.projectName, (err, projectId) => { + if (err != null) { + return done(err) + } + this.projectId = projectId + this.owner.makeTokenBased(this.projectId, err => { + if (err != null) { + return done(err) + } + this.owner.getProject(this.projectId, (err, project) => { + if (err != null) { + return done(err) + } + this.tokens = project.tokens + tryReadOnlyTokenAccept( + this.other1, + this.tokens.readOnly, + (response, body) => { + expect(response.statusCode).to.equal(200) + }, + (response, body) => { + expect(response.statusCode).to.equal(200) + expect(body.redirect).to.equal(`/project/${this.projectId}`) + expect(body.tokenAccessGranted).to.equal('readOnly') + }, + done + ) + }) + }) + }) + }) + + describe('link sharing changes test is active', function () { + beforeEach(function () { + this.getAssignmentForUser.resolves({ variant: 'active' }) + }) + + it('should redirect to project, same view permissions as before', function (done) { + async.series( + [ + cb => { + trySharingUpdatesPage( + this.other1, + this.projectId, + expectRedirectToProject, + cb + ) + }, + cb => { + trySharingUpdatesJoin( + this.other1, + this.projectId, + expectRedirectToProject, + cb + ) + }, + cb => { + trySharingUpdatesView( + this.other1, + this.projectId, + expectRedirectToProject, + cb + ) + }, + cb => { + // allow content access read-only + tryContentAccess( + this.other1, + this.projectId, + (response, body) => { + expect(body.privilegeLevel).to.equal('readOnly') + expect(body.isRestrictedUser).to.equal(true) + expect(body.isTokenMember).to.equal(true) + expect(body.isInvitedMember).to.equal(false) + expect(body.project.owner).to.have.keys('_id') + expect(body.project.owner).to.not.have.any.keys( + 'email', + 'first_name', + 'last_name' + ) + }, + cb + ) + }, + cb => { + tryEditorAccess( + this.other1, + this.projectId, + (response, body) => { + expect(response.statusCode).to.equal(200) + }, + cb + ) + }, + ], + done + ) + }) + }) + }) + }) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js index 0df201915e..96cb770b19 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js @@ -231,6 +231,38 @@ describe('CollaboratorsGetter', function () { }) }) + describe('isUserInvitedReadWriteMemberOfProject', function () { + describe('when user is a read write member of the project', function () { + it('should return true', async function () { + const isMember = + await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject( + this.readWriteRef1 + ) + expect(isMember).to.equal(true) + }) + }) + + describe('when user is a read only member of the project', function () { + it('should return false', async function () { + const isMember = + await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject( + this.readOnlyRef1 + ) + expect(isMember).to.equal(false) + }) + }) + + describe('when user is not a member of the project', function () { + it('should return false', async function () { + const isMember = + await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject( + this.nonMemberRef + ) + expect(isMember).to.equal(false) + }) + }) + }) + describe('getProjectsUserIsMemberOf', function () { beforeEach(function () { this.fields = 'mock fields' @@ -365,6 +397,28 @@ describe('CollaboratorsGetter', function () { }) }) + describe('userIsReadWriteTokenMember', function () { + it('should return true when the project is found', async function () { + this.ProjectMock.expects('findOne').chain('exec').resolves(this.project) + const isMember = + await this.CollaboratorsGetter.promises.userIsReadWriteTokenMember( + this.userId, + this.project._id + ) + expect(isMember).to.be.true + }) + + it('should return false when the project is not found', async function () { + this.ProjectMock.expects('findOne').chain('exec').resolves(null) + const isMember = + await this.CollaboratorsGetter.promises.userIsReadWriteTokenMember( + this.userId, + this.project._id + ) + expect(isMember).to.be.false + }) + }) + describe('getPublicShareTokens', function () { const userMock = new ObjectId() diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index c90d512795..6f404e9422 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -127,6 +127,8 @@ describe('ProjectController', function () { promises: { userIsTokenMember: sinon.stub().resolves(false), isUserInvitedMemberOfProject: sinon.stub().resolves(true), + userIsReadWriteTokenMember: sinon.stub().resolves(false), + isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(true), }, } this.ProjectEntityHandler = {} @@ -1002,6 +1004,53 @@ describe('ProjectController', function () { this.ProjectController.loadEditor(this.req, this.res) }) }) + + describe('link sharing changes active', function () { + beforeEach(function () { + this.SplitTestHandler.promises.getAssignmentForUser.resolves({ + variant: 'active', + }) + }) + + describe('when user is a read write token member (and not already a named editor)', function () { + beforeEach(function () { + this.CollaboratorsGetter.promises.userIsTokenMember.resolves(true) + this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( + true + ) + this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( + false + ) + }) + + it('should redirect to the sharing-updates page', function (done) { + this.res.redirect = url => { + expect(url).to.equal(`/project/${this.project_id}/sharing-updates`) + done() + } + this.ProjectController.loadEditor(this.req, this.res) + }) + }) + + describe('when user is a read write token member but also a named editor', function () { + beforeEach(function () { + this.CollaboratorsGetter.promises.userIsTokenMember.resolves(true) + this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( + true + ) + this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( + true + ) + }) + + it('should not redirect to the sharing-updates page, and should load the editor', function (done) { + this.res.render = (pageName, opts) => { + done() + } + this.ProjectController.loadEditor(this.req, this.res) + }) + }) + }) }) describe('userProjectsJson', function () { diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js b/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js index 176cfab903..e23c9085c5 100644 --- a/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js +++ b/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js @@ -21,6 +21,7 @@ describe('TokenAccessController', function () { } this.req = new MockRequest() this.res = new MockResponse() + this.next = sinon.stub().returns() this.Settings = {} this.TokenAccessHandler = { @@ -40,6 +41,8 @@ describe('TokenAccessController', function () { getProjectByToken: sinon.stub().resolves(this.project), getV1DocPublishedInfo: sinon.stub().resolves({ allow: true }), getV1DocInfo: sinon.stub(), + removeReadAndWriteUserFromProject: sinon.stub().resolves(), + moveReadAndWriteUserToReadOnly: sinon.stub().resolves(), }, } @@ -77,11 +80,26 @@ describe('TokenAccessController', function () { this.CollaboratorsHandler = { promises: { addUserIdToProject: sinon.stub().resolves(), + setCollaboratorPrivilegeLevel: sinon.stub().resolves(), + }, + } + + this.CollaboratorsGetter = { + promises: { + userIsReadWriteTokenMember: sinon.stub().resolves(), + isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(), + isUserInvitedMemberOfProject: sinon.stub().resolves(), }, } this.EditorRealTimeController = { emitToRoom: sinon.stub() } + this.ProjectGetter = { + promises: { + getProject: sinon.stub().resolves(this.project), + }, + } + this.TokenAccessController = SandboxedModule.require(MODULE_PATH, { requires: { '@overleaf/settings': this.Settings, @@ -96,7 +114,12 @@ describe('TokenAccessController', function () { '../SplitTests/SplitTestHandler': this.SplitTestHandler, '../Errors/Errors': (this.Errors = { NotFoundError: sinon.stub() }), '../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler, + '../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter, '../Editor/EditorRealTimeController': this.EditorRealTimeController, + '../Project/ProjectGetter': this.ProjectGetter, + '../Helpers/AsyncFormHelper': (this.AsyncFormHelper = { + redirect: sinon.stub(), + }), }, }) }) @@ -721,4 +744,204 @@ describe('TokenAccessController', function () { ) }) }) + + describe('ensureUserCanUseSharingUpdatesConsentPage', function () { + beforeEach(function () { + this.req.params = { Project_id: this.project._id } + }) + + describe('when not in link sharing changes test', function () { + beforeEach(function (done) { + this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done()) + this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( + this.req, + this.res, + done + ) + }) + + it('redirects to the project/editor', function () { + expect(this.AsyncFormHelper.redirect).to.have.been.calledWith( + this.req, + this.res, + `/project/${this.project._id}` + ) + }) + }) + + describe('when link sharing changes test active', function () { + beforeEach(function () { + this.SplitTestHandler.promises.getAssignmentForUser.resolves({ + variant: 'active', + }) + }) + + describe('when user is not an invited editor and is a read write token member', function () { + beforeEach(function (done) { + this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( + false + ) + this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( + true + ) + this.next.callsFake(() => done()) + this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( + this.req, + this.res, + this.next + ) + }) + + it('calls next', function () { + expect( + this.CollaboratorsGetter.promises + .isUserInvitedReadWriteMemberOfProject + ).to.have.been.calledWith(this.user._id, this.project._id) + expect( + this.CollaboratorsGetter.promises.userIsReadWriteTokenMember + ).to.have.been.calledWith(this.user._id, this.project._id) + expect(this.next).to.have.been.calledOnce + expect(this.next.firstCall.args[0]).to.not.exist + }) + }) + + describe('when user is already an invited editor', function () { + beforeEach(function (done) { + this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( + true + ) + this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done()) + this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( + this.req, + this.res, + done + ) + }) + + it('redirects to the project/editor', function () { + expect(this.AsyncFormHelper.redirect).to.have.been.calledWith( + this.req, + this.res, + `/project/${this.project._id}` + ) + }) + }) + + describe('when user not a read write token member', function () { + beforeEach(function (done) { + this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( + false + ) + this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done()) + this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( + this.req, + this.res, + done + ) + }) + + it('redirects to the project/editor', function () { + expect(this.AsyncFormHelper.redirect).to.have.been.calledWith( + this.req, + this.res, + `/project/${this.project._id}` + ) + }) + }) + }) + }) + + describe('moveReadWriteToCollaborators', function () { + beforeEach(function () { + this.req.params = { Project_id: this.project._id } + }) + + describe('read only invited viewer gaining edit access via link sharing', function () { + beforeEach(function (done) { + this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( + true + ) + this.res.callback = done + this.TokenAccessController.moveReadWriteToCollaborators( + this.req, + this.res, + done + ) + }) + + it('sets the privilege level to read and write for the invited viewer', function () { + expect( + this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel + ).to.have.been.calledWith( + this.project._id, + this.user._id, + PrivilegeLevels.READ_AND_WRITE + ) + expect(this.res.sendStatus).to.have.been.calledWith(204) + }) + }) + describe('previously joined token access user moving to named collaborator', function () { + beforeEach(function (done) { + this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( + false + ) + this.res.callback = done + this.TokenAccessController.moveReadWriteToCollaborators( + this.req, + this.res, + done + ) + }) + + it('sets the privilege level to read and write for the invited viewer', function () { + expect( + this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject + ).to.have.been.calledWith(this.user._id, this.project._id) + expect( + this.CollaboratorsHandler.promises.addUserIdToProject + ).to.have.been.calledWith( + this.project._id, + undefined, + this.user._id, + PrivilegeLevels.READ_AND_WRITE + ) + expect(this.res.sendStatus).to.have.been.calledWith(204) + }) + }) + }) + + describe('moveReadWriteToReadOnly', function () { + beforeEach(function () { + this.req.params = { Project_id: this.project._id } + }) + + describe('previously joined token access user moving to anonymous viewer', function () { + beforeEach(function (done) { + this.res.callback = done + this.TokenAccessController.moveReadWriteToReadOnly( + this.req, + this.res, + done + ) + }) + + it('removes them from read write token access refs and adds them to read only token access refs', function () { + expect( + this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly + ).to.have.been.calledWith(this.user._id, this.project._id) + expect(this.res.sendStatus).to.have.been.calledWith(204) + }) + + it('writes a project audit log', function () { + expect( + this.ProjectAuditLogHandler.promises.addEntry + ).to.have.been.calledWith( + this.project._id, + 'readonly-via-sharing-updates', + this.user._id, + this.req.ip + ) + }) + }) + }) }) diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js b/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js index 25c7ab7cfd..b6c0128043 100644 --- a/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js +++ b/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js @@ -200,6 +200,59 @@ describe('TokenAccessHandler', function () { }) }) + describe('removeReadAndWriteUserFromProject', function () { + beforeEach(function () { + this.Project.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves(null) }) + }) + + it('should call Project.updateOne', async function () { + await this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject( + this.userId, + this.projectId + ) + + expect(this.Project.updateOne.callCount).to.equal(1) + expect( + this.Project.updateOne.calledWith({ + _id: this.projectId, + }) + ).to.equal(true) + expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys( + 'tokenAccessReadAndWrite_refs' + ) + }) + }) + + describe('moveReadAndWriteUserToReadOnly', function () { + beforeEach(function () { + this.Project.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves(null) }) + }) + + it('should call Project.updateOne', async function () { + await this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly( + this.userId, + this.projectId + ) + + expect(this.Project.updateOne.callCount).to.equal(1) + expect( + this.Project.updateOne.calledWith({ + _id: this.projectId, + }) + ).to.equal(true) + expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys( + 'tokenAccessReadAndWrite_refs' + ) + expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys( + 'tokenAccessReadOnly_refs' + ) + }) + }) + describe('grantSessionTokenAccess', function () { beforeEach(function () { this.req = { session: {}, headers: {} }