diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs index a10db10cbc..075eb1d784 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs @@ -18,6 +18,7 @@ import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs' import Errors from '../Errors/Errors.js' import AuthenticationController from '../Authentication/AuthenticationController.mjs' import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs' +import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' // This rate limiter allows a different number of requests depending on the // number of callaborators a user is allowed. This is implemented by providing @@ -328,14 +329,26 @@ async function viewInvite(req, res) { // cleanup if set for register page delete req.session.sharedProjectData + const { variant: sharingUpdates } = + await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates') + // finally render the invite - res.render('project/invite/show', { - invite, - token, - project, - owner, - title: 'Project Invite', - }) + if (sharingUpdates === 'enabled') { + res.render('project/invite/show', { + token, + projectName: project.name, + projectId: invite.projectId, + title: 'Project Invite', + }) + } else { + res.render('project/invite/show-legacy', { + invite, + token, + project, + owner, + title: 'Project Invite', + }) + } } async function acceptInvite(req, res) { diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs b/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs index 416b6b4eb6..01551937e9 100644 --- a/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs +++ b/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs @@ -20,6 +20,7 @@ import UrlHelper from '../Helpers/UrlHelper.mjs' import UserGetter from '../User/UserGetter.mjs' import Settings from '@overleaf/settings' import LimitationsManager from '../Subscription/LimitationsManager.mjs' +import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' const { getSafeAdminDomainRedirect } = UrlHelper const { canRedirectToAdminDomain } = AdminAuthorizationHelper @@ -113,7 +114,15 @@ async function tokenAccessPage(req, res, next) { } } - res.render('project/token/access-react', { + const { variant: sharingUpdates } = + await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates') + + const viewPath = + sharingUpdates === 'enabled' + ? 'project/token/access-react' + : 'project/token/access-react-legacy' + + res.render(viewPath, { postUrl: makePostUrl(token), }) } catch (err) { diff --git a/services/web/app/views/project/invite/show-legacy.pug b/services/web/app/views/project/invite/show-legacy.pug new file mode 100644 index 0000000000..503ec78796 --- /dev/null +++ b/services/web/app/views/project/invite/show-legacy.pug @@ -0,0 +1,35 @@ +extends ../../layout-marketing + +block content + main#main-content.content.content-alt + .container + .row + .col-12.col-md-8.col-md-offset-2.offset-md-2 + .card.project-invite-accept + .card-body + .page-header.text-center + h1 #{translate("user_wants_you_to_see_project", {username:owner.first_name, projectname:""})} + br + em #{project.name} + .row.text-center + .col-12.col-md-12 + p + | #{translate("accepting_invite_as")}  + em #{user.email} + .row + .col-12.col-md-12 + form.form( + data-ol-regular-form + method='POST' + action='/project/' + invite.projectId + '/invite/token/' + token + '/accept' + ) + input(name='_csrf' type='hidden' value=csrfToken) + input(name='token' type='hidden' value=token) + .form-group.text-center + button.btn.btn-lg.btn-primary( + type='submit' + data-ol-disabled-inflight + ) + span(data-ol-inflight='idle') #{translate("join_project")} + span(hidden data-ol-inflight='pending') #{translate("joining")}… + .form-group.text-center diff --git a/services/web/app/views/project/invite/show.pug b/services/web/app/views/project/invite/show.pug index 503ec78796..e1b4d608f1 100644 --- a/services/web/app/views/project/invite/show.pug +++ b/services/web/app/views/project/invite/show.pug @@ -1,35 +1,17 @@ -extends ../../layout-marketing +extends ../../layout-website-redesign + +block vars + - isWebsiteRedesign = true + +block entrypointVar + - entrypoint = 'pages/project-invite' + +block append meta + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-inviteToken' data-type='string' content=token) + meta(name='ol-projectName' data-type='string' content=projectName) + meta(name='ol-project_id' data-type='string' content=projectId) block content main#main-content.content.content-alt - .container - .row - .col-12.col-md-8.col-md-offset-2.offset-md-2 - .card.project-invite-accept - .card-body - .page-header.text-center - h1 #{translate("user_wants_you_to_see_project", {username:owner.first_name, projectname:""})} - br - em #{project.name} - .row.text-center - .col-12.col-md-12 - p - | #{translate("accepting_invite_as")}  - em #{user.email} - .row - .col-12.col-md-12 - form.form( - data-ol-regular-form - method='POST' - action='/project/' + invite.projectId + '/invite/token/' + token + '/accept' - ) - input(name='_csrf' type='hidden' value=csrfToken) - input(name='token' type='hidden' value=token) - .form-group.text-center - button.btn.btn-lg.btn-primary( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight='idle') #{translate("join_project")} - span(hidden data-ol-inflight='pending') #{translate("joining")}… - .form-group.text-center + #project-invite-page diff --git a/services/web/app/views/project/token/access-react-legacy.pug b/services/web/app/views/project/token/access-react-legacy.pug new file mode 100644 index 0000000000..90d5f9ea4b --- /dev/null +++ b/services/web/app/views/project/token/access-react-legacy.pug @@ -0,0 +1,16 @@ +extends ../../layout-react + +block entrypointVar + - entrypoint = 'pages/token-access' + +block vars + - var suppressFooter = true + - var suppressPugCookieBanner = true + - var suppressSkipToContent = true + +block append meta + meta(name='ol-postUrl' data-type='string' content=postUrl) + meta(name='ol-user' data-type='json' content=user) + +block content + #token-access-page diff --git a/services/web/app/views/project/token/access-react.pug b/services/web/app/views/project/token/access-react.pug index 90d5f9ea4b..3ed897a5c4 100644 --- a/services/web/app/views/project/token/access-react.pug +++ b/services/web/app/views/project/token/access-react.pug @@ -1,16 +1,15 @@ -extends ../../layout-react +extends ../../layout-website-redesign block entrypointVar - entrypoint = 'pages/token-access' -block vars - - var suppressFooter = true - - var suppressPugCookieBanner = true - - var suppressSkipToContent = true - block append meta meta(name='ol-postUrl' data-type='string' content=postUrl) meta(name='ol-user' data-type='json' content=user) +block vars + - isWebsiteRedesign = true + block content - #token-access-page + main#main-content.content.content-alt + #token-access-page diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b969e9c481..df1d98a93b 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -956,6 +956,7 @@ "join_now": "", "join_overleaf_labs": "", "join_project": "", + "join_project_lowercase": "", "join_team_explanation": "", "join_x_enterprise_group": "", "join_x_managed_enterprise_group": "", @@ -2337,6 +2338,7 @@ "your_git_access_info_bullet_5": "", "your_git_access_tokens": "", "your_message_to_collaborators": "", + "your_name_and_email_address_will_be_visible_to_project_editors": "", "your_name_and_email_address_will_be_visible_to_the_project_owner_and_other_editors": "", "your_new_plan": "", "your_password_was_detected": "", @@ -2362,6 +2364,7 @@ "youre_already_setup_for_sso": "", "youre_creating_account_for_x_it_will_be_managed_by_y": "", "youre_joining": "", + "youre_joining_x_as_y": "", "youre_not_eligible_for_a_free_trial": "", "youre_on_free_trial_which_ends_on": "", "youre_signed_in_as_logout": "", diff --git a/services/web/frontend/js/features/share-project/invite-root.tsx b/services/web/frontend/js/features/share-project/invite-root.tsx new file mode 100644 index 0000000000..d39257211c --- /dev/null +++ b/services/web/frontend/js/features/share-project/invite-root.tsx @@ -0,0 +1,36 @@ +import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' +import getMeta from '@/utils/meta' +import Invite from '@/features/share-project/invite' +import useAsync from '@/shared/hooks/use-async' +import { postJSON } from '@/infrastructure/fetch-json' +import { useLocation } from '@/shared/hooks/use-location' +import { debugConsole } from '@/utils/debugging' + +export default function InviteRoot() { + const user = getMeta('ol-user') + const projectName = getMeta('ol-projectName') + const projectId = getMeta('ol-project_id') + const token = getMeta('ol-inviteToken') + const location = useLocation() + const { isLoading, runAsync } = useAsync() + const { isReady } = useWaitForI18n() + + const handleSubmit = () => { + runAsync(postJSON(`/project/${projectId}/invite/token/${token}/accept`)) + .then(() => location.assign(`/project/${projectId}`)) + .catch(debugConsole.error) + } + + if (!isReady) { + return null + } + + return ( + + ) +} diff --git a/services/web/frontend/js/features/share-project/invite.tsx b/services/web/frontend/js/features/share-project/invite.tsx new file mode 100644 index 0000000000..881cdd4825 --- /dev/null +++ b/services/web/frontend/js/features/share-project/invite.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from 'react-i18next' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLButton from '@/shared/components/ol/ol-button' +import overleafLogo from '@/shared/svgs/overleaf-logo.svg' +import getMeta from '@/utils/meta' + +type InviteProps = { + projectName: string + email: string + submitHandler: () => void + isLoading?: boolean +} + +function Invite({ projectName, email, submitHandler, isLoading }: InviteProps) { + const { t } = useTranslation() + const { appName } = getMeta('ol-ExposedSettings') + + return ( +
+ + +
+ {appName} +

+ {t('youre_joining_x_as_y', { projectName, email })} +

+
+ {t( + 'your_name_and_email_address_will_be_visible_to_project_editors' + )} +
+ + {t('join_project_lowercase')} + +
+
+
+
+ ) +} + +export default Invite diff --git a/services/web/frontend/js/features/token-access/components/require-accept-screen.tsx b/services/web/frontend/js/features/token-access/components/require-accept-screen.tsx index 7fc34917db..c7bdc609e6 100644 --- a/services/web/frontend/js/features/token-access/components/require-accept-screen.tsx +++ b/services/web/frontend/js/features/token-access/components/require-accept-screen.tsx @@ -1,6 +1,8 @@ import { FC } from 'react' import { useTranslation } from 'react-i18next' import getMeta from '@/utils/meta' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import Invite from '@/features/share-project/invite' export type RequireAcceptData = { projectName?: string @@ -12,6 +14,17 @@ export const RequireAcceptScreen: FC<{ }> = ({ requireAcceptData, sendPostRequest }) => { const { t } = useTranslation() const user = getMeta('ol-user') + const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates') + + if (isSharingUpdatesEnabled) { + return ( + sendPostRequest(true)} + /> + ) + } return (
diff --git a/services/web/frontend/js/features/token-access/components/token-access-root.tsx b/services/web/frontend/js/features/token-access/components/token-access-root.tsx index 121b20ae86..6f8537b4f4 100644 --- a/services/web/frontend/js/features/token-access/components/token-access-root.tsx +++ b/services/web/frontend/js/features/token-access/components/token-access-root.tsx @@ -13,6 +13,7 @@ import { } from '@/features/token-access/components/require-accept-screen' import importOverleafModules from '../../../../macros/import-overleaf-module.macro' import MaterialIcon from '@/shared/components/material-icon' +import { useFeatureFlag } from '@/shared/context/split-test-context' type Mode = 'access-attempt' | 'v1Import' | 'requireAccept' @@ -39,6 +40,7 @@ function TokenAccessRoot() { const [loadingScreenBrandHeight, setLoadingScreenBrandHeight] = useState('0px') const location = useLocation() + const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates') const sendPostRequest = useCallback( (confirmedByUser = false) => { @@ -112,11 +114,16 @@ function TokenAccessRoot() { return (
-
- - - -
+ {!isSharingUpdatesEnabled && ( +
+ + + +
+ )}
{mode === 'access-attempt' && ( ) +} diff --git a/services/web/frontend/js/pages/token-access.tsx b/services/web/frontend/js/pages/token-access.tsx index 72a208a0fe..22e0ccaa86 100644 --- a/services/web/frontend/js/pages/token-access.tsx +++ b/services/web/frontend/js/pages/token-access.tsx @@ -4,9 +4,14 @@ import './../infrastructure/error-reporter' import '@/i18n' import { createRoot } from 'react-dom/client' import TokenAccessRoot from '../features/token-access/components/token-access-root' +import { SplitTestProvider } from '@/shared/context/split-test-context' const element = document.getElementById('token-access-page') if (element) { const root = createRoot(element) - root.render() + root.render( + + + + ) } diff --git a/services/web/frontend/js/shared/svgs/overleaf-logo.svg b/services/web/frontend/js/shared/svgs/overleaf-logo.svg new file mode 100644 index 0000000000..576cb3a26d --- /dev/null +++ b/services/web/frontend/js/shared/svgs/overleaf-logo.svg @@ -0,0 +1 @@ + diff --git a/services/web/frontend/stylesheets/components/invite.scss b/services/web/frontend/stylesheets/components/invite.scss index 86f68ea3b7..c86206e2c3 100644 --- a/services/web/frontend/stylesheets/components/invite.scss +++ b/services/web/frontend/stylesheets/components/invite.scss @@ -23,3 +23,8 @@ font-size: var(--font-size-02); } } + +.project-join-container { + padding-top: var(--spacing-15); + text-align: center; +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 3b1346980d..f6cd053b23 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1214,6 +1214,7 @@ "join_now": "Join now", "join_overleaf_labs": "Join Overleaf Labs", "join_project": "Join Project", + "join_project_lowercase": "Join project", "join_sl_to_view_project": "Join __appName__ to view this project", "join_team_explanation": "Please click the button below to join the group subscription and enjoy the benefits of an upgraded __appName__ account", "join_x_enterprise_group": "Join __companyName__ enterprise group", @@ -2905,6 +2906,7 @@ "your_git_access_info_bullet_5": "Previously generated tokens will be shown here.", "your_git_access_tokens": "Your Git authentication tokens", "your_message_to_collaborators": "Send a message to your collaborators", + "your_name_and_email_address_will_be_visible_to_project_editors": "Your name and email address will be visible to project editors.", "your_name_and_email_address_will_be_visible_to_the_project_owner_and_other_editors": "Your name and email address will be visible to the project owner and other editors.", "your_new_plan": "Your new plan", "your_password_has_been_reset": "Your password has been reset", @@ -2934,6 +2936,7 @@ "youre_already_setup_for_sso": "You’re already set up for SSO", "youre_creating_account_for_x_it_will_be_managed_by_y": "You’re creating an account for __email__. It will be managed by __companyName__.", "youre_joining": "You’re joining", + "youre_joining_x_as_y": "You’re joining __projectName__ as __email__", "youre_not_eligible_for_a_free_trial": "You’re not eligible for a free trial. Upgrade to start using premium features.", "youre_on_free_trial_which_ends_on": "You’re on a free trial which ends on <0>__date__.", "youre_signed_in_as_logout": "You’re signed in as <0>__email__. <1>Log out.", diff --git a/services/web/test/frontend/components/token-access/token-access-page.spec.tsx b/services/web/test/frontend/components/token-access/token-access-page.spec.tsx index 829485a055..299ef787b4 100644 --- a/services/web/test/frontend/components/token-access/token-access-page.spec.tsx +++ b/services/web/test/frontend/components/token-access/token-access-page.spec.tsx @@ -1,4 +1,5 @@ import TokenAccessPage from '@/features/token-access/components/token-access-root' +import { SplitTestProvider } from '@/shared/context/split-test-context' import { location } from '@/shared/components/location' describe('', function () { @@ -9,6 +10,9 @@ describe('', function () { cy.window().then(win => { win.metaAttributesCache.set('ol-postUrl', url) win.metaAttributesCache.set('ol-user', { email: 'test@example.com' }) + win.metaAttributesCache.set('ol-splitTestVariants', { + 'sharing-updates': 'enabled', + }) }) }) @@ -22,15 +26,21 @@ describe('', function () { } ).as('grantRequest') - cy.mount() + cy.mount( + + + + ) cy.wait('@grantRequest').then(interception => { expect(interception.request.body.confirmedByUser).to.be.false }) - cy.get('.link-sharing-invite-header').should( - 'have.text', - ['You’re joining', 'Test Project', 'as test@example.com'].join('') + cy.findByRole('heading', { + name: /you’re joining Test Project as test@example.com/i, + }) + cy.findByText( + /your name and email address will be visible to project editors/i ) cy.intercept( @@ -44,7 +54,7 @@ describe('', function () { cy.stub(location, 'replace').as('replaceLocation') - cy.findByRole('button', { name: 'OK, join project' }).click() + cy.findByRole('button', { name: /join project/i }).click() cy.wait('@confirmedGrantRequest').then(interception => { expect(interception.request.body.confirmedByUser).to.be.true @@ -61,7 +71,11 @@ describe('', function () { 'grantRequest' ) - cy.mount() + cy.mount( + + + + ) cy.wait('@grantRequest') @@ -83,7 +97,11 @@ describe('', function () { cy.stub(location, 'replace').as('replaceLocation') - cy.mount() + cy.mount( + + + + ) cy.wait('@grantRequest') @@ -102,7 +120,11 @@ describe('', function () { cy.stub(location, 'replace').as('replaceLocation') - cy.mount() + cy.mount( + + + + ) cy.wait('@grantRequest') @@ -125,7 +147,11 @@ describe('', function () { cy.stub(location, 'replace').as('replaceLocation') - cy.mount() + cy.mount( + + + + ) cy.wait('@grantRequest') @@ -149,7 +175,11 @@ describe('', function () { cy.stub(location, 'replace').as('replaceLocation') - cy.mount() + cy.mount( + + + + ) cy.wait('@grantRequest') diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs index edcf57f16c..84b0887eeb 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs @@ -763,59 +763,105 @@ describe('CollaboratorsInviteController', function () { }) describe('when the token is valid', function () { - beforeEach(async function (ctx) { - await new Promise(resolve => { - ctx.res.callback = () => resolve() - ctx.CollaboratorsInviteController.viewInvite( - ctx.req, - ctx.res, - ctx.next + describe('when the sharing-updates variant is "enabled"', function () { + beforeEach(async function (ctx) { + ctx.SplitTestHandler.promises.getAssignment.resolves({ + variant: 'enabled', + }) + await new Promise(resolve => { + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) + }) + + it('should render the new view template', function (ctx) { + expect(ctx.res.render).toHaveBeenCalledTimes(1) + expect(ctx.res.render).toHaveBeenCalledWith( + 'project/invite/show', + expect.anything() ) }) + + it('should not call next', function (ctx) { + ctx.next.callCount.should.equal(0) + }) }) - it('should render the view template', function (ctx) { - expect(ctx.res.render).toHaveBeenCalledTimes(1) - expect(ctx.res.render).toHaveBeenCalledWith( - 'project/invite/show', - expect.anything() - ) + describe('when the sharing-updates variant is "default"', function () { + beforeEach(async function (ctx) { + ctx.SplitTestHandler.promises.getAssignment.resolves({ + variant: 'default', + }) + await new Promise(resolve => { + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) + }) + + it('should render the legacy view template', function (ctx) { + expect(ctx.res.render).toHaveBeenCalledTimes(1) + expect(ctx.res.render).toHaveBeenCalledWith( + 'project/invite/show-legacy', + expect.anything() + ) + }) + + it('should not call next', function (ctx) { + ctx.next.callCount.should.equal(0) + }) }) - it('should not call next', function (ctx) { - ctx.next.callCount.should.equal(0) - }) + describe('common behaviour', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) + }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { - ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( - 1 - ) - ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(ctx.currentUser._id, ctx.projectId) - .should.equal(true) - }) + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + 1 + ) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) + .should.equal(true) + }) - it('should call getInviteByToken', function (ctx) { - ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( - 1 - ) - ctx.CollaboratorsInviteGetter.promises.getInviteByToken - .calledWith(ctx.fakeProject._id, ctx.invite.token) - .should.equal(true) - }) + it('should call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + 1 + ) + ctx.CollaboratorsInviteGetter.promises.getInviteByToken + .calledWith(ctx.fakeProject._id, ctx.invite.token) + .should.equal(true) + }) - it('should call User.getUser', function (ctx) { - ctx.UserGetter.promises.getUser.callCount.should.equal(1) - ctx.UserGetter.promises.getUser - .calledWith({ _id: ctx.fakeProject.owner_ref }) - .should.equal(true) - }) + it('should call User.getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(1) + ctx.UserGetter.promises.getUser + .calledWith({ _id: ctx.fakeProject.owner_ref }) + .should.equal(true) + }) - it('should call ProjectGetter.getProject', function (ctx) { - ctx.ProjectGetter.promises.getProject.callCount.should.equal(1) - ctx.ProjectGetter.promises.getProject - .calledWith(ctx.projectId) - .should.equal(true) + it('should call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(1) + ctx.ProjectGetter.promises.getProject + .calledWith(ctx.projectId) + .should.equal(true) + }) }) })