Merge pull request #18170 from overleaf/ae-token-access-page

Convert token access page to React

GitOrigin-RevId: d7434f0de395c47a95d00767727fbe9d43f9abca
This commit is contained in:
Alf Eaton 2024-05-03 08:46:21 +01:00 committed by Copybot
parent ab5495023a
commit 9729befe59
25 changed files with 568 additions and 2 deletions

View File

@ -12,6 +12,7 @@ const {
handleAdminDomainRedirect,
} = require('../Authorization/AuthorizationMiddleware')
const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const orderedPrivilegeLevels = [
PrivilegeLevels.NONE,
@ -97,7 +98,18 @@ async function tokenAccessPage(req, res, next) {
}
}
res.render('project/token/access', {
const { variant } = await SplitTestHandler.promises.getAssignment(
req,
res,
'token-access-page'
)
const view =
variant === 'react'
? 'project/token/access-react'
: 'project/token/access'
res.render(view, {
postUrl: makePostUrl(token),
})
} catch (err) {

View File

@ -0,0 +1,16 @@
extends ../../layout-marketing
block entrypointVar
- entrypoint = 'pages/token-access'
block vars
- var suppressFooter = true
- var suppressCookieBanner = 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
div#token-access-page

View File

@ -22,6 +22,7 @@
"accept_or_reject_each_changes_individually": "",
"accept_terms_and_conditions": "",
"accepted_invite": "",
"accepting_invite_as": "",
"access_denied": "",
"account_has_been_link_to_institution_account": "",
"account_has_past_due_invoice_change_plan_warning": "",
@ -532,6 +533,7 @@
"history_view_all": "",
"history_view_labels": "",
"hit_enter_to_reply": "",
"home": "",
"hotkey_add_a_comment": "",
"hotkey_autocomplete_menu": "",
"hotkey_beginning_of_document": "",
@ -619,6 +621,7 @@
"invite_not_accepted": "",
"invited_to_group": "",
"invited_to_group_have_individual_subcription": "",
"invited_to_join": "",
"ip_address": "",
"is_email_affiliated": "",
"issued_on": "",
@ -1335,6 +1338,7 @@
"to_use_text_wrapping_in_your_table_make_sure_you_include_the_array_package": "",
"toggle_compile_options_menu": "",
"token": "",
"token_access_failure": "",
"token_limit_reached": "",
"token_read_only": "",
"token_read_write": "",

View File

@ -0,0 +1,55 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
export const AccessAttemptScreen: FC<{
loadingScreenBrandHeight: string
inflight: boolean
accessError: string | boolean
}> = ({ loadingScreenBrandHeight, inflight, accessError }) => {
const { t } = useTranslation()
return (
<div className="loading-screen">
<div className="loading-screen-brand-container">
<div
className="loading-screen-brand"
style={{ height: loadingScreenBrandHeight }}
/>
</div>
<h3 className="loading-screen-label text-center">
{t('join_project')}
{inflight && <LoadingScreenEllipses />}
</h3>
{accessError && (
<div className="global-alerts text-center">
<div>
<br />
{accessError === 'not_found' ? (
<div>
<h4 aria-live="assertive">Project not found</h4>
</div>
) : (
<div>
<div className="alert alert-danger" aria-live="assertive">
{t('token_access_failure')}
</div>
<p>
<a href="/">{t('home')}</a>
</p>
</div>
)}
</div>
</div>
)}
</div>
)
}
const LoadingScreenEllipses = () => (
<span aria-hidden>
<span className="loading-screen-ellip">.</span>
<span className="loading-screen-ellip">.</span>
<span className="loading-screen-ellip">.</span>
</span>
)

View File

@ -0,0 +1,57 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
export type RequireAcceptData = {
projectName?: string
}
export const RequireAcceptScreen: FC<{
requireAcceptData: RequireAcceptData
sendPostRequest: (confirmedByUser: boolean) => void
}> = ({ requireAcceptData, sendPostRequest }) => {
const { t } = useTranslation()
const user = getMeta('ol-user')
return (
<div className="loading-screen">
<div className="container">
<div className="row">
<div className="col-md-8 col-md-offset-2">
<div className="card">
<div className="page-header text-centered">
<h1>
{t('invited_to_join')}
<br />
<em>{requireAcceptData.projectName || 'This project'}</em>
</h1>
</div>
{user && (
<div className="row text-center">
<div className="col-md-12">
<p>
{t('accepting_invite_as')} <em>{user.email}</em>
</p>
</div>
</div>
)}
<div className="row text-center">
<div className="col-md-12">
<button
className="btn btn-lg btn-primary"
type="submit"
onClick={() => sendPostRequest(true)}
>
{t('join_project')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,124 @@
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import withErrorBoundary from '@/infrastructure/error-boundary'
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
import { useCallback, useEffect, useRef, useState } from 'react'
import getMeta from '@/utils/meta'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { useLocation } from '@/shared/hooks/use-location'
import {
V1ImportData,
V1ImportDataScreen,
} from '@/features/token-access/components/v1-import-data-screen'
import { AccessAttemptScreen } from '@/features/token-access/components/access-attempt-screen'
import {
RequireAcceptData,
RequireAcceptScreen,
} from '@/features/token-access/components/require-accept-screen'
import Icon from '@/shared/components/icon'
type Mode = 'access-attempt' | 'v1Import' | 'requireAccept'
function TokenAccessRoot() {
const [mode, setMode] = useState<Mode>('access-attempt')
const [inflight, setInflight] = useState(false)
const [accessError, setAccessError] = useState<string | boolean>(false)
const [v1ImportData, setV1ImportData] = useState<V1ImportData>()
const [requireAcceptData, setRequireAcceptData] =
useState<RequireAcceptData>()
const [loadingScreenBrandHeight, setLoadingScreenBrandHeight] =
useState('0px')
const location = useLocation()
const sendPostRequest = useCallback(
(confirmedByUser = false) => {
setInflight(true)
postJSON(getMeta('ol-postUrl'), {
body: {
confirmedByUser,
tokenHashPrefix: document.location.hash,
},
})
.then(async data => {
setAccessError(false)
if (data.redirect) {
location.replace(data.redirect)
} else if (data.v1Import) {
setMode('v1Import')
setV1ImportData(data.v1Import)
} else if (data.requireAccept) {
setMode('requireAccept')
setRequireAcceptData(data.requireAccept)
} else {
debugConsole.warn(
'invalid data from server in success response',
data
)
setAccessError(true)
}
})
.catch(error => {
debugConsole.warn('error response from server', error)
setAccessError(error.response?.status === 404 ? 'not_found' : 'error')
})
.finally(() => {
setInflight(false)
})
},
[location]
)
const postedRef = useRef(false)
useEffect(() => {
if (!postedRef.current) {
postedRef.current = true
sendPostRequest()
setTimeout(() => {
setLoadingScreenBrandHeight('20%')
}, 500)
}
}, [sendPostRequest])
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return (
<div className="full-size">
<div>
<a
href="/project"
// TODO: class name
style={{ fontSize: '2rem', marginLeft: '1rem', color: '#ddd' }}
>
<Icon type="arrow-left" />
</a>
</div>
{mode === 'access-attempt' && (
<AccessAttemptScreen
accessError={accessError}
inflight={inflight}
loadingScreenBrandHeight={loadingScreenBrandHeight}
/>
)}
{mode === 'v1Import' && v1ImportData && (
<V1ImportDataScreen v1ImportData={v1ImportData} />
)}
{mode === 'requireAccept' && requireAcceptData && (
<RequireAcceptScreen
requireAcceptData={requireAcceptData}
sendPostRequest={sendPostRequest}
/>
)}
</div>
)
}
export default withErrorBoundary(TokenAccessRoot, GenericErrorBoundaryFallback)

View File

@ -0,0 +1,84 @@
import { FC } from 'react'
export type V1ImportData = {
name?: string
status: string
projectId: string
}
export const V1ImportDataScreen: FC<{ v1ImportData: V1ImportData }> = ({
v1ImportData,
}) => {
return (
<div className="loading-screen">
<div className="container">
<div className="row">
<div className="col-sm-8 col-sm-offset-2">
<h1 className="text-center">
{v1ImportData.status === 'mustLogin'
? 'Please log in'
: 'Overleaf v1 Project'}
</h1>
<img
className="v2-import__img"
src="/img/v1-import/v2-editor.png"
alt="The new V2 editor."
/>
{v1ImportData.status === 'cannotImport' && (
<div>
<h2 className="text-center">
Cannot Access Overleaf v1 Project
</h2>
<p className="text-center row-spaced-small">
Please contact the project owner or{' '}
<a href="/contact">contact support</a> for assistance.
</p>
</div>
)}
{v1ImportData.status === 'mustLogin' && (
<div>
<p className="text-center row-spaced-small">
You will need to log in to access this project.
</p>
<div className="row-spaced text-center">
<a
className="btn btn-primary"
href={`/login?redir=${encodeURIComponent(document.location.pathname)}`}
>
Log in to access project
</a>
</div>
</div>
)}
{v1ImportData.status === 'canDownloadZip' && (
<div>
<p className="text-center row-spaced-small">
<strong>{v1ImportData.name || 'This project'}</strong> has not
yet been moved into the new version of Overleaf. This project
was created anonymously and therefore cannot be automatically
imported. Please download a zip file of the project and upload
that to continue editing it. If you would like to delete this
project after you have made a copy, please contact support.
</p>
<div className="row-spaced text-center">
<a
className="btn btn-primary"
href={`/overleaf/project/${v1ImportData.projectId}/download/zip`}
>
Download project zip file
</a>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,13 @@
import 'jquery'
import 'bootstrap'
import './../utils/meta'
import './../utils/webpack-public-path'
import './../infrastructure/error-reporter'
import './../i18n'
import ReactDOM from 'react-dom'
import TokenAccessRoot from '../features/token-access/components/token-access-root'
const element = document.getElementById('token-access-page')
if (element) {
ReactDOM.render(<TokenAccessRoot />, element)
}

View File

@ -5,6 +5,10 @@ export const location = {
// eslint-disable-next-line no-restricted-syntax
window.location.assign(url)
},
replace(url) {
// eslint-disable-next-line no-restricted-syntax
window.location.replace(url)
},
reload() {
// eslint-disable-next-line no-restricted-syntax
window.location.reload()

View File

@ -14,11 +14,20 @@ export const useLocation = () => {
[isMounted]
)
const replace = useCallback(
url => {
if (isMounted.current) {
location.replace(url)
}
},
[isMounted]
)
const reload = useCallback(() => {
if (isMounted.current) {
location.reload()
}
}, [isMounted])
return useMemo(() => ({ assign, reload }), [assign, reload])
return useMemo(() => ({ assign, replace, reload }), [assign, replace, reload])
}

View File

@ -0,0 +1,168 @@
import TokenAccessPage from '@/features/token-access/components/token-access-root'
import { location } from '@/shared/components/location'
describe('<TokenAccessPage/>', function () {
// this is a URL for a read-only token, but the process is the same for read-write tokens
const url = '/read/123/grant'
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache = new Map<string, any>([
['ol-postUrl', url],
['ol-user', { email: 'test@example.com' }],
])
})
})
it('handles a successful token access request', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
requireAccept: { projectName: 'Test Project' },
},
}
).as('grantRequest')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest').then(interception => {
expect(interception.request.body.confirmedByUser).to.be.false
})
cy.get('h1').should(
'have.text',
['You have been invited to join', 'Test Project'].join('')
)
cy.contains('You are accepting this invite as test@example.com')
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
redirect: '/project/123',
},
}
).as('confirmedGrantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.findByRole('button', { name: 'Join Project' }).click()
cy.wait('@confirmedGrantRequest').then(interception => {
expect(interception.request.body.confirmedByUser).to.be.true
})
cy.get('@replaceLocation').should(
'have.been.calledOnceWith',
'/project/123'
)
})
it('handles a project not found response', function () {
cy.intercept({ method: 'post', url, times: 1 }, { statusCode: 404 }).as(
'grantRequest'
)
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('h3').should('have.text', 'Join Project')
cy.get('h4').should('have.text', 'Project not found')
cy.findByRole('button', { name: 'Join Project' }).should('not.exist')
})
it('handles a redirect response', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
redirect: '/restricted',
},
}
).as('grantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('@replaceLocation').should('have.been.calledOnceWith', '/restricted')
})
it('handles a v1 "must login" response', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
v1Import: { status: 'mustLogin' },
},
}
).as('grantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('h1').should('have.text', 'Please log in')
cy.findByRole('link', { name: 'Log in to access project' })
.should('have.attr', 'href')
.and('match', /^\/login\?redir=/)
})
it('handles a v1 "cannot import" response', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
v1Import: { status: 'cannotImport' },
},
}
).as('grantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('h1').should('have.text', 'Overleaf v1 Project')
cy.get('h2').should('have.text', 'Cannot Access Overleaf v1 Project')
})
it('handles a v1 "can download zip" response', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
v1Import: {
status: 'canDownloadZip',
projectId: '123',
name: 'Test Project',
},
},
}
).as('grantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('h1').should('have.text', 'Overleaf v1 Project')
cy.findByRole('link', { name: 'Download project zip file' }).should(
'have.attr',
'href',
'/overleaf/project/123/download/zip'
)
})
})

View File

@ -14,6 +14,7 @@ describe('<ActionsCopyProject />', function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
})
})

View File

@ -12,6 +12,7 @@ describe('<ModalContentNewProjectForm />', function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
})
})

View File

@ -699,6 +699,7 @@ describe('<UserNotifications />', function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
})
fetchMock.reset()

View File

@ -57,6 +57,7 @@ describe('<ProjectListRoot />', function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
})
})

View File

@ -17,6 +17,7 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
assignStub = sinon.stub()
locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
})
render(

View File

@ -12,6 +12,7 @@ describe('<DownloadProjectButton />', function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
})
render(<DownloadProjectButtonTooltip project={projectsData[0]} />)

View File

@ -35,6 +35,7 @@ describe('<ReconfirmationInfo/>', function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
})
})

View File

@ -61,6 +61,7 @@ describe('<LeaveModalForm />', function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
})
window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })

View File

@ -88,6 +88,7 @@ describe('<ShareProjectModal/>', function () {
beforeEach(function () {
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: sinon.stub(),
replace: sinon.stub(),
reload: sinon.stub(),
})
fetchMock.get('/user/contacts', { contacts })

View File

@ -82,6 +82,7 @@ describe('<GroupSubscriptionMemberships />', function () {
reloadStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: sinon.stub(),
replace: sinon.stub(),
reload: reloadStub,
})

View File

@ -54,6 +54,7 @@ describe('<PersonalSubscription />', function () {
reloadStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: sinon.stub(),
replace: sinon.stub(),
reload: reloadStub,
})
})

View File

@ -196,6 +196,7 @@ describe('<ActiveSubscription />', function () {
beforeEach(function () {
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: reloadStub,
})
})

View File

@ -31,6 +31,7 @@ describe('<ChangePlanModal />', function () {
reloadStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: sinon.stub(),
replace: sinon.stub(),
reload: reloadStub,
})
})

View File

@ -66,6 +66,12 @@ describe('TokenAccessController', function () {
},
}
this.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
},
}
this.TokenAccessController = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': this.Settings,
@ -77,6 +83,7 @@ describe('TokenAccessController', function () {
'../Authorization/AuthorizationMiddleware':
this.AuthorizationMiddleware,
'../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler,
'../SplitTests/SplitTestHandler': this.SplitTestHandler,
'../Errors/Errors': (this.Errors = { NotFoundError: sinon.stub() }),
},
})