Compare commits

...

35 Commits

Author SHA1 Message Date
Alf Eaton 9729befe59 Merge pull request #18170 from overleaf/ae-token-access-page
Convert token access page to React

GitOrigin-RevId: d7434f0de395c47a95d00767727fbe9d43f9abca
2024-05-03 08:05:01 +00:00
Alf Eaton ab5495023a [visual] Add support for `description` list environments (#13460)
GitOrigin-RevId: d1ddfeed4ba453afa348e57d75fdb3e12d29f5fc
2024-05-03 08:04:53 +00:00
CloudBuild 49a74544b8 auto update translation
GitOrigin-RevId: 51358bc2a16648dee1351a35b7338de9321f5a87
2024-05-03 08:04:49 +00:00
Eric Mc Sween 4114901617 Merge pull request #18142 from overleaf/em-docupdater-resync-ranges
Adjust ranges for tracked deletes when resyncing projects

GitOrigin-RevId: 5f8e6279cdc31e76a2f93cf2129eaca8cac3cb78
2024-05-03 08:04:41 +00:00
Eric Mc Sween 65f20a4d56 Merge pull request #18186 from overleaf/em-migration-dependencies
Add a migration helper checking dependent migrations

GitOrigin-RevId: 96aa6238b20115206554faaa4c2aefc537bbe7e8
2024-05-03 08:04:36 +00:00
Jakob Ackermann 4c49841637 Merge pull request #18153 from overleaf/jpa-validate-session-in-store
[web] check for redis connection being out of sync in session store

GitOrigin-RevId: c271e88d4e1fbcb0f7a57f4775e8ef88b70b16a8
2024-05-03 08:04:25 +00:00
Jakob Ackermann 0576e02127 Merge pull request #18152 from overleaf/jpa-stricter-session-validation
[web] stricter session validation

GitOrigin-RevId: 3ef916318fde7f31e3e3fd0f7082dde7a2975a27
2024-05-03 08:04:20 +00:00
Tim Down a452e1e8cd Merge pull request #18195 from overleaf/revert-18120-td-bs5-load-js
Revert "Load correct JS for the active Bootstrap version"

GitOrigin-RevId: 7f6e846b5461cfbacec874ed55bba577e414f3a6
2024-05-03 08:04:16 +00:00
Tim Down 56150d9dbc Load correct JS for the active Bootstrap version (#18120)
* Load correct JS for the active Bootstrap version

* Tidy up bootstrapVersion declaration

* Add Bootstrap JS to website redesign layout

* FIx error on interstitial subscriptions page

* Remove unnecessary import of jQuery and Bootstrap

* Use global entrypointScripts in bootstrap-js mixin

GitOrigin-RevId: 6b1977354a72dc69008fc0d2e3baec2f28d97f6b
2024-05-03 08:04:07 +00:00
CloudBuild fb05c0bb82 auto update translation
GitOrigin-RevId: 1850bdf3c1c7cd7c3e4b60ed895278602f4be0f9
2024-05-02 08:04:04 +00:00
Jessica Lawshe a827e925c3 Merge pull request #18158 from overleaf/jel-managed-enrollment-label
[web] Fix text wrapping of label on managed users enrollment

GitOrigin-RevId: f87d51d1f32d64b9fdebd865f340f39bad844870
2024-05-02 08:04:00 +00:00
Jessica Lawshe ae0abd6445 Merge pull request #18159 from overleaf/jel-group-invite-header
[web] Break word on group invite header

GitOrigin-RevId: 790c24e8291f1dbdfa9231e4c9e3d4e531bf2b8f
2024-05-02 08:03:52 +00:00
Andrew Rumble 92f62f91c1 Merge pull request #18148 from overleaf/ar-add-output-zip-endpoint-to-clsi
[clsi] Add endpoints to get zip of output files

GitOrigin-RevId: a1a935e8170ab5a8d40baa6d96f8e42fe22c2e8c
2024-05-02 08:03:44 +00:00
CloudBuild d02f175afa auto update translation
GitOrigin-RevId: 55307f35eccdc6ea38d1b58a45bd06f2b8a2adaa
2024-05-01 08:05:09 +00:00
Jimmy Domagala-Tang 0ca7a385d5 Merge pull request #18131 from overleaf/jdt-promo-hooks
feat: split logic for promos out to hooks
GitOrigin-RevId: 8f713cdf309f84dddb20e8da76009512bd990a8f
2024-05-01 08:05:04 +00:00
Antoine Clausse a26c655220 Delete 3 migration scripts for compile-timeouts (#18163)
Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: 2d66052994159b6d902b807f02488095d65562e1
2024-05-01 08:05:00 +00:00
Antoine Clausse 6a6f155029 [web] Use React hooks to get split-test variants instead of `getSplitTestVariant` (`getMeta`) (#18133)
* Fix split-tests loading in React component: use `useSplitTestContext` instead of `getSplitTestVariant`

* Replace use of `isSplitTestEnabled` by `useSplitTestContext`

* Add SplitTestProvider to roots, and fix tests

* Create `useFeatureFlag` hook

* Use `useFeatureFlag` where applicable

GitOrigin-RevId: 9ff7bb3975d50bc4d07d74d93c482d56dc96f615
2024-05-01 08:04:55 +00:00
Domagoj Kriskovic ebb34b40c1 [web] "back to editor" button when history is opened (#18137)
* [web] "back to editor" button when history is opened

* rename to shouldReopenChat

* move to separate component

* show online users widget

* using MaterialIcon

* import useState directly

* fix formatting

GitOrigin-RevId: c37432f16518ef83510c48d90722e74b228b5ab1
2024-05-01 08:04:51 +00:00
Rebeka Dekany 62c2937dac Merge pull request #18164 from overleaf/rd-remove-endpoints
[web] Remove publicly accessible endpoints

GitOrigin-RevId: c8e57faf6418274cac36b6e721c97a4ca70a1193
2024-05-01 08:04:46 +00:00
Alf Eaton 417de9ee87 Fix formatting
GitOrigin-RevId: 94ebd836a8cc3fbbb3ea1b7284b1c1863263d96f
2024-05-01 08:04:41 +00:00
Copybot faf9bc39c4 Merge pull request #1108 from chschenk:feature_maxEntitiesPerProject
GitOrigin-RevId: ceeb1c13e5bbc3eb498e0ee1040ab8bbfeb574a9
2024-05-01 08:04:36 +00:00
Alf Eaton 08c784f58a Translate You on project dashboard (#18122)
GitOrigin-RevId: 5157df9079460c5aa8122fc29b14babf12a32bc4
2024-05-01 08:04:31 +00:00
Alf Eaton 8921b8484e Merge pull request #18140 from overleaf/ae-log-rules
Add new regular expressions for matching compiler error messages

GitOrigin-RevId: ab6e17951c29c2a68b385b7e0cb77abf2d22281d
2024-05-01 08:04:27 +00:00
Andrew Rumble 13bb42885e Merge pull request #18025 from overleaf/ar-add-dropbox-unlinks-to-audit-log
Add dropbox unlinks to audit log

GitOrigin-RevId: 9038293b42446843763ea83caa3f9414123961a1
2024-05-01 08:04:18 +00:00
Rebeka Dekany 285a0cae03 Merge pull request #17309 from overleaf/rd-bootstrap-5-stylelint
[web] Introducing Stylelint as the CSS linter

GitOrigin-RevId: 89ee8860cdb3a94949749577b63cde2c3dc213fb
2024-05-01 08:04:13 +00:00
Rebeka Dekany 46485e0347 Merge pull request #18030 from overleaf/rd-bootstrap-settings-css
Migrate account-settings.less file to Bootstrap 5 and Sass

GitOrigin-RevId: 898cd811d6a0576cb0faacdd729461198324d2d5
2024-05-01 08:04:08 +00:00
Jessica Lawshe e9586079d4 Merge pull request #18047 from overleaf/jel-latexqc-webpack-dev-middleware
[latexqc] Upgrade `webpack-dev-middleware`

GitOrigin-RevId: b7036f623c4fb27174c2b4f22b49ff1b257af829
2024-04-30 08:04:52 +00:00
Eric Mc Sween 501be34862 Merge pull request #18156 from overleaf/em-fix-queue-size-metric
Fix queue size by error metric in project history dashboard

GitOrigin-RevId: e837c6fc00acd23671f017c70cd9b2c643c02482
2024-04-30 08:04:47 +00:00
Andrew Rumble 9c3d9ef590 Merge pull request #17935 from overleaf/ar-refactor-compile-async
[web] make CompileManager async

GitOrigin-RevId: 617bde1f429fa9aafc7d4bf4ec628b2a22386b19
2024-04-30 08:04:43 +00:00
Miguel Serrano cee678591f Merge pull request #18145 from overleaf/msm-ce-history-scripts
[CE] Add history utility scripts (flush/resync)

GitOrigin-RevId: 3f46609c279bef70f1ee6e63f74648f1c2b99a97
2024-04-30 08:04:38 +00:00
Antoine Clausse cdd79e8ec0 Fix: unset recent users `featuresUpdatedAt` after wrong update (#18149)
* Copy previous script

* Remove `featuresUpdatedAt` that was wrongly set on recent users

* Fix! `signupDate` -> `signUpDate`

* Add test on `migration_compile_timeout_60s_to_20s_fixup_new_users.js`

* style: `$unset: { featuresUpdatedAt: 1 }` -> `$unset: { featuresUpdatedAt: '' }`

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Add comment on test (https://github.com/overleaf/internal/pull/18149#discussion_r1582999534)

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

---------

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: 408f5c7d48e60722aba736167b8e8858e9570d99
2024-04-30 08:04:33 +00:00
Antoine Clausse 711d50a2f1 [web] Create script to update forgotten `featuresUpdatedAt` after the migration to 20s compile timeout (#18113)
* Copy `migration_compile_timeout_60s_to_20s.js` script

* Update `featuresUpdatedAt`

* Add a comment about `featuresUpdatedAt` in migration_compile_timeout_60s_to_20s.js

* Fix test on migration_compile_timeout_60s_to_20s.js

* Fix: Include users having `featuresUpdatedAt` undefined in the update

* Add test on `migration_compile_timeout_60s_to_20s_fixup_features_updated_at`

GitOrigin-RevId: 4b2baf955a6a9f39bf9ce00b7839af551064c6cb
2024-04-30 08:04:28 +00:00
CloudBuild 70c05dd5f7 auto update translation
GitOrigin-RevId: 1c8fdfb7e8e0e3cb88e6f2f7e51d6a3b2da27826
2024-04-29 08:04:58 +00:00
Jakob Ackermann afca054a22 Merge pull request #18136 from overleaf/jpa-fix-i18n-scanner-glob
[web] instruct i18next-scanner to look at frontend code only

GitOrigin-RevId: 094cc571810f142b535d0813c2002944a0e1ab9d
2024-04-29 08:04:45 +00:00
Christopher Schenk 0c265db259 Make the number of max entities per project configurable 2023-03-29 11:31:54 +02:00
142 changed files with 4988 additions and 1340 deletions

1291
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -80,6 +80,13 @@ COPY server-ce/config/custom-environment-variables.json /overleaf/services/histo
ADD server-ce/bin/grunt /usr/local/bin/grunt
RUN chmod +x /usr/local/bin/grunt
# Copy history helper scripts
# ---------------------------
ADD server-ce/bin/flush-history-queues /overleaf/bin/flush-history-queues
RUN chmod +x /overleaf/bin/flush-history-queues
ADD server-ce/bin/force-history-resyncs /overleaf/bin/force-history-resyncs
RUN chmod +x /overleaf/bin/force-history-resyncs
# File that controls open|closed status of the site
# -------------------------------------------------
ENV SITE_MAINTENANCE_FILE "/etc/overleaf/site_status"

View File

@ -0,0 +1,8 @@
#!/bin/bash
set -euo pipefail
source /etc/container_environment.sh
source /etc/overleaf/env.sh
cd /overleaf/services/project-history
node scripts/flush_all.js 100000

View File

@ -0,0 +1,8 @@
#!/bin/bash
set -euo pipefail
source /etc/container_environment.sh
source /etc/overleaf/env.sh
cd /overleaf/services/project-history
node scripts/force_resync.js 1000 force

View File

@ -14,6 +14,7 @@ const Metrics = require('@overleaf/metrics')
const smokeTest = require('./test/smoke/js/SmokeTests')
const ContentTypeMapper = require('./app/js/ContentTypeMapper')
const Errors = require('./app/js/Errors')
const { createOutputZip } = require('./app/js/OutputController')
const Path = require('path')
@ -170,6 +171,20 @@ const staticOutputServer = ForbidSymlinks(
}
)
// This needs to be before GET /project/:project_id/build/:build_id/output/*
app.get(
'/project/:project_id/build/:build_id/output/output.zip',
bodyParser.json(),
createOutputZip
)
// This needs to be before GET /project/:project_id/user/:user_id/build/:build_id/output/*
app.get(
'/project/:project_id/user/:user_id/build/:build_id/output/output.zip',
bodyParser.json(),
createOutputZip
)
app.get(
'/project/:project_id/user/:user_id/build/:build_id/output/*',
function (req, res, next) {

View File

@ -0,0 +1,37 @@
const logger = require('@overleaf/logger')
const OutputFileArchiveManager = require('./OutputFileArchiveManager')
const { expressify } = require('@overleaf/promise-utils')
function cleanFiles(files) {
if (!Array.isArray(files)) {
return []
}
return files.filter(file => /^output\./g.test(file))
}
async function createOutputZip(req, res) {
const {
project_id: projectId,
user_id: userId,
build_id: buildId,
} = req.params
const files = cleanFiles(req.query.files)
logger.debug({ projectId, userId, buildId, files }, 'Will create zip file')
const archive = await OutputFileArchiveManager.archiveFilesForBuild(
projectId,
userId,
buildId,
files
)
archive.on('error', err => {
logger.warn({ err }, 'error emitted when creating output files archive')
})
res.attachment('output.zip')
res.setHeader('X-Content-Type-Options', 'nosniff')
archive.pipe(res)
}
module.exports = { createOutputZip: expressify(createOutputZip) }

View File

@ -0,0 +1,90 @@
let OutputFileArchiveManager
const archiver = require('archiver')
const OutputCacheManager = require('./OutputCacheManager')
const OutputFileFinder = require('./OutputFileFinder')
const Settings = require('@overleaf/settings')
const { open } = require('node:fs/promises')
const path = require('node:path')
const { NotFoundError } = require('./Errors')
function getContentDir(projectId, userId) {
let subDir
if (userId != null) {
subDir = `${projectId}-${userId}`
} else {
subDir = projectId
}
return `${Settings.path.outputDir}/${subDir}/`
}
module.exports = OutputFileArchiveManager = {
async archiveFilesForBuild(projectId, userId, build, files = []) {
const contentDir = getContentDir(projectId, userId)
const validFiles = await (files.length > 0
? this._getRequestedOutputFiles(projectId, userId, build, files)
: this._getAllOutputFiles(projectId, userId, build))
const archive = archiver('zip')
const missingFiles = files.filter(
file => !validFiles.includes(path.basename(file))
)
for (const file of validFiles) {
try {
const fileHandle = await open(
`${contentDir}${OutputCacheManager.path(build, file)}`
)
const fileStream = fileHandle.createReadStream()
archive.append(fileStream, { name: file })
} catch (error) {
missingFiles.push(file)
}
}
if (missingFiles.length > 0) {
archive.append(missingFiles.join('\n'), {
name: 'missing_files.txt',
})
}
await archive.finalize()
return archive
},
async _getAllOutputFiles(projectId, userId, build) {
const contentDir = getContentDir(projectId, userId)
try {
const { outputFiles } = await OutputFileFinder.promises.findOutputFiles(
[],
`${contentDir}${OutputCacheManager.path(build, '.')}`
)
return outputFiles.map(({ path }) => path)
} catch (error) {
if (
error.code === 'ENOENT' ||
error.code === 'ENOTDIR' ||
error.code === 'EACCES'
) {
throw new NotFoundError('Output files not found')
}
throw error
}
},
async _getRequestedOutputFiles(projectId, userId, build, files) {
const outputFiles = new Set(
await OutputFileArchiveManager._getAllOutputFiles(
projectId,
userId,
build
)
)
return files.filter(file => outputFiles.has(file))
},
}

View File

@ -23,6 +23,7 @@
"@overleaf/o-error": "*",
"@overleaf/promise-utils": "*",
"@overleaf/settings": "*",
"archiver": "5.3.2",
"async": "3.2.2",
"body-parser": "^1.19.0",
"bunyan": "^1.8.15",

View File

@ -0,0 +1,110 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const MODULE_PATH = require('path').join(
__dirname,
'../../../app/js/OutputController'
)
describe('OutputController', function () {
describe('createOutputZip', function () {
beforeEach(function () {
this.archive = {
on: sinon.stub(),
pipe: sinon.stub(),
}
this.archiveFilesForBuild = sinon.stub().resolves(this.archive)
this.OutputController = SandboxedModule.require(MODULE_PATH, {
requires: {
'./OutputFileArchiveManager': {
archiveFilesForBuild: this.archiveFilesForBuild,
},
},
})
})
describe('when OutputFileArchiveManager creates an archive', function () {
beforeEach(function (done) {
this.res = {
attachment: sinon.stub(),
setHeader: sinon.stub(),
}
this.req = {
params: {
project_id: 'project-id-123',
user_id: 'user-id-123',
build_id: 'build-id-123',
},
query: {
files: ['output.tex', 'not-output.tex'],
},
}
this.archive.pipe.callsFake(() => done())
this.OutputController.createOutputZip(this.req, this.res)
})
it('does not pass files that do not start with "output" to OutputFileArchiveManager', function () {
sinon.assert.calledWith(
this.archiveFilesForBuild,
'project-id-123',
'user-id-123',
'build-id-123',
['output.tex']
)
})
it('pipes the archive to the response', function () {
sinon.assert.calledWith(this.archive.pipe, this.res)
})
it('calls the express convenience method to set attachment headers', function () {
sinon.assert.calledWith(this.res.attachment, 'output.zip')
})
it('sets the X-Content-Type-Options header to nosniff', function () {
sinon.assert.calledWith(
this.res.setHeader,
'X-Content-Type-Options',
'nosniff'
)
})
})
describe('when OutputFileArchiveManager throws an error', function () {
let error
beforeEach(function (done) {
error = new Error('error message')
this.archiveFilesForBuild.rejects(error)
this.res = {
status: sinon.stub().returnsThis(),
send: sinon.stub(),
}
this.req = {
params: {
project_id: 'project-id-123',
user_id: 'user-id-123',
build_id: 'build-id-123',
},
query: {
files: ['output.tex'],
},
}
this.OutputController.createOutputZip(
this.req,
this.res,
(this.next = sinon.stub().callsFake(() => {
done()
}))
)
})
it('calls next with the error', function () {
sinon.assert.calledWith(this.next, error)
})
})
})
})

View File

@ -0,0 +1,234 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { assert, expect } = require('chai')
const MODULE_PATH = require('path').join(
__dirname,
'../../../app/js/OutputFileArchiveManager'
)
describe('OutputFileArchiveManager', function () {
const userId = 'user-id-123'
const projectId = 'project-id-123'
const buildId = 'build-id-123'
afterEach(function () {
sinon.restore()
})
beforeEach(function () {
this.OutputFileFinder = {
promises: {
findOutputFiles: sinon.stub().resolves({ outputFiles: [] }),
},
}
this.OutputCacheManger = {
path: sinon.stub().callsFake((build, path) => {
return `${build}/${path}`
}),
}
this.archive = {
append: sinon.stub(),
finalize: sinon.stub(),
}
this.archiver = sinon.stub().returns(this.archive)
this.outputDir = '/output/dir'
this.fs = {
open: sinon.stub().callsFake(file => ({
createReadStream: sinon.stub().returns(`handle: ${file}`),
})),
}
this.OutputFileArchiveManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'./OutputFileFinder': this.OutputFileFinder,
'./OutputCacheManager': this.OutputCacheManger,
archiver: this.archiver,
'node:fs/promises': this.fs,
'node:path': {
basename: sinon.stub().callsFake(path => path.split('/').pop()),
},
'@overleaf/settings': {
path: {
outputDir: this.outputDir,
},
},
},
})
})
describe('when called with no files', function () {
beforeEach(async function () {
this.OutputFileFinder.promises.findOutputFiles.resolves({
outputFiles: [
{ path: 'file_1' },
{ path: 'file_2' },
{ path: 'file_3' },
{ path: 'file_4' },
],
})
await this.OutputFileArchiveManager.archiveFilesForBuild(
projectId,
userId,
buildId
)
})
it('creates a zip archive', function () {
sinon.assert.calledWith(this.archiver, 'zip')
})
it('adds all the output files to the archive', function () {
expect(this.archive.append.callCount).to.equal(4)
sinon.assert.calledWith(
this.archive.append,
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_1`,
sinon.match({ name: 'file_1' })
)
sinon.assert.calledWith(
this.archive.append,
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_2`,
sinon.match({ name: 'file_2' })
)
sinon.assert.calledWith(
this.archive.append,
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_3`,
sinon.match({ name: 'file_3' })
)
sinon.assert.calledWith(
this.archive.append,
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_4`,
sinon.match({ name: 'file_4' })
)
})
it('finalizes the archive after all files are appended', function () {
sinon.assert.called(this.archive.finalize)
expect(this.archive.finalize.calledBefore(this.archive.append)).to.be
.false
})
})
describe('when called with a list of files that all are in the output directory', function () {
beforeEach(async function () {
this.OutputFileFinder.promises.findOutputFiles.resolves({
outputFiles: [
{ path: 'file_1' },
{ path: 'file_2' },
{ path: 'file_3' },
{ path: 'file_4' },
],
})
await this.OutputFileArchiveManager.archiveFilesForBuild(
projectId,
userId,
buildId,
['file_1', 'file_4']
)
})
it('creates a zip archive', function () {
sinon.assert.calledWith(this.archiver, 'zip')
})
it('adds only output files from the list of files to the archive', function () {
expect(this.archive.append.callCount).to.equal(2)
sinon.assert.calledWith(
this.archive.append,
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_1`,
sinon.match({
name: 'file_1',
})
)
sinon.assert.calledWith(
this.archive.append,
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_4`,
sinon.match({
name: 'file_4',
})
)
})
it('finalizes the archive after all files are appended', function () {
sinon.assert.called(this.archive.finalize)
expect(this.archive.finalize.calledBefore(this.archive.append)).to.be
.false
})
})
describe('when called with a list of files and one of the files is missing from the output directory', function () {
beforeEach(async function () {
this.OutputFileFinder.promises.findOutputFiles.resolves({
outputFiles: [
{ path: 'file_1' },
{ path: 'file_2' },
{ path: 'file_3' },
],
})
await this.OutputFileArchiveManager.archiveFilesForBuild(
projectId,
userId,
buildId,
['file_1', 'file_4']
)
})
it('creates a zip archive', function () {
sinon.assert.calledWith(this.archiver, 'zip')
})
it('adds the files that were found to the archive', function () {
sinon.assert.calledWith(
this.archive.append,
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_1`,
sinon.match({ name: 'file_1' })
)
})
it('adds a file listing any missing files', function () {
sinon.assert.calledWith(
this.archive.append,
'file_4',
sinon.match({
name: 'missing_files.txt',
})
)
})
it('finalizes the archive after all files are appended', function () {
sinon.assert.called(this.archive.finalize)
expect(this.archive.finalize.calledBefore(this.archive.append)).to.be
.false
})
})
describe('when the output directory cannot be accessed', function () {
beforeEach(async function () {
this.OutputFileFinder.promises.findOutputFiles.rejects({
code: 'ENOENT',
})
})
it('rejects with a NotFoundError', async function () {
try {
await this.OutputFileArchiveManager.archiveFilesForBuild(
projectId,
userId,
buildId
)
assert.fail('should have thrown a NotFoundError')
} catch (err) {
expect(err).to.haveOwnProperty('name', 'NotFoundError')
}
})
it('does not create an archive', function () {
expect(this.archiver.called).to.be.false
})
})
})

View File

@ -0,0 +1,183 @@
// @ts-check
const _ = require('lodash')
const { isDelete } = require('./Utils')
/**
* @typedef {import('./types').Comment} Comment
* @typedef {import('./types').HistoryComment} HistoryComment
* @typedef {import('./types').HistoryRanges} HistoryRanges
* @typedef {import('./types').HistoryTrackedChange} HistoryTrackedChange
* @typedef {import('./types').Ranges} Ranges
* @typedef {import('./types').TrackedChange} TrackedChange
*/
/**
* Convert editor ranges to history ranges
*
* @param {Ranges} ranges
* @return {HistoryRanges}
*/
function toHistoryRanges(ranges) {
const changes = ranges.changes ?? []
const comments = (ranges.comments ?? []).slice()
// Changes are assumed to be sorted, but not comments
comments.sort((a, b) => a.op.p - b.op.p)
/**
* This will allow us to go through comments at a different pace as we loop
* through tracked changes
*/
const commentsIterator = new CommentsIterator(comments)
/**
* Current offset between editor pos and history pos
*/
let offset = 0
/**
* History comments that might overlap with the tracked change considered
*
* @type {HistoryComment[]}
*/
let pendingComments = []
/**
* The final history comments generated
*
* @type {HistoryComment[]}
*/
const historyComments = []
/**
* The final history tracked changes generated
*
* @type {HistoryTrackedChange[]}
*/
const historyChanges = []
for (const change of changes) {
historyChanges.push(toHistoryChange(change, offset))
// After this point, we're only interested in tracked deletes
if (!isDelete(change.op)) {
continue
}
// Fill pendingComments with new comments that start before this tracked
// delete and might overlap
for (const comment of commentsIterator.nextComments(change.op.p)) {
pendingComments.push(toHistoryComment(comment, offset))
}
// Save comments that are fully before this tracked delete
const newPendingComments = []
for (const historyComment of pendingComments) {
const commentEnd = historyComment.op.p + historyComment.op.c.length
if (commentEnd <= change.op.p) {
historyComments.push(historyComment)
} else {
newPendingComments.push(historyComment)
}
}
pendingComments = newPendingComments
// The rest of pending comments overlap with this tracked change. Adjust
// their history length.
for (const historyComment of pendingComments) {
historyComment.op.hlen =
(historyComment.op.hlen ?? historyComment.op.c.length) +
change.op.d.length
}
// Adjust the offset
offset += change.op.d.length
}
// Save the last pending comments
for (const historyComment of pendingComments) {
historyComments.push(historyComment)
}
// Save any comments that came after the last tracked change
for (const comment of commentsIterator.nextComments()) {
historyComments.push(toHistoryComment(comment, offset))
}
const historyRanges = {}
if (historyComments.length > 0) {
historyRanges.comments = historyComments
}
if (historyChanges.length > 0) {
historyRanges.changes = historyChanges
}
return historyRanges
}
class CommentsIterator {
/**
* Build a CommentsIterator
*
* @param {Comment[]} comments
*/
constructor(comments) {
this.comments = comments
this.currentIndex = 0
}
/**
* Generator that returns the next comments to consider
*
* @param {number} beforePos - only return comments that start before this position
* @return {Iterable<Comment>}
*/
*nextComments(beforePos = Infinity) {
while (this.currentIndex < this.comments.length) {
const comment = this.comments[this.currentIndex]
if (comment.op.p < beforePos) {
yield comment
this.currentIndex += 1
} else {
return
}
}
}
}
/**
* Convert an editor tracked change into a history tracked change
*
* @param {TrackedChange} change
* @param {number} offset - how much the history change is ahead of the
* editor change
* @return {HistoryTrackedChange}
*/
function toHistoryChange(change, offset) {
/** @type {HistoryTrackedChange} */
const historyChange = _.cloneDeep(change)
if (offset > 0) {
historyChange.op.hpos = change.op.p + offset
}
return historyChange
}
/**
* Convert an editor comment into a history comment
*
* @param {Comment} comment
* @param {number} offset - how much the history comment is ahead of the
* editor comment
* @return {HistoryComment}
*/
function toHistoryComment(comment, offset) {
/** @type {HistoryComment} */
const historyComment = _.cloneDeep(comment)
if (offset > 0) {
historyComment.op.hpos = comment.op.p + offset
}
return historyComment
}
module.exports = {
toHistoryRanges,
}

View File

@ -10,6 +10,7 @@ const logger = require('@overleaf/logger')
const metrics = require('./Metrics')
const { docIsTooLarge } = require('./Limits')
const { addTrackedDeletesToContent } = require('./Utils')
const HistoryConversions = require('./HistoryConversions')
const OError = require('@overleaf/o-error')
/**
@ -170,7 +171,8 @@ const ProjectHistoryRedisManager = {
}
if (historyRangesSupport) {
projectUpdate.resyncDocContent.ranges = ranges
projectUpdate.resyncDocContent.ranges =
HistoryConversions.toHistoryRanges(ranges)
}
const jsonUpdate = JSON.stringify(projectUpdate)

View File

@ -1,5 +1,8 @@
import { TrackingPropsRawData } from 'overleaf-editor-core/types/lib/types'
/**
* An update coming from the editor
*/
export type Update = {
doc: string
op: Op[]
@ -37,6 +40,9 @@ export type CommentOp = {
u?: boolean
}
/**
* Ranges record on a document
*/
export type Ranges = {
comments?: Comment[]
changes?: TrackedChange[]
@ -53,14 +59,35 @@ export type Comment = {
export type TrackedChange = {
id: string
op: Op
op: InsertOp | DeleteOp
metadata: {
user_id: string
ts: string
}
}
export type HistoryOp = HistoryInsertOp | HistoryDeleteOp | HistoryCommentOp | HistoryRetainOp
/**
* Updates sent to project-history
*/
export type HistoryUpdate = {
op: HistoryOp[]
doc: string
v?: number
meta?: {
pathname?: string
doc_length?: number
history_doc_length?: number
tc?: boolean
user_id?: string
}
projectHistoryId?: string
}
export type HistoryOp =
| HistoryInsertOp
| HistoryDeleteOp
| HistoryCommentOp
| HistoryRetainOp
export type HistoryInsertOp = InsertOp & {
commentIds?: string[]
@ -89,16 +116,13 @@ export type HistoryCommentOp = CommentOp & {
hlen?: number
}
export type HistoryUpdate = {
op: HistoryOp[]
doc: string
v?: number
meta?: {
pathname?: string
doc_length?: number
history_doc_length?: number
tc?: boolean
user_id?: string
}
projectHistoryId?: string
export type HistoryRanges = {
comments?: HistoryComment[]
changes?: HistoryTrackedChange[]
}
export type HistoryComment = Comment & { op: HistoryCommentOp }
export type HistoryTrackedChange = TrackedChange & {
op: HistoryInsertOp | HistoryDeleteOp
}

View File

@ -0,0 +1,117 @@
const _ = require('lodash')
const { expect } = require('chai')
const HistoryConversions = require('../../../app/js/HistoryConversions')
describe('HistoryConversions', function () {
describe('toHistoryRanges', function () {
it('handles empty ranges', function () {
expect(HistoryConversions.toHistoryRanges({})).to.deep.equal({})
})
it("doesn't modify comments when there are no tracked changes", function () {
const ranges = {
comments: [makeComment('comment1', 5, 12)],
}
const historyRanges = HistoryConversions.toHistoryRanges(ranges)
expect(historyRanges).to.deep.equal(ranges)
})
it('adjusts comments and tracked changes to account for tracked deletes', function () {
const comments = [
makeComment('comment0', 0, 1),
makeComment('comment1', 10, 12),
makeComment('comment2', 20, 10),
makeComment('comment3', 15, 3),
]
const changes = [
makeTrackedDelete('change0', 2, 5),
makeTrackedInsert('change1', 4, 5),
makeTrackedDelete('change2', 10, 10),
makeTrackedDelete('change3', 21, 6),
makeTrackedDelete('change4', 50, 7),
]
const ranges = { comments, changes }
const historyRanges = HistoryConversions.toHistoryRanges(ranges)
expect(historyRanges.comments).to.have.deep.members([
comments[0],
// shifted by change0 and change2, extended by change3
enrichOp(comments[1], {
hpos: 25, // 10 + 5 + 10
hlen: 18, // 12 + 6
}),
// shifted by change0 and change2, extended by change3
enrichOp(comments[2], {
hpos: 35, // 20 + 5 + 10
hlen: 16, // 10 + 6
}),
// shifted by change0 and change2
enrichOp(comments[3], {
hpos: 30, // 15 + 5 + 10
}),
])
expect(historyRanges.changes).to.deep.equal([
changes[0],
enrichOp(changes[1], {
hpos: 9, // 4 + 5
}),
enrichOp(changes[2], {
hpos: 15, // 10 + 5
}),
enrichOp(changes[3], {
hpos: 36, // 21 + 5 + 10
}),
enrichOp(changes[4], {
hpos: 71, // 50 + 5 + 10 + 6
}),
])
})
})
})
function makeComment(id, pos, length) {
return {
id,
op: {
c: 'c'.repeat(length),
p: pos,
t: id,
},
metadata: makeMetadata(),
}
}
function makeTrackedInsert(id, pos, length) {
return {
id,
op: {
i: 'i'.repeat(length),
p: pos,
},
metadata: makeMetadata(),
}
}
function makeTrackedDelete(id, pos, length) {
return {
id,
op: {
d: 'd'.repeat(length),
p: pos,
},
metadata: makeMetadata(),
}
}
function makeMetadata() {
return {
user_id: 'user-id',
ts: new Date().toISOString(),
}
}
function enrichOp(commentOrChange, extraFields) {
const result = _.cloneDeep(commentOrChange)
Object.assign(result.op, extraFields)
return result
}

View File

@ -170,10 +170,11 @@ _mocks._countAndProcessUpdates = (
_processUpdatesBatch(projectId, updates, extendLock, cb)
},
error => {
if (error) {
return callback(error)
}
callback(null, queueSize)
// Unconventional callback signature. The caller needs the queue size
// even when an error is thrown in order to record the queue size in
// the projectHistoryFailures collection. We'll have to find another
// way to achieve this when we promisify.
callback(error, queueSize)
}
)
} else {

View File

@ -0,0 +1,3 @@
{
"extends": ["stylelint-config-standard-scss"]
}

View File

@ -11,7 +11,6 @@ const basicAuth = require('basic-auth')
const tsscmp = require('tsscmp')
const UserHandler = require('../User/UserHandler')
const UserSessionsManager = require('../User/UserSessionsManager')
const SessionStoreManager = require('../../infrastructure/SessionStoreManager')
const Analytics = require('../Analytics/AnalyticsManager')
const passport = require('passport')
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
@ -409,31 +408,6 @@ const AuthenticationController = {
return expressify(middleware)
},
validateUserSession: function () {
// Middleware to check that the user's session is still good on key actions,
// such as opening a a project. Could be used to check that session has not
// exceeded a maximum lifetime (req.session.session_created), or for session
// hijacking checks (e.g. change of ip address, req.session.ip_address). For
// now, just check that the session has been loaded from the session store
// correctly.
return function (req, res, next) {
// check that the session store is returning valid results
if (req.session && !SessionStoreManager.hasValidationToken(req)) {
// Force user to update session by destroying the current one.
// A new session will be created on the next request.
req.session.destroy(() => {
// need to destroy the existing session and generate a new one
// otherwise they will already be logged in when they are redirected
// to the login page
if (acceptsJson(req)) return send401WithChallenge(res)
AuthenticationController._redirectToLoginOrRegisterPage(req, res)
})
} else {
next()
}
}
},
_globalLoginWhitelist: [],
addEndpointToLoginWhitelist(endpoint) {
return AuthenticationController._globalLoginWhitelist.push(endpoint)

View File

@ -9,254 +9,219 @@ const ClsiManager = require('./ClsiManager')
const Metrics = require('@overleaf/metrics')
const { RateLimiter } = require('../../infrastructure/RateLimiter')
const UserAnalyticsIdCache = require('../Analytics/UserAnalyticsIdCache')
const {
callbackify,
callbackifyMultiResult,
} = require('@overleaf/promise-utils')
function instrumentWithTimer(fn, key) {
return async (...args) => {
const timer = new Metrics.Timer(key)
try {
return await fn(...args)
} finally {
timer.done()
}
}
}
async function compile(projectId, userId, options = {}) {
const recentlyCompiled = await CompileManager._checkIfRecentlyCompiled(
projectId,
userId
)
if (recentlyCompiled) {
return { status: 'too-recently-compiled', outputFiles: [] }
}
try {
const canCompile = await CompileManager._checkIfAutoCompileLimitHasBeenHit(
options.isAutoCompile,
'everyone'
)
if (!canCompile) {
return { status: 'autocompile-backoff', outputFiles: [] }
}
} catch (error) {
return { status: 'autocompile-backoff', outputFiles: [] }
}
await ProjectRootDocManager.promises.ensureRootDocumentIsSet(projectId)
const limits =
await CompileManager.promises.getProjectCompileLimits(projectId)
for (const key in limits) {
const value = limits[key]
options[key] = value
}
try {
const canCompile = await CompileManager._checkCompileGroupAutoCompileLimit(
options.isAutoCompile,
limits.compileGroup
)
if (!canCompile) {
return { status: 'autocompile-backoff', outputFiles: [] }
}
} catch (error) {
return { message: 'autocompile-backoff', outputFiles: [] }
}
// only pass userId down to clsi if this is a per-user compile
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
const {
status,
outputFiles,
clsiServerId,
validationProblems,
stats,
timings,
outputUrlPrefix,
} = await ClsiManager.promises.sendRequest(projectId, compileAsUser, options)
return {
status,
outputFiles,
clsiServerId,
limits,
validationProblems,
stats,
timings,
outputUrlPrefix,
}
}
const instrumentedCompile = instrumentWithTimer(compile, 'editor.compile')
async function getProjectCompileLimits(projectId) {
const project = await ProjectGetter.promises.getProject(projectId, {
owner_ref: 1,
})
const owner = await UserGetter.promises.getUser(project.owner_ref, {
_id: 1,
alphaProgram: 1,
analyticsId: 1,
betaProgram: 1,
features: 1,
})
const ownerFeatures = (owner && owner.features) || {}
// put alpha users into their own compile group
if (owner && owner.alphaProgram) {
ownerFeatures.compileGroup = 'alpha'
}
const analyticsId = await UserAnalyticsIdCache.get(owner._id)
const compileGroup =
ownerFeatures.compileGroup || Settings.defaultFeatures.compileGroup
const limits = {
timeout:
ownerFeatures.compileTimeout || Settings.defaultFeatures.compileTimeout,
compileGroup,
compileBackendClass: compileGroup === 'standard' ? 'n2d' : 'c2d',
ownerAnalyticsId: analyticsId,
}
return limits
}
async function wordCount(projectId, userId, file, clsiserverid) {
const limits =
await CompileManager.promises.getProjectCompileLimits(projectId)
return await ClsiManager.promises.wordCount(
projectId,
userId,
file,
limits,
clsiserverid
)
}
async function stopCompile(projectId, userId) {
const limits =
await CompileManager.promises.getProjectCompileLimits(projectId)
return await ClsiManager.promises.stopCompile(projectId, userId, limits)
}
async function deleteAuxFiles(projectId, userId, clsiserverid) {
const limits =
await CompileManager.promises.getProjectCompileLimits(projectId)
return await ClsiManager.promises.deleteAuxFiles(
projectId,
userId,
limits,
clsiserverid
)
}
module.exports = CompileManager = {
compile(projectId, userId, options = {}, _callback) {
const timer = new Metrics.Timer('editor.compile')
const callback = function (...args) {
timer.done()
_callback(...args)
}
CompileManager._checkIfRecentlyCompiled(
projectId,
userId,
function (error, recentlyCompiled) {
if (error) {
return callback(error)
}
if (recentlyCompiled) {
return callback(null, 'too-recently-compiled', [])
}
CompileManager._checkIfAutoCompileLimitHasBeenHit(
options.isAutoCompile,
'everyone',
function (err, canCompile) {
if (err || !canCompile) {
return callback(null, 'autocompile-backoff', [])
}
ProjectRootDocManager.ensureRootDocumentIsSet(
projectId,
function (error) {
if (error) {
return callback(error)
}
CompileManager.getProjectCompileLimits(
projectId,
function (error, limits) {
if (error) {
return callback(error)
}
for (const key in limits) {
const value = limits[key]
options[key] = value
}
// Put a lower limit on autocompiles for free users, based on compileGroup
CompileManager._checkCompileGroupAutoCompileLimit(
options.isAutoCompile,
limits.compileGroup,
function (err, canCompile) {
if (err || !canCompile) {
return callback(null, 'autocompile-backoff', [])
}
// only pass userId down to clsi if this is a per-user compile
const compileAsUser = Settings.disablePerUserCompiles
? undefined
: userId
ClsiManager.sendRequest(
projectId,
compileAsUser,
options,
function (
error,
status,
outputFiles,
clsiServerId,
validationProblems,
stats,
timings,
outputUrlPrefix
) {
if (error) {
return callback(error)
}
callback(
null,
status,
outputFiles,
clsiServerId,
limits,
validationProblems,
stats,
timings,
outputUrlPrefix
)
}
)
}
)
}
)
}
)
}
)
}
)
promises: {
compile: instrumentedCompile,
deleteAuxFiles,
getProjectCompileLimits,
stopCompile,
wordCount,
},
compile: callbackifyMultiResult(instrumentedCompile, [
'status',
'outputFiles',
'clsiServerId',
'limits',
'validationProblems',
'stats',
'timings',
'outputUrlPrefix',
]),
stopCompile(projectId, userId, callback) {
CompileManager.getProjectCompileLimits(projectId, function (error, limits) {
if (error) {
return callback(error)
}
ClsiManager.stopCompile(projectId, userId, limits, callback)
})
},
stopCompile: callbackify(stopCompile),
deleteAuxFiles(projectId, userId, clsiserverid, callback) {
CompileManager.getProjectCompileLimits(projectId, function (error, limits) {
if (error) {
return callback(error)
}
ClsiManager.deleteAuxFiles(
projectId,
userId,
limits,
clsiserverid,
callback
)
})
},
deleteAuxFiles: callbackify(deleteAuxFiles),
getProjectCompileLimits(projectId, callback) {
ProjectGetter.getProject(
projectId,
{ owner_ref: 1 },
function (error, project) {
if (error) {
return callback(error)
}
UserGetter.getUser(
project.owner_ref,
{
_id: 1,
alphaProgram: 1,
analyticsId: 1,
betaProgram: 1,
features: 1,
},
function (err, owner) {
if (err) {
return callback(err)
}
const ownerFeatures = (owner && owner.features) || {}
// put alpha users into their own compile group
if (owner && owner.alphaProgram) {
ownerFeatures.compileGroup = 'alpha'
}
UserAnalyticsIdCache.callbacks.get(
owner._id,
function (err, analyticsId) {
if (err) {
return callback(err)
}
const compileGroup =
ownerFeatures.compileGroup ||
Settings.defaultFeatures.compileGroup
const limits = {
timeout:
ownerFeatures.compileTimeout ||
Settings.defaultFeatures.compileTimeout,
compileGroup,
compileBackendClass:
compileGroup === 'standard' ? 'n2d' : 'c2d',
ownerAnalyticsId: analyticsId,
}
callback(null, limits)
}
)
}
)
}
)
},
getProjectCompileLimits: callbackify(getProjectCompileLimits),
COMPILE_DELAY: 1, // seconds
_checkIfRecentlyCompiled(projectId, userId, callback) {
async _checkIfRecentlyCompiled(projectId, userId) {
const key = `compile:${projectId}:${userId}`
rclient.set(
key,
true,
'EX',
this.COMPILE_DELAY,
'NX',
function (error, ok) {
if (error) {
return callback(error)
}
if (ok === 'OK') {
callback(null, false)
} else {
callback(null, true)
}
}
)
const ok = await rclient.set(key, true, 'EX', this.COMPILE_DELAY, 'NX')
return ok !== 'OK'
},
_checkCompileGroupAutoCompileLimit(isAutoCompile, compileGroup, callback) {
async _checkCompileGroupAutoCompileLimit(isAutoCompile, compileGroup) {
if (!isAutoCompile) {
return callback(null, true)
return true
}
if (compileGroup === 'standard') {
// apply extra limits to the standard compile group
CompileManager._checkIfAutoCompileLimitHasBeenHit(
return await CompileManager._checkIfAutoCompileLimitHasBeenHit(
isAutoCompile,
compileGroup,
callback
compileGroup
)
} else {
Metrics.inc(`auto-compile-${compileGroup}`)
callback(null, true)
return true
}
}, // always allow priority group users to compile
_checkIfAutoCompileLimitHasBeenHit(isAutoCompile, compileGroup, callback) {
async _checkIfAutoCompileLimitHasBeenHit(isAutoCompile, compileGroup) {
if (!isAutoCompile) {
return callback(null, true)
return true
}
Metrics.inc(`auto-compile-${compileGroup}`)
const rateLimiter = getAutoCompileRateLimiter(compileGroup)
rateLimiter
.consume('global', 1, { method: 'global' })
.then(() => {
callback(null, true)
})
.catch(() => {
// Don't differentiate between errors and rate limits. Silently trigger
// the rate limit if there's an error consuming the points.
Metrics.inc(`auto-compile-${compileGroup}-limited`)
callback(null, false)
})
try {
await rateLimiter.consume('global', 1, { method: 'global' })
return true
} catch (e) {
// Don't differentiate between errors and rate limits. Silently trigger
// the rate limit if there's an error consuming the points.
Metrics.inc(`auto-compile-${compileGroup}-limited`)
return false
}
},
wordCount(projectId, userId, file, clsiserverid, callback) {
CompileManager.getProjectCompileLimits(projectId, function (error, limits) {
if (error) {
return callback(error)
}
ClsiManager.wordCount(
projectId,
userId,
file,
limits,
clsiserverid,
callback
)
})
},
wordCount: callbackify(wordCount),
}
const autoCompileRateLimiters = new Map()

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

@ -4,6 +4,7 @@ const metrics = require('@overleaf/metrics')
const logger = require('@overleaf/logger')
const Settings = require('@overleaf/settings')
const SessionManager = require('../Features/Authentication/SessionManager')
const Metrics = require('@overleaf/metrics')
const MAX_SESSION_SIZE_THRESHOLD = 4096
@ -55,22 +56,30 @@ class CustomSessionStore extends RedisStore {
}
}
// Override the get, set, touch, and destroy methods to record metrics
get(sid, cb) {
super.get(sid, (err, ...args) => {
if (args[0]) {
CustomSessionStore.metric('get', args[0])
}
cb(err, ...args)
super.get(sid, (err, sess) => {
if (err || !sess || !checkValidationToken(sid, sess)) return cb(err, null)
CustomSessionStore.metric('get', sess)
cb(null, sess)
})
}
set(sid, sess, cb) {
// Refresh the validation token just before writing to Redis
// This will ensure that the token is always matching to the sessionID that we write the session value for.
// Potential reasons for missing/mismatching token:
// - brand-new session
// - cycling of the sessionID as part of the login flow
// - upgrade from a client side session to a redis session
// - accidental writes in the app code
sess.validationToken = computeValidationToken(sid)
CustomSessionStore.metric('set', sess)
const originalId = sess.req.signedCookies[Settings.cookieName]
if (sid === originalId || sid === sess.req.newSessionId) {
this.#updateInPlaceStore.set(sid, sess, cb)
} else {
Metrics.inc('security.session', 1, { status: 'new' })
// Multiple writes can get issued with the new sid. Keep track of it.
Object.defineProperty(sess.req, 'newSessionId', { value: sid })
this.#initialSetStore.set(sid, sess, cb)
@ -89,6 +98,35 @@ class CustomSessionStore extends RedisStore {
}
}
function computeValidationToken(sid) {
// This should be a deterministic function of the client-side sessionID,
// prepended with a version number in case we want to change it later.
return 'v1:' + sid.slice(-4)
}
function checkValidationToken(sid, sess) {
const sessionToken = sess.validationToken
if (sessionToken) {
const clientToken = computeValidationToken(sid)
// Reject sessions where the validation token is out of sync with the sessionID.
// If you change the method for computing the token (above) then you need to either check or ignore previous versions of the token.
if (sessionToken === clientToken) {
Metrics.inc('security.session', 1, { status: 'ok' })
return true
} else {
logger.warn(
{ sid, sessionToken, clientToken },
'session token validation failed'
)
Metrics.inc('security.session', 1, { status: 'error' })
return false
}
} else {
Metrics.inc('security.session', 1, { status: 'missing' })
return false
}
}
// Helper function to return a redacted version of session object
// so we can identify the largest keys without exposing sensitive
// data

View File

@ -14,7 +14,6 @@ const HttpPermissionsPolicyMiddleware = require('./HttpPermissionsPolicy')
const sessionsRedisClient = UserSessionsRedis.client()
const SessionAutostartMiddleware = require('./SessionAutostartMiddleware')
const SessionStoreManager = require('./SessionStoreManager')
const AnalyticsManager = require('../Features/Analytics/AnalyticsManager')
const session = require('express-session')
const CustomSessionStore = require('./CustomSessionStore')
@ -181,11 +180,6 @@ if (Features.hasFeature('saas')) {
webRouter.use(AnalyticsManager.analyticsIdMiddleware)
}
// patch the session store to generate a validation token for every new session
SessionStoreManager.enableValidationToken(sessionStore)
// use middleware to reject all requests with invalid tokens
webRouter.use(SessionStoreManager.validationMiddleware)
// passport
webRouter.use(passport.initialize())
webRouter.use(passport.session())

View File

@ -1,77 +0,0 @@
const Metrics = require('@overleaf/metrics')
const logger = require('@overleaf/logger')
function computeValidationToken(req) {
// this should be a deterministic function of the client-side sessionID,
// prepended with a version number in case we want to change it later
return 'v1:' + req.sessionID.slice(-4)
}
function checkValidationToken(req) {
if (req.session) {
const sessionToken = req.session.validationToken
if (sessionToken) {
const clientToken = computeValidationToken(req)
// Reject invalid sessions. If you change the method for computing the
// token (above) then you need to either check or ignore previous
// versions of the token.
if (sessionToken === clientToken) {
Metrics.inc('security.session', 1, { status: 'ok' })
return true
} else {
logger.error(
{
sessionToken,
clientToken,
},
'session token validation failed'
)
Metrics.inc('security.session', 1, { status: 'error' })
return false
}
} else {
Metrics.inc('security.session', 1, { status: 'missing' })
}
}
return true // fallback to allowing session
}
module.exports = {
enableValidationToken(sessionStore) {
// generate an identifier from the sessionID for every new session
const originalGenerate = sessionStore.generate
sessionStore.generate = function (req) {
originalGenerate(req)
// add the validation token as a property that cannot be overwritten
Object.defineProperty(req.session, 'validationToken', {
value: computeValidationToken(req),
enumerable: true,
writable: false,
})
Metrics.inc('security.session', 1, { status: 'new' })
}
},
validationMiddleware(req, res, next) {
if (!req.session.noSessionCallback) {
if (!checkValidationToken(req)) {
// the session must exist for it to fail validation
return req.session.destroy(() => {
return next(new Error('invalid session'))
})
}
}
next()
},
hasValidationToken(req) {
if (req && req.session && req.session.validationToken) {
return true
} else {
return false
}
},
computeValidationToken,
checkValidationToken,
}

View File

@ -513,7 +513,6 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
RateLimiterMiddleware.rateLimit(openProjectRateLimiter, {
params: ['Project_id'],
}),
AuthenticationController.validateUserSession(),
AuthorizationMiddleware.ensureUserCanReadProject,
ProjectController.loadEditor
)
@ -1324,28 +1323,6 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
}
)
webRouter.get('/no-cache', function (req, res, next) {
res.header('Cache-Control', 'max-age=0')
res.sendStatus(404)
})
webRouter.get('/oops-express', (req, res, next) =>
next(new Error('Test error'))
)
webRouter.get('/oops-internal', function (req, res, next) {
throw new Error('Test error')
})
webRouter.get('/oops-mongo', (req, res, next) =>
require('./models/Project').Project.findOne({}, function () {
throw new Error('Test error')
})
)
privateApiRouter.get('/opps-small', function (req, res, next) {
logger.err('test error occured')
res.sendStatus(200)
})
webRouter.post('/error/client', function (req, res, next) {
logger.warn(
{ err: req.body.error, meta: req.body.meta },

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

@ -18,7 +18,7 @@ block content
.col-md-8.col-md-offset-2.text-center(ng-cloak)
.card(ng-controller="TeamInviteController")
.page-header
h1.text-centered(ng-non-bindable) #{translate("invited_to_group", {inviterName: inviterName, appName: appName})}
h1.text-centered(ng-non-bindable) !{translate("invited_to_group", {inviterName: inviterName, appName: appName}, [{name: 'span', attrs: {class: 'team-invite-name'}}])}
div(ng-show="view =='restrictedByManagedGroup'")
.alert.alert-info

View File

@ -7,7 +7,7 @@ block content
.col-md-8.col-md-offset-2.text-center
.card
.page-header
h1.text-centered #{translate("invited_to_group", {inviterName: inviterName, appName: appName})}
h1.text-centered !{translate("invited_to_group", {inviterName: inviterName, appName: appName }, [{name: 'span', attrs: {class: 'team-invite-name'}}])}
if (accountExists)
div

View File

@ -364,7 +364,10 @@ module.exports = {
robotsNoindex: process.env.ROBOTS_NOINDEX === 'true' || false,
maxEntitiesPerProject: 2000,
maxEntitiesPerProject: parseInt(
process.env.MAX_ENTITIES_PER_PROJECT || '2000',
10
),
projectUploadTimeout: parseInt(
process.env.PROJECT_UPLOAD_TIMEOUT || '120000',

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": "",
@ -97,6 +98,7 @@
"autocomplete_references": "",
"back": "",
"back_to_configuration": "",
"back_to_editor": "",
"back_to_subscription": "",
"back_to_your_projects": "",
"beta_program_already_participating": "",
@ -531,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": "",
@ -618,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": "",
@ -1334,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,21 @@
import { useTranslation } from 'react-i18next'
import { Button } from 'react-bootstrap'
import MaterialIcon from '@/shared/components/material-icon'
function BackToEditorButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation()
return (
<Button
bsSize="sm"
bsStyle={null}
onClick={onClick}
className="back-to-editor-btn btn-secondary"
>
<MaterialIcon type="arrow_back" className="toolbar-btn-secondary-icon" />
<p className="toolbar-label">{t('back_to_editor')}</p>
</Button>
)
}
export default BackToEditorButton

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react'
import React, { useState, useCallback } from 'react'
import ToolbarHeader from './toolbar-header'
import { useEditorContext } from '../../../shared/context/editor-context'
import { useChatContext } from '../../chat/context/chat-context'
@ -66,11 +66,21 @@ const EditorNavigationToolbarRoot = React.memo(
[reviewPanelOpen, setReviewPanelOpen]
)
const [shouldReopenChat, setShouldReopenChat] = useState(chatIsOpen)
const toggleHistoryOpen = useCallback(() => {
const action = view === 'history' ? 'close' : 'open'
eventTracking.sendMB('navigation-clicked-history', { action })
if (chatIsOpen && action === 'open') {
setShouldReopenChat(true)
toggleChatOpen()
}
if (shouldReopenChat && action === 'close') {
setShouldReopenChat(false)
toggleChatOpen()
}
setView(view === 'history' ? 'editor' : 'history')
}, [view, setView])
}, [view, chatIsOpen, shouldReopenChat, setView, toggleChatOpen])
const openShareModal = useCallback(() => {
eventTracking.sendMB('navigation-clicked-share')

View File

@ -1,18 +1,13 @@
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
function HistoryToggleButton({ historyIsOpen, onClick }) {
function HistoryToggleButton({ onClick }) {
const { t } = useTranslation()
const classes = classNames('btn', 'btn-full-height', {
active: historyIsOpen,
})
return (
<div className="toolbar-item">
<button className={classes} onClick={onClick}>
<button className="btn btn-full-height" onClick={onClick}>
<Icon type="history" fw />
<p className="toolbar-label">{t('history')}</p>
</button>
@ -21,7 +16,6 @@ function HistoryToggleButton({ historyIsOpen, onClick }) {
}
HistoryToggleButton.propTypes = {
historyIsOpen: PropTypes.bool,
onClick: PropTypes.func.isRequired,
}

View File

@ -13,6 +13,7 @@ import TrackChangesToggleButton from './track-changes-toggle-button'
import HistoryToggleButton from './history-toggle-button'
import ShareProjectButton from './share-project-button'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import BackToEditorButton from './back-to-editor-button'
const [publishModalModules] = importOverleafModules('publishModal')
const PublishButton = publishModalModules?.import.default
@ -64,34 +65,37 @@ const ToolbarHeader = React.memo(function ToolbarHeader({
<div className="toolbar-right">
<OnlineUsersWidget onlineUsers={onlineUsers} goToUser={goToUser} />
{trackChangesVisible && (
<TrackChangesToggleButton
onMouseDown={toggleReviewPanelOpen}
disabled={historyIsOpen}
trackChangesIsOpen={reviewPanelOpen}
/>
)}
{historyIsOpen ? (
<BackToEditorButton onClick={toggleHistoryOpen} />
) : (
<>
{trackChangesVisible && (
<TrackChangesToggleButton
onMouseDown={toggleReviewPanelOpen}
disabled={historyIsOpen}
trackChangesIsOpen={reviewPanelOpen}
/>
)}
<ShareProjectButton onClick={openShareModal} />
{shouldDisplayPublishButton && (
<PublishButton cobranding={cobranding} />
)}
<ShareProjectButton onClick={openShareModal} />
{shouldDisplayPublishButton && (
<PublishButton cobranding={cobranding} />
)}
{!isRestrictedTokenMember && (
<HistoryToggleButton
historyIsOpen={historyIsOpen}
onClick={toggleHistoryOpen}
/>
)}
{!isRestrictedTokenMember && (
<HistoryToggleButton onClick={toggleHistoryOpen} />
)}
<LayoutDropdownButton />
<LayoutDropdownButton />
{!isRestrictedTokenMember && (
<ChatToggleButton
chatIsOpen={chatIsOpen}
onClick={toggleChatOpen}
unreadMessageCount={unreadMessageCount}
/>
{!isRestrictedTokenMember && (
<ChatToggleButton
chatIsOpen={chatIsOpen}
onClick={toggleChatOpen}
unreadMessageCount={unreadMessageCount}
/>
)}
</>
)}
</div>
</header>

View File

@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import StartFreeTrialButton from '../../../../shared/components/start-free-trial-button'
import { paywallPrompt } from '../../../../main/account-upgrade'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function FeatureItem({ text }: { text: string }) {
return (
@ -18,8 +18,7 @@ export function OwnerPaywallPrompt() {
const { t } = useTranslation()
const [clickedFreeTrialButton, setClickedFreeTrialButton] = useState(false)
const { splitTestVariants } = useSplitTestContext()
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
useEffect(() => {
eventTracking.send('subscription-funnel', 'editor-click-feature', 'history')

View File

@ -6,7 +6,7 @@ import ToolbarFileInfo from './toolbar-file-info'
import ToolbarRestoreFileButton from './toolbar-restore-file-button'
import { isFileRemoved } from '../../../utils/file-diff'
import ToolbarRevertFileButton from './toolbar-revert-file-button'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type ToolbarProps = {
diff: Nullable<Diff>
@ -14,13 +14,13 @@ type ToolbarProps = {
}
export default function Toolbar({ diff, selection }: ToolbarProps) {
const { splitTestVariants } = useSplitTestContext()
const hasRevertFiles = useFeatureFlag('revert-files')
const showRestoreFileButton =
selection.selectedFile && isFileRemoved(selection.selectedFile)
const showRevertFileButton =
splitTestVariants['revert-file'] === 'enabled' &&
hasRevertFiles &&
selection.selectedFile &&
!isFileRemoved(selection.selectedFile)

View File

@ -2,15 +2,14 @@ import Notification from '@/shared/components/notification'
import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
import { useTranslation } from 'react-i18next'
import { FC } from 'react'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export const CompileTimeWarningUpgradePromptInner: FC<{
handleDismissWarning: () => void
}> = ({ handleDismissWarning }) => {
const { t } = useTranslation()
const { splitTestVariants } = useSplitTestContext()
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
return (
<Notification

View File

@ -46,6 +46,7 @@ function PdfLogEntry({
<div
className={classNames('log-entry', customClass)}
aria-label={entryAriaLabel}
data-ruleid={ruleId}
>
<PreviewLogEntryHeader
level={level}

View File

@ -6,7 +6,7 @@ import PdfLogEntry from './pdf-log-entry'
import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
import { Button } from 'react-bootstrap'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function TimeoutUpgradePromptNew() {
const {
@ -49,8 +49,7 @@ const CompileTimeout = memo(function CompileTimeout({
}: CompileTimeoutProps) {
const { t } = useTranslation()
const { splitTestVariants } = useSplitTestContext()
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
return (
<PdfLogEntry

View File

@ -1,5 +1,5 @@
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useTranslation, Trans } from 'react-i18next'
import Notification from '../../notification'
import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
import getMeta from '@/utils/meta'
@ -30,7 +30,18 @@ export default function GroupInvitationNotificationJoin({
<Notification
bsStyle="info"
onDismiss={dismissGroupInviteNotification}
body={t('invited_to_group', { inviterName })}
body={
<Trans
i18nKey="invited_to_group"
values={{ inviterName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={
/* eslint-disable-next-line react/jsx-key */
[<span className="team-invite-name" />]
}
/>
}
action={
<Button
bsStyle={newNotificationStyle ? null : 'info'}

View File

@ -0,0 +1,22 @@
import { FC } from 'react'
import { UserRef } from '../../../../../../../types/project/dashboard/api'
import { useTranslation } from 'react-i18next'
import { getUserName } from '@/features/project-list/util/user'
export const LastUpdatedBy: FC<{
lastUpdatedBy: UserRef
lastUpdatedDate: string
}> = ({ lastUpdatedBy, lastUpdatedDate }) => {
const { t } = useTranslation()
const userName = getUserName(lastUpdatedBy)
return (
<span>
{t('last_updated_date_by_x', {
lastUpdatedDate,
person: userName === 'You' ? t('you') : userName,
})}
</span>
)
}

View File

@ -1,22 +1,14 @@
import { useTranslation } from 'react-i18next'
import { formatDate, fromNowDate } from '../../../../../utils/dates'
import { Project } from '../../../../../../../types/project/dashboard/api'
import Tooltip from '../../../../../shared/components/tooltip'
import { getUserName } from '../../../util/user'
import { LastUpdatedBy } from '@/features/project-list/components/table/cells/last-updated-by'
type LastUpdatedCellProps = {
project: Project
}
export default function LastUpdatedCell({ project }: LastUpdatedCellProps) {
const { t } = useTranslation()
const displayText = project.lastUpdatedBy
? t('last_updated_date_by_x', {
lastUpdatedDate: fromNowDate(project.lastUpdated),
person: getUserName(project.lastUpdatedBy),
})
: fromNowDate(project.lastUpdated)
const lastUpdatedDate = fromNowDate(project.lastUpdated)
const tooltipText = formatDate(project.lastUpdated)
return (
@ -26,8 +18,14 @@ export default function LastUpdatedCell({ project }: LastUpdatedCellProps) {
description={tooltipText}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
{/* OverlayTrigger won't fire unless icon is wrapped in a span */}
<span>{displayText}</span>
{project.lastUpdatedBy ? (
<LastUpdatedBy
lastUpdatedBy={project.lastUpdatedBy}
lastUpdatedDate={lastUpdatedDate}
/>
) : (
<span>{lastUpdatedDate}</span>
)}
</Tooltip>
)
}

View File

@ -41,11 +41,13 @@ type OwnerCellProps = {
}
export default function OwnerCell({ project }: OwnerCellProps) {
const { t } = useTranslation()
const ownerName = getOwnerName(project)
return (
<>
{ownerName}
{ownerName === 'You' ? t('you') : ownerName}
{project.source === 'token' ? (
<LinkSharingIcon
className="hidden-xs"

View File

@ -0,0 +1,13 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
export const ProjectListOwnerName = memo<{ ownerName: string }>(
({ ownerName }) => {
const { t } = useTranslation()
const x = ownerName === 'You' ? t('you') : ownerName
return <> {t('owned_by_x', { x })}</>
}
)
ProjectListOwnerName.displayName = 'ProjectListOwnerName'

View File

@ -8,12 +8,14 @@ import ActionsDropdown from '../dropdown/actions-dropdown'
import { getOwnerName } from '../../util/project'
import { Project } from '../../../../../../types/project/dashboard/api'
import { ProjectCheckbox } from './project-checkbox'
import { ProjectListOwnerName } from '@/features/project-list/components/table/project-list-owner-name'
type ProjectListTableRowProps = {
project: Project
}
function ProjectListTableRow({ project }: ProjectListTableRowProps) {
const { t } = useTranslation()
const ownerName = getOwnerName(project)
return (
@ -30,7 +32,7 @@ function ProjectListTableRow({ project }: ProjectListTableRowProps) {
</td>
<td className="dash-cell-date-owner visible-xs pb-0">
<LastUpdatedCell project={project} />
{ownerName ? <> {t('owned_by_x', { x: ownerName })}</> : null}
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
</td>
<td className="dash-cell-owner hidden-xs">
<OwnerCell project={project} />

View File

@ -5,7 +5,7 @@ import { useSSOContext, SSOSubscription } from '../context/sso-context'
import { SSOLinkingWidget } from './linking/sso-widget'
import getMeta from '../../../utils/meta'
import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
function LinkingSection() {
@ -51,16 +51,12 @@ function LinkingSection() {
// currently the only thing that is in the langFeedback section is writefull,
// which is behind a split test. we should hide this section if the user is not in the split test
// todo: remove split test check, and split test context after gradual rollout is complete
const {
splitTestVariants,
}: { splitTestVariants: Record<string, string | undefined> } =
useSplitTestContext()
const hasWritefullOauthPromotion = useFeatureFlag('writefull-oauth-promotion')
// even if they arent in the split test, if they have it enabled let them toggle it off
const user = getMeta('ol-user')
const shouldLoadWritefull =
(splitTestVariants['writefull-oauth-promotion'] === 'enabled' ||
user.writefull?.enabled === true) &&
(hasWritefullOauthPromotion || user.writefull?.enabled === true) &&
!window.writefull // check if the writefull extension is installed, in which case we dont handle the integration
const haslangFeedbackLinkingWidgets =

View File

@ -5,16 +5,14 @@ import { useUserContext } from '../../../shared/context/user-context'
import { upgradePlan } from '../../../main/account-upgrade'
import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
import Icon from '../../../shared/components/icon'
import { useSplitTestContext } from '../../../shared/context/split-test-context'
import { useFeatureFlag } from '../../../shared/context/split-test-context'
export default function AddCollaboratorsUpgrade() {
const { t } = useTranslation()
const user = useUserContext()
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
const { splitTestVariants } = useSplitTestContext()
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
return (
<div className="add-collaborators-upgrade">

View File

@ -6,7 +6,7 @@ import { useProjectContext } from '../../../../shared/context/project-context'
import { useUserContext } from '../../../../shared/context/user-context'
import { startFreeTrial, upgradePlan } from '../../../../main/account-upgrade'
import { memo } from 'react'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type UpgradeTrackChangesModalProps = {
show: boolean
@ -21,8 +21,7 @@ function UpgradeTrackChangesModal({
const project = useProjectContext()
const user = useUserContext()
const { splitTestVariants } = useSplitTestContext()
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
return (
<AccessibleModal show={show} onHide={() => setShow(false)}>

View File

@ -14,7 +14,7 @@ import { Button, Overlay, Popover } from 'react-bootstrap'
import Close from '../../../../../shared/components/close'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import { sendMB } from '../../../../../infrastructure/event-tracking'
import { useSplitTestContext } from '../../../../../shared/context/split-test-context'
import { useFeatureFlag } from '../../../../../shared/context/split-test-context'
import { User } from '../../../../../../../types/user'
import { useUserContext } from '../../../../../shared/context/user-context'
import grammarlyExtensionPresent from '../../../../../shared/utils/grammarly'
@ -30,10 +30,8 @@ export const PromotionOverlay: FC = ({ children }) => {
const { inactiveTutorials, currentPopup, setCurrentPopup } =
useEditorContext()
const {
splitTestVariants,
}: { splitTestVariants: Record<string, string | undefined> } =
useSplitTestContext()
const hasTableGeneratorPromotion = useFeatureFlag('table-generator-promotion')
const user = useUserContext() as User | undefined
@ -50,7 +48,7 @@ export const PromotionOverlay: FC = ({ children }) => {
currentPopup && currentPopup !== 'table-generator-promotion'
const showPromotion =
splitTestVariants['table-generator-promotion'] === 'enabled' &&
hasTableGeneratorPromotion &&
!popupPresent &&
!inactiveTutorials.includes('table-generator-promotion') &&
!hideBecauseNewUser

View File

@ -10,8 +10,10 @@ import {
ancestorListType,
toggleListForRanges,
unwrapBulletList,
unwrapDescriptionList,
unwrapNumberedList,
wrapInBulletList,
wrapInDescriptionList,
wrapInNumberedList,
} from './lists'
import { snippet } from '@codemirror/autocomplete'
@ -114,6 +116,8 @@ export const indentDecrease: Command = view => {
return unwrapBulletList(view)
case 'enumerate':
return unwrapNumberedList(view)
case 'description':
return unwrapDescriptionList(view)
default:
return false
}
@ -136,6 +140,8 @@ export const indentIncrease: Command = view => {
return wrapInBulletList(view)
case 'enumerate':
return wrapInNumberedList(view)
case 'description':
return wrapInDescriptionList(view)
default:
return false
}

View File

@ -343,5 +343,7 @@ export const toggleListForRanges =
export const wrapInBulletList = wrapRangesInList('itemize')
export const wrapInNumberedList = wrapRangesInList('enumerate')
export const wrapInDescriptionList = wrapRangesInList('description')
export const unwrapBulletList = unwrapRangesFromList('itemize')
export const unwrapNumberedList = unwrapRangesFromList('enumerate')
export const unwrapDescriptionList = unwrapRangesFromList('description')

View File

@ -51,6 +51,9 @@ import { EditableInlineGraphicsWidget } from './visual-widgets/editable-inline-g
import {
CloseBrace,
OpenBrace,
CloseBracket,
OpenBracket,
OptionalArgument,
ShortTextArgument,
TextArgument,
} from '../../lezer-latex/latex.terms.mjs'
@ -74,6 +77,7 @@ import {
validateParsedTable,
} from '../../components/table-generator/utils'
import { debugConsole } from '@/utils/debugging'
import { DescriptionItemWidget } from './visual-widgets/description-item'
type Options = {
previewByPath: (path: string) => PreviewPath | null
@ -101,13 +105,17 @@ function decorateArgumentBraces(
argumentNode: SyntaxNode | null | undefined,
start: number,
decorateEmptyArguments = false,
endWidget?: WidgetType
endWidget?: WidgetType,
braceTypes = {
open: OpenBrace,
close: CloseBrace,
}
): Range<Decoration>[] {
if (!argumentNode) {
return []
}
const openBrace = argumentNode.getChild('OpenBrace')
const closeBrace = argumentNode.getChild('CloseBrace')
const openBrace = argumentNode.getChild(braceTypes.open)
const closeBrace = argumentNode.getChild(braceTypes.close)
if (openBrace && closeBrace) {
if (
@ -120,10 +128,9 @@ function decorateArgumentBraces(
widget: startWidget,
}).range(start, openBrace.to),
Decoration.replace({ widget: endWidget }).range(
closeBrace.from,
closeBrace.to
),
Decoration.replace({
widget: endWidget,
}).range(closeBrace.from, closeBrace.to),
]
}
}
@ -377,6 +384,7 @@ export const atomicDecorations = (options: Options) => {
switch (envName) {
case 'itemize':
case 'enumerate':
case 'description':
startListEnvironment(envName)
listDepth++
break
@ -480,6 +488,7 @@ export const atomicDecorations = (options: Options) => {
switch (envName) {
case 'itemize':
case 'enumerate':
case 'description':
if (currentListEnvironment === envName) {
endListEnvironment()
}
@ -963,16 +972,51 @@ export const atomicDecorations = (options: Options) => {
state.sliceDoc(line.from, nodeRef.from)
)
const from = onlySpaceBeforeNode ? line.from : nodeRef.from
decorations.push(
Decoration.replace({
widget: new ItemWidget(
currentListEnvironment || 'document',
currentOrdinal,
listDepth
),
}).range(from, nodeRef.to)
)
return false
if (currentListEnvironment === 'description') {
const argumentNode = nodeRef.node.getChild(OptionalArgument)
const to = argumentNode ? argumentNode.from : nodeRef.to
const onlySpaceAfterNode =
!argumentNode &&
/^\s*$/.test(state.sliceDoc(nodeRef.to, line.to))
if (!onlySpaceAfterNode) {
// decorate the \item command and subsequent whitespace, if there is other content on the line
decorations.push(
Decoration.replace({
widget: new DescriptionItemWidget(listDepth),
}).range(from, to)
)
}
if (argumentNode) {
// decorate the optional argument
const decorateBrackets = shouldDecorate(state, argumentNode)
decorations.push(
...decorateArgumentBraces(
new BraceWidget(decorateBrackets ? '' : '['),
argumentNode,
from,
false,
new BraceWidget(decorateBrackets ? '' : ']'),
{ open: OpenBracket, close: CloseBracket }
)
)
}
} else {
decorations.push(
Decoration.replace({
widget: new ItemWidget(
currentListEnvironment || 'document',
currentOrdinal,
listDepth
),
}).range(from, nodeRef.to)
)
return false
}
}
} else if (nodeRef.type.is('NewTheoremCommand')) {
const result = parseTheoremArguments(state, nodeRef.node)

View File

@ -49,7 +49,10 @@ const chooseTargetPosition = (
targetNode = node
} else if (node.type.is('ItemCtrlSeq')) {
targetNode = node.parent
} else if (node.type.is('Whitespace')) {
} else if (
node.type.is('Whitespace') &&
node.nextSibling?.type.is('Command')
) {
targetNode = node.nextSibling?.firstChild?.firstChild
}

View File

@ -12,6 +12,7 @@ import {
indentIncrease,
} from '../toolbar/commands'
import { createListItem } from '@/features/source-editor/extensions/visual/utils/list-item'
import { getListType } from '../../utils/tree-operations/lists'
/**
* A keymap which provides behaviours for the visual editor,
@ -37,7 +38,7 @@ export const visualKeymap = Prec.highest(
if (line.number === endLine.number - 1) {
// last item line
if (line.text.trim() === '\\item') {
if (/^\\item(\[])?$/.test(line.text.trim())) {
// no content on this line
// outside the end of the current list
@ -85,8 +86,7 @@ export const visualKeymap = Prec.highest(
}
// handle a list item that isn't at the end of a list
const insert = '\n' + createListItem(state, from)
let insert = '\n' + createListItem(state, from)
const countWhitespaceAfterPosition = (pos: number) => {
const line = state.doc.lineAt(pos)
@ -95,9 +95,16 @@ export const visualKeymap = Prec.highest(
return matches ? matches[1].length : 0
}
// move the cursor past any whitespace on the new line
const pos =
from + insert.length + countWhitespaceAfterPosition(from)
let pos: number
if (getListType(state, listNode) === 'description') {
insert = insert.replace(/\\item $/, '\\item[] ')
// position the cursor inside the square brackets
pos = from + insert.length - 2
} else {
// move the cursor past any whitespace on the new line
pos = from + insert.length + countWhitespaceAfterPosition(from)
}
handled = true

View File

@ -16,6 +16,7 @@ export const visualHighlightStyle = syntaxHighlighting(
{ tag: tags.string, class: 'ol-cm-monospace' },
{ tag: tags.punctuation, class: 'ol-cm-punctuation' },
{ tag: tags.literal, class: 'ol-cm-monospace' },
{ tag: tags.strong, class: 'ol-cm-strong' },
{
tag: tags.monospace,
fontFamily: 'var(--source-font-family)',
@ -70,6 +71,9 @@ const mainVisualTheme = EditorView.theme({
fontVariant: 'normal',
textDecoration: 'none',
},
'.ol-cm-strong': {
fontWeight: 700,
},
'.ol-cm-punctuation': {
fontFamily: 'var(--source-font-family)',
lineHeight: 1,
@ -184,7 +188,7 @@ const mainVisualTheme = EditorView.theme({
'.ol-cm-begin-theorem > .ol-cm-environment-padding:first-of-type': {
flex: 0,
},
'.ol-cm-item': {
'.ol-cm-item, .ol-cm-description-item': {
paddingInlineStart: 'calc(var(--list-depth) * 2ch)',
},
'.ol-cm-item::before': {

View File

@ -0,0 +1,31 @@
import { WidgetType } from '@codemirror/view'
export class DescriptionItemWidget extends WidgetType {
constructor(public listDepth: number) {
super()
}
toDOM() {
const element = document.createElement('span')
element.classList.add('ol-cm-description-item')
this.setProperties(element)
return element
}
eq(widget: DescriptionItemWidget) {
return widget.listDepth === this.listDepth
}
updateDOM(element: HTMLElement) {
this.setProperties(element)
return true
}
ignoreEvent(event: Event): boolean {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
setProperties(element: HTMLElement) {
element.style.setProperty('--list-depth', String(this.listDepth))
}
}

View File

@ -18,6 +18,12 @@ export const environments = new Map([
\\end{array}`,
],
['center', snippet('center')],
[
'description',
`\\begin{description}
\t\\item[$1] $2
\\end{description}`,
],
['document', snippetNoIndent('document')],
['equation', snippet('equation')],
['equation*', snippet('equation*')],

View File

@ -155,6 +155,7 @@ export const LaTeXLanguage = LRLanguage.define({
'DocumentClass/OptionalArgument/ShortOptionalArg/Normal':
t.attributeValue,
'DocumentClass/ShortTextArgument/ShortArg/Normal': t.typeName,
'ListEnvironment/BeginEnv/OptionalArgument/...': t.monospace,
Number: t.number,
OpenBrace: t.brace,
CloseBrace: t.brace,
@ -193,6 +194,7 @@ export const LaTeXLanguage = LRLanguage.define({
'BareFilePathArgument/SpaceDelimitedLiteralArgContent':
t.attributeValue,
TrailingContent: t.comment,
'Item/OptionalArgument/ShortOptionalArg/...': t.strong,
// TODO: t.strong, t.emphasis
}),
],

View File

@ -354,7 +354,7 @@ KnownCommand {
CenteringCtrlSeq
} |
Item {
ItemCtrlSeq optionalWhitespace?
ItemCtrlSeq OptionalArgument? optionalWhitespace?
} |
Maketitle {
MaketitleCtrlSeq optionalWhitespace?

View File

@ -744,6 +744,7 @@ const otherKnownEnvNames = {
enumerate: ListEnvName,
itemize: ListEnvName,
table: TableEnvName,
description: ListEnvName,
}
export const specializeEnvName = (name, terms) => {

View File

@ -257,7 +257,7 @@ export const withinFormattingCommand = (state: EditorState) => {
}
}
export type ListEnvironmentName = 'itemize' | 'enumerate'
export type ListEnvironmentName = 'itemize' | 'enumerate' | 'description'
export const listDepthForNode = (node: SyntaxNode) => {
let depth = 0

View File

@ -0,0 +1,21 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
export const getListType = (
state: EditorState,
listEnvironmentNode: SyntaxNode
) => {
const beginEnvNameNode = listEnvironmentNode
.getChild('BeginEnv')
?.getChild('EnvNameGroup')
?.getChild('ListEnvName')
const endEnvNameNode = listEnvironmentNode
.getChild('EndEnv')
?.getChild('EnvNameGroup')
?.getChild('ListEnvName')
if (beginEnvNameNode && endEnvNameNode) {
return state.sliceDoc(beginEnvNameNode.from, beginEnvNameNode.to).trim()
}
}

View File

@ -1,6 +1,7 @@
import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context'
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import SubscriptionDashboard from './subscription-dashboard'
import { SplitTestProvider } from '@/shared/context/split-test-context'
function Root() {
const { isReady } = useWaitForI18n()
@ -10,9 +11,11 @@ function Root() {
}
return (
<SubscriptionDashboardProvider>
<SubscriptionDashboard />
</SubscriptionDashboardProvider>
<SplitTestProvider>
<SubscriptionDashboardProvider>
<SubscriptionDashboard />
</SubscriptionDashboardProvider>
</SplitTestProvider>
)
}

View File

@ -1,5 +1,5 @@
import { Col, Row } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { Trans } from 'react-i18next'
import GroupInvitesItemFooter from './group-invites-item-footer'
import type { TeamInvite } from '../../../../../../types/team-invite'
@ -10,17 +10,22 @@ type GroupInvitesItemProps = {
export default function GroupInvitesItem({
teamInvite,
}: GroupInvitesItemProps) {
const { t } = useTranslation()
return (
<Row className="row-spaced">
<Col md={8} mdOffset={2} className="text-center">
<div className="card">
<div className="page-header">
<h2>
{t('invited_to_group', {
inviterName: teamInvite.inviterName,
})}
<Trans
i18nKey="invited_to_group"
values={{ inviterName: teamInvite.inviterName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={
/* eslint-disable-next-line react/jsx-key */
[<span className="team-invite-name" />]
}
/>
</h2>
</div>
<GroupInvitesItemFooter teamInvite={teamInvite} />

View File

@ -1,6 +1,7 @@
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context'
import SuccessfulSubscription from './successful-subscription'
import { SplitTestProvider } from '@/shared/context/split-test-context'
function Root() {
const { isReady } = useWaitForI18n()
@ -10,9 +11,11 @@ function Root() {
}
return (
<SubscriptionDashboardProvider>
<SuccessfulSubscription />
</SubscriptionDashboardProvider>
<SplitTestProvider>
<SubscriptionDashboardProvider>
<SuccessfulSubscription />
</SubscriptionDashboardProvider>
</SplitTestProvider>
)
}

View File

@ -30,7 +30,7 @@ import {
import { isRecurlyLoaded } from '../util/is-recurly-loaded'
import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids'
import { debugConsole } from '@/utils/debugging'
import { getSplitTestVariant } from '@/utils/splitTestUtils'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { formatCurrencyLocalized } from '@/shared/utils/currency'
type SubscriptionDashboardContextValue = {
@ -80,11 +80,6 @@ export const SubscriptionDashboardContext = createContext<
SubscriptionDashboardContextValue | undefined
>(undefined)
const getFormatCurrencies = () =>
getSplitTestVariant('local-ccy-format-v2') === 'enabled'
? formatCurrencyLocalized
: formatCurrencyDefault
export function SubscriptionDashboardProvider({
children,
}: {
@ -150,6 +145,10 @@ export function SubscriptionDashboardProvider({
memberGroupSubscriptions?.length > 0
)
const formatCurrency = useFeatureFlag('local-ccy-format-v2')
? formatCurrencyLocalized
: formatCurrencyDefault
useEffect(() => {
if (!isRecurlyLoaded()) {
setRecurlyLoadError(true)
@ -164,7 +163,6 @@ export function SubscriptionDashboardProvider({
plansWithoutDisplayPrice &&
personalSubscription?.recurly
) {
const formatCurrency = getFormatCurrencies()
const { currency, taxRate } = personalSubscription.recurly
const fetchPlansDisplayPrices = async () => {
for (const plan of plansWithoutDisplayPrice) {
@ -192,7 +190,12 @@ export function SubscriptionDashboardProvider({
}
fetchPlansDisplayPrices().catch(debugConsole.error)
}
}, [personalSubscription, plansWithoutDisplayPrice, i18n.language])
}, [
personalSubscription,
plansWithoutDisplayPrice,
i18n.language,
formatCurrency,
])
useEffect(() => {
if (
@ -209,7 +212,6 @@ export function SubscriptionDashboardProvider({
setGroupPlanToChangeToPriceError(false)
let priceData
try {
const formatCurrency = getFormatCurrencies()
priceData = await loadGroupDisplayPriceWithTaxPromise(
groupPlanToChangeToCode,
currency,
@ -233,6 +235,7 @@ export function SubscriptionDashboardProvider({
groupPlanToChangeToSize,
personalSubscription,
groupPlanToChangeToCode,
formatCurrency,
i18n.language,
])

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

@ -3,49 +3,43 @@ import ruleset from './HumanReadableLogsRules'
export default {
parse(rawLog, options) {
let parsedLogEntries
if (typeof rawLog === 'string') {
const latexLogParser = new LatexLogParser(rawLog, options)
parsedLogEntries = latexLogParser.parse()
} else {
parsedLogEntries = rawLog
}
const _getRule = function (logMessage) {
for (const rule of ruleset) {
if (rule.regexToMatch.test(logMessage)) {
return rule
}
}
}
const parsedLogEntries =
typeof rawLog === 'string'
? new LatexLogParser(rawLog, options).parse()
: rawLog
const seenErrorTypes = {} // keep track of types of errors seen
for (const entry of parsedLogEntries.all) {
const ruleDetails = _getRule(entry.message)
const ruleDetails = ruleset.find(rule =>
rule.regexToMatch.test(entry.message)
)
if (ruleDetails != null) {
let type
if (ruleDetails.ruleId != null) {
if (ruleDetails) {
if (ruleDetails.ruleId) {
entry.ruleId = ruleDetails.ruleId
}
if (ruleDetails.newMessage != null) {
if (ruleDetails.newMessage) {
entry.message = entry.message.replace(
ruleDetails.regexToMatch,
ruleDetails.newMessage
)
}
if (ruleDetails.contentRegex) {
const match = entry.content.match(ruleDetails.contentRegex)
if (match) {
entry.contentDetails = match.slice(1)
}
}
if (entry.contentDetails && ruleDetails.improvedTitle) {
const message = ruleDetails.improvedTitle(
entry.message,
entry.contentDetails
)
if (Array.isArray(message)) {
entry.message = message[0]
// removing the messageComponent, as the markup possible in it was causing crashes when
@ -56,17 +50,19 @@ export default {
entry.message = message
}
}
// suppress any entries that are known to cascade from previous error types
if (ruleDetails.cascadesFrom != null) {
for (type of ruleDetails.cascadesFrom) {
if (ruleDetails.cascadesFrom) {
for (const type of ruleDetails.cascadesFrom) {
if (seenErrorTypes[type]) {
entry.suppressed = true
}
}
}
// record the types of errors seen
if (ruleDetails.types != null) {
for (type of ruleDetails.types) {
if (ruleDetails.types) {
for (const type of ruleDetails.types) {
seenErrorTypes[type] = true
}
}
@ -74,8 +70,7 @@ export default {
}
// filter out the suppressed errors (from the array entries in parsedLogEntries)
for (const key in parsedLogEntries) {
const errors = parsedLogEntries[key]
for (const [key, errors] of Object.entries(parsedLogEntries)) {
if (typeof errors === 'object' && errors.length > 0) {
parsedLogEntries[key] = Array.from(errors).filter(
err => !err.suppressed

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

@ -34,7 +34,7 @@ import { useUserContext } from './user-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type PdfFile = Record<string, any>
@ -342,7 +342,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
}
}, [compiling, isProjectOwner, hasShortCompileTimeout])
const { splitTestVariants } = useSplitTestContext()
const hasCompileLogsEvents = useFeatureFlag('compile-log-events')
// handle the data returned from a compile request
// note: this should _only_ run when `data` changes,
@ -401,7 +401,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
)
}
if (splitTestVariants['compile-log-events'] === 'enabled') {
if (hasCompileLogsEvents) {
sendMB('compile-log-entries', {
status: data.status,
stopOnFirstError: data.options.stopOnFirstError,
@ -479,6 +479,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
}, [
data,
ide,
hasCompileLogsEvents,
hasPremiumCompile,
isProjectOwner,
projectId,
@ -487,7 +488,6 @@ export const LocalCompileProvider: FC = ({ children }) => {
setLogEntries,
setLogEntryAnnotations,
setPdfFile,
splitTestVariants,
])
// switch to logs if there's an error

View File

@ -39,3 +39,8 @@ export function useSplitTestContext() {
return context
}
export function useFeatureFlag(name: string) {
const { splitTestVariants } = useSplitTestContext()
return splitTestVariants[name] === 'enabled'
}

View File

@ -0,0 +1,85 @@
import { useCallback, useState } from 'react'
import * as eventTracking from '@/infrastructure/event-tracking'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { useEditorContext } from '@/shared/context/editor-context'
export const useTutorial = (
tutorialKey: string,
eventData: Record<string, any> = {}
) => {
const [showPopup, setShowPopup] = useState(false)
const { deactivateTutorial, currentPopup, setCurrentPopup } =
useEditorContext()
const completeTutorial = useCallback(
async ({
event = 'promo-click',
action = 'complete',
...rest
}: {
event: 'promo-click' | 'promo-dismiss'
action: 'complete' | 'postpone'
} & Record<string, any>) => {
eventTracking.sendMB(event, { ...eventData, ...rest })
try {
await postJSON(`/tutorial/${tutorialKey}/${action}`)
} catch (err) {
debugConsole.error(err)
}
setShowPopup(false)
deactivateTutorial(tutorialKey)
},
[deactivateTutorial, eventData, tutorialKey]
)
const dismissTutorial = useCallback(async () => {
await completeTutorial({
event: 'promo-dismiss',
action: 'complete',
})
}, [completeTutorial])
const maybeLater = useCallback(async () => {
await completeTutorial({
event: 'promo-click',
action: 'postpone',
button: 'maybe-later',
})
}, [completeTutorial])
// try to show the popup if we don't already have one showing, returns true if it can show, false if it can't
const tryShowingPopup = useCallback(() => {
if (currentPopup === null) {
setCurrentPopup(tutorialKey)
setShowPopup(true)
eventTracking.sendMB('promo-prompt', eventData)
return true
}
return false
}, [currentPopup, setCurrentPopup, tutorialKey, eventData])
const clearPopup = useCallback(
(force: boolean = false) => {
// popups should only clear themselves, in cases they need to cleanup or shouldnt show anymore
// allow forcing the clear if needed, eg: higher prio alert needs to show
if (force || currentPopup === tutorialKey) {
setCurrentPopup(null)
setShowPopup(false)
}
},
[setCurrentPopup, setShowPopup, currentPopup, tutorialKey]
)
return {
completeTutorial,
dismissTutorial,
maybeLater,
tryShowingPopup,
clearPopup,
showPopup,
}
}
export default useTutorial

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

@ -30,7 +30,7 @@
.affiliations-table-cell-tabbed {
margin: @margin-sm 0 0 @margin-md;
padding-left: @margin-sm;
border-left: 2px solid @table-border-color;
border-left: 2px solid @table-border-color; // don't migrate this line of style
}
.affiliations-table-row--highlighted {
background-color: tint(@content-alt-bg-color, 6%);
@ -152,8 +152,9 @@ tbody > tr.affiliations-table-warning-row > td {
}
.settings-widget-container {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: 40px 1fr auto;
gap: 20px;
align-items: center;
padding: 10px;
@ -177,8 +178,7 @@ tbody > tr.affiliations-table-warning-row > td {
}
.title-row {
display: flex;
align-items: center;
margin: 0;
margin-bottom: 10px;
> h4 {
@ -196,6 +196,12 @@ tbody > tr.affiliations-table-warning-row > td {
}
}
@media (max-width: @screen-xs-max) {
.settings-widget-container {
grid-template-columns: 1fr;
}
}
// Should not be migrated to BS5
.settings-reconfirm-info {
display: flex;

View File

@ -22,12 +22,22 @@
.toolbar-right,
.toolbar-left {
button {
button:not(.back-to-editor-btn) {
background: transparent;
box-shadow: none;
}
}
.toolbar-right .back-to-editor-btn {
margin-right: 25px;
display: flex;
align-items: center;
.toolbar-label {
margin-bottom: 0;
}
}
> a:focus,
button:focus {
outline: none;
@ -38,7 +48,7 @@
.toolbar-left > a:not(.btn),
.toolbar-left > button,
.toolbar-right > a:not(.btn),
.toolbar-right > button {
.toolbar-right > button:not(.back-to-editor-btn) {
display: inline-block;
color: @toolbar-icon-btn-color;
background-color: transparent;

View File

@ -77,6 +77,12 @@
margin: 3em 0;
}
.team-invite {
.team-invite-name {
word-break: break-word;
}
}
.capitalised {
text-transform: capitalize;
}

View File

@ -50,5 +50,5 @@
cursor: pointer;
background: transparent;
border: 0;
-webkit-appearance: none;
appearance: none;
}

View File

@ -6,7 +6,6 @@ $prefix: bs-;
$font-family-sans-serif: 'Noto Sans', sans-serif;
$font-family-serif: 'Merriweather', serif;
$font-family-monospace: 'DM Mono', monospace;
$font-size-base: 1rem;
$font-size-sm: var(--font-size-02);
$line-height-base: 1.5;
@ -19,6 +18,7 @@ $btn-padding-y: $spacing-02;
$btn-border-radius: $border-radius-full;
$btn-border-radius-lg: $border-radius-full;
$btn-border-radius-sm: $border-radius-full;
$btn-white-space: nowrap;
// Colors
$primary: $bg-accent-01;

View File

@ -40,5 +40,11 @@
// Utilities
@import 'bootstrap-5/scss/utilities/api';
// Mixins
@import 'bootstrap-5/scss/mixins/breakpoints';
// Components custom style
@import '../components/all';
// Pages custom style
@import '../pages/all';

View File

@ -29,3 +29,7 @@ $footer-height: 50px;
hr {
opacity: unset;
}
.horizontal-divider {
border-top: 1px solid var(--border-divider);
}

View File

@ -11,7 +11,7 @@
}
.badge-close {
@include reset-button();
@include reset-button;
display: flex;
align-items: center;
@ -20,6 +20,7 @@
// a random number that would cause the close button to expand enough
// so that it won't be affected by badge's padding
$expand: 100px;
padding: $expand $spacing-01;
margin: (-$expand) (-$spacing-02) (-$expand) $spacing-02;
user-select: none;

View File

@ -83,6 +83,7 @@
$color: var(--content-primary-dark),
$background: var(--blue-70)
);
background: var(--premium-gradient);
}
}
@ -141,6 +142,7 @@
height: 20px;
width: 20px;
}
.loading-spinner-large {
border-width: 0.2em;
height: 24px;
@ -157,6 +159,7 @@
display: inline-flex;
align-items: center;
gap: var(--spacing-04); // Add gap between text and icons
justify-content: center;
.icon-small {
font-size: 20px;

View File

@ -4,6 +4,7 @@
.dropdown-menu {
@include shadow-sm;
border: none;
border-radius: var(--border-radius-base);
padding: var(--spacing-02);
@ -13,6 +14,7 @@
.dropdown-item {
@include body-sm;
border-radius: var(--border-radius-base);
color: var(--neutral-90);
display: grid;
@ -22,16 +24,16 @@
padding: var(--spacing-05) var(--spacing-04);
position: relative;
&:hover:not(.active) {
background-color: var(--bg-light-secondary);
cursor: pointer;
}
&:active {
background-color: var(--bg-accent-03);
color: inherit;
}
&:hover:not(.active) {
background-color: var(--bg-light-secondary);
cursor: pointer;
}
&[variant='danger'] {
color: var(--content-danger);
@ -54,6 +56,7 @@
.dropdown-item-description {
@include body-xs;
color: var(--content-secondary);
margin-top: var(--spacing-01);
}
@ -97,6 +100,7 @@
&.btn-danger {
border-left: 1px solid rgb($neutral-90, 0.16);
}
&.btn-secondary {
border-left: 1px solid var(--neutral-60);
}

View File

@ -2,6 +2,7 @@
// will be deprecated once notifications moved to use .notification (see below)
flex-grow: 1;
width: 90%;
@include media-breakpoint-up(md) {
width: auto;
}
@ -13,6 +14,7 @@
// will be deprecated once notifications moved to use .notification (see below)
margin-top: calc($line-height-computed / 2); // match paragraph padding
order: 1;
@include media-breakpoint-up(md) {
margin-top: 0;
order: 0;
@ -38,7 +40,7 @@
&:hover,
&:focus {
background-color: rgba(var(--neutral-90), 0.08);
background-color: rgb(var(--neutral-90) 0.08);
color: var(--content-secondary);
}
}
@ -108,9 +110,6 @@
height: $spacing-12;
align-items: center;
display: flex;
}
.notification-close-btn {
padding: 0 0 0 $spacing-06;
button {
@ -125,7 +124,7 @@
&:hover,
&:focus {
background-color: rgba(var(--neutral-90), 0.08);
background-color: rgb(var(--neutral-90) 0.08);
color: var(--content-secondary);
}
}
@ -134,34 +133,43 @@
&.notification-type-info {
background-color: var(--bg-info-03);
border: 1px solid var(--blue-20);
.notification-icon {
color: var(--blue-50);
}
}
&.notification-type-success {
background-color: var(--bg-accent-03);
border: 1px solid var(--green-20);
.notification-icon {
color: var(--green-50);
}
}
&.notification-type-warning {
background-color: var(--bg-warning-03);
border: 1px solid var(--yellow-20);
.notification-icon {
color: var(--yellow-40);
}
}
&.notification-type-error {
background-color: var(--bg-danger-03);
border: 1px solid var(--red-20);
.notification-icon {
color: var(--red-50);
}
}
&.notification-type-offer {
background-color: var(--bg-light-primary);
border: 1px solid var(--neutral-20);
.notification-icon {
color: var(--neutral-50);
}
@ -203,9 +211,11 @@
.reconfirm-notification {
display: flex;
width: 100%;
.fa-warning {
margin-right: $spacing-05;
}
.btn-reconfirm {
float: right;
margin-left: $spacing-05;
@ -238,6 +248,7 @@
.reconfirm-notification {
background-color: var(--neutral-10);
border-radius: $border-radius-base;
.fa-warning {
color: var(--yellow-40);
}

View File

@ -1,5 +1,6 @@
.tooltip {
line-height: 20px;
@include shadow-md;
&.#{$prefix}tooltip-top {

View File

@ -8,7 +8,7 @@
/* ====== Sass color variables ====== */
/* Neutral */
$white: #ffffff;
$white: #fff;
$neutral-10: #f4f5f6;
$neutral-20: #e7e9ee;
$neutral-30: #d0d5dd;
@ -60,28 +60,22 @@ $bg-light-primary: $white;
$bg-light-secondary: $neutral-10;
$bg-light-tertiary: $neutral-20;
$bg-light-disabled: $neutral-20;
$bg-dark-primary: $neutral-90;
$bg-dark-secondary: $neutral-80;
$bg-dark-tertiary: $neutral-70;
$bg-dark-disabled: $neutral-70;
$bg-accent-01: $green-50;
$bg-accent-02: $green-60;
$bg-accent-03: $green-10;
$bg-danger-01: $red-50;
$bg-danger-02: $red-60;
$bg-danger-03: $red-10;
$bg-warning-01: $yellow-50;
$bg-warning-02: $yellow-60;
$bg-warning-03: $yellow-10;
$bg-info-01: $blue-50;
$bg-info-02: $blue-60;
$bg-info-03: $blue-10;
$content-primary: $neutral-90;
$content-secondary: $neutral-70;
$content-disabled: $neutral-40;
@ -89,21 +83,18 @@ $content-placeholder: $neutral-60;
$content-danger: $red-50;
$content-warning: $yellow-50;
$content-positive: $green-50;
$border-primary: $neutral-60;
$border-hover: $neutral-70;
$border-disabled: $neutral-20;
$border-active: $blue-50;
$border-danger: $red-50;
$border-divider: $neutral-20;
$link-web: $green-60;
$link-web-hover: $green-70;
$link-web-visited: $green-70;
$link-ui: $blue-50;
$link-ui-hover: $blue-60;
$link-ui-visited: $blue-60;
$content-primary-dark: $white;
$content-secondary-dark: $neutral-20;
$content-disabled-dark: $neutral-60;
@ -111,14 +102,12 @@ $content-placeholder-dark: $neutral-50;
$content-danger-dark: $red-40;
$content-warning-dark: $yellow-40;
$content-positive-dark: $green-40;
$border-primary-dark: $neutral-30;
$border-hover-dark: $neutral-20;
$border-disabled-dark: $neutral-80;
$border-active-dark: $blue-30;
$border-danger-dark: $red-40;
$border-divider-dark: $neutral-80;
$link-web-dark: $green-30;
$link-web-hover-dark: $green-40;
$link-web-visited-dark: $green-40;
@ -181,28 +170,22 @@ $link-ui-visited-dark: $blue-40;
--bg-light-secondary: var(--neutral-10);
--bg-light-tertiary: var(--neutral-20);
--bg-light-disabled: var(--neutral-20);
--bg-dark-primary: var(--neutral-90);
--bg-dark-secondary: var(--neutral-80);
--bg-dark-tertiary: var(--neutral-70);
--bg-dark-disabled: var(--neutral-70);
--bg-accent-01: var(--green-50);
--bg-accent-02: var(--green-60);
--bg-accent-03: var(--green-10);
--bg-danger-01: var(--red-50);
--bg-danger-02: var(--red-60);
--bg-danger-03: var(--red-10);
--bg-warning-01: var(--yellow-50);
--bg-warning-02: var(--yellow-60);
--bg-warning-03: var(--yellow-10);
--bg-info-01: var(--blue-50);
--bg-info-02: var(--blue-60);
--bg-info-03: var(--blue-10);
--content-primary: var(--neutral-90);
--content-secondary: var(--neutral-70);
--content-disabled: var(--neutral-40);
@ -210,21 +193,18 @@ $link-ui-visited-dark: $blue-40;
--content-danger: var(--red-50);
--content-warning: var(--yellow-50);
--content-positive: var(--green-50);
--border-primary: var(--neutral-60);
--border-hover: var(--neutral-70);
--border-disabled: var(--neutral-20);
--border-active: var(--blue-50);
--border-danger: var(--red-50);
--border-divider: var(--neutral-20);
--link-web: var(--green-60);
--link-web-hover: var(--green-70);
--link-web-visited: var(--green-70);
--link-ui: var(--blue-50);
--link-ui-hover: var(--blue-60);
--link-ui-visited: var(--blue-60);
--content-primary-dark: var(--white);
--content-secondary-dark: var(--neutral-20);
--content-disabled-dark: var(--neutral-60);
@ -232,14 +212,12 @@ $link-ui-visited-dark: $blue-40;
--content-danger-dark: var(--red-40);
--content-warning-dark: var(--yellow-40);
--content-positive-dark: var(--green-40);
--border-primary-dark: var(--neutral-30);
--border-hover-dark: var(--neutral-20);
--border-disabled-dark: var(--neutral-80);
--border-active-dark: var(--blue-30);
--border-danger-dark: var(--red-40);
--border-divider-dark: var(--neutral-80);
--link-web-dark: var(--green-30);
--link-web-hover-dark: var(--green-40);
--link-web-visited-dark: var(--green-40);

View File

@ -1,18 +1,18 @@
// Cards
@mixin shadow-sm {
box-shadow: 0px 2px 8px rgba(30, 37, 48, 0.08);
box-shadow: 0 2px 8px rgb(30 37 48 / 8%);
}
// Tooltips, Callouts, Dropdowns, etc.
@mixin shadow-md {
box-shadow:
0px 4px 24px rgba(30, 37, 48, 0.12),
0px 1px 4px rgba(30, 37, 48, 0.08);
0 4px 24px rgb(30 37 48 / 12%),
0 1px 4px rgb(30 37 48 / 8%);
}
// Modals, drawers
@mixin shadow-lg {
box-shadow:
0px 8px 24px rgba(30, 37, 48, 0.16),
0px 4px 8px rgba(30, 37, 48, 0.16);
0 8px 24px rgb(30 37 48 / 16%),
0 4px 8px rgb(30 37 48 / 16%);
}

View File

@ -15,7 +15,6 @@
--font-size-11: 3.75rem; // 60px
--font-size-12: 4.5rem; // 72px
--font-size-13: 6em; // 96px
--line-height-01: 1rem; // 16px
--line-height-02: 1.25rem; // 20px
--line-height-03: 1.5rem; // 24px
@ -40,6 +39,7 @@
font-size: var(--font-size-12);
line-height: var(--line-height-11);
}
@mixin display-sm {
font-size: var(--font-size-11);
line-height: var(--line-height-10);

View File

@ -0,0 +1,208 @@
.affiliations-table {
table-layout: fixed;
}
.affiliations-table-cell {
padding: var(--spacing-04);
overflow-wrap: break-word;
}
.affiliations-table-cell-tabbed {
margin: var(--spacing-04) 0 0 var(--spacing-07);
padding-left: var(--spacing-04);
}
.affiliations-table-row--highlighted {
background-color: var(--bg-light-secondary);
}
.affiliations-table-inline-action {
text-transform: capitalize;
}
.affiliation-change-container {
margin-top: var(--spacing-04);
}
.affiliations-table-label {
padding-top: var(--spacing-02);
}
.btn-link-accounts {
margin-bottom: var(--spacing-03);
}
.settings-widget-status-icon,
.dropbox-sync-icon {
position: relative;
font-size: 1.3em;
line-height: 1.3em;
vertical-align: top;
&.status-error,
&.dropbox-sync-icon-error {
color: var(--bg-danger-01);
}
&.status-success,
&.dropbox-sync-icon-success {
color: var(--content-positive);
}
&.status-pending,
&.dropbox-sync-icon-updating {
color: var(--bg-info-01);
&::after {
content: '\f021';
position: absolute;
top: 0;
left: 50%;
margin-left: -20%;
font-size: 60%;
color: #fff;
animation: fa-spin 2s infinite linear;
}
}
}
.settings-widgets-container {
border: 1px solid var(--border-divider);
hr {
margin: 0 var(--spacing-05);
}
}
.settings-widget-container {
display: grid;
grid-template-columns: 50px 1fr auto;
gap: var(--spacing-07);
align-items: center;
padding: var(--spacing-05);
> div {
display: flex;
flex-direction: column;
padding-right: var(--spacing-07);
&:last-child {
padding-right: var(--spacing-00);
}
}
img {
width: 40px;
height: 40px;
}
.description-container {
flex-grow: 1;
}
.title-row {
margin: var(--spacing-00);
margin-bottom: var(--spacing-05);
> h4 {
margin: 0;
margin-right: var(--spacing-05);
}
}
p {
margin-bottom: var(--spacing-05);
&:last-child {
margin-bottom: var(--spacing-00);
}
}
}
@include media-breakpoint-down(sm) {
.settings-widget-container {
grid-template-columns: 1fr;
}
}
// Prevents icon from large account linking sections, such as the git bridge,
// from rendering in the center of the widget, anchoring it to the top
.linking-icon-fixed-position {
align-self: start;
padding-top: var(--spacing-05);
}
// overrides the default `Col` padding, as the inner `affiliations-table-cell` has its own padding, and
// the content length of the git-bridge token table is pretty much fixed (tokens and dates)
.linking-git-bridge-table-cell {
padding-right: 0;
}
.linking-git-bridge-revoke-button {
padding: var(--spacing-01) var(--spacing-02);
}
.enrollment-alert {
width: 100%;
border-radius: var(--border-radius-base);
padding: var(--spacing-05);
background-color: var(--bg-info-03);
border: 1px solid var(--border-divider);
display: flex;
flex-direction: row;
.icon {
flex: 1 1 auto;
padding: 0 var(--spacing-06) 0 var(--spacing-02);
}
a {
text-decoration: underline;
}
}
.security-row {
.line-header > b {
color: var(--content-primary);
}
color: var(--content-secondary);
display: flex;
flex-direction: row;
padding: var(--spacing-03) 0;
.icon {
color: var(--content-primary);
display: flex;
flex: 1 1 7%;
padding: 0 var(--spacing-06);
margin-top: var(--spacing-06);
}
.text {
flex: 1 1 93%;
display: flex;
flex-direction: column;
margin-right: var(--spacing-06);
}
.button-column {
display: flex;
align-items: center;
}
.status-label {
@include body-sm;
border-radius: var(--border-radius-base);
padding: var(--spacing-01) var(--spacing-02);
margin-top: var(--spacing-02);
margin-left: var(--spacing-04);
flex-shrink: 0;
&.status-label-configured {
background-color: var(--bg-accent-01);
color: var(--content-secondary-dark);
}
&.status-label-ready {
background-color: var(--bg-light-tertiary);
color: var(--content-primary);
}
}
}

View File

@ -0,0 +1 @@
@import 'account-settings';

View File

@ -1,3 +1,4 @@
// migrated to layout.scss
.horizontal-divider {
border-top: 1px solid @hr-border;
}

View File

@ -77,6 +77,7 @@ h3.group-settings-title {
color: @gray-dark;
font-size: @font-size-small;
padding-left: 0;
text-wrap: wrap;
}
.btn {

View File

@ -5,7 +5,7 @@ const typescript = require('typescript')
module.exports = {
input: [
'frontend/js/**/*.{js,jsx,ts,tsx}',
'modules/**/*.{js,jsx,ts,tsx}',
'modules/*/frontend/js/**/*.{js,jsx,ts,tsx}',
'!frontend/js/vendor/**',
],
output: './',

View File

@ -17,7 +17,6 @@
"already_have_sl_account": "Máte už účet v __appName__?",
"and": "a",
"annual": "Roční",
"annual_billing_enabled": "Roční vyúčtování povoleno",
"anonymous": "Anonymní",
"auto_complete": "Automatické dokončování",
"back_to_your_projects": "Zpět k vašim projektům",
@ -35,7 +34,6 @@
"change_password": "Změnit heslo",
"change_plan": "Změnit tarif",
"change_project_owner": "Změnit majitele projektu",
"change_to_annual_billing_and_save": "Získejte <0>__percentage__</0> z ročního vyúčtování. Pokud provedete změnu hned, ušetříte <1>__yearlySaving__</1> za rok.",
"change_to_this_plan": "Změnit na tento tarif",
"chat": "Chat",
"checking_dropbox_status": "kontroluji stav Dropboxu",
@ -158,7 +156,6 @@
"month": "měsíc",
"monthly": "Měsíční",
"more": "Více",
"move_to_annual_billing": "Přesun k ročnímu vyúčtování",
"must_be_email_address": "Musíte zadat emailovou adresu",
"name": "Jméno",
"native": "Výchozí",

View File

@ -88,7 +88,6 @@
"an_error_occurred_when_verifying_the_coupon_code": "En fejl opstod under valideringen af rabatkoden",
"and": "og",
"annual": "Årlig",
"annual_billing_enabled": "Årlig betaling aktiveret",
"anonymous": "Anonym",
"anyone_with_link_can_edit": "Alle med dette link kan redigere dette projekt",
"anyone_with_link_can_view": "Alle med dette link kan se dette projekt",
@ -123,6 +122,7 @@
"automatic_user_registration": "Automatisk brugerregistrering",
"back": "Tilbage",
"back_to_account_settings": "Tilbage til kontoindstillinger",
"back_to_editor": "Tilbage til skrivevinduet",
"back_to_log_in": "Tilbage til login",
"back_to_subscription": "Tilbage til abonnement",
"back_to_your_projects": "Tilbage til dine projekter",
@ -185,7 +185,6 @@
"change_plan": "Ændre abonnement",
"change_primary_email_address_instructions": "For at ændre din primære e-mailadresse, tilføj først din nye primære e-mailadresse (ved at klikke <0>Tilføj endnu en e-mailadesse</0>) og bekræft den. Klik derefter på <0>Gør til primær</0>. <1>Lær mere</1> omkring håndtering af dine __appName__ e-mailadresser",
"change_project_owner": "Skift projektejer",
"change_to_annual_billing_and_save": "Spar <0>__percentage__</0> ved årlig betaling. Hvis du skifter nu, sparer du <1>__yearlySaving__</1> per år.",
"change_to_group_plan": "Skift til gruppeabonnement",
"change_to_this_plan": "Ændring til dette abonnement",
"changing_the_position_of_your_figure": "Ændr positionen af din figur",
@ -717,7 +716,7 @@
"invite_not_accepted": "Invitationen er endnu ikke accepteret",
"invite_not_valid": "Dette er ikke en gyldig projekt invitation",
"invite_not_valid_description": "Invitationen kan være udløbet. Kontakt venligst projektets ejer",
"invited_to_group": "__inviterName__ har inviteret dig til at tilslutte dig et gruppeabonnement på __appName__",
"invited_to_group": "<0>__inviterName__</0> har inviteret dig til at tilslutte dig et gruppeabonnement på __appName__",
"invited_to_join": "Du er blevet inviteret til at deltage",
"ip_address": "IP adresse",
"is_email_affiliated": "Er din e-mailaddresse tilknyttet en institution?",
@ -890,7 +889,6 @@
"more_project_collaborators": "<0>Flere</0> <0>samarbejdspartnere</0> i projekter",
"more_than_one_kind_of_snippet_was_requested": "Linket til at åbne dette indhold i Overleaf havde nogle ugyldige parametre. Hvis du bliver ved med at opleve det her med links fra en bestemt side, bliver du næsten nødt til at fortælle dem om det.",
"most_popular": "Mest populære",
"move_to_annual_billing": "Skift til årlig betaling",
"must_be_email_address": "Skal være en e-mailaddresse",
"n_items": "__count__ enhed",
"n_items_plural": "__count__ enheder",
@ -909,7 +907,6 @@
"need_to_add_new_primary_before_remove": "Du bliver nødt til at tilføje en ny primær e-mailaddresse før du kan slette denne.",
"need_to_leave": "Nød til at gå?",
"need_to_upgrade_for_more_collabs": "Du bliver nød til at opgradere din konto for at tilføje flere samarbejdspartnere",
"need_to_upgrade_for_more_collabs_variant": "Du har nået det maksimale antal samarbejdspartnere. Opgradér din konto for at tilføje flere.",
"new_file": "Ny fil",
"new_folder": "Ny mappe",
"new_name": "Nyt navn",

View File

@ -88,7 +88,6 @@
"an_error_occurred_when_verifying_the_coupon_code": "Beim Überprüfen des Gutscheincodes ist ein Fehler aufgetreten",
"and": "und",
"annual": "Jährlich",
"annual_billing_enabled": "Jährliche Abrechnung aktiviert",
"anonymous": "Anonym",
"anyone_with_link_can_edit": "Jeder mit diesem Link kann dieses Projekt bearbeiten",
"anyone_with_link_can_view": "Jeder mit diesem Link kann dieses Projekt anzeigen",
@ -123,6 +122,7 @@
"automatic_user_registration": "Automatische Nutzerregistrierung",
"back": "Zurück",
"back_to_account_settings": "Zurück zu den Kontoeinstellungen",
"back_to_editor": "Zurück zum Editor",
"back_to_log_in": "Zurück zur Anmeldung",
"back_to_subscription": "Zurück zum Abonnement",
"back_to_your_projects": "Zurück zu deinen Projekten",
@ -185,7 +185,6 @@
"change_plan": "Abonnement ändern",
"change_primary_email_address_instructions": "Um deine primäre E-Mail-Adresse zu ändern, füge bitte zuerst deine neue primäre E-Mail-Adresse hinzu (indem du auf <0>„E-Mail-Adresse hinzufügen“</0> klickst) und bestätige diese. Klicke dann auf die Schaltfläche <0>Als primär festlegen</0>. <1>Erfahre mehr</1> über das Verwalten deiner __appName__ E-Mails.",
"change_project_owner": "Projektinhaber ändern",
"change_to_annual_billing_and_save": "Spare <0>__percentage__</0> durch jährliche Abrechnung. Wenn du jetzt wechselst, sparst du <1>__yearlySaving__</1> pro Jahr.",
"change_to_group_plan": "Wechsle zu einem Gruppen-Abonnement",
"change_to_this_plan": "Auf dieses Abonnement wechseln",
"changing_the_position_of_your_figure": "Position der Abbildung verändern",
@ -724,7 +723,7 @@
"invite_not_accepted": "Einladung noch nicht angenommen",
"invite_not_valid": "Dies ist keine gültige Projekteinladung",
"invite_not_valid_description": "Die Einladung ist wahrscheinlich abgelaufen. Bitte kontaktiere den Projektbesitzer",
"invited_to_group": "__inviterName__ hat dich eingeladen, einem Team auf __appName__ beizutreten",
"invited_to_group": "<0>__inviterName__</0> hat dich eingeladen, einem Team auf __appName__ beizutreten",
"invited_to_group_login": "Um diese Einladung anzunehmen, melde dich als __emailAddress__ an.",
"invited_to_group_login_benefits": "Als Mitglied dieser Gruppe hast Du Zugriff auf __appName__-Premiumfunktionen wie zusätzliche Mitarbeiter, ein höheres Zeitlimit beim Kompilieren und die Nachverfolgung von Änderungen in Echtzeit.",
"invited_to_group_register": "Um die Einladung von __inviterName__ anzunehmen, erstelle zunächst ein Konto.",
@ -889,7 +888,6 @@
"more_info": "Mehr Infos",
"more_than_one_kind_of_snippet_was_requested": "Der Link zum Öffnen dieses Inhalts auf Overleaf enthielt einige ungültige Parameter. Wenn dies bei Links auf einer bestimmten Website weiterhin auftritt, melde dies bitte dort.",
"most_popular": "am beliebtesten",
"move_to_annual_billing": "Zur jährlichen Abrechnung wechseln",
"must_be_email_address": "Es muss eine E-Mail-Adresse sein!",
"n_items": "__count__ Artikel",
"n_items_plural": "__count__ Artikel",
@ -903,7 +901,6 @@
"need_to_add_new_primary_before_remove": "Du musst eine neue primäre E-Mail-Adresse hinzufügen, bevor du diese entfernen kannst.",
"need_to_leave": "Du musst gehen?",
"need_to_upgrade_for_more_collabs": "Du musst dein Konto upgraden um mehr Mitarbeiter hinzuzufügen",
"need_to_upgrade_for_more_collabs_variant": "Du hast die maximale Anzahl an Mitbearbeitern erreicht. Führe ein Upgrade für dein Konto aus, um weitere hinzuzufügen.",
"new_file": "Neue Datei",
"new_folder": "Neuer Ordner",
"new_name": "Neuer Name",

View File

@ -146,6 +146,7 @@
"back": "Back",
"back_to_account_settings": "Back to account settings",
"back_to_configuration": "Back to configuration",
"back_to_editor": "Back to editor",
"back_to_log_in": "Back to log in",
"back_to_subscription": "Back to Subscription",
"back_to_your_projects": "Back to your projects",
@ -896,7 +897,7 @@
"invite_not_accepted": "Invite not yet accepted",
"invite_not_valid": "This is not a valid project invite",
"invite_not_valid_description": "The invite may have expired. Please contact the project owner",
"invited_to_group": "__inviterName__ has invited you to join a group subscription on __appName__",
"invited_to_group": "<0>__inviterName__</0> has invited you to join a group subscription on __appName__",
"invited_to_group_have_individual_subcription": "__inviterName__ has invited you to join a group __appName__ subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?",
"invited_to_group_login": "To accept this invitation you need to log in as __emailAddress__.",
"invited_to_group_login_benefits": "As part of this group, youll have access to __appName__ premium features such as additional collaborators, greater maximum compile time, and real-time track changes.",

Some files were not shown because too many files have changed in this diff Show More