21 Commits

Author SHA1 Message Date
Jakob Ackermann
3a206074f2 [web] move premium compiles up one bracket (#29410)
GitOrigin-RevId: 53423f855b2c215f6cc926b480422b2ce3477a74
2025-10-31 09:06:32 +00:00
Mathias Jakobsen
046e74a2c6 Merge pull request #29443 from overleaf/mj-mobile-navbar-bg
[web] Restore transparent background for hamburger navbar items on mobile

GitOrigin-RevId: ce3a095ded307427f2175d7db2a562c106e4b2e9
2025-10-31 09:06:18 +00:00
Mathias Jakobsen
b0d05c0cf0 Merge pull request #29380 from overleaf/mj-layout-editing-sessions
[analytics+web] Add layout info to editing sessions

GitOrigin-RevId: d5f3161444718004aa722a6f413f6b5ff9c95aea
2025-10-30 11:35:52 +00:00
Miguel Serrano
bfed1ac2ae [CE/SP] Rebuild base image (#29422)
GitOrigin-RevId: fa9ec39481a766ce3fa0f58c5f7db6267d23659c
2025-10-30 11:35:47 +00:00
Jakob Ackermann
49667a605b [web] tweak Jenkins pipeline following move to faster CI VMs (#29188)
* [web] tweak Jenkins pipeline following move to faster CI VMs

The webpack build time has been reduced by about 30%. Adjust the sleep
statement for delaying the webpack build accordingly.

Push the production docker image layers from the parallel steps already.
Use a shared "scratch" tag to avoid generating cruft per branch/build.

* [web] avoid sharing the cypress/downloads folder in CI

GitOrigin-RevId: 52fdf420ec04fd26e3823ff9fca8c52f7a7764d7
2025-10-30 09:07:43 +00:00
Tim Down
530020467d Merge pull request #29400 from overleaf/td-ciam-dir
Move CIAM styles into separate directory

GitOrigin-RevId: 3f6d6af8d25ee5c9e425bb2958075ac514fed2e9
2025-10-30 09:07:28 +00:00
Andrew Rumble
e8c829deab Clean up direct usages of db.docHistory
Co-authored-by: Ilkin Ismailov <ilkin.ismailov@overleaf.com>
GitOrigin-RevId: 63bc36f15d85f68770bbbff5a7f64d5bc167c7f0
2025-10-30 09:07:18 +00:00
Andrew Rumble
0036ad5f31 Remove docHistory collection from db helper
Co-authored-by: Ilkin Ismailov <ilkin.ismailov@overleaf.com>
GitOrigin-RevId: 788b794ff75564230df99b1b726da87bab468ef9
2025-10-30 09:07:12 +00:00
Andrew Rumble
766a7c6a37 Add migration to remove docHistory collection
Co-authored-by: Ilkin Ismailov <ilkin.ismailov@overleaf.com>
GitOrigin-RevId: b9770bf1612dbffef6408b5e5c15890f87696773
2025-10-30 09:07:06 +00:00
Andrew Rumble
448ec1a273 Remove celebrate mock
GitOrigin-RevId: 05dd09d1c4457bf1966d729296fd863ae4dc03aa
2025-10-30 09:07:00 +00:00
Andrew Rumble
ddb037680b Switch to using Zod instead of Joi
GitOrigin-RevId: d725d5bd573402f48b176733bcea2d8ba4fa7c2d
2025-10-30 09:06:54 +00:00
Andrew Rumble
19f1f6f702 Remove Joi and celebrate
GitOrigin-RevId: 12cf2e0266a2e57d674d13a2e6fe8368c980d2a1
2025-10-30 09:06:49 +00:00
MoxAmber
87d8e142cc Merge pull request #29332 from overleaf/as-sso-prevent-double-linking
[web] Prevent users from attempting to link to the same SSO institution twice

GitOrigin-RevId: 7e708eadc9f9aedc2007cb83f7f48df83561fa84
2025-10-30 09:06:42 +00:00
MoxAmber
cfe6c3ceeb Merge pull request #29324 from overleaf/as-sso-ensure-email-confirmed
[web] Ensure email confirmedAt is always set for users created via Commons SSO

GitOrigin-RevId: c00ea58a0f9e2a1e93d7edc3836aa815d8ba16ac
2025-10-30 09:06:36 +00:00
roo hutton
d0ba35ab8f Merge pull request #29349 from overleaf/rh-compile-timeout-info-tracking
Add missing tracking to compile timeout info for default variant

GitOrigin-RevId: 4dfea0f55a1887b64e9c41d417c4a1cc0510453d
2025-10-30 09:06:22 +00:00
roo hutton
c2e0c40808 Merge pull request #29193 from overleaf/rh-stripe-addon-error
Redirect to plans page if trying to add add-on to non-existing subscription

GitOrigin-RevId: 65e0a88c32beca00d700292b14b2e7aa6e4dad20
2025-10-30 09:06:17 +00:00
Antoine Clausse
698a6013de [web] Create an initial implementation for the CIAM page layout (#29373)
* Add a Storybook Layout page compiling all the "small pages" layouts

* Add a CIAM page layout to Storybook and create an initial Layout

* Use rem in font mixins

* Add a `--ciam-` prefix to the new CSS variables

* Fix linting

GitOrigin-RevId: 7a89fd1531c87597a918a9170d174cce556d77c4
2025-10-30 09:06:10 +00:00
Borja
cb0cfcfd82 Add languages and further functionality to Translate (#29342)
GitOrigin-RevId: 5e575c2aa51490071bfbd7498fd81b4e30ffa77f
2025-10-30 09:06:05 +00:00
David
38edeca871 Merge pull request #29292 from overleaf/dp-dashboard-theme-toggle
Add theme toggle to project dashboard

GitOrigin-RevId: 4c76bcc36f77d7fd883798f8ccfcb5d1cf1a54b0
2025-10-30 09:05:56 +00:00
David
fea346f4a6 Merge pull request #27930 from overleaf/mj-dropdown-dark-mode
[web] Add dark mode for dropdowns

GitOrigin-RevId: 8fcce98101fc32fa1abbc0fbcd1615a8bc0898e4
2025-10-30 09:05:51 +00:00
David
dd1f55e0dd Merge pull request #29215 from overleaf/dp-rail-tab-storage
Store selected rail tab on a per-project basis

GitOrigin-RevId: 4770b047bb02c2b63dcf8bc0808bdd675d38c7a2
2025-10-30 09:05:46 +00:00
49 changed files with 1108 additions and 178 deletions

2
package-lock.json generated
View File

@@ -53178,7 +53178,6 @@
"bull": "^3.18.0",
"bunyan": "^1.8.15",
"cache-flow": "^1.9.0",
"celebrate": "^15.0.3",
"connect-redis": "^6.1.3",
"content-disposition": "^0.5.0",
"contentful": "^10.8.5",
@@ -53201,7 +53200,6 @@
"i18next": "^23.10.0",
"i18next-fs-backend": "^2.3.1",
"i18next-http-middleware": "^3.5.0",
"joi": "^17.12.0",
"jose": "^4.3.8",
"json2csv": "^4.3.3",
"jsonwebtoken": "^9.0.0",

View File

@@ -10,7 +10,7 @@ ENV TEXMFVAR=/var/lib/overleaf/tmp/texmf-var
# Update to ensure dependencies are updated
# ------------------------------------------
ENV REBUILT_AFTER="2025-10-22"
ENV REBUILT_AFTER="2025-10-30"
# Install dependencies
# --------------------

View File

@@ -279,7 +279,7 @@ pipeline {
stages {
stage('Wait a bit to give tests all the CPU capacity') {
steps {
sh 'sleep 90'
sh 'sleep 60'
}
}
stage('Build Webpack') {
@@ -330,6 +330,13 @@ pipeline {
}
}
}
stage('Push Production image early') {
steps {
dir('services/web') {
sh 'make push_scratch'
}
}
}
}
}
}

View File

@@ -183,21 +183,26 @@ test_writefull:
build_test_frontend_ct:
docker run --rm --volume /dev/shm:/dev/shm --user root $(IMAGE_CI) bash -ec 'for path in /overleaf/services/web/cypress/results /overleaf/services/web/node_modules/.cache; do mkdir -p $$path; chown -R node:node $$path; done && tar -cC / overleaf | tar -xC /dev/shm'
test_frontend_ct_core_other: export CYPRESS_DOWNLOADS=./cypress/downloads/core
test_frontend_ct_core_other: export CYPRESS_RESULTS=./cypress/results/core
test_frontend_ct_core_other: export CYPRESS_SPEC_PATTERN=./test/frontend/**/*.spec.{js,jsx,ts,tsx}
test_frontend_ct_core_other: export CYPRESS_EXCLUDE_SPEC_PATTERN=./test/frontend/features/**/*.spec.{js,jsx,ts,tsx}
test_frontend_ct_core_features: export CYPRESS_DOWNLOADS=./cypress/downloads/core
test_frontend_ct_core_features: export CYPRESS_RESULTS=./cypress/results/core
test_frontend_ct_core_features: export CYPRESS_SPEC_PATTERN=./test/frontend/features/**/*.spec.{js,jsx,ts,tsx}
test_frontend_ct_core_features: export CYPRESS_EXCLUDE_SPEC_PATTERN=./test/frontend/features/source-editor/**/*.spec.{js,jsx,ts,tsx}
test_frontend_ct_modules: export CYPRESS_DOWNLOADS=./cypress/downloads/modules
test_frontend_ct_modules: export CYPRESS_RESULTS=./cypress/results/modules
test_frontend_ct_modules: export CYPRESS_SPEC_PATTERN=./modules/**/test/frontend/**/*.spec.{js,jsx,ts,tsx}
test_frontend_ct_editor_other: export CYPRESS_DOWNLOADS=./cypress/downloads/editor_other
test_frontend_ct_editor_other: export CYPRESS_RESULTS=./cypress/results/editor_other
test_frontend_ct_editor_other: export CYPRESS_SPEC_PATTERN=./test/frontend/features/source-editor/**/*.spec.{js,jsx,ts,tsx}
test_frontend_ct_editor_other: export CYPRESS_EXCLUDE_SPEC_PATTERN=./test/frontend/features/source-editor/components/codemirror-editor-visual*.spec.{js,jsx,ts,tsx}
test_frontend_ct_editor_visual: export CYPRESS_DOWNLOADS=./cypress/downloads/editor_visual
test_frontend_ct_editor_visual: export CYPRESS_RESULTS=./cypress/results/editor_visual
test_frontend_ct_editor_visual: export CYPRESS_SPEC_PATTERN=./test/frontend/features/source-editor/components/codemirror-editor-visual*.spec.{js,jsx,ts,tsx}
@@ -547,6 +552,10 @@ shellcheck_fix:
IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER)
IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME)
IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME)-$(BUILD_NUMBER)
# A shared scratch tag that is used by all the pipelines for pushing the layers of the production image ASAP from a parallel step.
# We do not want to make the image available before all the tests have passed.
# Using a single tag avoids generating cruft in our docker repository / AR.
IMAGE_SCRATCH ?= $(IMAGE_REPO):do-not-use-this-tag-for-deploys--it-is-used-for-early-pushes-in-ci
IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \
$(MONOREPO)/package.json \
$(MONOREPO)/package-lock.json \
@@ -613,6 +622,7 @@ build:
docker build \
--build-arg SENTRY_RELEASE \
--tag $(IMAGE_REPO_FINAL) \
--tag $(IMAGE_SCRATCH) \
--target app \
--file Dockerfile \
../..
@@ -623,6 +633,9 @@ publish:
push_branch:
docker push $(IMAGE_CACHE)
push_scratch:
docker push $(IMAGE_SCRATCH)
SENTRY_IMAGE=getsentry/sentry-cli:2.16.1
sentry_prefetch:
docker pull $(SENTRY_IMAGE)

View File

@@ -52,6 +52,8 @@ function getNewCompileBackendClass(projectId, compileBackendClass) {
return 'n4'
case 'c2d':
return 'n4'
case 'c4d':
return 'n4'
default:
throw new Error('unknown ?compileBackendClass')
}

View File

@@ -279,12 +279,7 @@ const _CompileController = {
status,
compileTime: timings?.compileE2E,
timeout: limits.timeout,
server:
clsiServerId?.includes('-c2d-') ||
clsiServerId?.includes('-c3d-') ||
clsiServerId?.includes('-c4d-')
? 'faster'
: 'normal',
server: clsiServerId?.includes('-c4d-') ? 'faster' : 'normal',
clsiServerId,
isAutoCompile,
isInitialCompile: stats?.isInitialCompile === 1,

View File

@@ -135,7 +135,7 @@ async function _getProjectCompileLimits(project) {
timeout:
ownerFeatures.compileTimeout || Settings.defaultFeatures.compileTimeout,
compileGroup,
compileBackendClass: compileGroup === 'standard' ? 'n2d' : 'c2d',
compileBackendClass: compileGroup === 'standard' ? 'n2d' : 'c4d',
ownerAnalyticsId: analyticsId,
}
return limits

View File

@@ -523,6 +523,12 @@ async function previewAddonPurchase(req, res) {
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
}
if (
err instanceof Error &&
err.constructor.name === 'PaymentServiceResourceNotFoundError'
) {
return res.redirect('/user/subscription/plans#ai-assist')
}
throw err
}
@@ -543,6 +549,12 @@ async function previewAddonPurchase(req, res) {
if (err instanceof DuplicateAddOnError) {
return res.redirect('/user/subscription?redirect-reason=double-buy')
}
if (
err instanceof Error &&
err.constructor.name === 'PaymentServiceResourceNotFoundError'
) {
return res.redirect('/user/subscription/plans#ai-assist')
}
throw err
}

View File

@@ -71,7 +71,7 @@ async function createNewUser(attributes, options = {}) {
createdAt: new Date(),
reversedHostname,
}
if (Features.hasFeature('affiliations')) {
if (Features.hasFeature('affiliations') && !options.requireAffiliation) {
emailData.affiliationUnchecked = true
}
if (

View File

@@ -1,7 +1,5 @@
// @ts-check
const { Joi: CelebrateJoi, celebrate, errors } = require('celebrate')
const { ObjectId } = require('mongodb-legacy')
const { NotFoundError } = require('../Features/Errors/Errors')
const {
validateReq,
@@ -15,29 +13,7 @@ const { isZodErrorLike, fromError } = require('zod-validation-error')
* @typedef {import('express').ErrorRequestHandler} ErrorRequestHandler
*/
const objectIdValidator = {
type: 'objectId',
base: CelebrateJoi.any(),
messages: {
'objectId.invalid': 'needs to be a valid ObjectId',
},
coerce(value) {
return {
value: typeof value === typeof ObjectId ? value : new ObjectId(value),
}
},
prepare(value, helpers) {
if (!ObjectId.isValid(value)) {
return {
errors: helpers.error('objectId.invalid'),
}
}
},
}
const Joi = CelebrateJoi.extend(objectIdValidator)
const errorMiddleware = [
errors(),
/** @type {ErrorRequestHandler} */
(err, req, res, next) => {
if (!isZodErrorLike(err)) {
@@ -48,14 +24,6 @@ const errorMiddleware = [
},
]
/**
* Validation middleware
* @deprecated Please use Zod schemas and `validateReq` instead
*/
function validate(schema) {
return celebrate(schema, { allowUnknown: true })
}
const validateReqWeb = (req, schema) => {
try {
return validateReq(req, schema)
@@ -69,8 +37,6 @@ const validateReqWeb = (req, schema) => {
}
module.exports = {
Joi,
validate,
errorMiddleware,
validateReq: validateReqWeb,
z,

View File

@@ -39,7 +39,6 @@ const db = {
deletedUsers: internalDb.collection('deletedUsers'),
dropboxEntities: internalDb.collection('dropboxEntities'),
dropboxProjects: internalDb.collection('dropboxProjects'),
docHistory: internalDb.collection('docHistory'),
docHistoryIndex: internalDb.collection('docHistoryIndex'),
docSnapshots: internalDb.collection('docSnapshots'),
docs: internalDb.collection('docs'),

View File

@@ -34,6 +34,7 @@ block append meta
meta(name='ol-tags' data-type='json' content=tags)
meta(name='ol-portalTemplates' data-type='json' content=portalTemplates)
meta(name='ol-userSettings' data-type='json' content=userSettings)
meta(name='ol-overallThemes' data-type='json' content=overallThemes)
meta(
name='ol-prefetchedProjectsBlob'
data-type='json'

View File

@@ -14,6 +14,7 @@ if (process.env.CI) {
export default defineConfig({
fixturesFolder: 'cypress/fixtures',
video: process.env.CYPRESS_VIDEO === 'true',
downloadsFolder: process.env.CYPRESS_DOWNLOADS || 'cypress/downloads',
screenshotsFolder: process.env.CYPRESS_RESULTS || 'cypress/results',
videosFolder: process.env.CYPRESS_RESULTS || 'cypress/results',
viewportHeight: 800,

View File

@@ -89,6 +89,7 @@ services:
user: "${DOCKER_USER:-1000:1000}"
environment:
CI:
CYPRESS_DOWNLOADS:
CYPRESS_RESULTS:
CYPRESS_SPEC_PATTERN:
CYPRESS_EXCLUDE_SPEC_PATTERN:

View File

@@ -1857,6 +1857,7 @@
"the_visual_editor_cant_preview_this_type_of_image_file": "",
"the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "",
"their_projects_will_be_transferred_to_another_user": "",
"theme": "",
"then_x_price_per_month": "",
"then_x_price_per_year": "",
"there_are_lots_of_options_to_edit_and_customize_your_figures": "",

View File

@@ -7,14 +7,33 @@ import { useCallback, useEffect, useRef } from 'react'
import useEventListener from '@/shared/hooks/use-event-listener'
import useDomEventListener from '@/shared/hooks/use-dom-event-listener'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
import {
IdeLayout,
IdeView,
useLayoutContext,
} from '@/shared/context/layout-context'
import {
RailTabKey,
useRailContext,
} from '@/features/ide-redesign/contexts/rail-context'
function createEditingSessionHeartbeatData(
editorType: EditorType,
newEditor: boolean
newEditor: boolean,
view: IdeView | null,
layout: IdeLayout,
railOpen: boolean,
railTab: RailTabKey,
hasDetachedPdf: boolean
) {
const newEditorSegmentation = newEditor ? { railOpen, railTab } : {}
return {
editorType,
editorRedesign: newEditor,
editorView: view,
editorLayout: layout,
hasDetachedPdf,
...newEditorSegmentation,
}
}
@@ -31,6 +50,8 @@ export function useEditingSessionHeartbeat() {
const { projectId } = useIdeReactContext()
const { getEditorType } = useEditorManagerContext()
const newEditor = useIsNewEditorEnabled()
const { view, pdfLayout: layout, detachIsLinked } = useLayoutContext()
const { isOpen: railIsOpen, selectedTab: selectedRailTab } = useRailContext()
// Keep track of how many heartbeats we've sent so that we can calculate how
// long to wait until the next one
@@ -59,7 +80,12 @@ export function useEditingSessionHeartbeat() {
const segmentation = createEditingSessionHeartbeatData(
editorType,
newEditor
newEditor,
view,
layout,
railIsOpen,
selectedRailTab,
detachIsLinked
)
debugConsole.log('[Event] send heartbeat request', segmentation)
@@ -80,7 +106,16 @@ export function useEditingSessionHeartbeat() {
heartBeatResetTimerRef.current = window.setTimeout(() => {
heartBeatSentRecentlyRef.current = false
}, backoffSecs * 1000)
}, [getEditorType, projectId, newEditor])
}, [
getEditorType,
projectId,
newEditor,
view,
layout,
railIsOpen,
selectedRailTab,
detachIsLinked,
])
// Hook the heartbeat up to editor events
useEventListener('cursor:editor:update', editingSessionHeartbeat)

View File

@@ -3,6 +3,7 @@ import MaterialIcon from '@/shared/components/material-icon'
import { Trans, useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import { useStopOnFirstError } from '@/shared/hooks/use-stop-on-first-error'
import { sendMB } from '@/infrastructure/event-tracking'
import { useCallback, useMemo } from 'react'
import ErrorState from './error-state'
import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
@@ -149,7 +150,16 @@ const ReasonsForTimeoutInfo = () => {
i18nKey="project_timed_out_optimize_images"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="https://www.overleaf.com/learn/how-to/Optimising_very_large_image_files" />,
<a
href="https://www.overleaf.com/learn/how-to/Optimising_very_large_image_files"
onClick={() => {
sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'optimize',
})
}}
/>,
]}
/>
</li>
@@ -158,7 +168,16 @@ const ReasonsForTimeoutInfo = () => {
i18nKey="a_fatal_compile_error_that_completely_blocks_compilation"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="https://www.overleaf.com/learn/how-to/Why_do_I_keep_getting_the_compile_timeout_error_message%3F#Fatal_compile_errors_blocking_the_compilation" />,
<a
href="https://www.overleaf.com/learn/how-to/Why_do_I_keep_getting_the_compile_timeout_error_message%3F#Fatal_compile_errors_blocking_the_compilation"
onClick={() => {
sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'fatal-error',
})
}}
/>,
]}
/>
{!lastCompileOptions.stopOnFirstError && (
@@ -191,6 +210,13 @@ const ReasonsForTimeoutInfo = () => {
href="/learn/how-to/Fixing_and_preventing_compile_timeouts"
rel="noopener noreferrer"
target="_blank"
onClick={() => {
sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'learn-more',
})
}}
/>,
]}
/>

View File

@@ -1,4 +1,5 @@
import { sendSearchEvent } from '@/features/event-tracking/search-events'
import { useProjectContext } from '@/shared/context/project-context'
import useEventListener from '@/shared/hooks/use-event-listener'
import usePersistedState from '@/shared/hooks/use-persisted-state'
import { isMac } from '@/shared/utils/os'
@@ -45,7 +46,11 @@ const RailContext = createContext<
>(undefined)
export const RailProvider: FC<React.PropsWithChildren> = ({ children }) => {
const [isOpen, setIsOpen] = usePersistedState('rail-is-open', true)
const { projectId } = useProjectContext()
const [isOpen, setIsOpen] = usePersistedState(
`rail-is-open-${projectId}`,
true
)
const [resizing, setResizing] = useState(false)
const [activeModal, setActiveModalInternal] = useState<RailModalKey | null>(
null
@@ -70,7 +75,7 @@ export const RailProvider: FC<React.PropsWithChildren> = ({ children }) => {
}, [setIsOpen])
const [selectedTab, setSelectedTab] = usePersistedState<RailTabKey>(
'selected-rail-tab',
`selected-rail-tab-${projectId}`,
'file-tree'
)

View File

@@ -5,6 +5,7 @@ import PdfLogEntry from './pdf-log-entry'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
import getMeta from '../../../utils/meta'
import { sendMB } from '@/infrastructure/event-tracking'
function PdfPreviewError({
error,
@@ -300,7 +301,16 @@ function TimedOutLogEntry() {
i18nKey="project_timed_out_optimize_images"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="https://www.overleaf.com/learn/how-to/Optimising_very_large_image_files" />,
<a
href="https://www.overleaf.com/learn/how-to/Optimising_very_large_image_files"
onClick={() => {
sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'optimize',
})
}}
/>,
]}
/>
</li>
@@ -309,7 +319,16 @@ function TimedOutLogEntry() {
i18nKey="project_timed_out_fatal_error"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="https://www.overleaf.com/learn/how-to/Why_do_I_keep_getting_the_compile_timeout_error_message%3F#Fatal_compile_errors_blocking_the_compilation" />,
<a
href="https://www.overleaf.com/learn/how-to/Why_do_I_keep_getting_the_compile_timeout_error_message%3F#Fatal_compile_errors_blocking_the_compilation"
onClick={() => {
sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'fatal-error',
})
}}
/>,
]}
/>
{!lastCompileOptions.stopOnFirstError && (
@@ -335,7 +354,16 @@ function TimedOutLogEntry() {
i18nKey="project_timed_out_learn_more"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="https://www.overleaf.com/learn/how-to/Why_do_I_keep_getting_the_compile_timeout_error_message%3F" />,
<a
href="https://www.overleaf.com/learn/how-to/Why_do_I_keep_getting_the_compile_timeout_error_message%3F"
onClick={() => {
sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'learn-more',
})
}}
/>,
]}
/>
</p>

View File

@@ -179,6 +179,13 @@ const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({
href="/learn/how-to/Optimising_very_large_image_files"
rel="noopener noreferrer"
target="_blank"
onClick={() => {
eventTracking.sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'optimize',
})
}}
/>,
]}
/>
@@ -192,6 +199,13 @@ const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({
href="/learn/how-to/Fixing_and_preventing_compile_timeouts#Step_3:_Assess_your_project_for_time-consuming_tasks_and_fatal_errors"
rel="noopener noreferrer"
target="_blank"
onClick={() => {
eventTracking.sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'fatal-error',
})
}}
/>,
]}
/>
@@ -225,6 +239,13 @@ const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({
href="/learn/how-to/Fixing_and_preventing_compile_timeouts"
rel="noopener noreferrer"
target="_blank"
onClick={() => {
eventTracking.sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'learn-more',
})
}}
/>,
]}
/>

View File

@@ -17,6 +17,7 @@ import { AccountMenuItems } from '@/shared/components/navbar/account-menu-items'
import { useScrolled } from '@/features/project-list/components/sidebar/use-scroll'
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
import { SurveyWidgetDsNav } from '@/features/project-list/components/survey-widget-ds-nav'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function SidebarDsNav() {
const { t } = useTranslation()
@@ -36,6 +37,8 @@ function SidebarDsNav() {
item => item.text === 'help_and_resources'
) as NavbarDropdownItemData
const { containerRef, scrolledUp, scrolledDown } = useScrolled()
const themedDsNav = useFeatureFlag('themed-project-dashboard')
return (
<div
className="project-list-sidebar-wrapper-react d-none d-md-flex"
@@ -159,6 +162,7 @@ function SidebarDsNav() {
<AccountMenuItems
sessionUser={sessionUser}
showSubscriptionLink={showSubscriptionLink}
showThemeToggle={themedDsNav}
/>
</Dropdown.Menu>
</Dropdown>

View File

@@ -0,0 +1,55 @@
import useSetOverallTheme from '@/features/editor-left-menu/hooks/use-set-overall-theme'
import MaterialIcon from '@/shared/components/material-icon'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import getMeta from '@/utils/meta'
import { OverallThemeMeta } from '@ol-types/project-settings'
import { useTranslation } from 'react-i18next'
const getIcon = (theme: OverallThemeMeta) => {
switch (theme.val) {
case 'light-':
return 'light_mode'
case 'system':
return 'computer'
default:
return 'dark_mode'
}
}
export default function ThemeToggle() {
const {
userSettings: { overallTheme },
} = useUserSettingsContext()
const setOverallTheme = useSetOverallTheme()
const overallThemes = getMeta('ol-overallThemes')
const { t } = useTranslation()
return (
<fieldset className="dropdown-item theme-toggle">
<legend>{t('theme')}</legend>
<div className="theme-toggle-radios">
{overallThemes.map(theme => (
<OLTooltip
key={theme.val}
description={theme.name}
id={`theme-switch-${theme.name}-tooltip`}
>
<div className="theme-toggle-radio">
<input
id={`theme-switch-${theme.name}`}
type="radio"
value={theme.val}
checked={overallTheme === theme.val}
onChange={() => setOverallTheme(theme.val)}
/>
<label htmlFor={`theme-switch-${theme.name}`}>
<MaterialIcon type={getIcon(theme)} />
</label>
</div>
</OLTooltip>
))}
</div>
</fieldset>
)
}

View File

@@ -245,6 +245,7 @@ function AddEmail() {
<AddEmailViaSSO
email={newEmail}
domainInfo={newEmailMatchedDomain}
userInstitutions={state.data.linkedInstitutionIds}
/>
</div>
</Cell>
@@ -259,12 +260,28 @@ function AddEmail() {
function AddEmailViaSSO({
email,
domainInfo,
userInstitutions,
}: {
email: string
domainInfo: DomainInfo
userInstitutions: string[]
}) {
if (domainInfo.university.ssoEnabled) {
// SSO for Commons institution
// Check if the user has already linked this institution
if (userInstitutions.includes(domainInfo.university.id.toString())) {
return (
<Notification
type="error"
ariaLive="polite"
content={
<>
This institution is already linked with your account via another
email address.
</>
}
/>
)
}
return <SsoLinkingInfo email={email} domainInfo={domainInfo} />
} else if (
domainInfo.group?.domainCaptureEnabled &&

View File

@@ -16,25 +16,31 @@ export const ToolbarButtonMenu: FC<
disabled?: boolean
disablePopover?: boolean
altCommand?: (view: EditorView) => void
onToggle?: (isOpen: boolean) => void
}>
> = memo(function ButtonMenu({
icon,
id,
label,
altCommand,
onToggle,
disabled,
disablePopover,
children,
}) {
const target = useRef<any>(null)
const { open, onToggle, ref } = useDropdown()
const { open, onToggle: handleToggle, ref } = useDropdown()
const view = useCodeMirrorViewContext()
useEffect(() => {
if (disablePopover && open) {
onToggle(false)
handleToggle(false)
}
}, [open, disablePopover, onToggle])
}, [open, disablePopover, handleToggle])
useEffect(() => {
onToggle?.(open)
}, [open, onToggle])
const button = (
<button
@@ -57,7 +63,7 @@ export const ToolbarButtonMenu: FC<
altCommand(view)
view.focus()
} else {
onToggle(!open)
handleToggle(!open)
}
}}
ref={target}
@@ -75,7 +81,7 @@ export const ToolbarButtonMenu: FC<
containerPadding={0}
transition
rootClose
onHide={() => onToggle(false)}
onHide={() => handleToggle(false)}
>
<OLPopover
id={`${id}-menu`}
@@ -85,7 +91,7 @@ export const ToolbarButtonMenu: FC<
<OLListGroup
role="menu"
onClick={() => {
onToggle(false)
handleToggle(false)
}}
>
{children}

View File

@@ -35,10 +35,31 @@ const en = {
'translate.es': 'Spanish',
'translate.de': 'German',
'translate.ja': 'Japanese',
'translate.sq': 'Albanian',
'translate.ar': 'Arabic',
'translate.bg': 'Bulgarian',
'translate.cs': 'Czech',
'translate.nl': 'Dutch',
'translate.fil': 'Filipino',
'translate.el': 'Greek',
'translate.he': 'Hebrew',
'translate.hu': 'Hungarian',
'translate.it': 'Italian',
'translate.ms': 'Malay',
'translate.fa': 'Persian(Farsi)',
'translate.pl': 'Polish',
'translate.ro': 'Romanian',
'translate.ru': 'Russian',
'translate.sr': 'Serbian',
'translate.sv': 'Swedish',
'translate.tr': 'Turkish',
'translate.uk': 'Ukrainian',
'translate.vi': 'Vietnamese',
'translate.other': 'Other',
'translate.not-listed': 'Language not listed?',
'translate.more-languages-coming-soon.title': 'More languages coming soon',
'translate.more-languages-coming-soon.body':
'Sorry, we dont currently offer any other languages for Translate. We will be adding more throughout October and November so watch this space!',
'Sorry, we dont currently offer any other languages for Translate. We will be adding more throughout November so watch this space!',
'blocked-suggestion-signpost.question':
'Do you want to permanently stop this suggestion from appearing again?',
'blocked-suggestion-signpost.tooltip': 'You can block a suggestion here.',
@@ -376,10 +397,31 @@ const es = {
'translate.es': 'Español',
'translate.de': 'Alemán',
'translate.ja': 'Japonés',
'translate.sq': 'Albanés',
'translate.ar': 'Árabe',
'translate.bg': 'Búlgaro',
'translate.cs': 'Checo',
'translate.nl': 'Neerlandés',
'translate.fil': 'Filipino',
'translate.el': 'Griego',
'translate.he': 'Hebreo',
'translate.hu': 'Húngaro',
'translate.it': 'Italiano',
'translate.ms': 'Malayo',
'translate.fa': 'Persa (Farsi)',
'translate.pl': 'Polaco',
'translate.ro': 'Rumano',
'translate.ru': 'Ruso',
'translate.sr': 'Serbio',
'translate.sv': 'Sueco',
'translate.tr': 'Turco',
'translate.uk': 'Ucraniano',
'translate.vi': 'Vietnamita',
'translate.other': 'Otro',
'translate.not-listed': '¿Idioma no listado?',
'translate.more-languages-coming-soon.title': 'Más idiomas próximamente',
'translate.more-languages-coming-soon.body':
'Lo sentimos, actualmente no ofrecemos otros idiomas para la traducción. Agregaremos más a lo largo de octubre y noviembre, ¡así que mantente atento!',
'Lo sentimos, actualmente no ofrecemos otros idiomas para la traducción. Agregaremos más a lo largo de noviembre, ¡así que mantente atento!',
'blocked-suggestion-signpost.question':
'¿Quieres dejar de ver esta sugerencia permanentemente? Puedes bloquear esta sugerencia aquí.',
'blocked-suggestion-signpost.tooltip':

View File

@@ -7,17 +7,21 @@ import NavDropdownDivider from './nav-dropdown-divider'
import NavDropdownLinkItem from './nav-dropdown-link-item'
import { useDsNavStyle } from '@/features/project-list/components/use-is-ds-nav'
import { SignOut } from '@phosphor-icons/react'
import ThemeToggle from '@/features/project-list/components/sidebar/theme-toggle'
export function AccountMenuItems({
sessionUser,
showSubscriptionLink,
showThemeToggle = false,
}: {
sessionUser: NavbarSessionUser
showSubscriptionLink: boolean
showThemeToggle?: boolean
}) {
const { t } = useTranslation()
const logOutFormId = 'logOutForm'
const dsNavStyle = useDsNavStyle()
return (
<>
<Dropdown.Item as="li" disabled role="menuitem">
@@ -32,6 +36,12 @@ export function AccountMenuItems({
{t('subscription')}
</NavDropdownLinkItem>
) : null}
{showThemeToggle && (
<DropdownListItem>
<ThemeToggle />
</DropdownListItem>
)}
<NavDropdownDivider />
<DropdownListItem>
{

View File

@@ -0,0 +1,297 @@
import _ from 'lodash'
import { ComponentType } from 'react'
import { Navbar } from 'react-bootstrap'
import OLPageContentCard from '@/shared/components/ol/ol-page-content-card'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLButton from '@/shared/components/ol/ol-button'
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
const lorem = (n: number) => {
const quacks = ['quack', 'quack', 'quack', 'quak']
let result = ''
if (n >= 1) result += 'Lorem'
if (n >= 2) result += ' epsom'
for (let i = 2; i < n; i++) {
const next =
result.at(-1) === '.'
? ' ' + _.capitalize(quacks[Math.floor(Math.random() * quacks.length)])
: quacks[Math.floor(Math.random() * (quacks.length + 1))]
result += next ? ' ' + next : '.'
}
if (result.at(-1) !== '.') result += '.'
return result
}
const Nav = () => <Navbar className="navbar-default navbar-main" />
export const UnsuportedBrowser = () => (
<main className="content content-alt full-height" id="main-content">
<div className="container full-height">
<div className="error-container full-height">
<div className="error-details">
<h1 className="error-status">Unsupported Browser</h1>
<p className="error-description">{lorem(60)}</p>
<hr />
<p>{lorem(40)}</p>
</div>
</div>
</div>
</main>
)
export const Error400 = () => (
<main className="content content-alt full-height" id="main-content">
<div className="container full-height">
<div className="error-container full-height">
<div className="error-details">
<p className="error-status">Something went wrong, sorry.</p>
<p className="error-description">{lorem(15)}</p>
</div>
</div>
</div>
</main>
)
export const Error404 = () => (
<>
<Nav />
<main className="content content-alt" id="main-content">
<div className="container">
<div className="error-container">
<div className="error-details">
<p className="error-status">Not found</p>
<p className="error-description">{lorem(20)}</p>
</div>
</div>
</div>
</main>
</>
)
export const Closed = () => (
<>
<Nav />
<main className="content" id="main-content">
<div className="container">
<div className="row">
<div className="col-lg-8 col-lg-offset-2 text-center">
<div className="page-header">
<h1>Maintenance</h1>
</div>
<p>{lorem(6)}</p>
</div>
</div>
</div>
</main>
</>
)
export const PlannedMaintenance = () => (
<>
<Nav />
<main className="content" id="main-content">
<div className="container">
<div className="row">
<div className="col-lg-8 col-lg-offset-2">
<div className="page-header">
<h1>Planned Maintenance</h1>
</div>
<p>{lorem(6)}</p>
</div>
</div>
</div>
</main>
</>
)
export const PostGateway = () => (
<>
<div className="content content-alt">
<div className="container">
<div className="row">
<div className="col-lg-6 offset-lg-3">
<div className="card">
<div className="card-body">
<p className="text-center">
Please wait while we process your request.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
export const AccountSuspended = () => (
<main className="content content-alt" id="main-content">
<div className="container-custom-sm mx-auto">
<div className="card">
<div className="card-body">
<h3>Your account is suspended</h3>
<p>{lorem(6)}</p>
</div>
</div>
</div>
</main>
)
export const Restricted = () => (
<>
<Nav />
<main className="content" id="main-content">
<div className="container">
<div className="row">
<div className="col-md-8 offset-md-2 text-center">
<div className="page-header">
<h2>
Restricted, sorry you dont have permission to load this page.
</h2>
</div>
<p>{lorem(23)}</p>
</div>
</div>
</div>
</main>
</>
)
export const OneTimeLogin = () => (
<>
<Nav />
<main className="content content-alt" id="main-content">
<div className="container">
<div className="row">
<div className="col-lg-6 offset-lg-3 col-xl-4 offset-xl-4">
<div className="card">
<div className="card-body">
<div className="page-header">
<h1>We're back!</h1>
</div>
<p>Overleaf is now running normally.</p>
</div>
</div>
</div>
</div>
</div>
</main>
</>
)
export const Invite = () => (
<main className="content content-alt" id="invite-root">
<OLRow className="row row-spaced">
<OLCol lg={{ span: 8, offset: 2 }}>
<OLPageContentCard>
<div className="page-header">
<h1 className="text-center">
<span className="team-invite-name">
max.mustermann@example.com
</span>{' '}
has invited you to join a group subscription on Overleaf
</h1>
</div>
<p className="text-center">{lorem(20)}</p>
</OLPageContentCard>
</OLCol>
</OLRow>
</main>
)
export const NotValid = () => (
<>
<Nav />
<main className="content content-alt" id="main-content">
<div className="container">
<div className="row">
<div className="col-md-8 col-md-offset-2 offset-md-2">
<div className="card project-invite-invalid">
<div className="card-body">
<div className="page-header text-center">
<h1>Invite not valid</h1>
</div>
<div className="row text-center">
<div className="col-12 col-md-12">
<p>{lorem(20)}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</>
)
export const CompleteRegistration = () => (
<>
<Nav />
<main className="content content-alt" id="main-content">
<div className="container">
<div className="row">
<div className="col-12 col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 offset-md-1 offset-lg-2">
<div className="card">
<div className="card-body">
<div className="page-header">
<h1 className="text-center">Dropbox Sync</h1>
</div>
<p>{lorem(20)}</p>
</div>
</div>
</div>
</div>
</div>
</main>
</>
)
export const Ciam = () => (
<div className="ciam-layout">
<a
href="/"
aria-label="Overleaf"
className="brand"
style={{ backgroundImage: `url("${overleafLogo}")` }}
/>
<div className="ciam-container">
<main className="ciam-card" id="main-content">
<h1>Create your Overleaf account</h1>
<p>{lorem(20)}</p>
<hr />
<p>{lorem(20)}</p>
<OLButton>Button</OLButton>
</main>
</div>
<footer>
<a href="https://www.overleaf.com/legal#Privacy">Privacy</a>
<a href="https://www.overleaf.com/legal#Terms">Terms</a>
</footer>
</div>
)
export default {
title: 'Shared / Layouts',
args: {
label: 'Option',
},
parameters: {
layout: 'fullscreen', // This is crucial for vh/vw layouts
},
decorators: [
(Story: ComponentType) => (
<div style={{ height: '100vh', width: '100vw' }}>
<style>
{`.content {
min-height: 100vh;
padding-top: 93px;
}`}
</style>
<Story />
</div>
),
],
}

View File

@@ -0,0 +1,70 @@
@import 'ciam-variables';
@import 'ciam-mixins';
.ciam-layout {
padding: var(--ciam-spacing-350);
display: flex;
min-height: 100%;
flex-direction: column;
font-family: var(--ciam-font-family-sans), sans-serif;
color: var(--ciam-color-text-primary);
font-size: var(--ciam-font-size-400);
line-height: 1.5;
@include ciam-body-md-regular;
.ciam-container {
flex: 1 1 auto;
}
.brand {
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
height: 64px;
width: 130px;
margin: var(--ciam-spacing-350) auto;
display: block;
@include media-breakpoint-up(sm) {
margin: var(--ciam-spacing-350) var(--ciam-spacing-800);
}
}
h1 {
@include ciam-heading-sm-semibold;
}
.ciam-card {
box-shadow:
0 4px 6px -4px rgb(0 0 0 / 10%),
0 1px 29px -3px rgb(0 0 0 / 16%);
padding: var(--ciam-spacing-800) var(--ciam-spacing-400);
border-radius: var(--ciam-border-radius-400);
max-width: 460px;
margin: var(--ciam-spacing-400) auto;
@include media-breakpoint-up(sm) {
padding: var(--ciam-spacing-1300);
}
}
footer {
display: flex;
gap: var(--ciam-spacing-600);
text-transform: uppercase;
justify-content: center;
margin: var(--ciam-spacing-350) auto;
@include media-breakpoint-up(sm) {
margin: var(--ciam-spacing-350) var(--ciam-spacing-800);
justify-content: start;
}
a {
text-decoration: none;
@include ciam-body-sm-regular;
}
}
}

View File

@@ -0,0 +1,68 @@
.ciam-layout {
--ciam-color-neutral-50: #fafafa;
--ciam-color-neutral-100: #f2f2f2;
--ciam-color-neutral-200: #e6e6e6;
--ciam-color-neutral-300: #d6d6d6;
--ciam-color-neutral-400: #c7c7c7;
--ciam-color-neutral-500: #b5b5b5;
--ciam-color-neutral-600: #a1a1a1;
--ciam-color-neutral-700: #8a8a8a;
--ciam-color-neutral-800: #6b6b6b;
--ciam-color-neutral-900: #383838;
--ciam-color-neutral-950: #262626;
--ciam-color-green-50: #f2f8f5;
--ciam-color-green-100: #e3f2eb;
--ciam-color-green-200: #c0e7d6;
--ciam-color-green-300: #8adbb7;
--ciam-color-green-400: #38cc89;
--ciam-color-green-500: #26b072;
--ciam-color-green-600: #158954;
--ciam-color-green-700: #19754c;
--ciam-color-green-800: #196241;
--ciam-color-green-900: #164630;
--ciam-color-green-950: #112c20;
--ciam-color-yellow-50: #fffaeb;
--ciam-color-yellow-100: #fff7db;
--ciam-color-yellow-200: #ffeeb8;
--ciam-color-yellow-300: #ffe58f;
--ciam-color-yellow-400: #ffda61;
--ciam-color-yellow-500: #ffcc20;
--ciam-color-yellow-600: #f0b800;
--ciam-color-yellow-700: #d1a000;
--ciam-color-yellow-800: #ad8500;
--ciam-color-yellow-900: #806200;
--ciam-color-yellow-950: #574200;
--ciam-color-red-50: #fff5f7;
--ciam-color-red-100: #fee7eb;
--ciam-color-red-200: #fdc9d3;
--ciam-color-red-300: #fba7b7;
--ciam-color-red-400: #f97b92;
--ciam-color-red-500: #f51d43;
--ciam-color-red-600: #e60a32;
--ciam-color-red-700: #c3092b;
--ciam-color-red-800: #a10723;
--ciam-color-red-900: #75051a;
--ciam-color-red-950: #530412;
--ciam-color-blue-50: #f7fafd;
--ciam-color-blue-100: #ecf2f9;
--ciam-color-blue-200: #d4e3f2;
--ciam-color-blue-300: #bdd4ea;
--ciam-color-blue-400: #a2c3e2;
--ciam-color-blue-500: #7facd7;
--ciam-color-blue-600: #5893cb;
--ciam-color-blue-700: #3470a8;
--ciam-color-blue-800: #2b5d8c;
--ciam-color-blue-900: #1e4161;
--ciam-color-blue-950: #17314a;
--ciam-color-teal-50: #f1f8f8;
--ciam-color-teal-100: #e3f2f1;
--ciam-color-teal-200: #c1e2df;
--ciam-color-teal-300: #9ed1cd;
--ciam-color-teal-400: #6dbab4;
--ciam-color-teal-500: #4a9d96;
--ciam-color-teal-600: #438e88;
--ciam-color-teal-700: #3b7d77;
--ciam-color-teal-800: #2f6460;
--ciam-color-teal-900: #244c49;
--ciam-color-teal-950: #193432;
}

View File

@@ -0,0 +1,107 @@
@mixin ciam-body-xs-regular() {
font-size: 0.75rem;
font-weight: 400;
line-height: 1.25rem;
}
@mixin ciam-body-xs-semibold() {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.25rem;
}
@mixin ciam-body-sm-regular() {
font-size: 0.875rem;
font-weight: 400;
line-height: 1.25rem;
}
@mixin ciam-body-sm-semibold() {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25rem;
}
@mixin ciam-body-md-regular() {
font-size: 1rem;
font-weight: 400;
line-height: 1.5rem;
}
@mixin ciam-body-md-semibold() {
font-size: 1rem;
font-weight: 600;
line-height: 1.5rem;
}
@mixin ciam-body-lg-regular() {
font-size: 1.125rem;
font-weight: 400;
line-height: 1.75rem;
}
@mixin ciam-body-lg-semibold() {
font-size: 1.125rem;
font-weight: 600;
line-height: 1.75rem;
}
@mixin ciam-heading-xs-regular() {
font-size: 1.125rem;
font-weight: 400;
line-height: 1.5rem;
}
@mixin ciam-heading-xs-semibold() {
font-size: 1.125rem;
font-weight: 600;
line-height: 1.5rem;
}
@mixin ciam-heading-sm-regular() {
font-size: 1.25rem;
font-weight: 400;
line-height: 1.75rem;
}
@mixin ciam-heading-sm-semibold() {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.75rem;
}
@mixin ciam-heading-md-regular() {
font-size: 1.5rem;
font-weight: 400;
line-height: 2rem;
}
@mixin ciam-heading-md-semibold() {
font-size: 1.5rem;
font-weight: 600;
line-height: 2rem;
}
@mixin ciam-heading-lg-regular() {
font-size: 2rem;
font-weight: 400;
line-height: 2.5rem;
}
@mixin ciam-heading-lg-semibold() {
font-size: 2rem;
font-weight: 600;
line-height: 2.5rem;
}
@mixin ciam-heading-xl-regular() {
font-size: 2.5rem;
font-weight: 400;
line-height: 3rem;
}
@mixin ciam-heading-xl-semibold() {
font-size: 2.5rem;
font-weight: 600;
line-height: 3rem;
}

View File

@@ -0,0 +1,35 @@
@import 'ciam-mixins';
@import 'ciam-colors';
// TODO: Replace `fuchsia` by the correct colors.
.ciam-layout {
// Spacings
--ciam-spacing-200: 8px;
--ciam-spacing-250: 10px;
--ciam-spacing-350: 12px;
--ciam-spacing-400: 16px;
--ciam-spacing-600: 24px; // TODO: confirm this variable name (couldn't find in design system)
--ciam-spacing-800: 32px; // TODO: confirm this variable name (couldn't find in design system)
--ciam-spacing-1300: 52px;
// Base variables
--ciam-color-text-secondary: var(--ciam-color-neutral-800);
--ciam-color-text-primary: var(--ciam-color-neutral-900);
--ciam-border-radius-200: var(--ciam-spacing-300);
--ciam-border-radius-400: var(--ciam-spacing-400);
--ciam-font-family-sans: 'Inter', sans-serif;
// Links
// used in services/web/frontend/stylesheets/base/links.scss
--link-color: var(--ciam-color-text-secondary);
--link-hover-color: fuchsia;
// TODO: validate that this is correct
--link-visited-color: var(--ciam-color-text-secondary);
--link-color-dark: fuchsia;
--link-hover-color-dark: fuchsia;
--link-visited-color-dark: fuchsia;
--link-text-decoration: underline;
--link-hover-text-decoration: none;
}

View File

@@ -1,3 +1,48 @@
@mixin light-dropdown-menu {
--dropdown-text-color: var(--neutral-90);
--dropdown-text-secondary: var(--content-secondary);
--dropdown-text-active: var(--green-70);
--dropdown-text-danger: var(--content-danger);
--dropdown-text-danger-hover: var(--content-danger);
--dropdown-header-text-color: var(--content-secondary);
--dropdown-background: var(--white);
--dropdown-background-active: var(--bg-accent-03);
--dropdown-background-hover: var(--bg-light-secondary);
--dropdown-background-danger-hover: var(--bg-danger-03);
--dropdown-border-divider: var(--border-divider);
--dropdown-text-hover: var(--content-primary);
--dropdown-border-width: 0;
--dropdown-border-color: transparent;
}
@mixin dark-dropdown-menu {
--dropdown-background: var(--bg-dark-primary);
--dropdown-background-hover: var(--bg-dark-secondary);
--dropdown-background-danger-hover: var(--bg-danger-02);
--dropdown-text-color: var(--content-primary-dark);
--dropdown-text-danger: var(--red-40);
--dropdown-text-danger-hover: var(--red-20);
--dropdown-text-secondary: var(--content-secondary-dark);
--dropdown-header-text-color: var(--content-secondary-dark);
--dropdown-text-hover: var(--content-primary-dark);
--dropdown-background-active: var(--green-70);
--dropdown-text-active: var(--green-10);
--dropdown-border-divider: var(--border-divider-dark);
--dropdown-border-width: 1px;
--dropdown-border-color: var(--border-primary);
}
:root {
@include light-dropdown-menu;
}
@include theme('default') {
.ide-redesign-main,
.project-ds-nav-page {
@include dark-dropdown-menu;
}
}
$dropdown-item-min-height: 36px;
.dropdown {
@@ -25,6 +70,7 @@ $dropdown-item-min-height: 36px;
min-height: $dropdown-item-min-height; // a minimum height of 36px to be accessible for touch screens
padding: var(--spacing-05) var(--spacing-06) var(--spacing-02)
var(--spacing-04);
color: var(--dropdown-header-text-color);
}
.dropdown-menu.dropdown-menu-unpositioned {
@@ -39,7 +85,9 @@ $dropdown-item-min-height: 36px;
.dropdown-menu {
@include shadow-md;
border: var(--dropdown-border-width) solid var(--dropdown-border-color);
min-width: 240px;
background-color: var(--dropdown-background);
&.dropdown-menu-sm-width {
min-width: 160px;
@@ -60,37 +108,40 @@ $dropdown-item-min-height: 36px;
place-content: center start;
min-height: $dropdown-item-min-height; // a minimum height of 36px to be accessible for touch screens
position: relative;
background-color: var(--dropdown-background);
&:active {
background-color: var(--bg-accent-03);
background-color: var(--dropdown-background-active);
}
&,
&:active,
&:visited {
color: var(--neutral-90);
color: var(--dropdown-text-color);
}
&:hover:not(.active),
&:focus:not(.active),
&.nested-dropdown-toggle-shown {
background-color: var(--bg-light-secondary);
color: var(--dropdown-text-hover);
background-color: var(--dropdown-background-hover);
cursor: pointer;
text-decoration: none;
}
&[variant='danger'] {
color: var(--content-danger);
color: var(--dropdown-text-danger);
&:hover:not(.active),
&:focus:not(.active) {
background-color: var(--bg-danger-03);
color: var(--dropdown-text-danger-hover);
background-color: var(--dropdown-background-danger-hover);
}
}
&.active {
background-color: var(--bg-accent-03);
color: var(--green-70);
background-color: var(--dropdown-background-active);
color: var(--dropdown-text-active);
}
&.btn-link {
@@ -104,14 +155,14 @@ $dropdown-item-min-height: 36px;
}
.dropdown-divider {
border-top-color: var(--border-divider);
border-top-color: var(--dropdown-border-divider);
margin: var(--spacing-01) var(--spacing-03);
}
.dropdown-item-description {
@include body-xs;
color: var(--content-secondary);
color: var(--dropdown-text-secondary);
margin-top: var(--spacing-01);
text-wrap: wrap;
}
@@ -163,7 +214,7 @@ $dropdown-item-min-height: 36px;
// override disabled styles when the state is active
.dropdown-item.active .dropdown-item-description {
background-color: initial;
color: var(--green-70);
color: var(--dropdown-text-active);
}
.dropdown-button-toggle {
@@ -198,7 +249,7 @@ $dropdown-item-min-height: 36px;
}
.dropdown-item-highlighted {
background-color: var(--bg-light-secondary);
background-color: var(--dropdown-background-hover);
}
.dropdown-item-material-icon-small {

View File

@@ -2,6 +2,11 @@
--bs-heading-color: var(--content-primary);
}
.modal {
// Until we add a dark modal version, all dropdowns in modals should use light theme
@include light-dropdown-menu;
}
@include media-breakpoint-up(sm) {
.modal-dialog {
@include modal-md;

View File

@@ -203,6 +203,7 @@
.dropdown-item {
padding: var(--spacing-02) var(--spacing-06) var(--spacing-02)
var(--spacing-08);
background-color: transparent;
&:not(.disabled) {
color: var(--navbar-hamburger-submenu-item-color);

View File

@@ -1,3 +1,7 @@
:root {
--select-background-highlighted: var(--bg-light-secondary);
}
.select-wrapper {
position: relative;
}
@@ -11,5 +15,5 @@
}
.select-highlighted {
background-color: var(--bg-light-secondary);
background-color: var(--select-background-highlighted);
}

View File

@@ -49,3 +49,6 @@
// Module styles
// TODO: find a way for modules to add styles dynamically
@import 'modules/all';
// DS unified access styles
@import 'ciam/all';

View File

@@ -337,3 +337,62 @@
}
}
}
.theme-toggle {
display: flex;
justify-content: space-between;
cursor: default !important;
align-items: center;
&:hover {
background-color: transparent !important;
color: var(--content-primary-themed);
}
legend {
font-size: var(--font-size-02);
line-height: var(--line-height-02);
margin-bottom: 0;
}
}
.theme-toggle-radios {
display: flex;
border-radius: var(--border-radius-full);
background-color: var(--bg-secondary-themed);
padding: var(--spacing-01);
gap: var(--spacing-01);
}
:root {
--theme-toggle-selected-background: var(--green-70);
}
@include theme('light') {
--theme-toggle-selected-background: var(--green-20);
}
.theme-toggle-radio {
display: flex;
input {
all: unset;
}
label {
display: flex;
padding: var(--spacing-03);
margin-bottom: 0;
border-radius: var(--border-radius-full);
cursor: pointer;
color: var(--content-primary-themed);
}
.material-symbols {
font-size: var(--font-size-03);
}
input:checked + label {
background-color: var(--theme-toggle-selected-background);
}
}

View File

@@ -116,7 +116,6 @@
"bull": "^3.18.0",
"bunyan": "^1.8.15",
"cache-flow": "^1.9.0",
"celebrate": "^15.0.3",
"connect-redis": "^6.1.3",
"content-disposition": "^0.5.0",
"contentful": "^10.8.5",
@@ -139,7 +138,6 @@
"i18next": "^23.10.0",
"i18next-fs-backend": "^2.3.1",
"i18next-http-middleware": "^3.5.0",
"joi": "^17.12.0",
"jose": "^4.3.8",
"json2csv": "^4.3.3",
"jsonwebtoken": "^9.0.0",

View File

@@ -17,15 +17,18 @@ import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
import { db } from '../../app/src/infrastructure/mongodb.js'
import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.js'
import { registerAccountMapping } from '../../app/src/Features/Analytics/AnalyticsManager.js'
import { triggerGracefulShutdown } from '../../app/src/infrastructure/GracefulShutdown.js'
import { gracefulShutdown } from '../../app/src/infrastructure/GracefulShutdown.js'
import Validation from '../../app/src/infrastructure/Validation.js'
import { scriptRunner } from '../lib/ScriptRunner.mjs'
const paramsSchema = Validation.Joi.object({
endDate: Validation.Joi.string().isoDate(),
commit: Validation.Joi.boolean().default(false),
verbose: Validation.Joi.boolean().default(false),
}).unknown(true)
const paramsSchema = Validation.z.object({
endDate: Validation.z.iso
.date()
.transform(v => new Date(v).toISOString())
.optional(),
commit: Validation.z.boolean().default(false).optional(),
verbose: Validation.z.boolean().default(false).optional(),
})
let mapped = 0
let subscriptionCount = 0
@@ -96,33 +99,31 @@ async function main(trackProgress) {
}
}
const {
error,
value: { commit, endDate, verbose },
} = paramsSchema.validate(
const { error, data } = paramsSchema.safeParse(
minimist(process.argv.slice(2), {
boolean: ['commit', 'verbose'],
string: ['endDate'],
})
)
logger.logger.level(verbose ? 'debug' : 'info')
if (error) {
logger.error({ error }, 'error with parameters')
triggerGracefulShutdown({
close(done) {
logger.info({}, 'shutting down')
done(1)
},
})
} else {
logger.info({ verbose, commit, endDate }, commit ? 'COMMITTING' : 'DRY RUN')
await scriptRunner(main)
triggerGracefulShutdown({
await gracefulShutdown({
close(done) {
logger.info({}, 'shutting down')
done()
},
})
process.exit(1)
}
const { commit, endDate, verbose } = data
logger.logger.level(verbose ? 'debug' : 'info')
logger.info({ verbose, commit, endDate }, commit ? 'COMMITTING' : 'DRY RUN')
await scriptRunner(main)
await gracefulShutdown({
close(done) {
logger.info({}, 'shutting down')
done()
},
})
process.exit()

View File

@@ -48,7 +48,8 @@ async function setAllowDowngradeToFalse() {
}
async function deleteHistoryCollections() {
await gracefullyDropCollection(db.docHistory)
const docHistory = await getCollectionInternal('docHistory')
await gracefullyDropCollection(docHistory)
await gracefullyDropCollection(db.docHistoryIndex)
const projectHistoryMetaData = await getCollectionInternal(
'projectHistoryMetaData'

View File

@@ -289,6 +289,32 @@ describe('<EmailsSection />', function () {
await screen.findByRole('button', { name: 'Link accounts and add email' })
})
it('prevents user from linking to same SSO institution twice', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [
{ email: 'bar@autocomplete.edu', samlProviderId: '1234' },
])
render(<EmailsSection />)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: 'Add another email',
})
await fetchMock.callHistory.flush(true)
fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/institutions/domains', institutionDomainData)
await userEvent.click(button)
const input = screen.getByRole('textbox', { name: 'Email' })
fireEvent.change(input, {
target: { value: 'baz@autocomplete.edu' },
})
await screen.findByText(
'This institution is already linked with your account via another email address.'
)
})
it('adds new email address with existing institution and custom departments', async function () {
const country = 'Germany'
const customDepartment = 'Custom department'

View File

@@ -852,7 +852,7 @@ describe('ClsiManager', function () {
ctx.project._id,
ctx.user_id,
{
compileBackendClass: 'c2d',
compileBackendClass: 'c4d',
compileGroup: 'priority',
}
)
@@ -868,7 +868,7 @@ describe('ClsiManager', function () {
url.host === CLSI_HOST &&
url.pathname ===
`/project/${ctx.project._id}/user/${ctx.user_id}/compile` &&
url.searchParams.get('compileBackendClass') === 'c2d' &&
url.searchParams.get('compileBackendClass') === 'c4d' &&
url.searchParams.get('compileGroup') === 'priority'
)
)
@@ -885,7 +885,7 @@ describe('ClsiManager', function () {
ctx.AnalyticsManager.recordEventForUserInBackground
).to.have.been.calledWith(ctx.user_id, 'double-compile-result', {
projectId: 'project-id',
compileBackendClass: 'c2d',
compileBackendClass: 'c4d',
newCompileBackendClass: 'n4',
status: 'success',
compileTime: 1337,
@@ -1055,7 +1055,7 @@ describe('ClsiManager', function () {
await ctx.ClsiManager.promises.deleteAuxFiles(
ctx.project._id,
ctx.user_id,
{ compileBackendClass: 'c2d', compileGroup: 'priority' },
{ compileBackendClass: 'c4d', compileGroup: 'priority' },
'node-1'
)
// wait for the background task to finish
@@ -1065,7 +1065,7 @@ describe('ClsiManager', function () {
it('should clear both cookies', function (ctx) {
expect(
ctx.ClsiCookieManager.promises.clearServerId
).to.have.been.calledWith(ctx.project._id, ctx.user_id, 'c2d')
).to.have.been.calledWith(ctx.project._id, ctx.user_id, 'c4d')
expect(
ctx.ClsiCookieManager.promises.clearServerId
).to.have.been.calledWith(ctx.project._id, ctx.user_id, 'n4')
@@ -1078,7 +1078,7 @@ describe('ClsiManager', function () {
url.host === CLSI_HOST &&
url.pathname ===
`/project/${ctx.project._id}/user/${ctx.user_id}` &&
url.searchParams.get('compileBackendClass') === 'c2d' &&
url.searchParams.get('compileBackendClass') === 'c4d' &&
url.searchParams.get('compileGroup') === 'priority' &&
url.searchParams.get('clsiserverid') === 'node-1'
),
@@ -1168,7 +1168,7 @@ describe('ClsiManager', function () {
ctx.project._id,
ctx.user_id,
false,
{ compileBackendClass: 'c2d', compileGroup: 'priority' },
{ compileBackendClass: 'c4d', compileGroup: 'priority' },
'node-1'
)
// wait for the background task to finish
@@ -1180,7 +1180,7 @@ describe('ClsiManager', function () {
sinon.match(
url =>
url.toString() ===
`http://clsi.example.com/project/${ctx.project._id}/user/${ctx.user_id}/wordcount?compileBackendClass=c2d&compileGroup=priority&file=main.tex&image=mock-image-name&clsiserverid=node-1`
`http://clsi.example.com/project/${ctx.project._id}/user/${ctx.user_id}/wordcount?compileBackendClass=c4d&compileGroup=priority&file=main.tex&image=mock-image-name&clsiserverid=node-1`
)
)
expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith(

View File

@@ -783,7 +783,7 @@ describe('CompileController', function () {
.stub()
.resolves({
compileGroup: 'priority',
compileBackendClass: 'c2d',
compileBackendClass: 'c4d',
})
await ctx.CompileController._proxyToClsi(
ctx.projectId,
@@ -798,7 +798,7 @@ describe('CompileController', function () {
it('should open a request to the CLSI', function (ctx) {
ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
`${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=priority&compileBackendClass=c2d`
`${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=priority&compileBackendClass=c4d`
)
})
})

View File

@@ -254,7 +254,7 @@ describe('CompileManager', function () {
.calledWith(null, {
timeout: ctx.timeout,
compileGroup: ctx.group,
compileBackendClass: 'c2d',
compileBackendClass: 'c4d',
ownerAnalyticsId: 'abc',
})
.should.equal(true)
@@ -284,7 +284,7 @@ describe('CompileManager', function () {
await ctx.CompileManager.promises.getProjectCompileLimits(
ctx.project_id
)
expect(compileBackendClass).to.equal('c2d')
expect(compileBackendClass).to.equal('c4d')
})
})
})

View File

@@ -264,17 +264,6 @@ describe('SubscriptionController', function () {
})
)
vi.doMock('celebrate', () => ({
default: (ctx.celebrate = {
celebrate: sinon.stub(),
errors: sinon.stub(),
Joi: {
any: sinon.stub(),
extend: sinon.stub(),
},
}),
}))
vi.doMock(
'../../../../app/src/Features/Subscription/GroupPlansData',
() => ({

View File

@@ -1,47 +0,0 @@
const { Joi } = require('../../../../app/src/infrastructure/Validation')
const { ObjectId } = require('mongodb-legacy')
const { expect } = require('chai')
const { ValidationError } = require('joi')
describe('Validation', function () {
const validObjectId = '123456781234567812345678'
const invalidObjectId = '12345678-1234-1234-12345678'
it('accepts valid ObjectId strings', async function () {
const schema = Joi.object({
test: Joi.objectId(),
})
const value = await schema.validateAsync({
test: validObjectId,
})
expect(value.test).to.be.instanceof(ObjectId)
expect(value.test.toHexString()).to.equal(validObjectId)
})
it('rejects invalid ObjectId strings', async function () {
const schema = Joi.object({
test: Joi.objectId(),
})
const promise = schema.validateAsync({
test: invalidObjectId,
})
expect(promise).to.be.rejectedWith(ValidationError)
})
it('accepts valid ObjectId objects', async function () {
const schema = Joi.object({
test: Joi.objectId(),
})
const value = await schema.validateAsync({
test: new ObjectId(validObjectId),
})
expect(value.test).to.be.instanceof(ObjectId)
expect(value.test.toHexString()).to.equal(validObjectId)
})
})

View File

@@ -1,6 +1,7 @@
/* eslint-disable no-unused-vars */
import Helpers from './lib/helpers.mjs'
import { getCollectionInternal } from './lib/mongodb.mjs'
const tags = ['server-ce', 'server-pro', 'saas']
@@ -42,16 +43,16 @@ const indexes = [
]
const migrate = async client => {
const { db } = client
const docHistory = await getCollectionInternal('docHistory')
await Helpers.addIndexesToCollection(db.docHistory, indexes)
await Helpers.addIndexesToCollection(docHistory, indexes)
}
const rollback = async client => {
const { db } = client
const docHistory = await getCollectionInternal('docHistory')
try {
await Helpers.dropIndexesFromCollection(db.docHistory, indexes)
await Helpers.dropIndexesFromCollection(docHistory, indexes)
} catch (err) {
console.error('Something went wrong rolling back the migrations', err)
}

View File

@@ -0,0 +1,17 @@
import Helpers from './lib/helpers.mjs'
const tags = ['saas']
const migrate = async () => {
await Helpers.dropCollection('docHistory')
}
const rollback = async () => {
// Can't really do anything here
}
export default {
tags,
migrate,
rollback,
}

View File

@@ -18,7 +18,6 @@ export const db = {
deletedUsers: internalDb.collection('deletedUsers'),
dropboxEntities: internalDb.collection('dropboxEntities'),
dropboxProjects: internalDb.collection('dropboxProjects'),
docHistory: internalDb.collection('docHistory'),
docHistoryIndex: internalDb.collection('docHistoryIndex'),
docSnapshots: internalDb.collection('docSnapshots'),
docs: internalDb.collection('docs'),