Merge pull request #31742 from overleaf/ii-project-sharing-join-project

[web] Join project page redesign

GitOrigin-RevId: d182ec4fb744f384f824c9e63b534da02a9f8e99
This commit is contained in:
ilkin-overleaf
2026-03-05 17:02:40 +02:00
committed by Copybot
parent d5a65e906f
commit 6539e26107
18 changed files with 371 additions and 106 deletions
@@ -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) {
@@ -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) {
@@ -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
+14 -32
View File
@@ -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
@@ -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
@@ -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
@@ -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": "",
@@ -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 (
<Invite
projectName={projectName}
email={user.email}
submitHandler={handleSubmit}
isLoading={isLoading}
/>
)
}
@@ -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 (
<div className="container">
<OLRow>
<OLCol lg={{ span: 6, offset: 3 }}>
<div className="project-join-container">
<img src={overleafLogo} alt={appName} />
<h1 className="h4 mb-2">
{t('youre_joining_x_as_y', { projectName, email })}
</h1>
<div className="mb-4">
{t(
'your_name_and_email_address_will_be_visible_to_project_editors'
)}
</div>
<OLButton
variant="primary"
size="lg"
disabled={isLoading}
isLoading={isLoading}
loadingLabel={`${t('joining')}`}
onClick={submitHandler}
>
{t('join_project_lowercase')}
</OLButton>
</div>
</OLCol>
</OLRow>
</div>
)
}
export default Invite
@@ -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 (
<Invite
projectName={requireAcceptData.projectName || 'This project'}
email={user?.email || ''}
submitHandler={() => sendPostRequest(true)}
/>
)
}
return (
<div className="container">
@@ -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 (
<div className="token-access-container">
<div className="token-access-action-header">
<a href="/project" className="token-access-home-link">
<MaterialIcon type="arrow_left_alt" style={{ fontSize: 'inherit' }} />
</a>
</div>
{!isSharingUpdatesEnabled && (
<div className="token-access-action-header">
<a href="/project" className="token-access-home-link">
<MaterialIcon
type="arrow_left_alt"
style={{ fontSize: 'inherit' }}
/>
</a>
</div>
)}
<div className="token-access-content">
{mode === 'access-attempt' && (
<AccessAttemptScreen
@@ -0,0 +1,12 @@
import './../utils/meta'
import '../utils/webpack-public-path'
import './../infrastructure/error-reporter'
import '@/i18n'
import { createRoot } from 'react-dom/client'
import InviteRoot from '@/features/share-project/invite-root'
const element = document.getElementById('project-invite-page')
if (element) {
const root = createRoot(element)
root.render(<InviteRoot />)
}
@@ -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(<TokenAccessRoot />)
root.render(
<SplitTestProvider>
<TokenAccessRoot />
</SplitTestProvider>
)
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="120" viewBox="0 0 104 120" fill="none"><path d="M103.521 3.769C87.2881 -2.58618 28.56 -4.88324 28.4835 30.1851C11.3321 41.1344 0 58.9749 0 78.117C0 101.241 18.7593 120 41.883 120C65.0067 120 83.7659 101.241 83.7659 78.117C83.7659 60.2766 72.5869 44.9629 56.8138 38.9905C53.7511 37.842 47.1662 35.7746 41.9595 36.234C34.4558 40.9813 25.2676 50.7821 20.9798 60.5828C27.4115 52.8494 37.442 49.4804 46.4005 50.9352C59.4937 53.0791 69.5242 64.4113 69.5242 78.1936C69.5242 93.4307 57.1967 105.758 41.9595 105.758C33.537 105.758 26.0333 102.006 20.9798 96.1106C13.3995 87.3052 11.4853 77.8108 13.0166 68.546C18.2999 36.0809 56.8138 17.6279 85.4504 10.507C76.1091 15.484 59.264 23.6002 47.4725 32.4056C81.8517 45.7285 87.4412 16.7091 103.521 3.769Z" fill="#046530"/></svg>

After

Width:  |  Height:  |  Size: 825 B

@@ -23,3 +23,8 @@
font-size: var(--font-size-02);
}
}
.project-join-container {
padding-top: var(--spacing-15);
text-align: center;
}
+3
View File
@@ -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": "Youre already set up for SSO",
"youre_creating_account_for_x_it_will_be_managed_by_y": "Youre creating an account for __email__. It will be managed by __companyName__.",
"youre_joining": "Youre joining",
"youre_joining_x_as_y": "Youre joining __projectName__ as __email__",
"youre_not_eligible_for_a_free_trial": "Youre not eligible for a free trial. Upgrade to start using premium features.",
"youre_on_free_trial_which_ends_on": "Youre on a free trial which ends on <0>__date__</0>.",
"youre_signed_in_as_logout": "Youre signed in as <0>__email__</0>. <1>Log out.</1>",
@@ -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('<TokenAccessPage/>', function () {
@@ -9,6 +10,9 @@ describe('<TokenAccessPage/>', 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('<TokenAccessPage/>', function () {
}
).as('grantRequest')
cy.mount(<TokenAccessPage />)
cy.mount(
<SplitTestProvider>
<TokenAccessPage />
</SplitTestProvider>
)
cy.wait('@grantRequest').then(interception => {
expect(interception.request.body.confirmedByUser).to.be.false
})
cy.get('.link-sharing-invite-header').should(
'have.text',
['Youre joining', 'Test Project', 'as test@example.com'].join('')
cy.findByRole('heading', {
name: /youre 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('<TokenAccessPage/>', 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('<TokenAccessPage/>', function () {
'grantRequest'
)
cy.mount(<TokenAccessPage />)
cy.mount(
<SplitTestProvider>
<TokenAccessPage />
</SplitTestProvider>
)
cy.wait('@grantRequest')
@@ -83,7 +97,11 @@ describe('<TokenAccessPage/>', function () {
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.mount(
<SplitTestProvider>
<TokenAccessPage />
</SplitTestProvider>
)
cy.wait('@grantRequest')
@@ -102,7 +120,11 @@ describe('<TokenAccessPage/>', function () {
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.mount(
<SplitTestProvider>
<TokenAccessPage />
</SplitTestProvider>
)
cy.wait('@grantRequest')
@@ -125,7 +147,11 @@ describe('<TokenAccessPage/>', function () {
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.mount(
<SplitTestProvider>
<TokenAccessPage />
</SplitTestProvider>
)
cy.wait('@grantRequest')
@@ -149,7 +175,11 @@ describe('<TokenAccessPage/>', function () {
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.mount(
<SplitTestProvider>
<TokenAccessPage />
</SplitTestProvider>
)
cy.wait('@grantRequest')
@@ -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)
})
})
})