diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 37491ab1ecc..2de84405b31 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -8,12 +8,8 @@ assignees: '' diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d9cc6a611db..1210a0ba364 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,7 +5,3 @@ ## Related issues / Pull Requests - -## Contributor Agreement - -- [ ] I confirm I have signed the [Contributor License Agreement](https://github.com/overleaf/overleaf/blob/main/CONTRIBUTING.md#contributor-license-agreement) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 774f860cc5a..676456da170 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,38 +1,29 @@ -# Contributing to Overleaf +# Contributing to Overleaf CE+ Thank you for reading this! If you'd like to report a bug or join in the development -of Overleaf, then here are some notes on how to do that. +of Overleaf CE+, then here are some notes on how to do that. ## Reporting bugs and opening issues -If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/overleaf/overleaf/issues).** +If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/yu-i-i/overleaf-cep).** If there is then please add any more information that you have, or give it a šŸ‘. When submitting an issue please describe the issue as clearly as possible, including how to reproduce the bug, which situations it appears in, what you expected to happen, and what actually happens. If you can include a screenshot for front end issues that is very helpful. -**Note**: If you are using [www.overleaf.com](www.overleaf.com) and have a problem, or if you would like to request a new feature, please contact the Support team at support@overleaf.com. Raise an issue here only to report bugs in the Community Edition release of Overleaf. ## Pull Requests -See [our wiki](https://github.com/overleaf/overleaf/wiki) +See [Overleaf wiki](https://github.com/overleaf/overleaf/wiki) for how to manage the Overleaf development environment and for our developer guidelines. We love pull requests, so be bold with them! Don't be afraid of going ahead and changing something, or adding a new feature. We're very happy to work with you to get your changes merged into Overleaf. -If you're looking for something to work on, have a look at the [open issues](https://github.com/overleaf/overleaf/issues). +If you're looking for something to work on, have a look at the [open issues](https://github.com/yu-i-i/overleaf-cep/issues). ## Security -Please see [our security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability. - -## Contributor License Agreement - -Before we can accept any contributions of code, we need you to agree to our -[Contributor License Agreement](https://docs.google.com/forms/d/e/1FAIpQLSef79XH3mb7yIiMzZw-yALEegS-wyFetvjTiNBfZvf_IHD2KA/viewform?usp=sf_link). -This is to ensure that you own the copyright of your contribution, and that you -agree to give us a license to use it in both the open source version, and the version -of Overleaf running at www.overleaf.com, which may have additional changes. +Please see [Overleaf security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability. diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 00000000000..9df30fb10fd --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,70 @@ +# CREDITS + +This project includes ideas and adapted code from several open-source projects.\ +In most cases the original code has been modified, optimized, or extended. + +## Symbol palette + +The symbol palette feature is based on the original Overleaf implementation: + +https://github.com/overleaf/web/tree/master/frontend/js/features/symbol-palette + +The original code was slightly improved, particularly in the parts related to keyboard input. + +## LDAP authentication + +LDAP authentication was inspired by: + +https://github.com/smhaller/ldap-overleaf-sl + +The project provided the idea and part of the implementation for adding LDAP users to the user's contacts. + +## Autocomplete of reference keys + +Autocomplete of reference keys is based on: + +https://github.com/lcpu-club/overleaf + +The implementation largely follows the referenced code, except that a different `.bib` file parser is used. + +## Real-time track changes and comments + +The Track Changes and Comments feature exists largely in the original Overleaf codebase.\ +The missing parts were implemented based on: + +https://github.com/ertuil/overleaf + +The referenced code was fixed, optimized, and extended. + +## Sandboxed compiles + +The Sandboxed Compiles feature was until recently largely present in the original Overleaf codebase.\ +The missing parts were implemented in this project. + +## Import file from external URL + +The "From External URL" feature exists in the original Overleaf code.\ +The missing proxy component was implemented in this project. + +## Git integration + +Git integration is based on: + +https://github.com/ayaka-notes/overleaf-pro/tree/feat-git-bridge + +The backend was completely rewritten.\ +The frontend uses the referenced code, which was optimized and improved. + +## Original Features + +The following features are original work implemented in this project: + +- Template Gallery +- Advanced administrator tools for managing user accounts and projects +- SAML authentication +- OpenID Connect authentication + +## Acknowledgments + +Thanks to the users of the project for valuable feedback, suggestions, +and help in identifying and fixing bugs. diff --git a/README.md b/README.md index 192f8b78774..f8e47998499 100644 --- a/README.md +++ b/README.md @@ -6,52 +6,60 @@

An open-source online real-time collaborative LaTeX editor.

- Wiki • + Wiki • Server Pro • - Contributing • - Mailing List • Authors • License

-A screenshot of a project being edited in Overleaf Community Edition +A screenshot of a project being edited in Overleaf Extended Community Edition

- Figure 1: A screenshot of a project being edited in Overleaf Community Edition. + Figure 1: A screenshot of a project being edited in Overleaf Extended Community Edition.

## Community Edition -[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. We run a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf. +[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. Overleaf runs a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf. -> [!CAUTION] -> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles. - -For more information on Sandbox Compiles check out our [documentation](https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles). +## Extended Community Edition (CE+) -## Enterprise +The present "extended" version of Overleaf CE includes: -If you want help installing and maintaining Overleaf in your lab or workplace, we offer an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises). It also includes more features for security (SSO with LDAP or SAML), administration and collaboration (e.g. tracked changes). [Find out more!](https://www.overleaf.com/for/enterprises) +- Template gallery +- Sandboxed compiles with TeX Live image selection +- LDAP authentication +- SAML authentication +- OpenID Connect authentication +- Real-time track changes and comments +- Autocomplete of reference keys +- Symbol palette +- Import file from external URL +- Advanced administrator tools for managing user accounts and projects +- Git integration -## Keeping up to date +> [!CAUTION] +> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles. +Therefore, in any environment where not all users can be fully trusted, it is strongly recommended to enable the Sandboxed Compiles feature available in the Extended Community Edition. -Sign up to the [mailing list](https://mailchi.mp/overleaf.com/community-edition-and-server-pro) to get updates on Overleaf releases and development. +For more information on Sandbox Compiles check out Overleaf [documentation](https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles). -## Installation +## Enterprise -We have detailed installation instructions in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/). +If you want help installing and maintaining Overleaf in your lab or workplace, Overleaf offers an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises). -## Upgrading +## Installation -If you are upgrading from a previous version of Overleaf, please see the [Release Notes section on the Wiki](https://github.com/overleaf/overleaf/wiki#release-notes) for all of the versions between your current version and the version you are upgrading to. +Detailed installation instructions can be found in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/). +Configuration details and release history for the Extended Community Edition can be found on the [Extended CE Wiki Page](https://github.com/yu-i-i/overleaf-cep/wiki). ## Overleaf Docker Image This repo contains two dockerfiles, [`Dockerfile-base`](server-ce/Dockerfile-base), which builds the -`sharelatex/sharelatex-base` image, and [`Dockerfile`](server-ce/Dockerfile) which builds the -`sharelatex/sharelatex` (or "community") image. +`sharelatex/sharelatex-base:ext-ce` image, and [`Dockerfile`](server-ce/Dockerfile) which builds the +`sharelatex/sharelatex:ext-ce` image. The Base image generally contains the basic dependencies like `wget`, plus `texlive`. -We split this out because it's a pretty heavy set of +This is split out because it's a pretty heavy set of dependencies, and it's nice to not have to rebuild all of that every time. The `sharelatex/sharelatex` image extends the base image and adds the actual Overleaf code @@ -59,22 +67,22 @@ and services. Use `make build-base` and `make build-community` from `server-ce/` to build these images. -We use the [Phusion base-image](https://github.com/phusion/baseimage-docker) -(which is extended by our `base` image) to provide us with a VM-like container +The [Phusion base-image](https://github.com/phusion/baseimage-docker) +(which is extended by the `base` image) provides a VM-like container in which to run the Overleaf services. Baseimage uses the `runit` service -manager to manage services, and we add our init-scripts from the `server-ce/runit` -folder. - -## Contributing - -Please see the [CONTRIBUTING](CONTRIBUTING.md) file for information on contributing to the development of Overleaf. +manager to manage services, and init scripts from the `server-ce/runit` +folder are added. ## Authors -[The Overleaf Team](https://www.overleaf.com/about) +[The Overleaf Team](https://www.overleaf.com/about)\ +[yu-i-i](https://github.com/yu-i-i/overleaf-cep) — CE extensions; references to adapted code are listed in [`CREDITS`](CREDITS.md) ## License The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file. -Copyright (c) Overleaf, 2014-2025. +Copyright (c) Overleaf, 2014-2026.\ +Copyright (c) yu-i-i, 2024-2026, for CE extensions. + +Portions of the code are derived from other open-source projects; see [`CREDITS`](CREDITS.md). diff --git a/develop/README.md b/develop/README.md index b6e0769aec0..e0e9f002d31 100644 --- a/develop/README.md +++ b/develop/README.md @@ -65,8 +65,10 @@ each service: | `filestore` | 9235 | | `notifications` | 9236 | | `real-time` | 9237 | +| `references` | 9238 | | `history-v1` | 9239 | | `project-history` | 9240 | +| `linked-url-proxy` | 9241 | To attach to a service using Chrome's _remote debugging_, go to and make sure _Discover network targets_ is checked. Next diff --git a/develop/dev.env b/develop/dev.env index f2e6004eece..54f465d77cb 100644 --- a/develop/dev.env +++ b/develop/dev.env @@ -8,6 +8,7 @@ FILESTORE_HOST=filestore GRACEFUL_SHUTDOWN_DELAY_SECONDS=0 HISTORY_V1_HOST=history-v1 HISTORY_REDIS_HOST=redis +LINKED_URL_PROXY_HOST=linked-url-proxy LISTEN_ADDRESS=0.0.0.0 MONGO_HOST=mongo MONGO_URL=mongodb://mongo/sharelatex?directConnection=true @@ -17,6 +18,7 @@ QUEUES_REDIS_HOST=redis DSMP_REDIS_HOST=redis REALTIME_HOST=real-time REDIS_HOST=redis +REFERENCES_HOST=references SESSION_SECRET=foo V1_HISTORY_HOST=history-v1 WEBPACK_HOST=webpack diff --git a/develop/docker-compose.dev.yml b/develop/docker-compose.dev.yml index 5315cddcc3c..dd236bc4538 100644 --- a/develop/docker-compose.dev.yml +++ b/develop/docker-compose.dev.yml @@ -79,6 +79,17 @@ services: - ../services/history-v1/knexfile.js:/overleaf/services/history-v1/knexfile.js - ../services/history-v1/migrations:/overleaf/services/history-v1/migrations + linked-url-proxy: + command: ["node", "--watch", "app.mjs"] + environment: + - NODE_OPTIONS=--inspect=0.0.0.0:9229 + ports: + - "127.0.0.1:9241:9229" + volumes: + - ../services/linked-url-proxy/app:/overleaf/services/linked-url-proxy/app + - ../services/linked-url-proxy/config:/overleaf/services/linked-url-proxy/config + - ../services/linked-url-proxy/app.mjs:/overleaf/services/linked-url-proxy/app.mjs + notifications: command: ["node", "--watch", "app.ts"] environment: @@ -112,6 +123,17 @@ services: - ../services/real-time/app.js:/overleaf/services/real-time/app.js - ../services/real-time/config:/overleaf/services/real-time/config + references: + command: ["node", "--watch", "app.js"] + environment: + - NODE_OPTIONS=--inspect=0.0.0.0:9229 + ports: + - "127.0.0.1:9238:9229" + volumes: + - ../services/references/app:/overleaf/services/references/app + - ../services/references/config:/overleaf/services/references/config + - ../services/references/app.js:/overleaf/services/references/app.js + web: command: ["node", "--watch", "app.mjs", "--watch-locales"] environment: diff --git a/develop/docker-compose.yml b/develop/docker-compose.yml index df63fca6a25..be6917aef82 100644 --- a/develop/docker-compose.yml +++ b/develop/docker-compose.yml @@ -95,6 +95,13 @@ services: volumes: - history-v1-buckets:/buckets + linked-url-proxy: + build: + context: .. + dockerfile: services/linked-url-proxy/Dockerfile + env_file: + - dev.env + mongo: image: mongo:8 command: --replSet overleaf @@ -139,6 +146,13 @@ services: volumes: - redis-data:/data + references: + build: + context: .. + dockerfile: services/references/Dockerfile + env_file: + - dev.env + web: build: context: .. @@ -148,7 +162,7 @@ services: - dev.env environment: - APP_NAME=Overleaf Community Edition - - ENABLED_LINKED_FILE_TYPES=project_file,project_output_file + - ENABLED_LINKED_FILE_TYPES=project_file,project_output_file,zotero - EMAIL_CONFIRMATION_DISABLED=true - NODE_ENV=development - OVERLEAF_ALLOW_PUBLIC_ACCESS=true @@ -166,9 +180,11 @@ services: - document-updater - filestore - history-v1 + - linked-url-proxy - notifications - project-history - real-time + - references webpack: build: @@ -181,3 +197,18 @@ services: - "127.0.0.1:80:3808" volumes: - ./webpack.config.dev-env.js:/overleaf/services/web/webpack.config.dev-env.js + + git-bridge: + image: quay.io/sharelatex/git-bridge:latest + expose: + - "8000" + ports: + - "8000:8000" + environment: + GIT_BRIDGE_API_BASE_URL: "http://web:3000/api/v0" + GIT_BRIDGE_OAUTH2_SERVER: "http://web:3000" + GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000" + GIT_BRIDGE_ROOT_DIR: "/data/git-bridge" + LOG_LEVEL: "DEBUG" + volumes: + - ./data/git-bridge:/data/git-bridge diff --git a/develop/webpack.config.dev-env.js b/develop/webpack.config.dev-env.js index 49f85408a8a..5878e0733a0 100644 --- a/develop/webpack.config.dev-env.js +++ b/develop/webpack.config.dev-env.js @@ -14,6 +14,11 @@ module.exports = merge(base, { target: 'http://real-time:3026', ws: true, }, + { + context: '/git/**', + target: 'http://git-bridge:8000', + pathRewrite: { '^/git': '' } + }, { context: ['!**/*.js', '!**/*.css', '!**/*.json'], target: 'http://web:3000', diff --git a/doc/screenshot.png b/doc/screenshot.png index 42a0602c2ef..19f825c2846 100644 Binary files a/doc/screenshot.png and b/doc/screenshot.png differ diff --git a/docker-compose.yml b/docker-compose.yml index 93deef25cc1..34cae6fccab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: OVERLEAF_REDIS_HOST: redis REDIS_HOST: redis - ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file" + ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,zotero" # Enables Thumbnail generation using ImageMagick ENABLE_CONVERSIONS: "true" diff --git a/package.json b/package.json index 614fc900fa9..08165a391ff 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "overleaf", "private": true, "dependencies": { + "passport-ldapauth": "^3.0.1", "patch-package": "^8.0.0" }, "devDependencies": { @@ -78,6 +79,7 @@ "services/history-v1", "services/idp", "services/latexqc", + "services/linked-url-proxy", "services/notifications", "services/project-history", "services/real-time", diff --git a/patches/@node-saml+node-saml+5.1.0.patch b/patches/@node-saml+node-saml+5.1.0.patch new file mode 100644 index 00000000000..81fd700b319 --- /dev/null +++ b/patches/@node-saml+node-saml+5.1.0.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/@node-saml/node-saml/lib/saml.js b/node_modules/@node-saml/node-saml/lib/saml.js +index fba15b9..a5778cb 100644 +--- a/node_modules/@node-saml/node-saml/lib/saml.js ++++ b/node_modules/@node-saml/node-saml/lib/saml.js +@@ -336,7 +336,8 @@ class SAML { + const requestOrResponse = request || response; + (0, utility_1.assertRequired)(requestOrResponse, "either request or response is required"); + let buffer; +- if (this.options.skipRequestCompression) { ++ // logout requestOrResponse must be compressed anyway ++ if (this.options.skipRequestCompression && operation !== "logout") { + buffer = Buffer.from(requestOrResponse, "utf8"); + } + else { +@@ -495,7 +496,7 @@ class SAML { + try { + xml = Buffer.from(container.SAMLResponse, "base64").toString("utf8"); + doc = await (0, xml_1.parseDomFromString)(xml); +- const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response']/@InResponseTo"); ++ const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response' or local-name()='LogoutResponse']/@InResponseTo"); + if (inResponseToNodes) { + inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null; + await this.validateInResponseTo(inResponseTo); diff --git a/patches/ldapauth-fork+5.0.5.patch b/patches/ldapauth-fork+5.0.5.patch new file mode 100644 index 00000000000..3fe729f581d --- /dev/null +++ b/patches/ldapauth-fork+5.0.5.patch @@ -0,0 +1,73 @@ +diff --git a/node_modules/ldapauth-fork/lib/ldapauth.js b/node_modules/ldapauth-fork/lib/ldapauth.js +index 88398ed..0334177 100644 +--- a/node_modules/ldapauth-fork/lib/ldapauth.js ++++ b/node_modules/ldapauth-fork/lib/ldapauth.js +@@ -87,6 +87,7 @@ function LdapAuth(opts) { + this.opts.bindProperty || (this.opts.bindProperty = 'dn'); + this.opts.groupSearchScope || (this.opts.groupSearchScope = 'sub'); + this.opts.groupDnProperty || (this.opts.groupDnProperty = 'dn'); ++ this.opts.tlsStarted = false; + + EventEmitter.call(this); + +@@ -126,21 +127,7 @@ function LdapAuth(opts) { + this._userClient.on('error', this._handleError.bind(this)); + + var self = this; +- if (this.opts.starttls) { +- // When starttls is enabled, this callback supplants the 'connect' callback +- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) { +- if (err) { +- self._handleError(err); +- } else { +- self._onConnectAdmin(); +- } +- }); +- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) { +- if (err) { +- self._handleError(err); +- } +- }); +- } else if (opts.reconnect) { ++ if (opts.reconnect && !this.opts.starttls) { + this.once('_installReconnectListener', function () { + self.log && self.log.trace('install reconnect listener'); + self._adminClient.on('connect', function () { +@@ -401,6 +388,37 @@ LdapAuth.prototype._findGroups = function (user, callback) { + */ + LdapAuth.prototype.authenticate = function (username, password, callback) { + var self = this; ++ if (this.opts.starttls && !this.opts.tlsStarted) { ++ // When starttls is enabled, this callback supplants the 'connect' callback ++ this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) { ++ if (err) { ++ self._handleError(err); ++ } else { ++ self._onConnectAdmin(function(){self._handleAuthenticate(username, password, callback);}); ++ } ++ }); ++ this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) { ++ if (err) { ++ self._handleError(err); ++ } ++ }); ++ } else { ++ self._handleAuthenticate(username, password, callback); ++ } ++}; ++ ++/** ++ * Authenticate given credentials against LDAP server ++ * ++ * @private ++ * @param {string} username - The username to authenticate ++ * @param {string} password - The password to verify ++ * @param {resultCallback} callback - Result handling callback ++ * @returns {undefined} ++ */ ++LdapAuth.prototype._handleAuthenticate = function (username, password, callback) { ++ this.opts.tlsStarted = true; ++ var self = this; + + if (typeof password === 'undefined' || password === null || password === '') { + return callback(new Error('no password given')); diff --git a/server-ce/Dockerfile b/server-ce/Dockerfile index ffe80865a50..61840ea6708 100644 --- a/server-ce/Dockerfile +++ b/server-ce/Dockerfile @@ -39,7 +39,7 @@ ADD server-ce/config/env.sh /etc/overleaf/env.sh # Configure nginx # --------------- ADD server-ce/nginx/nginx.conf.template /etc/nginx/templates/nginx.conf.template -ADD server-ce/nginx/overleaf.conf /etc/nginx/sites-enabled/overleaf.conf +ADD server-ce/nginx/overleaf.conf.template /etc/nginx/templates/overleaf.conf.template ADD server-ce/nginx/clsi-nginx.conf /etc/nginx/sites-enabled/clsi-nginx.conf @@ -111,7 +111,9 @@ ENV GRACEFUL_SHUTDOWN_DELAY_SECONDS=1 ENV NODE_ENV="production" ENV LOG_LEVEL="info" - +ENV GIT_BRIDGE_HOST=git-bridge +ENV GIT_BRIDGE_PORT=8000 +ENV GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE=52428800 EXPOSE 80 ENTRYPOINT ["/sbin/my_init"] diff --git a/server-ce/Makefile b/server-ce/Makefile index 56f16362d42..f426dcc7a22 100644 --- a/server-ce/Makefile +++ b/server-ce/Makefile @@ -36,6 +36,7 @@ build-base: --cache-from $(OVERLEAF_BASE_BRANCH) \ --tag $(OVERLEAF_BASE_TAG) \ --tag $(OVERLEAF_BASE_BRANCH) \ + --network=host \ $(MONOREPO_ROOT) @@ -51,6 +52,7 @@ build-community: --file Dockerfile \ --tag $(OVERLEAF_TAG) \ --tag $(OVERLEAF_BRANCH) \ + --network=host \ $(MONOREPO_ROOT) push: diff --git a/server-ce/config/env.sh b/server-ce/config/env.sh index b12ca242d34..76d8c0f142b 100644 --- a/server-ce/config/env.sh +++ b/server-ce/config/env.sh @@ -6,8 +6,10 @@ export DOCUMENT_UPDATER_HOST=127.0.0.1 export DOCUPDATER_HOST=127.0.0.1 export FILESTORE_HOST=127.0.0.1 export HISTORY_V1_HOST=127.0.0.1 +export LINKED_URL_PROXY_HOST=127.0.0.1 export NOTIFICATIONS_HOST=127.0.0.1 export PROJECT_HISTORY_HOST=127.0.0.1 export REALTIME_HOST=127.0.0.1 +export REFERENCES_HOST=127.0.0.1 export WEB_HOST=127.0.0.1 export WEB_API_HOST=127.0.0.1 diff --git a/server-ce/config/settings.js b/server-ce/config/settings.js index 7d9fd7702ee..59f4688f126 100644 --- a/server-ce/config/settings.js +++ b/server-ce/config/settings.js @@ -383,7 +383,16 @@ if (process.env.OVERLEAF_EMAIL_FROM_ADDRESS != null) { } if ( - process.env.OVERLEAF_EMAIL_SMTP_USER != null || + process.env.OVERLEAF_EMAIL_SMTP_USER != null && + process.env.OVERLEAF_EMAIL_SMTP_PASS_FILE != null + ) { + settings.email.parameters.auth = { + user: process.env.OVERLEAF_EMAIL_SMTP_USER, + pass: require("fs").readFileSync(process.env.OVERLEAF_EMAIL_SMTP_PASS_FILE, "utf8") + } + } + else if ( + process.env.OVERLEAF_EMAIL_SMTP_USER != null && process.env.OVERLEAF_EMAIL_SMTP_PASS != null ) { settings.email.parameters.auth = { diff --git a/server-ce/hotfix/5.5.3/saml-ext.patch b/server-ce/hotfix/5.5.3/saml-ext.patch new file mode 100644 index 00000000000..43e703d1008 --- /dev/null +++ b/server-ce/hotfix/5.5.3/saml-ext.patch @@ -0,0 +1,13 @@ +diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +index 29e9ae52cd..335ace8209 100644 +--- a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs ++++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +@@ -26,7 +26,7 @@ const SAMLModuleManager = { + callbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/login/callback`, + issuer: process.env.OVERLEAF_SAML_ISSUER, + audience: process.env.OVERLEAF_SAML_AUDIENCE, +- cert: readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), ++ idpCert: readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), + privateKey: readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), + decryptionPvk: readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), + signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, diff --git a/server-ce/hotfix/5.5.6-ext-3.3/Dockerfile b/server-ce/hotfix/5.5.6-ext-3.3/Dockerfile new file mode 100644 index 00000000000..3a25b2ffb3c --- /dev/null +++ b/server-ce/hotfix/5.5.6-ext-3.3/Dockerfile @@ -0,0 +1,20 @@ +FROM overleafcep/sharelatex:5.5.6-ext-v3.2 + +# add normalize and sanitize to linked-url-proxy +COPY *.patch* . +RUN bash -ec 'for p in *.patch; do echo "=== Applying $p ==="; patch -p1 < "$p" && rm $p; done' \ + && npm install als-normalize-urlpath@2.3.0 \ + && rm -f ./package.json.orig \ + ./services/history-v1/storage/scripts/back_fill_file_hash.mjs.orig \ + ./services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs.orig \ + ./services/linked-url-proxy/app/js/LinkedUrlProxyController.mjs.orig \ + ./services/web/config/settings.defaults.js.orig \ + ./services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx.orig \ + ./services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx.orig \ + ./services/web/package.json.orig \ + ./services/filestore/app.js.orig \ + ./package-lock.json.orig \ + ./package-lock.json.diff \ + /etc/my_init.d/100_make_overleaf_data_dirs.sh.orig \ + /etc/overleaf/settings.js.orig + diff --git a/server-ce/hotfix/5.5.6-ext-3.3/ext-3.3.patch b/server-ce/hotfix/5.5.6-ext-3.3/ext-3.3.patch new file mode 100644 index 00000000000..93ee46f37f7 --- /dev/null +++ b/server-ce/hotfix/5.5.6-ext-3.3/ext-3.3.patch @@ -0,0 +1,256 @@ +diff --git a/package.json b/package.json +index 64fbd258ed..7cc3c7f9b3 100644 +--- a/package.json ++++ b/package.json +@@ -66,6 +66,7 @@ + "services/history-v1", + "services/idp", + "services/latexqc", ++ "services/linked-url-proxy", + "services/notifications", + "services/project-history", + "services/real-time", +diff --git a/services/linked-url-proxy/Dockerfile b/services/linked-url-proxy/Dockerfile +index 677006182c..7f2053f614 100644 +--- a/services/linked-url-proxy/Dockerfile ++++ b/services/linked-url-proxy/Dockerfile +@@ -2,7 +2,7 @@ + # Instead run bin/update_build_scripts from + # https://github.com/overleaf/internal/ + +-FROM node:20.18.2 AS base ++FROM node:22.18.0 AS base + + WORKDIR /overleaf/services/linked-url-proxy + +@@ -13,16 +13,20 @@ RUN mkdir /home/node/.config && chown node:node /home/node/.config + FROM base AS app + + COPY package.json package-lock.json /overleaf/ ++COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json + COPY libraries/logger/package.json /overleaf/libraries/logger/package.json + COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json ++COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json + COPY libraries/settings/package.json /overleaf/libraries/settings/package.json + COPY services/linked-url-proxy/package.json /overleaf/services/linked-url-proxy/package.json + COPY patches/ /overleaf/patches/ + + RUN cd /overleaf && npm ci --quiet + ++COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ + COPY libraries/logger/ /overleaf/libraries/logger/ + COPY libraries/metrics/ /overleaf/libraries/metrics/ ++COPY libraries/o-error/ /overleaf/libraries/o-error/ + COPY libraries/settings/ /overleaf/libraries/settings/ + COPY services/linked-url-proxy/ /overleaf/services/linked-url-proxy/ + +diff --git a/services/linked-url-proxy/app/js/LinkedUrlProxyController.mjs b/services/linked-url-proxy/app/js/LinkedUrlProxyController.mjs +index 1269eede0d..ea82f65d16 100644 +--- a/services/linked-url-proxy/app/js/LinkedUrlProxyController.mjs ++++ b/services/linked-url-proxy/app/js/LinkedUrlProxyController.mjs +@@ -1,4 +1,6 @@ + import dns from 'dns/promises' ++import { sanitizeUrl } from 'strict-url-sanitise' ++import normalizeUrlPath from 'als-normalize-urlpath' + import ipaddr from 'ipaddr.js' + import { URL } from 'node:url' + import { Transform } from 'node:stream' +@@ -18,15 +20,7 @@ function isBlockedIp(ipStr, targetUrl) { + } + + const range = addr.range() +- if ([ +- 'loopback', +- 'private', +- 'linkLocal', +- 'multicast', +- 'reserved', +- 'broadcast', +- 'unspecified' +- ].includes(range)) { ++ if (!['unicast'].includes(addr.range())) { + return true + } + +@@ -44,7 +38,7 @@ function isBlockedIp(ipStr, targetUrl) { + return false + } + +-async function validateSourceUrl(hostname, targetUrl) { ++async function checkUrlAccess(hostname, targetUrl) { + const records = await dns.lookup(hostname, { all: true }).catch(() => []) + if (!records.length) { + const err = new Error(`DNS lookup failed for ${hostname}`) +@@ -62,22 +56,40 @@ async function validateSourceUrl(hostname, targetUrl) { + } + } + +-async function fetchValidated(urlStr, redirectCount = 0) { ++async function validateAndFetch(rawUrl, redirectCount = 0) { + if (redirectCount > Settings.maxRedirects) { + const err = new Error('Too many redirects') + err.info = { status: 421 } + throw err + } + +- const url = new URL(urlStr) ++ const sanitizedUrl = sanitizeUrl(rawUrl) ++ if (!sanitizedUrl) { ++ const err = new Error(`Invalid or unsafe URL: ${rawUrl}`) ++ err.info = { status: 400 } ++ throw err ++ } ++ ++ const url = new URL(sanitizedUrl) ++ + if (!['http:', 'https:'].includes(url.protocol)) { + const err = new Error(`${url.protocol} protocol is not allowed`) + err.info = { status: 400 } + throw err + } + +- // Validate DNS and blocked IPs +- await validateSourceUrl(url.hostname, urlStr) ++ const normalizedPath = normalizeUrlPath(url.pathname).pathname ++ ++ if (!normalizedPath) { ++ const err = new Error(`Invalid or unsafe URL path: ${url.pathname}`) ++ err.info = { status: 400 } ++ throw err ++ } ++ ++ const normalizedUrl = url.toString() ++ ++ // check DNS and allowed resources ++ await checkUrlAccess(url.hostname, normalizedUrl) + + const opts = { + redirect: 'manual', +@@ -86,7 +98,7 @@ async function fetchValidated(urlStr, redirectCount = 0) { + } + + try { +- const { stream, response } = await fetchStreamWithResponse(urlStr, opts) ++ const { stream, response } = await fetchStreamWithResponse(normalizedUrl, opts) + + const contentLengthHeader = response.headers.get('content-length') + if (contentLengthHeader) { +@@ -112,8 +124,8 @@ async function fetchValidated(urlStr, redirectCount = 0) { + if (status >= 300 && status < 400) { + const location = err.response.headers.get('Location') + if (location) { +- const nextUrl = new URL(location, url).toString() +- return fetchValidated(nextUrl, redirectCount + 1) ++ const nextUrl = new URL(location, normalizedUrl).toString() ++ return validateAndFetch(nextUrl, redirectCount + 1) + } else { + const e = new Error('Redirect response missing Location header') + e.info = { status: 421 } +@@ -141,14 +153,14 @@ async function proxy(req, res) { + return + } + +- const { stream: upstreamStream, response, headers } = await fetchValidated(targetUrl) ++ const { stream: upstreamStream, response, headers } = await validateAndFetch(targetUrl) + + res.statusCode = response.status || 200 + res.setHeader('Content-Type', headers['content-type'] || 'application/octet-stream') + res.setHeader('Cache-Control', 'no-store') + + function onError(err) { +- logger.warn({ err, url: req.url }, 'linked-url-proxy request failed') ++ logger.info({ err, url: req.url }, 'linked-url-proxy request failed') + try { upstreamStream.destroy() } catch (_) {} + if (!res.headersSent) { + let body = `Error: ${err?.message ?? String(err)}` +@@ -163,11 +175,9 @@ async function proxy(req, res) { + upstreamStream.pipe(res) + + } catch (err) { +- logger.warn({ err, url: req.url }, 'linked-url-proxy request failed') +- +- let status = err.info.status ++ const status = err.info?.status || 500 ++ logger.info({ linkedUrl: err.message, status, url: req.url }, 'linked-url-proxy request failed') + let body = `Error: ${err.message || String(err)}` +- + try { + res.writeHead(status, { 'Content-Type': 'text/plain' }) + res.end(body) +diff --git a/services/linked-url-proxy/package.json b/services/linked-url-proxy/package.json +index 845551c8ef..8081a80822 100644 +--- a/services/linked-url-proxy/package.json ++++ b/services/linked-url-proxy/package.json +@@ -2,18 +2,24 @@ + "name": "@overleaf/linked-url-proxy", + "description": "An API for providing linked url proxy", + "private": true, +- "type": "module", + "main": "app.mjs", + "scripts": { +- "start": "node app.mjs" ++ "start": "node app.mjs", ++ "nodemon": "node --watch app.mjs" + }, +- "version": "0.1.0", ++ "version": "0.1.1", + "dependencies": { + "@overleaf/settings": "*", + "@overleaf/logger": "*", + "@overleaf/metrics": "*", + "async": "^3.2.5", +- "express": "^4.21.2" +- "ipaddr.js": "^1.9.1" ++ "express": "^4.21.2", ++ "ipaddr.js": "^1.9.1", ++ "als-normalize-urlpath": "^2.3.0", ++ "strict-url-sanitise": "^0.0.1" ++ }, ++ "devDependencies": { ++ "als-normalize-urlpath": "^2.3.0", ++ "strict-url-sanitise": "^0.0.1" + } + } +diff --git a/services/linked-url-proxy/tsconfig.json b/services/linked-url-proxy/tsconfig.json +index c018d6e682..0639496cc8 100644 +--- a/services/linked-url-proxy/tsconfig.json ++++ b/services/linked-url-proxy/tsconfig.json +@@ -1,7 +1,7 @@ + { + "extends": "../../tsconfig.backend.json", + "include": [ +- "app.js", ++ "app.mjs", + "app.ts", + "app/js/**/*", + "benchmarks/**/*", +diff --git a/services/web/package.json b/services/web/package.json +index 21da1188a2..c2c4fcfaba 100644 +--- a/services/web/package.json ++++ b/services/web/package.json +@@ -69,6 +69,7 @@ + "last 1 year", + "safari > 14" + ], ++ + "dependencies": { + "@contentful/rich-text-html-renderer": "^16.0.2", + "@contentful/rich-text-types": "^16.0.2", +@@ -92,6 +93,7 @@ + "@xmldom/xmldom": "^0.7.13", + "accepts": "^1.3.7", + "ajv": "^8.12.0", ++ "als-normalize-urlpath": "^2.3.0", + "archiver": "^5.3.0", + "async": "^3.2.5", + "base-x": "^4.0.1", +@@ -166,7 +168,8 @@ + "request": "^2.88.2", + "requestretry": "^7.1.0", + "sanitize-html": "^2.8.1", +- "stripe": "^18.1.0", ++ "strict-url-sanitise": "^0.0.1", ++ "stripe": "^18.4.0", + "tough-cookie": "^4.0.0", + "tsscmp": "^1.0.6", + "uid-safe": "^2.1.5", diff --git a/server-ce/init_scripts/200_nginx_config_template.sh b/server-ce/init_scripts/200_nginx_config_template.sh index f5707260cea..611fbb997b5 100755 --- a/server-ce/init_scripts/200_nginx_config_template.sh +++ b/server-ce/init_scripts/200_nginx_config_template.sh @@ -16,11 +16,18 @@ fi nginx_template_file="${nginx_templates_dir}/nginx.conf.template" nginx_config_file="${nginx_dir}/nginx.conf" +overleaf_template_file="${nginx_templates_dir}/overleaf.conf.template" +overleaf_config_file="${nginx_dir}/sites-enabled/overleaf.conf" + if [ -f "${nginx_template_file}" ]; then export NGINX_KEEPALIVE_TIMEOUT="${NGINX_KEEPALIVE_TIMEOUT:-65}" export NGINX_WORKER_CONNECTIONS="${NGINX_WORKER_CONNECTIONS:-768}" export NGINX_WORKER_PROCESSES="${NGINX_WORKER_PROCESSES:-4}" + export MAX_UPLOAD_SIZE="${MAX_UPLOAD_SIZE:-50}" + export GIT_BRIDGE_HOST="${GIT_BRIDGE_HOST:-git-bridge}" + export GIT_BRIDGE_PORT="${GIT_BRIDGE_PORT:-8000}" + export GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE="${GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE:-52428800}" echo "Nginx: generating config file from template" # Note the single-quotes, they are important. @@ -31,10 +38,20 @@ if [ -f "${nginx_template_file}" ]; then ${NGINX_KEEPALIVE_TIMEOUT} ${NGINX_WORKER_CONNECTIONS} ${NGINX_WORKER_PROCESSES} + ${MAX_UPLOAD_SIZE} ' \ < "${nginx_template_file}" \ > "${nginx_config_file}" + # Config git-bridge in nginx + envsubst ' + ${GIT_BRIDGE_HOST} + ${GIT_BRIDGE_PORT} + ${GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE} + ' \ + < "${overleaf_template_file}" \ + > "${overleaf_config_file}" + echo "Checking Nginx config" nginx -t diff --git a/server-ce/nginx/nginx.conf.template b/server-ce/nginx/nginx.conf.template index 639937f41cf..2fb8ad3271c 100644 --- a/server-ce/nginx/nginx.conf.template +++ b/server-ce/nginx/nginx.conf.template @@ -46,7 +46,7 @@ http { gzip_disable "msie6"; gzip_proxied any; # allow upstream server to compress. - client_max_body_size 50m; + client_max_body_size ${MAX_UPLOAD_SIZE}m; # gzip_vary on; # gzip_proxied any; diff --git a/server-ce/nginx/overleaf.conf b/server-ce/nginx/overleaf.conf.template similarity index 80% rename from server-ce/nginx/overleaf.conf rename to server-ce/nginx/overleaf.conf.template index 77e59df5a0c..34ff6357dde 100644 --- a/server-ce/nginx/overleaf.conf +++ b/server-ce/nginx/overleaf.conf.template @@ -21,6 +21,23 @@ server { proxy_send_timeout 10m; } + location ^~ /git/ { + resolver_timeout 2s; + resolver 127.0.0.11 valid=10s; + set $git_upstream http://${GIT_BRIDGE_HOST}:${GIT_BRIDGE_PORT}; + rewrite ^/git/(.*)$ /$1 break; + client_max_body_size ${GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE}; + proxy_pass $git_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 10m; + proxy_send_timeout 10m; + } + location /socket.io { proxy_pass http://127.0.0.1:3026; proxy_http_version 1.1; diff --git a/server-ce/runit/linked-url-proxy-overleaf/run b/server-ce/runit/linked-url-proxy-overleaf/run new file mode 100755 index 00000000000..e628c4b027a --- /dev/null +++ b/server-ce/runit/linked-url-proxy-overleaf/run @@ -0,0 +1,12 @@ +#!/bin/bash + +NODE_PARAMS="" +if [ "$DEBUG_NODE" == "true" ]; then + echo "running debug - linked-url-proxy" + NODE_PARAMS="--inspect=0.0.0.0:30660" +fi + +source /etc/overleaf/env.sh +export LISTEN_ADDRESS=127.0.0.1 + +exec /sbin/setuser www-data /usr/bin/node $NODE_PARAMS /overleaf/services/linked-url-proxy/app.mjs >> /var/log/overleaf/linked-url-proxy.log 2>&1 diff --git a/server-ce/runit/references-overleaf/run b/server-ce/runit/references-overleaf/run new file mode 100755 index 00000000000..875023df9fa --- /dev/null +++ b/server-ce/runit/references-overleaf/run @@ -0,0 +1,12 @@ +#!/bin/bash + +NODE_PARAMS="" +if [ "$DEBUG_NODE" == "true" ]; then + echo "running debug - references" + NODE_PARAMS="--inspect=0.0.0.0:30560" +fi + +source /etc/overleaf/env.sh +export LISTEN_ADDRESS=127.0.0.1 + +exec /sbin/setuser www-data /usr/bin/node $NODE_PARAMS /overleaf/services/references/app.js >> /var/log/overleaf/references.log 2>&1 diff --git a/server-ce/services.js b/server-ce/services.js index d0b0a9c0765..15104dfe288 100644 --- a/server-ce/services.js +++ b/server-ce/services.js @@ -29,9 +29,15 @@ module.exports = [ { name: 'project-history', }, + { + name: 'references', + }, { name: 'history-v1', }, + { + name: 'linked-url-proxy', + }, ] if (require.main === module) { diff --git a/server-ce/test/docker-compose.yml b/server-ce/test/docker-compose.yml index c7237fa4f80..a36344ad925 100644 --- a/server-ce/test/docker-compose.yml +++ b/server-ce/test/docker-compose.yml @@ -19,7 +19,7 @@ services: OVERLEAF_EMAIL_SMTP_HOST: "mailtrap" OVERLEAF_EMAIL_SMTP_PORT: "25" OVERLEAF_EMAIL_SMTP_IGNORE_TLS: "true" - ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file" + ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,zotero" ENABLE_CONVERSIONS: "true" EMAIL_CONFIRMATION_DISABLED: "true" healthcheck: diff --git a/services/clsi/README.md b/services/clsi/README.md index e7edc98ad49..f5529bcc25b 100644 --- a/services/clsi/README.md +++ b/services/clsi/README.md @@ -29,7 +29,7 @@ The CLSI can be configured through the following environment variables: - `PROCESS_LIFE_SPAN_LIMIT_MS` - Process life span limit in milliseconds - `SMOKE_TEST` - Whether to run smoke tests - `TEXLIVE_IMAGE` - The TeX Live Docker image to use for sibling containers, e.g. `us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2025.1` -- `TEX_LIVE_IMAGE_NAME_OVERRIDE` - The name of the registry for the Docker image e.g. `us-east1-docker.pkg.dev/overleaf-ops/ol-docker` +- `TEX_LIVE_DOCKER_IMAGE_ROOT` - The name of the registry for the Docker image e.g. `us-east1-docker.pkg.dev/overleaf-ops/ol-docker` - `TEXLIVE_IMAGE_USER` - When using sibling containers, the user to run as in the TeX Live image. Defaults to `tex` - `TEXLIVE_OPENOUT_ANY` - Sets the `openout_any` environment variable for TeX Live (see the `\openout` primitive [documentation](http://tug.org/texinfohtml/web2c.html#tex-invocation)) diff --git a/services/clsi/app/js/CommandRunner.js b/services/clsi/app/js/CommandRunner.js index d98cfcc4d3f..02cfe1670a0 100644 --- a/services/clsi/app/js/CommandRunner.js +++ b/services/clsi/app/js/CommandRunner.js @@ -10,7 +10,7 @@ import logger from '@overleaf/logger' let commandRunnerPath if ((Settings.clsi != null ? Settings.clsi.dockerRunner : undefined) === true) { - commandRunnerPath = './DockerRunner.js' + commandRunnerPath = './DockerRunner.mjs' } else { commandRunnerPath = './LocalCommandRunner.js' } diff --git a/services/clsi/app/js/DockerRunner.mjs b/services/clsi/app/js/DockerRunner.mjs new file mode 100644 index 00000000000..fa32fee10cf --- /dev/null +++ b/services/clsi/app/js/DockerRunner.mjs @@ -0,0 +1,624 @@ +import { promisify } from 'node:util' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import Docker from 'dockerode' +import crypto from 'node:crypto' +import async from 'async' +import LockManager from './DockerLockManager.js' +import Path from 'node:path' +import _ from 'lodash' +import { getLastProjectAccessTime } from './LastProjectAccess.js' + +const dockerode = new Docker() + +const ONE_HOUR_IN_MS = 60 * 60 * 1000 +logger.debug('using docker runner') + +let containerMonitorTimeout +let containerMonitorInterval + +const DockerRunner = { + run( + projectId, + command, + directory, + image, + timeout, + environment, + compileGroup, + callback + ) { + command = command.map(arg => + arg.toString().replace('$COMPILE_DIR', '/compile') + ) + if (image == null) { + image = Settings.clsi.docker.image + } + + if ( + Settings.clsi.docker.allowedImages && + !Settings.clsi.docker.allowedImages.includes(image) + ) { + return callback(new Error('image not allowed')) + } + + if (Settings.texliveImageNameOveride != null) { + const img = Path.basename(image) + image = `${Settings.texliveImageNameOveride}/${img}` + } + + if (compileGroup === 'synctex-output') { + // In: directory = '/overleaf/services/clsi/output/projectId-userId/generated-files/buildId' + // directory.split('/').slice(-3) === 'projectId-userId/generated-files/buildId' + // sandboxedCompilesHostDirOutput = '/host/output' + // Out: directory = '/host/output/projectId-userId/generated-files/buildId' + directory = Path.join( + Settings.path.sandboxedCompilesHostDirOutput, + ...directory.split('/').slice(-3) + ) + } else { + // In: directory = '/overleaf/services/clsi/compiles/projectId-userId' + // Path.basename(directory) === 'projectId-userId' + // sandboxedCompilesHostDirCompiles = '/host/compiles' + // Out: directory = '/host/compiles/projectId-userId' + directory = Path.join( + Settings.path.sandboxedCompilesHostDirCompiles, + Path.basename(directory) + ) + } + + const volumes = { [directory]: '/compile' } + if ( + compileGroup === 'synctex' || + compileGroup === 'synctex-output' || + compileGroup === 'wordcount' + ) { + volumes[directory] += ':ro' + } + + const options = DockerRunner._getContainerOptions( + command, + image, + volumes, + timeout, + environment, + compileGroup + ) + const fingerprint = DockerRunner._fingerprintContainer(options) + const name = `project-${projectId}-${fingerprint}` + options.name = name + + // logOptions = _.clone(options) + // logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging" + logger.debug({ projectId }, 'running docker container') + DockerRunner._runAndWaitForContainer( + options, + volumes, + timeout, + (error, output) => { + if (error && error.statusCode === 500) { + logger.debug( + { err: error, projectId }, + 'error running container so destroying and retrying' + ) + DockerRunner.destroyContainer(name, null, true, error => { + if (error != null) { + return callback(error) + } + DockerRunner._runAndWaitForContainer( + options, + volumes, + timeout, + callback + ) + }) + } else { + callback(error, output) + } + } + ) + + // pass back the container name to allow it to be killed + return name + }, + + kill(containerId, callback) { + logger.debug({ containerId }, 'sending kill signal to container') + const container = dockerode.getContainer(containerId) + container.kill(error => { + if ( + error != null && + error.message != null && + error.message.match(/Cannot kill container .* is not running/) + ) { + logger.warn( + { err: error, containerId }, + 'container not running, continuing' + ) + error = null + } + if (error != null) { + logger.error({ err: error, containerId }, 'error killing container') + callback(error) + } else { + callback() + } + }) + }, + + _runAndWaitForContainer(options, volumes, timeout, _callback) { + const callback = _.once(_callback) + const { name } = options + + let streamEnded = false + let containerReturned = false + let output = {} + + function callbackIfFinished() { + if (streamEnded && containerReturned) { + callback(null, output) + } + } + + function attachStreamHandler(error, _output) { + if (error != null) { + return callback(error) + } + output = _output + streamEnded = true + callbackIfFinished() + } + + DockerRunner.startContainer( + options, + volumes, + attachStreamHandler, + (error, containerId) => { + if (error != null) { + return callback(error) + } + + DockerRunner.waitForContainer( + name, + timeout, + options, + (error, exitCode) => { + if (error != null) { + return callback(error) + } + if (exitCode === 137) { + // exit status from kill -9 + const err = new Error('terminated') + err.terminated = true + return callback(err) + } + if (exitCode === 1) { + // exit status from chktex + const err = new Error('exited') + err.code = exitCode + return callback(err) + } + containerReturned = true + logger.debug( + // The seccomp policy is very large. Avoid logging it. _.omit deep clones. + { exitCode, options: _.omit(options, 'HostConfig.SecurityOpt') }, + 'docker container has exited' + ) + callbackIfFinished() + } + ) + } + ) + }, + + _getContainerOptions( + command, + image, + volumes, + timeout, + environment, + compileGroup + ) { + const timeoutInSeconds = timeout / 1000 + + for (const hostVol in volumes) { + const dockerVol = volumes[hostVol] + if (volumes[hostVol].slice(-3).indexOf(':r') === -1) { + volumes[hostVol] = `${dockerVol}:rw` + } + } + + // merge settings and environment parameter + const env = {} + for (const src of [Settings.clsi.docker.env, environment || {}]) { + for (const key in src) { + const value = src[key] + env[key] = value + } + } + // set the path based on the image year + const match = image.match(/:([0-9]+)\.[0-9]+|:TL([0-9]+)/) + // the rolling build does not follow our .. convention + const year = match ? match[1] || match[2] : 'rolling' + + env.PATH = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/texlive/${year}/bin/x86_64-linux/` + const options = { + Cmd: command, + Image: image, + WorkingDir: '/compile', + NetworkDisabled: true, + Memory: 1024 * 1024 * 1024 * 1024, // 1 Gb + User: Settings.clsi.docker.user, + Env: Object.entries(env).map(([key, value]) => `${key}=${value}`), + HostConfig: { + Binds: Object.entries(volumes).map( + ([hostVol, dockerVol]) => `${hostVol}:${dockerVol}` + ), + LogConfig: { Type: 'none', Config: {} }, + Ulimits: [ + { + Name: 'cpu', + Soft: timeoutInSeconds + 5, + Hard: timeoutInSeconds + 10, + }, + ], + CapDrop: ['ALL'], + SecurityOpt: ['no-new-privileges'], + }, + } + + if (Settings.clsi.docker.seccomp_profile != null) { + options.HostConfig.SecurityOpt.push( + `seccomp=${Settings.clsi.docker.seccomp_profile}` + ) + } + + if (Settings.clsi.docker.apparmor_profile != null) { + options.HostConfig.SecurityOpt.push( + `apparmor=${Settings.clsi.docker.apparmor_profile}` + ) + } + + if (Settings.clsi.docker.runtime) { + options.HostConfig.Runtime = Settings.clsi.docker.runtime + } + + if (Settings.clsi.docker.Readonly) { + options.HostConfig.ReadonlyRootfs = true + options.HostConfig.Tmpfs = { '/tmp': 'rw,noexec,nosuid,size=65536k' } + options.Volumes['/home/tex'] = {} + } + + // Allow per-compile group overriding of individual settings + if ( + Settings.clsi.docker.compileGroupConfig && + Settings.clsi.docker.compileGroupConfig[compileGroup] + ) { + const override = Settings.clsi.docker.compileGroupConfig[compileGroup] + for (const key in override) { + _.set(options, key, override[key]) + } + } + + return options + }, + + _fingerprintContainer(containerOptions) { + // Yay, Hashing! + const json = JSON.stringify(containerOptions) + return crypto.createHash('md5').update(json).digest('hex') + }, + + startContainer(options, volumes, attachStreamHandler, callback) { + LockManager.runWithLock( + options.name, + releaseLock => + DockerRunner._startContainer( + options, + volumes, + attachStreamHandler, + releaseLock + ), + callback + ) + }, + + // Check that volumes exist and are directories + _startContainer(options, volumes, attachStreamHandler, callback) { + callback = _.once(callback) + const { name } = options + + logger.debug({ containerName: name }, 'starting container') + const container = dockerode.getContainer(name) + + function createAndStartContainer() { + dockerode.createContainer(options, (error, container) => { + if (error != null) { + return callback(error) + } + startExistingContainer() + }) + } + + function startExistingContainer() { + DockerRunner.attachToContainer( + options.name, + attachStreamHandler, + error => { + if (error != null) { + return callback(error) + } + container.start(error => { + if (error != null && error.statusCode !== 304) { + callback(error) + } else { + // already running + callback() + } + }) + } + ) + } + + container.inspect((error, stats) => { + if (error != null && error.statusCode === 404) { + createAndStartContainer() + } else if (error != null) { + logger.err( + { containerName: name, error }, + 'unable to inspect container to start' + ) + callback(error) + } else { + startExistingContainer() + } + }) + }, + + attachToContainer(containerId, attachStreamHandler, attachStartCallback) { + const container = dockerode.getContainer(containerId) + container.attach({ stdout: 1, stderr: 1, stream: 1 }, (error, stream) => { + if (error != null) { + logger.error( + { err: error, containerId }, + 'error attaching to container' + ) + return attachStartCallback(error) + } else { + attachStartCallback() + } + + logger.debug({ containerId }, 'attached to container') + + const MAX_OUTPUT = 1024 * 1024 * 2 // limit output to 2MB + function createStringOutputStream(name) { + return { + data: '', + overflowed: false, + write(data) { + if (this.overflowed) { + return + } + if (this.data.length < MAX_OUTPUT) { + this.data += data + } else { + logger.info( + { + containerId, + length: this.data.length, + maxLen: MAX_OUTPUT, + }, + `${name} exceeds max size` + ) + this.data += `(...truncated at ${MAX_OUTPUT} chars...)` + this.overflowed = true + } + }, + // kill container if too much output + // docker.containers.kill(containerId, () ->) + } + } + + const stdout = createStringOutputStream('stdout') + const stderr = createStringOutputStream('stderr') + + container.modem.demuxStream(stream, stdout, stderr) + + stream.on('error', err => + logger.error( + { err, containerId }, + 'error reading from container stream' + ) + ) + + stream.on('end', () => + attachStreamHandler(null, { stdout: stdout.data, stderr: stderr.data }) + ) + }) + }, + + waitForContainer(containerId, timeout, options, _callback) { + const callback = _.once(_callback) + + const container = dockerode.getContainer(containerId) + + let timedOut = false + const timeoutId = setTimeout(() => { + timedOut = true + logger.debug({ containerId }, 'timeout reached, killing container') + container.kill(err => { + logger.warn({ err, containerId }, 'failed to kill container') + }) + }, timeout) + + logger.debug({ containerId }, 'waiting for docker container') + container.wait((error, res) => { + if (error?.statusCode === 404 && options.HostConfig.AutoRemove) { + logger.debug( + { containerId }, + 'auto-destroy container destroyed before starting to wait' + ) + clearTimeout(timeoutId) + return callback(null, 0) + } + if (error != null) { + clearTimeout(timeoutId) + logger.warn({ err: error, containerId }, 'error waiting for container') + return callback(error) + } + if (timedOut) { + logger.debug({ containerId }, 'docker container timed out') + error = new Error('container timed out') + error.timedout = true + callback(error) + } else { + clearTimeout(timeoutId) + logger.debug( + { containerId, exitCode: res.StatusCode }, + 'docker container returned' + ) + callback(null, res.StatusCode) + } + }) + }, + + destroyContainer(containerName, containerId, shouldForce, callback) { + // We want the containerName for the lock and, ideally, the + // containerId to delete. There is a bug in the docker.io module + // where if you delete by name and there is an error, it throws an + // async exception, but if you delete by id it just does a normal + // error callback. We fall back to deleting by name if no id is + // supplied. + LockManager.runWithLock( + containerName, + releaseLock => + DockerRunner._destroyContainer( + containerId || containerName, + shouldForce, + releaseLock + ), + callback + ) + }, + + _destroyContainer(containerId, shouldForce, callback) { + logger.debug({ containerId }, 'destroying docker container') + const container = dockerode.getContainer(containerId) + container.remove({ force: shouldForce === true, v: true }, error => { + if (error != null && error.statusCode === 404) { + logger.warn( + { err: error, containerId }, + 'container not found, continuing' + ) + error = null + } + if (error != null) { + logger.error({ err: error, containerId }, 'error destroying container') + } else { + logger.debug({ containerId }, 'destroyed container') + } + callback(error) + }) + }, + + // handle expiry of docker containers + + MAX_CONTAINER_AGE: Settings.clsi.docker.maxContainerAge || ONE_HOUR_IN_MS, + + examineOldContainer(container, callback) { + const name = container.Name || (container.Names && container.Names[0]) + const created = container.Created * 1000 // creation time is returned in seconds + const now = Date.now() + const age = now - created + const maxAge = DockerRunner.MAX_CONTAINER_AGE + const ttl = maxAge - age + logger.debug( + { containerName: name, created, now, age, maxAge, ttl }, + 'checking whether to destroy container' + ) + return { name, id: container.Id, ttl } + }, + + destroyOldContainers(callback) { + dockerode.listContainers({ all: true }, (error, containers) => { + if (error != null) { + return callback(error) + } + const jobs = [] + for (const container of containers) { + const { name, id, ttl } = DockerRunner.examineOldContainer(container) + if (name.slice(0, 9) === '/project-' && ttl <= 0) { + // extract projectId: /project--... + const match = name.match(/^\/project-([a-f0-9]{24})-/) + const projectId = match && match[1] + + if (projectId != null) { + const lastAccess = getLastProjectAccessTime(projectId) + // if last access time is missing, lastAccess === 0, container will be destroyed + // provided that MAX_CONTAINER_AGE is less that 56 years, but anyway, let's check it + if (lastAccess && lastAccess > Date.now() - DockerRunner.MAX_CONTAINER_AGE) { + continue + } + } + + // strip the / prefix + // the LockManager uses the plain container name + const plainName = name.slice(1) + jobs.push(cb => + DockerRunner.destroyContainer(plainName, id, false, () => cb()) + ) + } + } + // Ignore errors because some containers get stuck but + // will be destroyed next time + async.series(jobs, callback) + }) + }, + + startContainerMonitor() { + logger.debug( + { maxAge: DockerRunner.MAX_CONTAINER_AGE }, + 'starting container expiry' + ) + + // guarantee only one monitor is running + DockerRunner.stopContainerMonitor() + + // randomise the start time + const randomDelay = Math.floor(Math.random() * 5 * 60 * 1000) + containerMonitorTimeout = setTimeout(() => { + containerMonitorInterval = setInterval( + () => + DockerRunner.destroyOldContainers(err => { + if (err) { + logger.error({ err }, 'failed to destroy old containers') + } + }), + ONE_HOUR_IN_MS + ) + }, randomDelay) + }, + + stopContainerMonitor() { + if (containerMonitorTimeout) { + clearTimeout(containerMonitorTimeout) + containerMonitorTimeout = undefined + } + if (containerMonitorInterval) { + clearInterval(containerMonitorInterval) + containerMonitorInterval = undefined + } + }, + + canRunSyncTeXInOutputDir() { + return Boolean(Settings.path.sandboxedCompilesHostDirOutput) + }, +} + +DockerRunner.startContainerMonitor() + +DockerRunner.promises = { + run: promisify(DockerRunner.run), + kill: promisify(DockerRunner.kill), +} +export default DockerRunner diff --git a/services/clsi/buildscript.txt b/services/clsi/buildscript.txt index f69c82e7930..5063862282b 100644 --- a/services/clsi/buildscript.txt +++ b/services/clsi/buildscript.txt @@ -1,7 +1,7 @@ clsi --data-dirs=cache,compiles,output --dependencies= ---env-add=DOWNLOAD_HOST=http://clsi-nginx:8080,ALLOWED_COMPILE_GROUPS="clsi-perf simple-latex-file",ENABLE_PDF_CACHING="true",PDF_CACHING_ENABLE_WORKER_POOL="true",ALLOWED_IMAGES="quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1",TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2025.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=us-east1-docker.pkg.dev/overleaf-ops/ol-docker,TEXLIVE_IMAGE_USER="tex",SANDBOXED_COMPILES="true",SANDBOXED_COMPILES_HOST_DIR_COMPILES=$PWD/compiles,SANDBOXED_COMPILES_HOST_DIR_OUTPUT=$PWD/output +--env-add=DOWNLOAD_HOST=http://clsi-nginx:8080,ALLOWED_COMPILE_GROUPS="clsi-perf simple-latex-file",ENABLE_PDF_CACHING="true",PDF_CACHING_ENABLE_WORKER_POOL="true",ALLOWED_IMAGES="quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1",TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2025.1,TEX_LIVE_IMAGE_NAME_ROOT=us-east1-docker.pkg.dev/overleaf-ops/ol-docker,TEXLIVE_IMAGE_USER="tex",SANDBOXED_COMPILES="true",SANDBOXED_COMPILES_HOST_DIR_COMPILES=$PWD/compiles,SANDBOXED_COMPILES_HOST_DIR_OUTPUT=$PWD/output --env-pass-through= --esmock-loader=False --node-version=24.13.0 diff --git a/services/clsi/config/settings.defaults.cjs b/services/clsi/config/settings.defaults.cjs index 693194c1f4e..f9426da045f 100644 --- a/services/clsi/config/settings.defaults.cjs +++ b/services/clsi/config/settings.defaults.cjs @@ -74,7 +74,7 @@ module.exports = { parallelFileDownloads: parseInt(process.env.FILESTORE_PARALLEL_FILE_DOWNLOADS, 10) || 1, filestoreDomainOveride: process.env.FILESTORE_DOMAIN_OVERRIDE, - texliveImageNameOveride: process.env.TEX_LIVE_IMAGE_NAME_OVERRIDE, + texliveImageNameOveride: process.env.TEX_LIVE_DOCKER_IMAGE_ROOT, texliveOpenoutAny: process.env.TEXLIVE_OPENOUT_ANY, texliveMaxPrintLine: process.env.TEXLIVE_MAX_PRINT_LINE, enablePdfCaching: process.env.ENABLE_PDF_CACHING === 'true', @@ -106,7 +106,7 @@ if (process.env.ALLOWED_COMPILE_GROUPS) { if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') { if ( - !fs.existsSync(Path.join(__dirname, '..', 'app', 'js', 'DockerRunner.js')) + !fs.existsSync(Path.join(__dirname, '..', 'app', 'js', 'DockerRunner.mjs')) ) { console.error( 'Sandboxed compiles are only available with Overleaf Server Pro. Compare Server Pro with Community Edition here: https://docs.overleaf.com/on-premises/welcome/server-pro-vs.-community-edition' @@ -121,13 +121,13 @@ if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') { image: process.env.TEXLIVE_IMAGE || process.env.TEX_LIVE_DOCKER_IMAGE || - 'quay.io/sharelatex/texlive-full:2017.1', + process.env.ALL_TEX_LIVE_DOCKER_IMAGES.split(',')[0].trim(), env: { HOME: '/tmp', CLSI: 1, }, socketPath: '/var/run/docker.sock', - user: process.env.TEXLIVE_IMAGE_USER || 'tex', + user: process.env.TEXLIVE_IMAGE_USER || 'www-data', }, optimiseInDocker: true, expireProjectAfterIdleMs: 24 * 60 * 60 * 1000, diff --git a/services/clsi/docker-compose.ci.yml b/services/clsi/docker-compose.ci.yml index a1e188b4e51..8c9b512cf89 100644 --- a/services/clsi/docker-compose.ci.yml +++ b/services/clsi/docker-compose.ci.yml @@ -33,7 +33,7 @@ services: PDF_CACHING_ENABLE_WORKER_POOL: "true" ALLOWED_IMAGES: "quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1" TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1 - TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker + TEX_LIVE_DOCKER_IMAGE_ROOT: us-east1-docker.pkg.dev/overleaf-ops/ol-docker TEXLIVE_IMAGE_USER: "tex" SANDBOXED_COMPILES: "true" SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles diff --git a/services/clsi/docker-compose.yml b/services/clsi/docker-compose.yml index 0ab691e5356..f1497458f71 100644 --- a/services/clsi/docker-compose.yml +++ b/services/clsi/docker-compose.yml @@ -47,7 +47,7 @@ services: PDF_CACHING_ENABLE_WORKER_POOL: "true" ALLOWED_IMAGES: "quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1" TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1 - TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker + TEX_LIVE_DOCKER_IMAGE_ROOT: us-east1-docker.pkg.dev/overleaf-ops/ol-docker TEXLIVE_IMAGE_USER: "tex" SANDBOXED_COMPILES: "true" SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles diff --git a/services/clsi/seccomp/clsi-profile.json b/services/clsi/seccomp/clsi-profile.json new file mode 100644 index 00000000000..0a89877ead7 --- /dev/null +++ b/services/clsi/seccomp/clsi-profile.json @@ -0,0 +1,879 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "architectures": [ + "SCMP_ARCH_X86_64", + "SCMP_ARCH_X86", + "SCMP_ARCH_X32", + "SCMP_ARCH_AARCH64", + "SCMP_ARCH_ARM" + ], + "syscalls": [ + { + "name": "getrandom", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "access", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "arch_prctl", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "brk", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "chdir", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "chmod", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "clock_getres", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "clock_gettime", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "clock_nanosleep", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "clone", + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2080505856, + "valueTwo": 0, + "op": "SCMP_CMP_MASKED_EQ" + } + ] + }, + { + "name": "clone3", + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38 + }, + { + "name": "close", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "copy_file_range", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "creat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "dup", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "dup2", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "dup3", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "execve", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "execveat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "exit", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "exit_group", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "faccessat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fadvise64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fadvise64_64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fallocate", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fchdir", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fchmod", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fchmodat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fcntl", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fcntl64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fdatasync", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fork", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fstat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fstat64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fstatat64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fstatfs", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fstatfs64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fsync", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "ftruncate", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "ftruncate64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "futex", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "futimesat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getcpu", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getcwd", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getdents", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getdents64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getegid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getegid32", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "geteuid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "geteuid32", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getgid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getgid32", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getgroups", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getgroups32", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getpgid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getpgrp", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getpid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getppid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getpriority", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getresgid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getresgid32", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getresuid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getresuid32", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getrlimit", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "get_robust_list", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getrusage", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getsid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "gettid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getuid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "getuid32", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "ioctl", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "kill", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "_llseek", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "lseek", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "lstat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "lstat64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "madvise", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "mkdir", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "mkdirat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "mmap", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "mmap2", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "mprotect", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "mremap", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "munmap", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "newfstatat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "open", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "openat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "openat2", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "pause", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "pipe", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "pipe2", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "prctl", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "pread64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "preadv", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "prlimit64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "pwrite64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "pwritev", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "read", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "readlink", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "readlinkat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "readv", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rename", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "renameat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "renameat2", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "restart_syscall", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rmdir", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rt_sigaction", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rt_sigpending", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rt_sigprocmask", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rt_sigqueueinfo", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rt_sigreturn", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rt_sigsuspend", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rt_sigtimedwait", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "rt_tgsigqueueinfo", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sched_getaffinity", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sched_getparam", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sched_get_priority_max", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sched_get_priority_min", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sched_getscheduler", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sched_rr_get_interval", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sched_yield", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sendfile", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sendfile64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "setgroups", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "setgroups32", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "set_robust_list", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "set_tid_address", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sigaltstack", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "stat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "statx", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "stat64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "statfs", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "statfs64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sync", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sync_file_range", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "syncfs", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "sysinfo", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "tgkill", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "timer_create", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "timer_delete", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "timer_getoverrun", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "timer_gettime", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "timer_settime", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "times", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "tkill", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "truncate", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "truncate64", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "umask", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "uname", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "unlink", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "unlinkat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "utime", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "utimensat", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "utimes", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "vfork", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "vhangup", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "wait4", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "waitid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "write", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "writev", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "pread", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "setgid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "setuid", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "capget", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "capset", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "fchown", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "gettimeofday", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "epoll_pwait", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "poll", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "openat2", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "statx", + "action": "SCMP_ACT_ALLOW", + "args": [] + }, + { + "name": "clone3", + "action": "SCMP_ACT_ALLOW", + "args": [] + } + ] +} diff --git a/services/clsi/test/unit/js/DockerRunner.test.js b/services/clsi/test/unit/js/DockerRunner.test.js index b591cf93a34..3fce8bb9a5d 100644 --- a/services/clsi/test/unit/js/DockerRunner.test.js +++ b/services/clsi/test/unit/js/DockerRunner.test.js @@ -5,7 +5,7 @@ import Path from 'node:path' const modulePath = Path.join( import.meta.dirname, - '../../../app/js/DockerRunner' + '../../../app/js/DockerRunner.mjs' ) describe('DockerRunner', () => { diff --git a/services/filestore/app.js b/services/filestore/app.js index 9d138c81113..bdcd2930c4a 100644 --- a/services/filestore/app.js +++ b/services/filestore/app.js @@ -66,6 +66,11 @@ if (settings.filestore.stores.template_files) { keyBuilder.templateFileKeyMiddleware, fileController.insertFile ) + app.delete( + '/template/:template_id/v/:version/:format', + keyBuilder.templateFileKeyMiddleware, + fileController.deleteFile + ) } app.get( diff --git a/services/filestore/app/js/FileController.js b/services/filestore/app/js/FileController.js index 0ccb3325168..42c4babcf4e 100644 --- a/services/filestore/app/js/FileController.js +++ b/services/filestore/app/js/FileController.js @@ -10,6 +10,7 @@ export default { getFile, getFileHead, insertFile, + deleteFile, } function getFile(req, res, next) { @@ -88,6 +89,22 @@ function getFile(req, res, next) { }) } +function deleteFile(req, res, next) { + metrics.inc('deleteFile') + const { key, bucket } = req + + req.requestLogger.addFields({ key, bucket }) + req.requestLogger.setMessage('deleting file') + + FileHandler.deleteFile(bucket, key, function (err) { + if (err) { + next(err) + } else { + res.sendStatus(204) + } + }) +} + function getFileHead(req, res, next) { const { key, bucket } = req diff --git a/services/filestore/app/js/FileConverter.js b/services/filestore/app/js/FileConverter.js index cdde44b1e75..6030d378c57 100644 --- a/services/filestore/app/js/FileConverter.js +++ b/services/filestore/app/js/FileConverter.js @@ -6,7 +6,7 @@ import Errors from './Errors.js' const { ConversionError } = Errors -const APPROVED_FORMATS = ['png'] +const APPROVED_FORMATS = ['png', 'jpg'] const FOURTY_SECONDS = 40 * 1000 const KILL_SIGNAL = 'SIGTERM' diff --git a/services/filestore/app/js/FileHandler.js b/services/filestore/app/js/FileHandler.js index 5e70afb3ad6..693ba97f587 100644 --- a/services/filestore/app/js/FileHandler.js +++ b/services/filestore/app/js/FileHandler.js @@ -13,6 +13,7 @@ const { ConversionError, InvalidParametersError } = Errors const FileHandler = { insertFile: callbackify(insertFile), + deleteFile: callbackify(deleteFile), getFile: callbackify(getFile), getRedirectUrl: callbackify(getRedirectUrl), getFileSize: callbackify(getFileSize), @@ -20,6 +21,7 @@ const FileHandler = { getFile, getRedirectUrl, insertFile, + deleteFile, getFileSize, }, } @@ -46,6 +48,25 @@ async function insertFile(bucket, key, stream) { await PersistorManager.sendStream(bucket, key, stream) } +async function deleteFile(bucket, key) { + const convertedKey = KeyBuilder.getConvertedFolderKey(key) + if (!convertedKey.match(/^[0-9a-f]{24}\/([0-9a-f]{24}|v\/[0-9]+\/[a-z]+)/i)) { + throw new InvalidParametersError('key does not match validation regex', { + bucket, + key, + convertedKey, + }) + } + const jobs = [PersistorManager.deleteObject(bucket, key)] + if ( + Settings.enableConversions && + bucket === Settings.filestore.stores.template_files + ) { + jobs.push(PersistorManager.deleteDirectory(bucket, convertedKey)) + } + await Promise.all(jobs) +} + async function getFile(bucket, key, opts) { opts = opts || {} if (!opts.format && !opts.style) { @@ -111,7 +132,9 @@ async function _getConvertedFileAndCache(bucket, key, convertedKey, opts) { let convertedFsPath try { convertedFsPath = await _convertFile(bucket, key, opts) - await ImageOptimiser.promises.compressPng(convertedFsPath) + if (convertedFsPath.toLowerCase().endsWith(".png")) { + await ImageOptimiser.promises.compressPng(convertedFsPath) + } await PersistorManager.sendFile(bucket, convertedKey, convertedFsPath) } catch (err) { LocalFileWriter.deleteFile(convertedFsPath, () => {}) diff --git a/services/linked-url-proxy/.nvmrc b/services/linked-url-proxy/.nvmrc new file mode 100644 index 00000000000..3fe3b1570a5 --- /dev/null +++ b/services/linked-url-proxy/.nvmrc @@ -0,0 +1 @@ +24.13.0 diff --git a/services/linked-url-proxy/Dockerfile b/services/linked-url-proxy/Dockerfile new file mode 100644 index 00000000000..756c53a8f95 --- /dev/null +++ b/services/linked-url-proxy/Dockerfile @@ -0,0 +1,36 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +FROM node:24.13.0 AS base + +WORKDIR /overleaf/services/linked-url-proxy + +# Google Cloud Storage needs a writable $HOME/.config for resumable uploads +# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) +RUN mkdir /home/node/.config && chown node:node /home/node/.config + +FROM base AS app + +COPY package.json package-lock.json /overleaf/ +COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json +COPY libraries/logger/package.json /overleaf/libraries/logger/package.json +COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json +COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json +COPY libraries/settings/package.json /overleaf/libraries/settings/package.json +COPY services/linked-url-proxy/package.json /overleaf/services/linked-url-proxy/package.json +COPY patches/ /overleaf/patches/ + +RUN cd /overleaf && npm ci --quiet + +COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ +COPY libraries/logger/ /overleaf/libraries/logger/ +COPY libraries/metrics/ /overleaf/libraries/metrics/ +COPY libraries/o-error/ /overleaf/libraries/o-error/ +COPY libraries/settings/ /overleaf/libraries/settings/ +COPY services/linked-url-proxy/ /overleaf/services/linked-url-proxy/ + +FROM app +USER node + +CMD ["node", "--expose-gc", "app.mjs"] diff --git a/services/linked-url-proxy/LICENSE b/services/linked-url-proxy/LICENSE new file mode 100644 index 00000000000..ac8619dcb91 --- /dev/null +++ b/services/linked-url-proxy/LICENSE @@ -0,0 +1,662 @@ + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/services/linked-url-proxy/Makefile b/services/linked-url-proxy/Makefile new file mode 100644 index 00000000000..261bda967f3 --- /dev/null +++ b/services/linked-url-proxy/Makefile @@ -0,0 +1,174 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +BUILD_NUMBER ?= local +BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) +PROJECT_NAME = linked-url-proxy +BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') +HERE=$(shell pwd) +export MONOREPO ?= $(shell cd ../../ && pwd) +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) +IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ + $(MONOREPO)/package.json \ + $(MONOREPO)/package-lock.json \ + $(MONOREPO)/libraries/fetch-utils/package.json \ + $(MONOREPO)/libraries/logger/package.json \ + $(MONOREPO)/libraries/metrics/package.json \ + $(MONOREPO)/libraries/o-error/package.json \ + $(MONOREPO)/libraries/overleaf-editor-core/package.json \ + $(MONOREPO)/libraries/promise-utils/package.json \ + $(MONOREPO)/libraries/settings/package.json \ + $(MONOREPO)/libraries/stream-utils/package.json \ + $(MONOREPO)/services/linked-url-proxy/package.json \ + $(MONOREPO)/patches/* \ +| sha256sum | cut -d '-' -f1) + +DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml +DOCKER_COMPOSE := BUILD_NUMBER=$(BUILD_NUMBER) \ + BRANCH_NAME=$(BRANCH_NAME) \ + PROJECT_NAME=$(PROJECT_NAME) \ + MOCHA_GREP=${MOCHA_GREP} \ + docker compose ${DOCKER_COMPOSE_FLAGS} + +COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) +DOCKER_COMPOSE_TEST_ACCEPTANCE = \ + COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) + +COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) +DOCKER_COMPOSE_TEST_UNIT = \ + COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) + +clean: + -docker rmi $(IMAGE_CI) + -docker rmi $(IMAGE_REPO_FINAL) + -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes + -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes + -rm -rf reports/ + +# Run the linting commands in the scope of the monorepo. +# Eslint and prettier (plus some configs) are on the root. +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:24.13.0 npm run --silent + +RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/linked-url-proxy/reports:/overleaf/services/linked-url-proxy/reports $(IMAGE_CI) npm run --silent + +# Same but from the top of the monorepo +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:24.13.0 npm run --silent + +SHELLCHECK_OPTS = \ + --shell=bash \ + --external-sources +SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) +SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu + +shellcheck: + @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ + koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) + +shellcheck_fix: + @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ + diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ + if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ + elif [ -n "$$diff" ]; then echo "$$file"; \ + else echo "\033[2m$$file\033[0m"; fi \ + done + +format: + $(RUN_LINTING) format + +format_ci: + $(RUN_LINTING_CI) format + +format_fix: + $(RUN_LINTING) format:fix + +lint: + $(RUN_LINTING) lint + +lint_ci: + -$(RUN_LINTING_CI) lint -- --format json --output-file reports/eslint.json + sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json + +lint_fix: + $(RUN_LINTING) lint:fix + +typecheck: + $(RUN_LINTING) types:check + +typecheck_ci: + $(RUN_LINTING_CI) types:check + +test: format lint typecheck shellcheck test_unit test_acceptance + +test_unit: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) run --rm test_unit +endif + +test_clean: test_unit_clean +test_unit_clean: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 +endif + +test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run + $(MAKE) test_acceptance_clean + +test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug + $(MAKE) test_acceptance_clean + +test_acceptance_run: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance +endif + +test_acceptance_run_debug: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 --rm test_acceptance npm run test:acceptance -- --inspect=0.0.0.0:19999 --inspect-brk +endif + +test_clean: test_acceptance_clean +test_acceptance_clean: + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 + +test_acceptance_pre_run: +ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run +endif + +benchmarks: + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance npm run benchmarks + +build: + docker build \ + --pull \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --tag $(IMAGE_CI) \ + --tag $(IMAGE_CACHE) \ + --tag $(IMAGE_REPO_FINAL) \ + --cache-from $(IMAGE_CACHE) \ + --file Dockerfile \ + ../.. + +tar: + $(DOCKER_COMPOSE) up tar + +push: + docker push $(IMAGE_REPO_FINAL) + +push_branch: + docker push $(IMAGE_CACHE) + +.PHONY: clean \ + format format_fix \ + lint lint_fix \ + build_types typecheck \ + lint_ci format_ci typecheck_ci \ + shellcheck shellcheck_fix \ + test test_clean test_unit test_unit_clean \ + test_acceptance test_acceptance_debug test_acceptance_pre_run \ + test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ + benchmarks \ + build tar publish \ diff --git a/services/linked-url-proxy/README.md b/services/linked-url-proxy/README.md new file mode 100644 index 00000000000..3300e73314e --- /dev/null +++ b/services/linked-url-proxy/README.md @@ -0,0 +1,10 @@ +overleaf/linked-url-proxy +=============== + +An API for providing linked url proxy + +License +======= +The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. + +Copyright (c) yu-i-i (https://github.com/yu-i-i/overleaf-cep), 2025. diff --git a/services/linked-url-proxy/app.mjs b/services/linked-url-proxy/app.mjs new file mode 100644 index 00000000000..b15caeb1c71 --- /dev/null +++ b/services/linked-url-proxy/app.mjs @@ -0,0 +1,39 @@ +import '@overleaf/metrics/initialize.js' + +import express from 'express' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import Metrics from '@overleaf/metrics' +import LinkedUrlProxyController from './app/js/LinkedUrlProxyController.mjs' + +Metrics.open_sockets.monitor(true) +Metrics.memory.monitor(logger) +Metrics.leaked_sockets.monitor(logger) + +const app = express() + +Metrics.injectMetricsRoute(app) +app.use(Metrics.http.monitor(logger)) + +app.get('/', LinkedUrlProxyController.proxy) +app.get('/status', (req, res) => res.send({ status: 'linked-url-proxy is up' })) + +const host = Settings.internal.linkedUrlProxy.host +const port = Settings.internal.linkedUrlProxy.port + +logger.debug('Listening at', { host, port }) + +const server = app.listen(port, host, function (error) { + if (error) { + throw error + } + logger.info({ host, port }, 'linked-url-proxy HTTP server starting up') +}) + +process.on('SIGTERM', () => { + server.close(() => { + logger.info({ host, port }, 'linked-url-proxy HTTP server closed') + Metrics.close() + }) +}) + diff --git a/services/linked-url-proxy/app/js/LinkedUrlProxyController.mjs b/services/linked-url-proxy/app/js/LinkedUrlProxyController.mjs new file mode 100644 index 00000000000..cf715287419 --- /dev/null +++ b/services/linked-url-proxy/app/js/LinkedUrlProxyController.mjs @@ -0,0 +1,201 @@ +import dns from 'dns/promises' +import { sanitizeUrl } from 'strict-url-sanitise' +import normalizeUrlPath from 'als-normalize-urlpath' +import ipaddr from 'ipaddr.js' +import { URL } from 'node:url' +import { Transform } from 'node:stream' +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import { fetchStreamWithResponse, RequestFailedError } from '@overleaf/fetch-utils' +import { Agent } from 'undici' + +function isAllowedResource(targetUrl) { + if (!Settings.allowedResources) return false + return Settings.allowedResources.test(targetUrl) +} + +function isBlockedIp(ipStr, targetUrl) { + const addr = ipaddr.parse(ipStr) + if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) { + return isBlockedIp(addr.toIPv4Address().toString(), targetUrl) + } + + const range = addr.range() + if (!['unicast'].includes(addr.range())) { + return true + } + + for (const blocked of Settings.blockedNetworks) { + try { + const net = ipaddr.parseCIDR(blocked) + if (addr.match(net)) return true + } catch (e) { + logger.error({ blocked, error: e }, 'Invalid blockedNetworks entry') + const err = new Error(`Invalid blockedNetworks entry: ${blocked}`) + err.info = { status: 500 } + throw err + } + } + return false +} + +async function checkUrlAccess(hostname, targetUrl) { + const records = await dns.lookup(hostname, { all: true }).catch(() => []) + if (!records.length) { + const err = new Error(`DNS lookup failed for ${hostname}`) + err.info = { status: 421 } + throw err + } +// Permit explicitly allowed resources without checking blocked IPs + if (isAllowedResource(targetUrl)) return records[0].address + for (const { address } of records) { + if (isBlockedIp(address, targetUrl)) { + const err = new Error(`Blocked IP address: ${address}`) + err.info = { status: 403 } + throw err + } + } + return records[0].address +} + +async function validateAndFetch(rawUrl, redirectCount = 0) { + if (redirectCount > Settings.maxRedirects) { + const err = new Error('Too many redirects') + err.info = { status: 421 } + throw err + } + + const sanitizedUrl = sanitizeUrl(rawUrl) + if (!sanitizedUrl) { + const err = new Error(`Invalid or unsafe URL: ${rawUrl}`) + err.info = { status: 400 } + throw err + } + + const url = new URL(sanitizedUrl) + + if (!['http:', 'https:'].includes(url.protocol)) { + const err = new Error(`${url.protocol} protocol is not allowed`) + err.info = { status: 400 } + throw err + } + + const normalizedPath = normalizeUrlPath(url.pathname).pathname + + if (!normalizedPath) { + const err = new Error(`Invalid or unsafe URL path: ${url.pathname}`) + err.info = { status: 400 } + throw err + } + + const normalizedUrl = url.toString() + + // check DNS and allowed resources + const validatedIp = await checkUrlAccess(url.hostname, normalizedUrl) + + // pin fetch to validated IP to prevent DNS rebinding + const agent = new Agent({ + connect: { + lookup(hostname, opts, cb) { + cb(null, validatedIp, 4) + } + } + }) + + const opts = { + redirect: 'manual', + timeout: Settings.fetchTimeoutMs, + headers: Settings.userAgentHeader, + } + + try { + const { stream, response } = await fetchStreamWithResponse(normalizedUrl, { ...opts, dispatcher: agent }) + + const contentLengthHeader = response.headers.get('content-length') + if (contentLengthHeader) { + const n = parseInt(contentLengthHeader, 10) + if (!Number.isNaN(n) && n > Settings.maxUploadSize) { + const err = new Error('file too large') + err.info = { status: 413 } + try { stream.destroy() } catch (_) {} + throw err + } + } + + return { + stream, + response, + headers: Object.fromEntries(response.headers.entries()), + } + } catch (err) { + if (err instanceof RequestFailedError) { + const status = err.info.status + + // Handle redirects + if (status >= 300 && status < 400) { + const location = err.response.headers.get('Location') + if (location) { + const nextUrl = new URL(location, normalizedUrl).toString() + return validateAndFetch(nextUrl, redirectCount + 1) + } else { + const e = new Error('Redirect response missing Location header') + e.info = { status: 421 } + throw e + } + } + throw err + } + if (!err?.info?.status) { + if(err.type === "request-timeout") { + err.info = { status: 408 } + } else err.info = { status: 422 } + } + throw err + } +} + +async function proxy(req, res) { + try { + const u = new URL(req.url, `http://${req.headers.host}`) + const targetUrl = u.searchParams.get('url') + if (!targetUrl) { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.end('Missing ?url parameter') + return + } + + const { stream: upstreamStream, response, headers } = await validateAndFetch(targetUrl) + + res.statusCode = response.status || 200 + res.setHeader('Content-Type', headers['content-type'] || 'application/octet-stream') + res.setHeader('Cache-Control', 'no-store') + + function onError(err) { + logger.info({ err, url: req.url }, 'linked-url-proxy request failed') + try { upstreamStream.destroy() } catch (_) {} + if (!res.headersSent) { + let body = `Error: ${err?.message ?? String(err)}` + res.writeHead(err?.info?.status || 503, { 'Content-Type': 'text/plain' }) + res.end(body) + } else { + try { res.destroy() } catch (_) {} + } + } + + upstreamStream.on('error', onError) + upstreamStream.pipe(res) + + } catch (err) { + const status = err.info?.status || 500 + logger.info({ linkedUrl: err.message, status, url: req.url }, 'linked-url-proxy request failed') + let body = `Error: ${err.message || String(err)}` + try { + res.writeHead(status, { 'Content-Type': 'text/plain' }) + res.end(body) + } catch { + try { res.end() } catch {} + } + } +} + +export default { proxy } diff --git a/services/linked-url-proxy/buildscript.txt b/services/linked-url-proxy/buildscript.txt new file mode 100644 index 00000000000..6e934db0186 --- /dev/null +++ b/services/linked-url-proxy/buildscript.txt @@ -0,0 +1,6 @@ +linked-url-proxy +--env-add= +--env-pass-through= +--esmock-loader=False +--node-version=24.13.0 +--public-repo=False diff --git a/services/linked-url-proxy/config/settings.defaults.cjs b/services/linked-url-proxy/config/settings.defaults.cjs new file mode 100644 index 00000000000..7481847af6a --- /dev/null +++ b/services/linked-url-proxy/config/settings.defaults.cjs @@ -0,0 +1,27 @@ +const blockedNetworks = (process.env.OVERLEAF_LINKED_URL_BLOCKED_NETWORKS || '') + .split(/[,\s]+/) + .filter(Boolean) + .map(cidr => cidr.trim()) + +const allowedResources = process.env.OVERLEAF_LINKED_URL_ALLOWED_RESOURCES + ? new RegExp(process.env.OVERLEAF_LINKED_URL_ALLOWED_RESOURCES) + : null + +module.exports = { + maxRedirects: 5, + fetchTimeoutMs: 30000, + blockedNetworks, + allowedResources, + userAgentHeader: { + 'User-Agent': 'Overleaf Extended CE - LinkedURLProxy (https://github.com/yu-i-i/overleaf-cep)' + }, + maxUploadSize: process.env.MAX_UPLOAD_SIZE + ? parseInt(process.env.MAX_UPLOAD_SIZE, 10) * 1024 * 1024 + : 50 * 1024 * 1024, // 50 MB + internal: { + linkedUrlProxy: { + port: 3066, + host: process.env.LINKED_URL_PROXY_HOST || '127.0.0.1', + }, + }, +} diff --git a/services/linked-url-proxy/docker-compose.ci.yml b/services/linked-url-proxy/docker-compose.ci.yml new file mode 100644 index 00000000000..6373c3ea448 --- /dev/null +++ b/services/linked-url-proxy/docker-compose.ci.yml @@ -0,0 +1,32 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +services: + test_unit: + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + user: node + command: npm run test:unit:_run + environment: + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + + test_acceptance: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + environment: + ELASTIC_SEARCH_DSN: es:9200 + POSTGRES_HOST: postgres + MOCHA_GREP: ${MOCHA_GREP} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + user: node + command: npm run test:acceptance + + tar: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + volumes: + - ./:/tmp/build/ + command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . + user: root diff --git a/services/linked-url-proxy/docker-compose.yml b/services/linked-url-proxy/docker-compose.yml new file mode 100644 index 00000000000..4e88a2106b8 --- /dev/null +++ b/services/linked-url-proxy/docker-compose.yml @@ -0,0 +1,36 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +services: + test_unit: + image: node:24.13.0 + volumes: + - .:/overleaf/services/linked-url-proxy + - ../../node_modules:/overleaf/node_modules + - ../../libraries:/overleaf/libraries + working_dir: /overleaf/services/linked-url-proxy + environment: + MOCHA_GREP: ${MOCHA_GREP} + LOG_LEVEL: ${LOG_LEVEL:-} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + command: npm run --silent test:unit + user: node + + test_acceptance: + image: node:24.13.0 + volumes: + - .:/overleaf/services/linked-url-proxy + - ../../node_modules:/overleaf/node_modules + - ../../libraries:/overleaf/libraries + working_dir: /overleaf/services/linked-url-proxy + environment: + ELASTIC_SEARCH_DSN: es:9200 + POSTGRES_HOST: postgres + MOCHA_GREP: ${MOCHA_GREP} + LOG_LEVEL: ${LOG_LEVEL:-} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + user: node + command: npm run --silent test:acceptance diff --git a/services/linked-url-proxy/package.json b/services/linked-url-proxy/package.json new file mode 100644 index 00000000000..bc25fe4f34e --- /dev/null +++ b/services/linked-url-proxy/package.json @@ -0,0 +1,22 @@ +{ + "name": "@overleaf/linked-url-proxy", + "description": "An API for providing linked url proxy", + "private": true, + "main": "app.mjs", + "scripts": { + "start": "node app.mjs", + "nodemon": "node --watch app.mjs" + }, + "version": "0.1.1", + "dependencies": { + "@overleaf/settings": "*", + "@overleaf/logger": "*", + "@overleaf/metrics": "*", + "async": "^3.2.5", + "express": "^4.22.1", + "ipaddr.js": "^2.1.0", + "als-normalize-urlpath": "^2.3.0", + "strict-url-sanitise": "^0.0.1", + "undici": "^7.22.0" + } +} diff --git a/services/linked-url-proxy/tsconfig.json b/services/linked-url-proxy/tsconfig.json new file mode 100644 index 00000000000..0639496cc80 --- /dev/null +++ b/services/linked-url-proxy/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.backend.json", + "include": [ + "app.mjs", + "app.ts", + "app/js/**/*", + "benchmarks/**/*", + "config/**/*", + "scripts/**/*", + "test/**/*", + "types" + ] +} diff --git a/services/real-time/app/js/WebApiManager.js b/services/real-time/app/js/WebApiManager.js index 61e1670ce40..8a98b7c6240 100644 --- a/services/real-time/app/js/WebApiManager.js +++ b/services/real-time/app/js/WebApiManager.js @@ -1,4 +1,4 @@ -import { callbackifyMultiResult } from '@overleaf/promise-utils' +import { callbackifyMultiResult, callbackify } from '@overleaf/promise-utils' import OError from '@overleaf/o-error' import settings from '@overleaf/settings' import logger from '@overleaf/logger' @@ -62,13 +62,33 @@ async function joinProject(projectId, user) { } } +async function leaveProject(projectId, lastUserLeft) { + logger.debug({ projectId, lastUserLeft }, 'sending leave project request to web') + const url = new URL(settings.apis.web.url) + url.pathname = Path.posix.join('project', projectId, 'leave') + try { + await fetchJson(url, { + method: 'POST', + basicAuth: { + user: settings.apis.web.user, + password: settings.apis.web.pass, + }, + json: { lastUserLeft }, + }) + } catch (error) { + logger.warn({ error, projectId }, 'leave project request failed') + } +} + export default { joinProject: callbackifyMultiResult(joinProject, [ 'project', 'privilegeLevel', 'userMetadata', ]), + leaveProject: callbackify(leaveProject), promises: { joinProject, + leaveProject, }, } diff --git a/services/real-time/app/js/WebsocketController.js b/services/real-time/app/js/WebsocketController.js index 90128be1e52..fc0acab4f4e 100644 --- a/services/real-time/app/js/WebsocketController.js +++ b/services/real-time/app/js/WebsocketController.js @@ -193,6 +193,15 @@ export default WebsocketController = { } } ) + // Notify web that last user left (for WebDAV sync etc.) + WebApiManager.leaveProject(projectId, true, function (err) { + if (err) { + logger.warn( + { err, projectId }, + 'error notifying web of project leave' + ) + } + }) } callback() }, WebsocketController.FLUSH_IF_EMPTY_DELAY) diff --git a/services/references/.nvmrc b/services/references/.nvmrc new file mode 100644 index 00000000000..3fe3b1570a5 --- /dev/null +++ b/services/references/.nvmrc @@ -0,0 +1 @@ +24.13.0 diff --git a/services/references/Dockerfile b/services/references/Dockerfile new file mode 100644 index 00000000000..6b6c074adf0 --- /dev/null +++ b/services/references/Dockerfile @@ -0,0 +1,31 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +FROM node:24.13.0 AS base + +WORKDIR /overleaf/services/references + +# Google Cloud Storage needs a writable $HOME/.config for resumable uploads +# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) +RUN mkdir /home/node/.config && chown node:node /home/node/.config + +FROM base AS app + +COPY package.json package-lock.json /overleaf/ +COPY libraries/logger/package.json /overleaf/libraries/logger/package.json +COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json +COPY libraries/settings/package.json /overleaf/libraries/settings/package.json +COPY services/references/package.json /overleaf/services/references/package.json +COPY patches/ /overleaf/patches/ + +RUN cd /overleaf && npm ci --quiet +COPY libraries/logger/ /overleaf/libraries/logger/ +COPY libraries/metrics/ /overleaf/libraries/metrics/ +COPY libraries/settings/ /overleaf/libraries/settings/ +COPY services/references/ /overleaf/services/references/ + +FROM app +USER node + +CMD ["node", "--expose-gc", "app.js"] diff --git a/services/references/LICENSE b/services/references/LICENSE new file mode 100644 index 00000000000..ac8619dcb91 --- /dev/null +++ b/services/references/LICENSE @@ -0,0 +1,662 @@ + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/services/references/Makefile b/services/references/Makefile new file mode 100644 index 00000000000..c574589a9b0 --- /dev/null +++ b/services/references/Makefile @@ -0,0 +1,156 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +BUILD_NUMBER ?= local +BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) +PROJECT_NAME = references +BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') + +DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml +DOCKER_COMPOSE := BUILD_NUMBER=$(BUILD_NUMBER) \ + BRANCH_NAME=$(BRANCH_NAME) \ + PROJECT_NAME=$(PROJECT_NAME) \ + MOCHA_GREP=${MOCHA_GREP} \ + docker compose ${DOCKER_COMPOSE_FLAGS} + +COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) +DOCKER_COMPOSE_TEST_ACCEPTANCE = \ + COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) + +COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) +DOCKER_COMPOSE_TEST_UNIT = \ + COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) + +clean: + -docker rmi ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + -docker rmi us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + -$(DOCKER_COMPOSE_TEST_UNIT) down --rmi local + -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --rmi local + +HERE=$(shell pwd) +MONOREPO=$(shell cd ../../ && pwd) +# Run the linting commands in the scope of the monorepo. +# Eslint and prettier (plus some configs) are on the root. +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:24.13.0 npm run --silent + +RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent + +# Same but from the top of the monorepo +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:24.13.0 npm run --silent + +SHELLCHECK_OPTS = \ + --shell=bash \ + --external-sources +SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) +SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu + +shellcheck: + @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ + koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) + +shellcheck_fix: + @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ + diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ + if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ + elif [ -n "$$diff" ]; then echo "$$file"; \ + else echo "\033[2m$$file\033[0m"; fi \ + done + +format: + $(RUN_LINTING) format + +format_ci: + $(RUN_LINTING_CI) format + +format_fix: + $(RUN_LINTING) format:fix + +lint: + $(RUN_LINTING) lint + +lint_ci: + $(RUN_LINTING_CI) lint + +lint_fix: + $(RUN_LINTING) lint:fix + +typecheck: + $(RUN_LINTING) types:check + +typecheck_ci: + $(RUN_LINTING_CI) types:check + +test: format lint typecheck shellcheck test_unit test_acceptance + +test_unit: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) run --rm test_unit + $(MAKE) test_unit_clean +endif + +test_clean: test_unit_clean +test_unit_clean: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 +endif + +test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run + $(MAKE) test_acceptance_clean + +test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug + $(MAKE) test_acceptance_clean + +test_acceptance_run: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance +endif + +test_acceptance_run_debug: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 --rm test_acceptance npm run test:acceptance -- --inspect=0.0.0.0:19999 --inspect-brk +endif + +test_clean: test_acceptance_clean +test_acceptance_clean: + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 + +test_acceptance_pre_run: +ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run +endif + +benchmarks: + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance npm run benchmarks + +build: + docker build \ + --pull \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --tag ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ + --tag us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ + --tag us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):$(BRANCH_NAME) \ + --cache-from us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):$(BRANCH_NAME) \ + --cache-from us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):main \ + --file Dockerfile \ + ../.. + +tar: + $(DOCKER_COMPOSE) up tar + +publish: + + docker push $(DOCKER_REPO)/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + + +.PHONY: clean \ + format format_fix \ + lint lint_fix \ + build_types typecheck \ + lint_ci format_ci typecheck_ci \ + shellcheck shellcheck_fix \ + test test_clean test_unit test_unit_clean \ + test_acceptance test_acceptance_debug test_acceptance_pre_run \ + test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ + benchmarks \ + build tar publish \ diff --git a/services/references/README.md b/services/references/README.md new file mode 100644 index 00000000000..41844d259ae --- /dev/null +++ b/services/references/README.md @@ -0,0 +1,10 @@ +overleaf/references +=============== + +An API for providing citation-keys from user bib-files + +License +======= +The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. + +Based on https://github.com/overleaf/overleaf/commit/9964aebc794f9fd7ce1373ab3484f6b33b061af3 diff --git a/services/references/app.js b/services/references/app.js new file mode 100644 index 00000000000..d2b392afbbc --- /dev/null +++ b/services/references/app.js @@ -0,0 +1,36 @@ +import '@overleaf/metrics/initialize.js' + +import express from 'express' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import metrics from '@overleaf/metrics' +import ReferencesAPIController from './app/js/ReferencesAPIController.js' +import bodyParser from 'body-parser' + +const app = express() +metrics.injectMetricsRoute(app) + +app.use(bodyParser.json({ limit: '2mb' })) +app.use(metrics.http.monitor(logger)) + +app.post('/project/:project_id/index', ReferencesAPIController.index) +app.get('/status', (req, res) => res.send({ status: 'references api is up' })) + +const host = Settings.internal.references.host +const port = Settings.internal.references.port + +logger.debug('Listening at', { host, port }) + +const server = app.listen(port, host, function (error) { + if (error) { + throw error + } + logger.info({ host, port }, 'references HTTP server starting up') +}) + +process.on('SIGTERM', () => { + server.close(() => { + logger.info({ host, port }, 'references HTTP server closed') + metrics.close() + }) +}) diff --git a/services/references/app/js/ReferencesAPIController.js b/services/references/app/js/ReferencesAPIController.js new file mode 100644 index 00000000000..ac51ca6bbdf --- /dev/null +++ b/services/references/app/js/ReferencesAPIController.js @@ -0,0 +1,42 @@ +import logger from '@overleaf/logger' +import BibtexParser from './bib2json.js' + +export default { + async index(req, res) { + const { docUrls, fullIndex } = req.body + try { + const responses = await Promise.all( + docUrls.map(async (docUrl) => { + try { + const response = await fetch(docUrl) + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + return response.text() + } catch (error) { + logger.error({ error }, "Failed to fetch document from URL: " + docUrl) + return null + } + }) + ) + const keys = [] + for (const body of responses) { + if (!body) continue + + try { + const parsedEntries = BibtexParser(body).entries + const ks = parsedEntries + .filter(entry => entry.EntryKey) + .map(entry => entry.EntryKey) + keys.push(...ks) + } catch (error) { + logger.error({ error }, "bib file skipped.") + } + } + res.status(200).json({ keys }) + } catch (error) { + logger.error({ error }, "Unexpected error during indexing process.") + res.status(500).json({ error: "Failed to process bib files." }) + } + } +} diff --git a/services/references/app/js/bib2json.js b/services/references/app/js/bib2json.js new file mode 100644 index 00000000000..99cfcf70ee2 --- /dev/null +++ b/services/references/app/js/bib2json.js @@ -0,0 +1,1967 @@ +/* eslint-disable */ +/** + * Parser.js + * Copyright 2012-13 Mayank Lahiri + * mlahiri@gmail.com + * Released under the BSD License. + * + * Modifications 2016 Sharelatex + * Modifications 2017-2020 Overleaf + * + * A forgiving Bibtex parser that can: + * + * (1) operate in streaming or block mode, extracting entries as dictionaries. + * (2) convert Latex special characters to UTF-8. + * (3) best-effort parse malformed entries. + * (4) run in a CommonJS environment or a browser, without any dependencies. + * (5) be advanced-compiled by Google Closure Compiler. + * + * Handwritten as a labor of love, not auto-generated from a grammar. + * + * Modes of usage: + * + * (1) Synchronous, string + * + * var entries = BibtexParser(text); + * console.log(entries); + * + * (2) Asynchronous, stream + * + * function entryCallback(entry) { console.log(entry); } + * var parser = new BibtexParser(entryCallback); + * parser.parse(chunk1); + * parser.parse(chunk2); + * ... + * + * @param {text|function(Object)} arg0 Either a Bibtex string or callback + * function for processing parsed entries. + * @param {array} allowedKeys optimization: do not output key/value pairs that are not on this allowlist + * @constructor + */ +function BibtexParser(arg0, allowedKeys) { + // Determine how this function is to be used + if (typeof arg0 === 'string') { + // Passed a string, synchronous call without 'new' + const entries = [] + function accumulator(entry) { + entries.push(entry) + } + const parser = new BibtexParser(accumulator, allowedKeys) + parser.parse(arg0) + return { + entries, + errors: parser.getErrors(), + } + } + if (typeof arg0 !== 'function') { + throw 'Invalid parser construction.' + } + this.ALLOWEDKEYS_ = allowedKeys || [] + this.reset_(arg0) + this.initMacros_() + return this +} + +/** @enum {number} */ +BibtexParser.prototype.STATES_ = { + ENTRY_OR_JUNK: 0, + OBJECT_TYPE: 1, + ENTRY_KEY: 2, + KV_KEY: 3, + EQUALS: 4, + KV_VALUE: 5, +} +BibtexParser.prototype.reset_ = function (arg0) { + /** @private */ this.DATA_ = {} + /** @private */ this.CALLBACK_ = arg0 + /** @private */ this.CHAR_ = 0 + /** @private */ this.LINE_ = 1 + /** @private */ this.CHAR_IN_LINE_ = 0 + /** @private */ this.SKIPWS_ = true + /** @private */ this.SKIPCOMMENT_ = true + /** @private */ this.SKIPKVPAIR_ = false + /** @private */ this.PARSETMP_ = {} + /** @private */ this.SKIPTILLEOL_ = false + /** @private */ this.VALBRACES_ = null + /** @private */ this.BRACETYPE_ = null + /** @private */ this.BRACECOUNT_ = 0 + /** @private */ this.STATE_ = this.STATES_.ENTRY_OR_JUNK + /** @private */ this.ERRORS_ = [] +} +/** @private */ BibtexParser.prototype.ENTRY_TYPES_ = { + inproceedings: 1, + proceedings: 2, + article: 3, + techreport: 4, + misc: 5, + mastersthesis: 6, + book: 7, + phdthesis: 8, + incollection: 9, + unpublished: 10, + inbook: 11, + manual: 12, + periodical: 13, + booklet: 14, + masterthesis: 15, + conference: 16, + /* additional fields from biblatex */ + artwork: 17, + audio: 18, + bibnote: 19, + bookinbook: 20, + collection: 21, + commentary: 22, + customa: 23, + customb: 24, + customc: 25, + customd: 26, + custome: 27, + customf: 28, + image: 29, + inreference: 30, + jurisdiction: 31, + legal: 32, + legislation: 33, + letter: 34, + movie: 35, + music: 36, + mvbook: 37, + mvcollection: 38, + mvproceedings: 39, + mvreference: 40, + online: 41, + patent: 42, + performance: 43, + reference: 44, + report: 45, + review: 46, + set: 47, + software: 48, + standard: 49, + suppbook: 50, + suppcollection: 51, + thesis: 52, + video: 53, +} +BibtexParser.prototype.initMacros_ = function () { + // macros can be extended by the user via + // @string { macroName = "macroValue" } + /** @private */ this.MACROS_ = { + jan: 'January', + feb: 'February', + mar: 'March', + apr: 'April', + may: 'May', + jun: 'June', + jul: 'July', + aug: 'August', + sep: 'September', + oct: 'October', + nov: 'November', + dec: 'December', + Jan: 'January', + Feb: 'February', + Mar: 'March', + Apr: 'April', + May: 'May', + Jun: 'June', + Jul: 'July', + Aug: 'August', + Sep: 'September', + Oct: 'October', + Nov: 'November', + Dec: 'December', + } +} + +/** + * Gets an array of all errors encountered during parsing. + * Array entries are of the format: + * [ line number, character in line, character in stream, error text ] + * + * @returns Array + * @public + */ +BibtexParser.prototype.getErrors = function () { + return this.ERRORS_ +} + +/** + * Processes a chunk of data + * @public + */ +BibtexParser.prototype.parse = function (chunk) { + for (let i = 0; i < chunk.length; i++) this.processCharacter_(chunk[i]) +} + +/** + * Logs error at current stream position. + * + * @private + */ +BibtexParser.prototype.error_ = function (text) { + this.ERRORS_.push([this.LINE_, this.CHAR_IN_LINE_, this.CHAR_, text]) +} + +/** + * Called after an entire entry has been parsed from the stream. + * Performs post-processing and invokes the entry callback pointed to by + * this.CALLBACK_. Parsed (but unprocessed) entry data is in this.DATA_. + */ +BibtexParser.prototype.processEntry_ = function () { + const data = this.DATA_ + if (data.Fields) + for (const f in data.Fields) { + let raw = data.Fields[f] + + // Convert Latex/Bibtex special characters to UTF-8 equivalents + for (let i = 0; i < this.CHARCONV_.length; i++) { + const re = this.CHARCONV_[i][0] + const rep = this.CHARCONV_[i][1] + raw = raw.replace(re, rep) + } + + // Basic substitutions + raw = raw + .replace(/[\n\r\t]/g, ' ') + .replace(/\s\s+/g, ' ') + .replace(/^\s+|\s+$/g, '') + + // Remove braces and backslashes + const len = raw.length + let processedArr = [] + for (let i = 0; i < len; i++) { + let c = raw[i] + let skip = false + if (c == '\\' && i < len - 1) c = raw[++i] + else { + if (c == '{' || c == '}') skip = true + } + if (!skip) processedArr.push(c) + } + data.Fields[f] = processedArr.join('') + processedArr = null + } + + if (data.ObjectType == 'string') { + for (const f in data.Fields) { + this.MACROS_[f] = data.Fields[f] + } + } else { + // Parsed a new Bibtex entry + this.CALLBACK_(data) + } +} + +/** + * Processes next character in the stream, invoking the callback after + * each entry has been found and processed. + * + * @private + * @param {string} c Next character in input stream + */ +BibtexParser.prototype.processCharacter_ = function (c) { + // Housekeeping + this.CHAR_++ + this.CHAR_IN_LINE_++ + if (c == '\n') { + this.LINE_++ + this.CHAR_IN_LINE_ = 1 + } + + // Convenience states for skipping whitespace when needed + if (this.SKIPTILLEOL_) { + if (c == '\n') this.SKIPTILLEOL_ = false + return + } + if (this.SKIPCOMMENT_ && c == '%') { + this.SKIPTILLEOL_ = true + return + } + if (this.SKIPWS_ && /\s/.test(c)) return + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + this.SKIPTILLEOL_ = false + + // Main state machine + let AnotherIteration = true + while (AnotherIteration) { + // console.log(this.LINE_, this.CHAR_IN_LINE_, this.STATE_, c) + AnotherIteration = false + switch (this.STATE_) { + // -- Scan for an object marker ('@') + // -- Reset temporary data structure in case previous entry was garbled + case this.STATES_.ENTRY_OR_JUNK: + if (c == '@') { + // SUCCESS: Parsed a valid start-of-object marker. + // NEXT_STATE: OBJECT_TYPE + this.STATE_ = this.STATES_.OBJECT_TYPE + this.DATA_ = { + ObjectType: '', + } + } + this.BRACETYPE_ = null + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + break + + // Start at first non-whitespace character after start-of-object '@' + // -- Accept [A-Za-z], break on non-matching character + // -- Populate this.DATA_.EntryType and this.DATA_.ObjectType + case this.STATES_.OBJECT_TYPE: + if (/[A-Za-z]/.test(c)) { + this.DATA_.ObjectType += c.toLowerCase() + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + } else { + // Break from state and validate object type + const ot = this.DATA_.ObjectType + if (ot == 'comment') { + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + } else { + if (ot == 'string') { + this.DATA_.ObjectType = ot + this.DATA_.Fields = {} + this.BRACETYPE_ = c + this.BRACECOUNT_ = 1 + this.STATE_ = this.STATES_.KV_KEY + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.PARSETMP_ = { + Key: '', + } + } else { + if (ot == 'preamble') { + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + } else { + if (ot in this.ENTRY_TYPES_) { + // SUCCESS: Parsed a valid object type. + // NEXT_STATE: ENTRY_KEY + this.DATA_.ObjectType = 'entry' + this.DATA_.EntryType = ot + this.DATA_.EntryKey = '' + this.STATE_ = this.STATES_.ENTRY_KEY + AnotherIteration = true + } else { + // ERROR: Unrecognized object type. + // NEXT_STATE: ENTRY_OR_JUNK + this.error_( + 'Unrecognized object type: "' + this.DATA_.ObjectType + '"' + ) + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + } + } + } + } + } + break + + // Start at first non-alphabetic character after an entry type + // -- Populate this.DATA_.EntryKey + case this.STATES_.ENTRY_KEY: + if ((c === '{' || c === '(') && this.BRACETYPE_ == null) { + this.BRACETYPE_ = c + this.BRACECOUNT_ = 1 + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + break + } + if (/[,%\s]/.test(c)) { + if (this.DATA_.EntryKey.length < 1) { + // Skip comments and whitespace before entry key + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + } else { + if (this.BRACETYPE_ == null) { + // ERROR: No opening brace for object + // NEXT_STATE: ENTRY_OR_JUNK + this.error_('No opening brace for object.') + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + } else { + // SUCCESS: Parsed an entry key + // NEXT_STATE: KV_KEY + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + AnotherIteration = true + this.STATE_ = this.STATES_.KV_KEY + this.PARSETMP_.Key = '' + this.DATA_.Fields = {} + } + } + } else { + this.DATA_.EntryKey += c + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + } + break + + // Start at first non-whitespace/comment character after entry key. + // -- Populate this.PARSETMP_.Key + case this.STATES_.KV_KEY: + // Test for end of entry + if ( + (c == '}' && this.BRACETYPE_ == '{') || + (c == ')' && this.BRACETYPE_ == '(') + ) { + // SUCCESS: Parsed an entry, possible incomplete + // NEXT_STATE: ENTRY_OR_JUNK + this.processEntry_() + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + break + } + if (/[\-A-Za-z:]/.test(c)) { + // Add to key + this.PARSETMP_.Key += c + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + } else { + // Either end of key or we haven't encountered start of key + if (this.PARSETMP_.Key.length < 1) { + // Keep going till we see a key + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + } else { + // SUCCESS: Found full key in K/V pair + // NEXT_STATE: EQUALS + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.EQUALS + AnotherIteration = true + + if (this.DATA_.ObjectType !== 'string') { + // this entry is not a macro + // normalize the key to lower case + this.PARSETMP_.Key = this.PARSETMP_.Key.toLowerCase() + + // optimization: skip key/value pairs that are not on the allowlist + this.SKIPKVPAIR_ = + // has allowedKeys set + this.ALLOWEDKEYS_.length && + // key is not on the allowlist + this.ALLOWEDKEYS_.indexOf(this.PARSETMP_.Key) === -1 + } else { + this.SKIPKVPAIR_ = false + } + } + } + break + + // Start at first non-alphabetic character after K/V pair key. + case this.STATES_.EQUALS: + if ( + (c == '}' && this.BRACETYPE_ == '{') || + (c == ')' && this.BRACETYPE_ == '(') + ) { + // ERROR: K/V pair with key but no value + // NEXT_STATE: ENTRY_OR_JUNK + this.error_( + 'Key-value pair has key "' + this.PARSETMP_.Key + '", but no value.' + ) + this.processEntry_() + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + break + } + if (c == '=') { + // SUCCESS: found an equal signs separating key and value + // NEXT_STATE: KV_VALUE + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.KV_VALUE + this.PARSETMP_.Value = [] + this.VALBRACES_ = { '"': [], '{': [] } + } + break + + // Start at first non-whitespace/comment character after '=' + // -- Populate this.PARSETMP_.Value + case this.STATES_.KV_VALUE: + const delim = this.VALBRACES_ + // valueCharsArray is the list of characters that make up the + // current value + const valueCharsArray = this.PARSETMP_.Value + let doneParsingValue = false + + // Test for special characters + if (c == '"' || c == '{' || c == '}' || c == ',') { + if (c == ',') { + // This comma can mean: + // (1) just another comma literal + // (2) end of a macro reference + if (delim['"'].length + delim['{'].length === 0) { + // end of a macro reference + const macro = this.PARSETMP_.Value.join('').trim() + if (macro in this.MACROS_) { + // Successful macro reference + this.PARSETMP_.Value = [this.MACROS_[macro]] + } else { + // Reference to an undefined macro + this.error_('Reference to an undefined macro: ' + macro) + } + doneParsingValue = true + } + } + if (c == '"') { + // This quote can mean: + // (1) opening delimiter + // (2) closing delimiter + // (3) literal, if we have a '{' on the stack + if (delim['"'].length + delim['{'].length === 0) { + // opening delimiter + delim['"'].push(this.CHAR_) + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + break + } + if ( + delim['"'].length == 1 && + delim['{'].length == 0 && + (valueCharsArray.length == 0 || + valueCharsArray[valueCharsArray.length - 1] != '\\') + ) { + // closing delimiter + doneParsingValue = true + } else { + // literal, add to value + } + } + if (c == '{') { + // This brace can mean: + // (1) opening delimiter + // (2) stacked verbatim delimiter + if ( + valueCharsArray.length == 0 || + valueCharsArray[valueCharsArray.length - 1] != '\\' + ) { + delim['{'].push(this.CHAR_) + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + } else { + // literal, add to value + } + } + if (c == '}') { + // This brace can mean: + // (1) closing delimiter + // (2) closing stacked verbatim delimiter + // (3) end of object definition if value was a macro + if (delim['"'].length + delim['{'].length === 0) { + // end of object definition, after macro + const macro = this.PARSETMP_.Value.join('').trim() + if (macro in this.MACROS_) { + // Successful macro reference + this.PARSETMP_.Value = [this.MACROS_[macro]] + } else { + // Reference to an undefined macro + this.error_('Reference to an undefined macro: ' + macro) + } + AnotherIteration = true + doneParsingValue = true + } else { + // sometimes imported bibs will have {\},{\\}, {\\\}, {\\\\}, etc for whitespace, + // which would otherwise break the parsing. we watch for these occurences of + // 1+ backslashes in an empty bracket pair to gracefully handle the malformed bib file + const doubleSlash = + valueCharsArray.length >= 2 && + valueCharsArray[valueCharsArray.length - 1] === '\\' && // for \\} + valueCharsArray[valueCharsArray.length - 2] === '\\' + const singleSlash = + valueCharsArray.length >= 2 && + valueCharsArray[valueCharsArray.length - 1] === '\\' && // for {\} + valueCharsArray[valueCharsArray.length - 2] === '{' + + if ( + valueCharsArray.length == 0 || + valueCharsArray[valueCharsArray.length - 1] != '\\' || // for } + doubleSlash || + singleSlash + ) { + if (delim['{'].length > 0) { + // pop stack for stacked verbatim delimiter + delim['{'].splice(delim['{'].length - 1, 1) + if (delim['{'].length + delim['"'].length == 0) { + // closing delimiter + doneParsingValue = true + } else { + // end verbatim block + } + } + } else { + // literal, add to value + } + } + } + } + + // If here, then we are either done parsing the value or + // have a literal that should be added to the value. + if (doneParsingValue) { + // SUCCESS: value parsed + // NEXT_STATE: KV_KEY + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.KV_KEY + if (!this.SKIPKVPAIR_) { + this.DATA_.Fields[this.PARSETMP_.Key] = + this.PARSETMP_.Value.join('') + } + this.PARSETMP_ = { Key: '' } + this.VALBRACES_ = null + } else { + this.PARSETMP_.Value.push(c) + if (this.PARSETMP_.Value.length >= 1000 * 20) { + this.PARSETMP_.Value = [] + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + this.DATA_ = { ObjectType: '' } + this.BRACETYPE_ = null + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + } + } + break + } // end switch (this.STATE_) + } // end while(AnotherIteration) +} // end function processCharacter + +/** @private */ BibtexParser.prototype.CHARCONV_ = [ + [/\\space /g, '\u0020'], + [/\\textdollar /g, '\u0024'], + [/\\textquotesingle /g, '\u0027'], + [/\\ast /g, '\u002A'], + [/\\textbackslash /g, '\u005C'], + [/\\\^\{\}/g, '\u005E'], + [/\\textasciigrave /g, '\u0060'], + [/\\lbrace /g, '\u007B'], + [/\\vert /g, '\u007C'], + [/\\rbrace /g, '\u007D'], + [/\\textasciitilde /g, '\u007E'], + [/\\textexclamdown /g, '\u00A1'], + [/\\textcent /g, '\u00A2'], + [/\\textsterling /g, '\u00A3'], + [/\\textcurrency /g, '\u00A4'], + [/\\textyen /g, '\u00A5'], + [/\\textbrokenbar /g, '\u00A6'], + [/\\textsection /g, '\u00A7'], + [/\\textasciidieresis /g, '\u00A8'], + [/\\textcopyright /g, '\u00A9'], + [/\\textordfeminine /g, '\u00AA'], + [/\\guillemotleft /g, '\u00AB'], + [/\\lnot /g, '\u00AC'], + [/\\textregistered /g, '\u00AE'], + [/\\textasciimacron /g, '\u00AF'], + [/\\textdegree /g, '\u00B0'], + [/\\pm /g, '\u00B1'], + [/\\textasciiacute /g, '\u00B4'], + [/\\mathrm\{\\mu\}/g, '\u00B5'], + [/\\textparagraph /g, '\u00B6'], + [/\\cdot /g, '\u00B7'], + [/\\c\{\}/g, '\u00B8'], + [/\\textordmasculine /g, '\u00BA'], + [/\\guillemotright /g, '\u00BB'], + [/\\textonequarter /g, '\u00BC'], + [/\\textonehalf /g, '\u00BD'], + [/\\textthreequarters /g, '\u00BE'], + [/\\textquestiondown /g, '\u00BF'], + [/\\`\{A\}/g, '\u00C0'], + [/\\'\{A\}/g, '\u00C1'], + [/\\\^\{A\}/g, '\u00C2'], + [/\\~\{A\}/g, '\u00C3'], + [/\\"\{A\}/g, '\u00C4'], + [/\\AA /g, '\u00C5'], + [/\\AE /g, '\u00C6'], + [/\\c\{C\}/g, '\u00C7'], + [/\\`\{E\}/g, '\u00C8'], + [/\\'\{E\}/g, '\u00C9'], + [/\\\^\{E\}/g, '\u00CA'], + [/\\"\{E\}/g, '\u00CB'], + [/\\`\{I\}/g, '\u00CC'], + [/\\'\{I\}/g, '\u00CD'], + [/\\\^\{I\}/g, '\u00CE'], + [/\\"\{I\}/g, '\u00CF'], + [/\\DH /g, '\u00D0'], + [/\\~\{N\}/g, '\u00D1'], + [/\\`\{O\}/g, '\u00D2'], + [/\\'\{O\}/g, '\u00D3'], + [/\\\^\{O\}/g, '\u00D4'], + [/\\~\{O\}/g, '\u00D5'], + [/\\"\{O\}/g, '\u00D6'], + [/\\texttimes /g, '\u00D7'], + [/\\O /g, '\u00D8'], + [/\\`\{U\}/g, '\u00D9'], + [/\\'\{U\}/g, '\u00DA'], + [/\\\^\{U\}/g, '\u00DB'], + [/\\"\{U\}/g, '\u00DC'], + [/\\'\{Y\}/g, '\u00DD'], + [/\\TH /g, '\u00DE'], + [/\\ss /g, '\u00DF'], + [/\\`\{a\}/g, '\u00E0'], + [/\\'\{a\}/g, '\u00E1'], + [/\\\^\{a\}/g, '\u00E2'], + [/\\~\{a\}/g, '\u00E3'], + [/\\"\{a\}/g, '\u00E4'], + [/\\aa /g, '\u00E5'], + [/\\ae /g, '\u00E6'], + [/\\c\{c\}/g, '\u00E7'], + [/\\`\{e\}/g, '\u00E8'], + [/\\'\{e\}/g, '\u00E9'], + [/\\\^\{e\}/g, '\u00EA'], + [/\\"\{e\}/g, '\u00EB'], + [/\\`\{\\i\}/g, '\u00EC'], + [/\\'\{\\i\}/g, '\u00ED'], + [/\\\^\{\\i\}/g, '\u00EE'], + [/\\"\{\\i\}/g, '\u00EF'], + [/\\dh /g, '\u00F0'], + [/\\~\{n\}/g, '\u00F1'], + [/\\`\{o\}/g, '\u00F2'], + [/\\'\{o\}/g, '\u00F3'], + [/\\\^\{o\}/g, '\u00F4'], + [/\\~\{o\}/g, '\u00F5'], + [/\\"\{o\}/g, '\u00F6'], + [/\\div /g, '\u00F7'], + [/\\o /g, '\u00F8'], + [/\\`\{u\}/g, '\u00F9'], + [/\\'\{u\}/g, '\u00FA'], + [/\\\^\{u\}/g, '\u00FB'], + [/\\"\{u\}/g, '\u00FC'], + [/\\'\{y\}/g, '\u00FD'], + [/\\th /g, '\u00FE'], + [/\\"\{y\}/g, '\u00FF'], + [/\\=\{A\}/g, '\u0100'], + [/\\=\{a\}/g, '\u0101'], + [/\\u\{A\}/g, '\u0102'], + [/\\u\{a\}/g, '\u0103'], + [/\\k\{A\}/g, '\u0104'], + [/\\k\{a\}/g, '\u0105'], + [/\\'\{C\}/g, '\u0106'], + [/\\'\{c\}/g, '\u0107'], + [/\\\^\{C\}/g, '\u0108'], + [/\\\^\{c\}/g, '\u0109'], + [/\\.\{C\}/g, '\u010A'], + [/\\.\{c\}/g, '\u010B'], + [/\\v\{C\}/g, '\u010C'], + [/\\v\{c\}/g, '\u010D'], + [/\\v\{D\}/g, '\u010E'], + [/\\v\{d\}/g, '\u010F'], + [/\\DJ /g, '\u0110'], + [/\\dj /g, '\u0111'], + [/\\=\{E\}/g, '\u0112'], + [/\\=\{e\}/g, '\u0113'], + [/\\u\{E\}/g, '\u0114'], + [/\\u\{e\}/g, '\u0115'], + [/\\.\{E\}/g, '\u0116'], + [/\\.\{e\}/g, '\u0117'], + [/\\k\{E\}/g, '\u0118'], + [/\\k\{e\}/g, '\u0119'], + [/\\v\{E\}/g, '\u011A'], + [/\\v\{e\}/g, '\u011B'], + [/\\\^\{G\}/g, '\u011C'], + [/\\\^\{g\}/g, '\u011D'], + [/\\u\{G\}/g, '\u011E'], + [/\\u\{g\}/g, '\u011F'], + [/\\.\{G\}/g, '\u0120'], + [/\\.\{g\}/g, '\u0121'], + [/\\c\{G\}/g, '\u0122'], + [/\\c\{g\}/g, '\u0123'], + [/\\\^\{H\}/g, '\u0124'], + [/\\\^\{h\}/g, '\u0125'], + [/\\Elzxh /g, '\u0127'], + [/\\~\{I\}/g, '\u0128'], + [/\\~\{\\i\}/g, '\u0129'], + [/\\=\{I\}/g, '\u012A'], + [/\\=\{\\i\}/g, '\u012B'], + [/\\u\{I\}/g, '\u012C'], + [/\\u\{\\i\}/g, '\u012D'], + [/\\k\{I\}/g, '\u012E'], + [/\\k\{i\}/g, '\u012F'], + [/\\.\{I\}/g, '\u0130'], + [/\\i /g, '\u0131'], + [/\\\^\{J\}/g, '\u0134'], + [/\\\^\{\\j\}/g, '\u0135'], + [/\\c\{K\}/g, '\u0136'], + [/\\c\{k\}/g, '\u0137'], + [/\\'\{L\}/g, '\u0139'], + [/\\'\{l\}/g, '\u013A'], + [/\\c\{L\}/g, '\u013B'], + [/\\c\{l\}/g, '\u013C'], + [/\\v\{L\}/g, '\u013D'], + [/\\v\{l\}/g, '\u013E'], + [/\\L /g, '\u0141'], + [/\\l /g, '\u0142'], + [/\\'\{N\}/g, '\u0143'], + [/\\'\{n\}/g, '\u0144'], + [/\\c\{N\}/g, '\u0145'], + [/\\c\{n\}/g, '\u0146'], + [/\\v\{N\}/g, '\u0147'], + [/\\v\{n\}/g, '\u0148'], + [/\\NG /g, '\u014A'], + [/\\ng /g, '\u014B'], + [/\\=\{O\}/g, '\u014C'], + [/\\=\{o\}/g, '\u014D'], + [/\\u\{O\}/g, '\u014E'], + [/\\u\{o\}/g, '\u014F'], + [/\\H\{O\}/g, '\u0150'], + [/\\H\{o\}/g, '\u0151'], + [/\\OE /g, '\u0152'], + [/\\oe /g, '\u0153'], + [/\\'\{R\}/g, '\u0154'], + [/\\'\{r\}/g, '\u0155'], + [/\\c\{R\}/g, '\u0156'], + [/\\c\{r\}/g, '\u0157'], + [/\\v\{R\}/g, '\u0158'], + [/\\v\{r\}/g, '\u0159'], + [/\\'\{S\}/g, '\u015A'], + [/\\'\{s\}/g, '\u015B'], + [/\\\^\{S\}/g, '\u015C'], + [/\\\^\{s\}/g, '\u015D'], + [/\\c\{S\}/g, '\u015E'], + [/\\c\{s\}/g, '\u015F'], + [/\\v\{S\}/g, '\u0160'], + [/\\v\{s\}/g, '\u0161'], + [/\\c\{T\}/g, '\u0162'], + [/\\c\{t\}/g, '\u0163'], + [/\\v\{T\}/g, '\u0164'], + [/\\v\{t\}/g, '\u0165'], + [/\\~\{U\}/g, '\u0168'], + [/\\~\{u\}/g, '\u0169'], + [/\\=\{U\}/g, '\u016A'], + [/\\=\{u\}/g, '\u016B'], + [/\\u\{U\}/g, '\u016C'], + [/\\u\{u\}/g, '\u016D'], + [/\\r\{U\}/g, '\u016E'], + [/\\r\{u\}/g, '\u016F'], + [/\\H\{U\}/g, '\u0170'], + [/\\H\{u\}/g, '\u0171'], + [/\\k\{U\}/g, '\u0172'], + [/\\k\{u\}/g, '\u0173'], + [/\\\^\{W\}/g, '\u0174'], + [/\\\^\{w\}/g, '\u0175'], + [/\\\^\{Y\}/g, '\u0176'], + [/\\\^\{y\}/g, '\u0177'], + [/\\"\{Y\}/g, '\u0178'], + [/\\'\{Z\}/g, '\u0179'], + [/\\'\{z\}/g, '\u017A'], + [/\\.\{Z\}/g, '\u017B'], + [/\\.\{z\}/g, '\u017C'], + [/\\v\{Z\}/g, '\u017D'], + [/\\v\{z\}/g, '\u017E'], + [/\\texthvlig /g, '\u0195'], + [/\\textnrleg /g, '\u019E'], + [/\\eth /g, '\u01AA'], + [/\\textdoublepipe /g, '\u01C2'], + [/\\'\{g\}/g, '\u01F5'], + [/\\Elztrna /g, '\u0250'], + [/\\Elztrnsa /g, '\u0252'], + [/\\Elzopeno /g, '\u0254'], + [/\\Elzrtld /g, '\u0256'], + [/\\Elzschwa /g, '\u0259'], + [/\\varepsilon /g, '\u025B'], + [/\\Elzpgamma /g, '\u0263'], + [/\\Elzpbgam /g, '\u0264'], + [/\\Elztrnh /g, '\u0265'], + [/\\Elzbtdl /g, '\u026C'], + [/\\Elzrtll /g, '\u026D'], + [/\\Elztrnm /g, '\u026F'], + [/\\Elztrnmlr /g, '\u0270'], + [/\\Elzltlmr /g, '\u0271'], + [/\\Elzltln /g, '\u0272'], + [/\\Elzrtln /g, '\u0273'], + [/\\Elzclomeg /g, '\u0277'], + [/\\textphi /g, '\u0278'], + [/\\Elztrnr /g, '\u0279'], + [/\\Elztrnrl /g, '\u027A'], + [/\\Elzrttrnr /g, '\u027B'], + [/\\Elzrl /g, '\u027C'], + [/\\Elzrtlr /g, '\u027D'], + [/\\Elzfhr /g, '\u027E'], + [/\\Elzrtls /g, '\u0282'], + [/\\Elzesh /g, '\u0283'], + [/\\Elztrnt /g, '\u0287'], + [/\\Elzrtlt /g, '\u0288'], + [/\\Elzpupsil /g, '\u028A'], + [/\\Elzpscrv /g, '\u028B'], + [/\\Elzinvv /g, '\u028C'], + [/\\Elzinvw /g, '\u028D'], + [/\\Elztrny /g, '\u028E'], + [/\\Elzrtlz /g, '\u0290'], + [/\\Elzyogh /g, '\u0292'], + [/\\Elzglst /g, '\u0294'], + [/\\Elzreglst /g, '\u0295'], + [/\\Elzinglst /g, '\u0296'], + [/\\textturnk /g, '\u029E'], + [/\\Elzdyogh /g, '\u02A4'], + [/\\Elztesh /g, '\u02A7'], + [/\\textasciicaron /g, '\u02C7'], + [/\\Elzverts /g, '\u02C8'], + [/\\Elzverti /g, '\u02CC'], + [/\\Elzlmrk /g, '\u02D0'], + [/\\Elzhlmrk /g, '\u02D1'], + [/\\Elzsbrhr /g, '\u02D2'], + [/\\Elzsblhr /g, '\u02D3'], + [/\\Elzrais /g, '\u02D4'], + [/\\Elzlow /g, '\u02D5'], + [/\\textasciibreve /g, '\u02D8'], + [/\\textperiodcentered /g, '\u02D9'], + [/\\r\{\}/g, '\u02DA'], + [/\\k\{\}/g, '\u02DB'], + [/\\texttildelow /g, '\u02DC'], + [/\\H\{\}/g, '\u02DD'], + [/\\tone\{55\}/g, '\u02E5'], + [/\\tone\{44\}/g, '\u02E6'], + [/\\tone\{33\}/g, '\u02E7'], + [/\\tone\{22\}/g, '\u02E8'], + [/\\tone\{11\}/g, '\u02E9'], + [/\\cyrchar\\C/g, '\u030F'], + [/\\Elzpalh /g, '\u0321'], + [/\\Elzrh /g, '\u0322'], + [/\\Elzsbbrg /g, '\u032A'], + [/\\Elzxl /g, '\u0335'], + [/\\Elzbar /g, '\u0336'], + [/\\'\{A\}/g, '\u0386'], + [/\\'\{E\}/g, '\u0388'], + [/\\'\{H\}/g, '\u0389'], + [/\\'\{\}\{I\}/g, '\u038A'], + [/\\'\{\}O/g, '\u038C'], + [/\\mathrm\{'Y\}/g, '\u038E'], + [/\\mathrm\{'\\Omega\}/g, '\u038F'], + [/\\acute\{\\ddot\{\\iota\}\}/g, '\u0390'], + [/\\Alpha /g, '\u0391'], + [/\\Beta /g, '\u0392'], + [/\\Gamma /g, '\u0393'], + [/\\Delta /g, '\u0394'], + [/\\Epsilon /g, '\u0395'], + [/\\Zeta /g, '\u0396'], + [/\\Eta /g, '\u0397'], + [/\\Theta /g, '\u0398'], + [/\\Iota /g, '\u0399'], + [/\\Kappa /g, '\u039A'], + [/\\Lambda /g, '\u039B'], + [/\\Xi /g, '\u039E'], + [/\\Pi /g, '\u03A0'], + [/\\Rho /g, '\u03A1'], + [/\\Sigma /g, '\u03A3'], + [/\\Tau /g, '\u03A4'], + [/\\Upsilon /g, '\u03A5'], + [/\\Phi /g, '\u03A6'], + [/\\Chi /g, '\u03A7'], + [/\\Psi /g, '\u03A8'], + [/\\Omega /g, '\u03A9'], + [/\\mathrm\{\\ddot\{I\}\}/g, '\u03AA'], + [/\\mathrm\{\\ddot\{Y\}\}/g, '\u03AB'], + [/\\'\{\$\\alpha\$\}/g, '\u03AC'], + [/\\acute\{\\epsilon\}/g, '\u03AD'], + [/\\acute\{\\eta\}/g, '\u03AE'], + [/\\acute\{\\iota\}/g, '\u03AF'], + [/\\acute\{\\ddot\{\\upsilon\}\}/g, '\u03B0'], + [/\\alpha /g, '\u03B1'], + [/\\beta /g, '\u03B2'], + [/\\gamma /g, '\u03B3'], + [/\\delta /g, '\u03B4'], + [/\\epsilon /g, '\u03B5'], + [/\\zeta /g, '\u03B6'], + [/\\eta /g, '\u03B7'], + [/\\texttheta /g, '\u03B8'], + [/\\iota /g, '\u03B9'], + [/\\kappa /g, '\u03BA'], + [/\\lambda /g, '\u03BB'], + [/\\mu /g, '\u03BC'], + [/\\nu /g, '\u03BD'], + [/\\xi /g, '\u03BE'], + [/\\pi /g, '\u03C0'], + [/\\rho /g, '\u03C1'], + [/\\varsigma /g, '\u03C2'], + [/\\sigma /g, '\u03C3'], + [/\\tau /g, '\u03C4'], + [/\\upsilon /g, '\u03C5'], + [/\\varphi /g, '\u03C6'], + [/\\chi /g, '\u03C7'], + [/\\psi /g, '\u03C8'], + [/\\omega /g, '\u03C9'], + [/\\ddot\{\\iota\}/g, '\u03CA'], + [/\\ddot\{\\upsilon\}/g, '\u03CB'], + [/\\'\{o\}/g, '\u03CC'], + [/\\acute\{\\upsilon\}/g, '\u03CD'], + [/\\acute\{\\omega\}/g, '\u03CE'], + [/\\Pisymbol\{ppi022\}\{87\}/g, '\u03D0'], + [/\\textvartheta /g, '\u03D1'], + [/\\Upsilon /g, '\u03D2'], + [/\\phi /g, '\u03D5'], + [/\\varpi /g, '\u03D6'], + [/\\Stigma /g, '\u03DA'], + [/\\Digamma /g, '\u03DC'], + [/\\digamma /g, '\u03DD'], + [/\\Koppa /g, '\u03DE'], + [/\\Sampi /g, '\u03E0'], + [/\\varkappa /g, '\u03F0'], + [/\\varrho /g, '\u03F1'], + [/\\textTheta /g, '\u03F4'], + [/\\backepsilon /g, '\u03F6'], + [/\\cyrchar\\CYRYO /g, '\u0401'], + [/\\cyrchar\\CYRDJE /g, '\u0402'], + [/\\cyrchar\{\\'\\CYRG\}/g, '\u0403'], + [/\\cyrchar\\CYRIE /g, '\u0404'], + [/\\cyrchar\\CYRDZE /g, '\u0405'], + [/\\cyrchar\\CYRII /g, '\u0406'], + [/\\cyrchar\\CYRYI /g, '\u0407'], + [/\\cyrchar\\CYRJE /g, '\u0408'], + [/\\cyrchar\\CYRLJE /g, '\u0409'], + [/\\cyrchar\\CYRNJE /g, '\u040A'], + [/\\cyrchar\\CYRTSHE /g, '\u040B'], + [/\\cyrchar\{\\'\\CYRK\}/g, '\u040C'], + [/\\cyrchar\\CYRUSHRT /g, '\u040E'], + [/\\cyrchar\\CYRDZHE /g, '\u040F'], + [/\\cyrchar\\CYRA /g, '\u0410'], + [/\\cyrchar\\CYRB /g, '\u0411'], + [/\\cyrchar\\CYRV /g, '\u0412'], + [/\\cyrchar\\CYRG /g, '\u0413'], + [/\\cyrchar\\CYRD /g, '\u0414'], + [/\\cyrchar\\CYRE /g, '\u0415'], + [/\\cyrchar\\CYRZH /g, '\u0416'], + [/\\cyrchar\\CYRZ /g, '\u0417'], + [/\\cyrchar\\CYRI /g, '\u0418'], + [/\\cyrchar\\CYRISHRT /g, '\u0419'], + [/\\cyrchar\\CYRK /g, '\u041A'], + [/\\cyrchar\\CYRL /g, '\u041B'], + [/\\cyrchar\\CYRM /g, '\u041C'], + [/\\cyrchar\\CYRN /g, '\u041D'], + [/\\cyrchar\\CYRO /g, '\u041E'], + [/\\cyrchar\\CYRP /g, '\u041F'], + [/\\cyrchar\\CYRR /g, '\u0420'], + [/\\cyrchar\\CYRS /g, '\u0421'], + [/\\cyrchar\\CYRT /g, '\u0422'], + [/\\cyrchar\\CYRU /g, '\u0423'], + [/\\cyrchar\\CYRF /g, '\u0424'], + [/\\cyrchar\\CYRH /g, '\u0425'], + [/\\cyrchar\\CYRC /g, '\u0426'], + [/\\cyrchar\\CYRCH /g, '\u0427'], + [/\\cyrchar\\CYRSH /g, '\u0428'], + [/\\cyrchar\\CYRSHCH /g, '\u0429'], + [/\\cyrchar\\CYRHRDSN /g, '\u042A'], + [/\\cyrchar\\CYRERY /g, '\u042B'], + [/\\cyrchar\\CYRSFTSN /g, '\u042C'], + [/\\cyrchar\\CYREREV /g, '\u042D'], + [/\\cyrchar\\CYRYU /g, '\u042E'], + [/\\cyrchar\\CYRYA /g, '\u042F'], + [/\\cyrchar\\cyra /g, '\u0430'], + [/\\cyrchar\\cyrb /g, '\u0431'], + [/\\cyrchar\\cyrv /g, '\u0432'], + [/\\cyrchar\\cyrg /g, '\u0433'], + [/\\cyrchar\\cyrd /g, '\u0434'], + [/\\cyrchar\\cyre /g, '\u0435'], + [/\\cyrchar\\cyrzh /g, '\u0436'], + [/\\cyrchar\\cyrz /g, '\u0437'], + [/\\cyrchar\\cyri /g, '\u0438'], + [/\\cyrchar\\cyrishrt /g, '\u0439'], + [/\\cyrchar\\cyrk /g, '\u043A'], + [/\\cyrchar\\cyrl /g, '\u043B'], + [/\\cyrchar\\cyrm /g, '\u043C'], + [/\\cyrchar\\cyrn /g, '\u043D'], + [/\\cyrchar\\cyro /g, '\u043E'], + [/\\cyrchar\\cyrp /g, '\u043F'], + [/\\cyrchar\\cyrr /g, '\u0440'], + [/\\cyrchar\\cyrs /g, '\u0441'], + [/\\cyrchar\\cyrt /g, '\u0442'], + [/\\cyrchar\\cyru /g, '\u0443'], + [/\\cyrchar\\cyrf /g, '\u0444'], + [/\\cyrchar\\cyrh /g, '\u0445'], + [/\\cyrchar\\cyrc /g, '\u0446'], + [/\\cyrchar\\cyrch /g, '\u0447'], + [/\\cyrchar\\cyrsh /g, '\u0448'], + [/\\cyrchar\\cyrshch /g, '\u0449'], + [/\\cyrchar\\cyrhrdsn /g, '\u044A'], + [/\\cyrchar\\cyrery /g, '\u044B'], + [/\\cyrchar\\cyrsftsn /g, '\u044C'], + [/\\cyrchar\\cyrerev /g, '\u044D'], + [/\\cyrchar\\cyryu /g, '\u044E'], + [/\\cyrchar\\cyrya /g, '\u044F'], + [/\\cyrchar\\cyryo /g, '\u0451'], + [/\\cyrchar\\cyrdje /g, '\u0452'], + [/\\cyrchar\{\\'\\cyrg\}/g, '\u0453'], + [/\\cyrchar\\cyrie /g, '\u0454'], + [/\\cyrchar\\cyrdze /g, '\u0455'], + [/\\cyrchar\\cyrii /g, '\u0456'], + [/\\cyrchar\\cyryi /g, '\u0457'], + [/\\cyrchar\\cyrje /g, '\u0458'], + [/\\cyrchar\\cyrlje /g, '\u0459'], + [/\\cyrchar\\cyrnje /g, '\u045A'], + [/\\cyrchar\\cyrtshe /g, '\u045B'], + [/\\cyrchar\{\\'\\cyrk\}/g, '\u045C'], + [/\\cyrchar\\cyrushrt /g, '\u045E'], + [/\\cyrchar\\cyrdzhe /g, '\u045F'], + [/\\cyrchar\\CYROMEGA /g, '\u0460'], + [/\\cyrchar\\cyromega /g, '\u0461'], + [/\\cyrchar\\CYRYAT /g, '\u0462'], + [/\\cyrchar\\CYRIOTE /g, '\u0464'], + [/\\cyrchar\\cyriote /g, '\u0465'], + [/\\cyrchar\\CYRLYUS /g, '\u0466'], + [/\\cyrchar\\cyrlyus /g, '\u0467'], + [/\\cyrchar\\CYRIOTLYUS /g, '\u0468'], + [/\\cyrchar\\cyriotlyus /g, '\u0469'], + [/\\cyrchar\\CYRBYUS /g, '\u046A'], + [/\\cyrchar\\CYRIOTBYUS /g, '\u046C'], + [/\\cyrchar\\cyriotbyus /g, '\u046D'], + [/\\cyrchar\\CYRKSI /g, '\u046E'], + [/\\cyrchar\\cyrksi /g, '\u046F'], + [/\\cyrchar\\CYRPSI /g, '\u0470'], + [/\\cyrchar\\cyrpsi /g, '\u0471'], + [/\\cyrchar\\CYRFITA /g, '\u0472'], + [/\\cyrchar\\CYRIZH /g, '\u0474'], + [/\\cyrchar\\CYRUK /g, '\u0478'], + [/\\cyrchar\\cyruk /g, '\u0479'], + [/\\cyrchar\\CYROMEGARND /g, '\u047A'], + [/\\cyrchar\\cyromegarnd /g, '\u047B'], + [/\\cyrchar\\CYROMEGATITLO /g, '\u047C'], + [/\\cyrchar\\cyromegatitlo /g, '\u047D'], + [/\\cyrchar\\CYROT /g, '\u047E'], + [/\\cyrchar\\cyrot /g, '\u047F'], + [/\\cyrchar\\CYRKOPPA /g, '\u0480'], + [/\\cyrchar\\cyrkoppa /g, '\u0481'], + [/\\cyrchar\\cyrthousands /g, '\u0482'], + [/\\cyrchar\\cyrhundredthousands /g, '\u0488'], + [/\\cyrchar\\cyrmillions /g, '\u0489'], + [/\\cyrchar\\CYRSEMISFTSN /g, '\u048C'], + [/\\cyrchar\\cyrsemisftsn /g, '\u048D'], + [/\\cyrchar\\CYRRTICK /g, '\u048E'], + [/\\cyrchar\\cyrrtick /g, '\u048F'], + [/\\cyrchar\\CYRGUP /g, '\u0490'], + [/\\cyrchar\\cyrgup /g, '\u0491'], + [/\\cyrchar\\CYRGHCRS /g, '\u0492'], + [/\\cyrchar\\cyrghcrs /g, '\u0493'], + [/\\cyrchar\\CYRGHK /g, '\u0494'], + [/\\cyrchar\\cyrghk /g, '\u0495'], + [/\\cyrchar\\CYRZHDSC /g, '\u0496'], + [/\\cyrchar\\cyrzhdsc /g, '\u0497'], + [/\\cyrchar\\CYRZDSC /g, '\u0498'], + [/\\cyrchar\\cyrzdsc /g, '\u0499'], + [/\\cyrchar\\CYRKDSC /g, '\u049A'], + [/\\cyrchar\\cyrkdsc /g, '\u049B'], + [/\\cyrchar\\CYRKVCRS /g, '\u049C'], + [/\\cyrchar\\cyrkvcrs /g, '\u049D'], + [/\\cyrchar\\CYRKHCRS /g, '\u049E'], + [/\\cyrchar\\cyrkhcrs /g, '\u049F'], + [/\\cyrchar\\CYRKBEAK /g, '\u04A0'], + [/\\cyrchar\\cyrkbeak /g, '\u04A1'], + [/\\cyrchar\\CYRNDSC /g, '\u04A2'], + [/\\cyrchar\\cyrndsc /g, '\u04A3'], + [/\\cyrchar\\CYRNG /g, '\u04A4'], + [/\\cyrchar\\cyrng /g, '\u04A5'], + [/\\cyrchar\\CYRPHK /g, '\u04A6'], + [/\\cyrchar\\cyrphk /g, '\u04A7'], + [/\\cyrchar\\CYRABHHA /g, '\u04A8'], + [/\\cyrchar\\cyrabhha /g, '\u04A9'], + [/\\cyrchar\\CYRSDSC /g, '\u04AA'], + [/\\cyrchar\\cyrsdsc /g, '\u04AB'], + [/\\cyrchar\\CYRTDSC /g, '\u04AC'], + [/\\cyrchar\\cyrtdsc /g, '\u04AD'], + [/\\cyrchar\\CYRY /g, '\u04AE'], + [/\\cyrchar\\cyry /g, '\u04AF'], + [/\\cyrchar\\CYRYHCRS /g, '\u04B0'], + [/\\cyrchar\\cyryhcrs /g, '\u04B1'], + [/\\cyrchar\\CYRHDSC /g, '\u04B2'], + [/\\cyrchar\\cyrhdsc /g, '\u04B3'], + [/\\cyrchar\\CYRTETSE /g, '\u04B4'], + [/\\cyrchar\\cyrtetse /g, '\u04B5'], + [/\\cyrchar\\CYRCHRDSC /g, '\u04B6'], + [/\\cyrchar\\cyrchrdsc /g, '\u04B7'], + [/\\cyrchar\\CYRCHVCRS /g, '\u04B8'], + [/\\cyrchar\\cyrchvcrs /g, '\u04B9'], + [/\\cyrchar\\CYRSHHA /g, '\u04BA'], + [/\\cyrchar\\cyrshha /g, '\u04BB'], + [/\\cyrchar\\CYRABHCH /g, '\u04BC'], + [/\\cyrchar\\cyrabhch /g, '\u04BD'], + [/\\cyrchar\\CYRABHCHDSC /g, '\u04BE'], + [/\\cyrchar\\cyrabhchdsc /g, '\u04BF'], + [/\\cyrchar\\CYRpalochka /g, '\u04C0'], + [/\\cyrchar\\CYRKHK /g, '\u04C3'], + [/\\cyrchar\\cyrkhk /g, '\u04C4'], + [/\\cyrchar\\CYRNHK /g, '\u04C7'], + [/\\cyrchar\\cyrnhk /g, '\u04C8'], + [/\\cyrchar\\CYRCHLDSC /g, '\u04CB'], + [/\\cyrchar\\cyrchldsc /g, '\u04CC'], + [/\\cyrchar\\CYRAE /g, '\u04D4'], + [/\\cyrchar\\cyrae /g, '\u04D5'], + [/\\cyrchar\\CYRSCHWA /g, '\u04D8'], + [/\\cyrchar\\cyrschwa /g, '\u04D9'], + [/\\cyrchar\\CYRABHDZE /g, '\u04E0'], + [/\\cyrchar\\cyrabhdze /g, '\u04E1'], + [/\\cyrchar\\CYROTLD /g, '\u04E8'], + [/\\cyrchar\\cyrotld /g, '\u04E9'], + [/\\hspace\{0.6em\}/g, '\u2002'], + [/\\hspace\{1em\}/g, '\u2003'], + [/\\hspace\{0.33em\}/g, '\u2004'], + [/\\hspace\{0.25em\}/g, '\u2005'], + [/\\hspace\{0.166em\}/g, '\u2006'], + [/\\hphantom\{0\}/g, '\u2007'], + [/\\hphantom\{,\}/g, '\u2008'], + [/\\hspace\{0.167em\}/g, '\u2009'], + [/\\mkern1mu /g, '\u200A'], + [/\\textendash /g, '\u2013'], + [/\\textemdash /g, '\u2014'], + [/\\rule\{1em\}\{1pt\}/g, '\u2015'], + [/\\Vert /g, '\u2016'], + [/\\Elzreapos /g, '\u201B'], + [/\\textquotedblleft /g, '\u201C'], + [/\\textquotedblright /g, '\u201D'], + [/\\textdagger /g, '\u2020'], + [/\\textdaggerdbl /g, '\u2021'], + [/\\textbullet /g, '\u2022'], + [/\\ldots /g, '\u2026'], + [/\\textperthousand /g, '\u2030'], + [/\\textpertenthousand /g, '\u2031'], + [/\\backprime /g, '\u2035'], + [/\\guilsinglleft /g, '\u2039'], + [/\\guilsinglright /g, '\u203A'], + [/\\mkern4mu /g, '\u205F'], + [/\\nolinebreak /g, '\u2060'], + [/\\ensuremath\{\\Elzpes\}/g, '\u20A7'], + [/\\mbox\{\\texteuro\} /g, '\u20AC'], + [/\\dddot /g, '\u20DB'], + [/\\ddddot /g, '\u20DC'], + [/\\mathbb\{C\}/g, '\u2102'], + [/\\mathscr\{g\}/g, '\u210A'], + [/\\mathscr\{H\}/g, '\u210B'], + [/\\mathfrak\{H\}/g, '\u210C'], + [/\\mathbb\{H\}/g, '\u210D'], + [/\\hslash /g, '\u210F'], + [/\\mathscr\{I\}/g, '\u2110'], + [/\\mathfrak\{I\}/g, '\u2111'], + [/\\mathscr\{L\}/g, '\u2112'], + [/\\mathscr\{l\}/g, '\u2113'], + [/\\mathbb\{N\}/g, '\u2115'], + [/\\cyrchar\\textnumero /g, '\u2116'], + [/\\wp /g, '\u2118'], + [/\\mathbb\{P\}/g, '\u2119'], + [/\\mathbb\{Q\}/g, '\u211A'], + [/\\mathscr\{R\}/g, '\u211B'], + [/\\mathfrak\{R\}/g, '\u211C'], + [/\\mathbb\{R\}/g, '\u211D'], + [/\\Elzxrat /g, '\u211E'], + [/\\texttrademark /g, '\u2122'], + [/\\mathbb\{Z\}/g, '\u2124'], + [/\\Omega /g, '\u2126'], + [/\\mho /g, '\u2127'], + [/\\mathfrak\{Z\}/g, '\u2128'], + [/\\ElsevierGlyph\{2129\}/g, '\u2129'], + [/\\AA /g, '\u212B'], + [/\\mathscr\{B\}/g, '\u212C'], + [/\\mathfrak\{C\}/g, '\u212D'], + [/\\mathscr\{e\}/g, '\u212F'], + [/\\mathscr\{E\}/g, '\u2130'], + [/\\mathscr\{F\}/g, '\u2131'], + [/\\mathscr\{M\}/g, '\u2133'], + [/\\mathscr\{o\}/g, '\u2134'], + [/\\aleph /g, '\u2135'], + [/\\beth /g, '\u2136'], + [/\\gimel /g, '\u2137'], + [/\\daleth /g, '\u2138'], + [/\\textfrac\{1\}\{3\}/g, '\u2153'], + [/\\textfrac\{2\}\{3\}/g, '\u2154'], + [/\\textfrac\{1\}\{5\}/g, '\u2155'], + [/\\textfrac\{2\}\{5\}/g, '\u2156'], + [/\\textfrac\{3\}\{5\}/g, '\u2157'], + [/\\textfrac\{4\}\{5\}/g, '\u2158'], + [/\\textfrac\{1\}\{6\}/g, '\u2159'], + [/\\textfrac\{5\}\{6\}/g, '\u215A'], + [/\\textfrac\{1\}\{8\}/g, '\u215B'], + [/\\textfrac\{3\}\{8\}/g, '\u215C'], + [/\\textfrac\{5\}\{8\}/g, '\u215D'], + [/\\textfrac\{7\}\{8\}/g, '\u215E'], + [/\\leftarrow /g, '\u2190'], + [/\\uparrow /g, '\u2191'], + [/\\rightarrow /g, '\u2192'], + [/\\downarrow /g, '\u2193'], + [/\\leftrightarrow /g, '\u2194'], + [/\\updownarrow /g, '\u2195'], + [/\\nwarrow /g, '\u2196'], + [/\\nearrow /g, '\u2197'], + [/\\searrow /g, '\u2198'], + [/\\swarrow /g, '\u2199'], + [/\\nleftarrow /g, '\u219A'], + [/\\nrightarrow /g, '\u219B'], + [/\\arrowwaveright /g, '\u219C'], + [/\\arrowwaveright /g, '\u219D'], + [/\\twoheadleftarrow /g, '\u219E'], + [/\\twoheadrightarrow /g, '\u21A0'], + [/\\leftarrowtail /g, '\u21A2'], + [/\\rightarrowtail /g, '\u21A3'], + [/\\mapsto /g, '\u21A6'], + [/\\hookleftarrow /g, '\u21A9'], + [/\\hookrightarrow /g, '\u21AA'], + [/\\looparrowleft /g, '\u21AB'], + [/\\looparrowright /g, '\u21AC'], + [/\\leftrightsquigarrow /g, '\u21AD'], + [/\\nleftrightarrow /g, '\u21AE'], + [/\\Lsh /g, '\u21B0'], + [/\\Rsh /g, '\u21B1'], + [/\\ElsevierGlyph\{21B3\}/g, '\u21B3'], + [/\\curvearrowleft /g, '\u21B6'], + [/\\curvearrowright /g, '\u21B7'], + [/\\circlearrowleft /g, '\u21BA'], + [/\\circlearrowright /g, '\u21BB'], + [/\\leftharpoonup /g, '\u21BC'], + [/\\leftharpoondown /g, '\u21BD'], + [/\\upharpoonright /g, '\u21BE'], + [/\\upharpoonleft /g, '\u21BF'], + [/\\rightharpoonup /g, '\u21C0'], + [/\\rightharpoondown /g, '\u21C1'], + [/\\downharpoonright /g, '\u21C2'], + [/\\downharpoonleft /g, '\u21C3'], + [/\\rightleftarrows /g, '\u21C4'], + [/\\dblarrowupdown /g, '\u21C5'], + [/\\leftrightarrows /g, '\u21C6'], + [/\\leftleftarrows /g, '\u21C7'], + [/\\upuparrows /g, '\u21C8'], + [/\\rightrightarrows /g, '\u21C9'], + [/\\downdownarrows /g, '\u21CA'], + [/\\leftrightharpoons /g, '\u21CB'], + [/\\rightleftharpoons /g, '\u21CC'], + [/\\nLeftarrow /g, '\u21CD'], + [/\\nLeftrightarrow /g, '\u21CE'], + [/\\nRightarrow /g, '\u21CF'], + [/\\Leftarrow /g, '\u21D0'], + [/\\Uparrow /g, '\u21D1'], + [/\\Rightarrow /g, '\u21D2'], + [/\\Downarrow /g, '\u21D3'], + [/\\Leftrightarrow /g, '\u21D4'], + [/\\Updownarrow /g, '\u21D5'], + [/\\Lleftarrow /g, '\u21DA'], + [/\\Rrightarrow /g, '\u21DB'], + [/\\rightsquigarrow /g, '\u21DD'], + [/\\DownArrowUpArrow /g, '\u21F5'], + [/\\forall /g, '\u2200'], + [/\\complement /g, '\u2201'], + [/\\partial /g, '\u2202'], + [/\\exists /g, '\u2203'], + [/\\nexists /g, '\u2204'], + [/\\varnothing /g, '\u2205'], + [/\\nabla /g, '\u2207'], + [/\\in /g, '\u2208'], + [/\\not\\in /g, '\u2209'], + [/\\ni /g, '\u220B'], + [/\\not\\ni /g, '\u220C'], + [/\\prod /g, '\u220F'], + [/\\coprod /g, '\u2210'], + [/\\sum /g, '\u2211'], + [/\\mp /g, '\u2213'], + [/\\dotplus /g, '\u2214'], + [/\\setminus /g, '\u2216'], + [/\\circ /g, '\u2218'], + [/\\bullet /g, '\u2219'], + [/\\surd /g, '\u221A'], + [/\\propto /g, '\u221D'], + [/\\infty /g, '\u221E'], + [/\\rightangle /g, '\u221F'], + [/\\angle /g, '\u2220'], + [/\\measuredangle /g, '\u2221'], + [/\\sphericalangle /g, '\u2222'], + [/\\mid /g, '\u2223'], + [/\\nmid /g, '\u2224'], + [/\\parallel /g, '\u2225'], + [/\\nparallel /g, '\u2226'], + [/\\wedge /g, '\u2227'], + [/\\vee /g, '\u2228'], + [/\\cap /g, '\u2229'], + [/\\cup /g, '\u222A'], + [/\\int /g, '\u222B'], + [/\\int\\!\\int /g, '\u222C'], + [/\\int\\!\\int\\!\\int /g, '\u222D'], + [/\\oint /g, '\u222E'], + [/\\surfintegral /g, '\u222F'], + [/\\volintegral /g, '\u2230'], + [/\\clwintegral /g, '\u2231'], + [/\\ElsevierGlyph\{2232\}/g, '\u2232'], + [/\\ElsevierGlyph\{2233\}/g, '\u2233'], + [/\\therefore /g, '\u2234'], + [/\\because /g, '\u2235'], + [/\\Colon /g, '\u2237'], + [/\\ElsevierGlyph\{2238\}/g, '\u2238'], + [/\\mathbin\{\{:\}\\!\\!\{\-\}\\!\\!\{:\}\}/g, '\u223A'], + [/\\homothetic /g, '\u223B'], + [/\\sim /g, '\u223C'], + [/\\backsim /g, '\u223D'], + [/\\lazysinv /g, '\u223E'], + [/\\wr /g, '\u2240'], + [/\\not\\sim /g, '\u2241'], + [/\\ElsevierGlyph\{2242\}/g, '\u2242'], + [/\\NotEqualTilde /g, '\u2242-00338'], + [/\\simeq /g, '\u2243'], + [/\\not\\simeq /g, '\u2244'], + [/\\cong /g, '\u2245'], + [/\\approxnotequal /g, '\u2246'], + [/\\not\\cong /g, '\u2247'], + [/\\approx /g, '\u2248'], + [/\\not\\approx /g, '\u2249'], + [/\\approxeq /g, '\u224A'], + [/\\tildetrpl /g, '\u224B'], + [/\\not\\apid /g, '\u224B-00338'], + [/\\allequal /g, '\u224C'], + [/\\asymp /g, '\u224D'], + [/\\Bumpeq /g, '\u224E'], + [/\\NotHumpDownHump /g, '\u224E-00338'], + [/\\bumpeq /g, '\u224F'], + [/\\NotHumpEqual /g, '\u224F-00338'], + [/\\doteq /g, '\u2250'], + [/\\not\\doteq/g, '\u2250-00338'], + [/\\doteqdot /g, '\u2251'], + [/\\fallingdotseq /g, '\u2252'], + [/\\risingdotseq /g, '\u2253'], + [/\\eqcirc /g, '\u2256'], + [/\\circeq /g, '\u2257'], + [/\\estimates /g, '\u2259'], + [/\\ElsevierGlyph\{225A\}/g, '\u225A'], + [/\\starequal /g, '\u225B'], + [/\\triangleq /g, '\u225C'], + [/\\ElsevierGlyph\{225F\}/g, '\u225F'], + [/\\not =/g, '\u2260'], + [/\\equiv /g, '\u2261'], + [/\\not\\equiv /g, '\u2262'], + [/\\leq /g, '\u2264'], + [/\\geq /g, '\u2265'], + [/\\leqq /g, '\u2266'], + [/\\geqq /g, '\u2267'], + [/\\lneqq /g, '\u2268'], + [/\\lvertneqq /g, '\u2268-0FE00'], + [/\\gneqq /g, '\u2269'], + [/\\gvertneqq /g, '\u2269-0FE00'], + [/\\ll /g, '\u226A'], + [/\\NotLessLess /g, '\u226A-00338'], + [/\\gg /g, '\u226B'], + [/\\NotGreaterGreater /g, '\u226B-00338'], + [/\\between /g, '\u226C'], + [/\\not\\kern\-0.3em\\times /g, '\u226D'], + [/\\not/g, '\u226F'], + [/\\not\\leq /g, '\u2270'], + [/\\not\\geq /g, '\u2271'], + [/\\lessequivlnt /g, '\u2272'], + [/\\greaterequivlnt /g, '\u2273'], + [/\\ElsevierGlyph\{2274\}/g, '\u2274'], + [/\\ElsevierGlyph\{2275\}/g, '\u2275'], + [/\\lessgtr /g, '\u2276'], + [/\\gtrless /g, '\u2277'], + [/\\notlessgreater /g, '\u2278'], + [/\\notgreaterless /g, '\u2279'], + [/\\prec /g, '\u227A'], + [/\\succ /g, '\u227B'], + [/\\preccurlyeq /g, '\u227C'], + [/\\succcurlyeq /g, '\u227D'], + [/\\precapprox /g, '\u227E'], + [/\\NotPrecedesTilde /g, '\u227E-00338'], + [/\\succapprox /g, '\u227F'], + [/\\NotSucceedsTilde /g, '\u227F-00338'], + [/\\not\\prec /g, '\u2280'], + [/\\not\\succ /g, '\u2281'], + [/\\subset /g, '\u2282'], + [/\\supset /g, '\u2283'], + [/\\not\\subset /g, '\u2284'], + [/\\not\\supset /g, '\u2285'], + [/\\subseteq /g, '\u2286'], + [/\\supseteq /g, '\u2287'], + [/\\not\\subseteq /g, '\u2288'], + [/\\not\\supseteq /g, '\u2289'], + [/\\subsetneq /g, '\u228A'], + [/\\varsubsetneqq /g, '\u228A-0FE00'], + [/\\supsetneq /g, '\u228B'], + [/\\varsupsetneq /g, '\u228B-0FE00'], + [/\\uplus /g, '\u228E'], + [/\\sqsubset /g, '\u228F'], + [/\\NotSquareSubset /g, '\u228F-00338'], + [/\\sqsupset /g, '\u2290'], + [/\\NotSquareSuperset /g, '\u2290-00338'], + [/\\sqsubseteq /g, '\u2291'], + [/\\sqsupseteq /g, '\u2292'], + [/\\sqcap /g, '\u2293'], + [/\\sqcup /g, '\u2294'], + [/\\oplus /g, '\u2295'], + [/\\ominus /g, '\u2296'], + [/\\otimes /g, '\u2297'], + [/\\oslash /g, '\u2298'], + [/\\odot /g, '\u2299'], + [/\\circledcirc /g, '\u229A'], + [/\\circledast /g, '\u229B'], + [/\\circleddash /g, '\u229D'], + [/\\boxplus /g, '\u229E'], + [/\\boxminus /g, '\u229F'], + [/\\boxtimes /g, '\u22A0'], + [/\\boxdot /g, '\u22A1'], + [/\\vdash /g, '\u22A2'], + [/\\dashv /g, '\u22A3'], + [/\\top /g, '\u22A4'], + [/\\perp /g, '\u22A5'], + [/\\truestate /g, '\u22A7'], + [/\\forcesextra /g, '\u22A8'], + [/\\Vdash /g, '\u22A9'], + [/\\Vvdash /g, '\u22AA'], + [/\\VDash /g, '\u22AB'], + [/\\nvdash /g, '\u22AC'], + [/\\nvDash /g, '\u22AD'], + [/\\nVdash /g, '\u22AE'], + [/\\nVDash /g, '\u22AF'], + [/\\vartriangleleft /g, '\u22B2'], + [/\\vartriangleright /g, '\u22B3'], + [/\\trianglelefteq /g, '\u22B4'], + [/\\trianglerighteq /g, '\u22B5'], + [/\\original /g, '\u22B6'], + [/\\image /g, '\u22B7'], + [/\\multimap /g, '\u22B8'], + [/\\hermitconjmatrix /g, '\u22B9'], + [/\\intercal /g, '\u22BA'], + [/\\veebar /g, '\u22BB'], + [/\\rightanglearc /g, '\u22BE'], + [/\\ElsevierGlyph\{22C0\}/g, '\u22C0'], + [/\\ElsevierGlyph\{22C1\}/g, '\u22C1'], + [/\\bigcap /g, '\u22C2'], + [/\\bigcup /g, '\u22C3'], + [/\\diamond /g, '\u22C4'], + [/\\cdot /g, '\u22C5'], + [/\\star /g, '\u22C6'], + [/\\divideontimes /g, '\u22C7'], + [/\\bowtie /g, '\u22C8'], + [/\\ltimes /g, '\u22C9'], + [/\\rtimes /g, '\u22CA'], + [/\\leftthreetimes /g, '\u22CB'], + [/\\rightthreetimes /g, '\u22CC'], + [/\\backsimeq /g, '\u22CD'], + [/\\curlyvee /g, '\u22CE'], + [/\\curlywedge /g, '\u22CF'], + [/\\Subset /g, '\u22D0'], + [/\\Supset /g, '\u22D1'], + [/\\Cap /g, '\u22D2'], + [/\\Cup /g, '\u22D3'], + [/\\pitchfork /g, '\u22D4'], + [/\\lessdot /g, '\u22D6'], + [/\\gtrdot /g, '\u22D7'], + [/\\verymuchless /g, '\u22D8'], + [/\\verymuchgreater /g, '\u22D9'], + [/\\lesseqgtr /g, '\u22DA'], + [/\\gtreqless /g, '\u22DB'], + [/\\curlyeqprec /g, '\u22DE'], + [/\\curlyeqsucc /g, '\u22DF'], + [/\\not\\sqsubseteq /g, '\u22E2'], + [/\\not\\sqsupseteq /g, '\u22E3'], + [/\\Elzsqspne /g, '\u22E5'], + [/\\lnsim /g, '\u22E6'], + [/\\gnsim /g, '\u22E7'], + [/\\precedesnotsimilar /g, '\u22E8'], + [/\\succnsim /g, '\u22E9'], + [/\\ntriangleleft /g, '\u22EA'], + [/\\ntriangleright /g, '\u22EB'], + [/\\ntrianglelefteq /g, '\u22EC'], + [/\\ntrianglerighteq /g, '\u22ED'], + [/\\vdots /g, '\u22EE'], + [/\\cdots /g, '\u22EF'], + [/\\upslopeellipsis /g, '\u22F0'], + [/\\downslopeellipsis /g, '\u22F1'], + [/\\barwedge /g, '\u2305'], + [/\\perspcorrespond /g, '\u2306'], + [/\\lceil /g, '\u2308'], + [/\\rceil /g, '\u2309'], + [/\\lfloor /g, '\u230A'], + [/\\rfloor /g, '\u230B'], + [/\\recorder /g, '\u2315'], + [/\\mathchar"2208/g, '\u2316'], + [/\\ulcorner /g, '\u231C'], + [/\\urcorner /g, '\u231D'], + [/\\llcorner /g, '\u231E'], + [/\\lrcorner /g, '\u231F'], + [/\\frown /g, '\u2322'], + [/\\smile /g, '\u2323'], + [/\\langle /g, '\u2329'], + [/\\rangle /g, '\u232A'], + [/\\ElsevierGlyph\{E838\}/g, '\u233D'], + [/\\Elzdlcorn /g, '\u23A3'], + [/\\lmoustache /g, '\u23B0'], + [/\\rmoustache /g, '\u23B1'], + [/\\textvisiblespace /g, '\u2423'], + [/\\ding\{172\}/g, '\u2460'], + [/\\ding\{173\}/g, '\u2461'], + [/\\ding\{174\}/g, '\u2462'], + [/\\ding\{175\}/g, '\u2463'], + [/\\ding\{176\}/g, '\u2464'], + [/\\ding\{177\}/g, '\u2465'], + [/\\ding\{178\}/g, '\u2466'], + [/\\ding\{179\}/g, '\u2467'], + [/\\ding\{180\}/g, '\u2468'], + [/\\ding\{181\}/g, '\u2469'], + [/\\circledS /g, '\u24C8'], + [/\\Elzdshfnc /g, '\u2506'], + [/\\Elzsqfnw /g, '\u2519'], + [/\\diagup /g, '\u2571'], + [/\\ding\{110\}/g, '\u25A0'], + [/\\square /g, '\u25A1'], + [/\\blacksquare /g, '\u25AA'], + [/\\fbox\{~~\}/g, '\u25AD'], + [/\\Elzvrecto /g, '\u25AF'], + [/\\ElsevierGlyph\{E381\}/g, '\u25B1'], + [/\\ding\{115\}/g, '\u25B2'], + [/\\bigtriangleup /g, '\u25B3'], + [/\\blacktriangle /g, '\u25B4'], + [/\\vartriangle /g, '\u25B5'], + [/\\blacktriangleright /g, '\u25B8'], + [/\\triangleright /g, '\u25B9'], + [/\\ding\{116\}/g, '\u25BC'], + [/\\bigtriangledown /g, '\u25BD'], + [/\\blacktriangledown /g, '\u25BE'], + [/\\triangledown /g, '\u25BF'], + [/\\blacktriangleleft /g, '\u25C2'], + [/\\triangleleft /g, '\u25C3'], + [/\\ding\{117\}/g, '\u25C6'], + [/\\lozenge /g, '\u25CA'], + [/\\bigcirc /g, '\u25CB'], + [/\\ding\{108\}/g, '\u25CF'], + [/\\Elzcirfl /g, '\u25D0'], + [/\\Elzcirfr /g, '\u25D1'], + [/\\Elzcirfb /g, '\u25D2'], + [/\\ding\{119\}/g, '\u25D7'], + [/\\Elzrvbull /g, '\u25D8'], + [/\\Elzsqfl /g, '\u25E7'], + [/\\Elzsqfr /g, '\u25E8'], + [/\\Elzsqfse /g, '\u25EA'], + [/\\bigcirc /g, '\u25EF'], + [/\\ding\{72\}/g, '\u2605'], + [/\\ding\{73\}/g, '\u2606'], + [/\\ding\{37\}/g, '\u260E'], + [/\\ding\{42\}/g, '\u261B'], + [/\\ding\{43\}/g, '\u261E'], + [/\\rightmoon /g, '\u263E'], + [/\\mercury /g, '\u263F'], + [/\\venus /g, '\u2640'], + [/\\male /g, '\u2642'], + [/\\jupiter /g, '\u2643'], + [/\\saturn /g, '\u2644'], + [/\\uranus /g, '\u2645'], + [/\\neptune /g, '\u2646'], + [/\\pluto /g, '\u2647'], + [/\\aries /g, '\u2648'], + [/\\taurus /g, '\u2649'], + [/\\gemini /g, '\u264A'], + [/\\cancer /g, '\u264B'], + [/\\leo /g, '\u264C'], + [/\\virgo /g, '\u264D'], + [/\\libra /g, '\u264E'], + [/\\scorpio /g, '\u264F'], + [/\\sagittarius /g, '\u2650'], + [/\\capricornus /g, '\u2651'], + [/\\aquarius /g, '\u2652'], + [/\\pisces /g, '\u2653'], + [/\\ding\{171\}/g, '\u2660'], + [/\\diamond /g, '\u2662'], + [/\\ding\{168\}/g, '\u2663'], + [/\\ding\{170\}/g, '\u2665'], + [/\\ding\{169\}/g, '\u2666'], + [/\\quarternote /g, '\u2669'], + [/\\eighthnote /g, '\u266A'], + [/\\flat /g, '\u266D'], + [/\\natural /g, '\u266E'], + [/\\sharp /g, '\u266F'], + [/\\ding\{33\}/g, '\u2701'], + [/\\ding\{34\}/g, '\u2702'], + [/\\ding\{35\}/g, '\u2703'], + [/\\ding\{36\}/g, '\u2704'], + [/\\ding\{38\}/g, '\u2706'], + [/\\ding\{39\}/g, '\u2707'], + [/\\ding\{40\}/g, '\u2708'], + [/\\ding\{41\}/g, '\u2709'], + [/\\ding\{44\}/g, '\u270C'], + [/\\ding\{45\}/g, '\u270D'], + [/\\ding\{46\}/g, '\u270E'], + [/\\ding\{47\}/g, '\u270F'], + [/\\ding\{48\}/g, '\u2710'], + [/\\ding\{49\}/g, '\u2711'], + [/\\ding\{50\}/g, '\u2712'], + [/\\ding\{51\}/g, '\u2713'], + [/\\ding\{52\}/g, '\u2714'], + [/\\ding\{53\}/g, '\u2715'], + [/\\ding\{54\}/g, '\u2716'], + [/\\ding\{55\}/g, '\u2717'], + [/\\ding\{56\}/g, '\u2718'], + [/\\ding\{57\}/g, '\u2719'], + [/\\ding\{58\}/g, '\u271A'], + [/\\ding\{59\}/g, '\u271B'], + [/\\ding\{60\}/g, '\u271C'], + [/\\ding\{61\}/g, '\u271D'], + [/\\ding\{62\}/g, '\u271E'], + [/\\ding\{63\}/g, '\u271F'], + [/\\ding\{64\}/g, '\u2720'], + [/\\ding\{65\}/g, '\u2721'], + [/\\ding\{66\}/g, '\u2722'], + [/\\ding\{67\}/g, '\u2723'], + [/\\ding\{68\}/g, '\u2724'], + [/\\ding\{69\}/g, '\u2725'], + [/\\ding\{70\}/g, '\u2726'], + [/\\ding\{71\}/g, '\u2727'], + [/\\ding\{73\}/g, '\u2729'], + [/\\ding\{74\}/g, '\u272A'], + [/\\ding\{75\}/g, '\u272B'], + [/\\ding\{76\}/g, '\u272C'], + [/\\ding\{77\}/g, '\u272D'], + [/\\ding\{78\}/g, '\u272E'], + [/\\ding\{79\}/g, '\u272F'], + [/\\ding\{80\}/g, '\u2730'], + [/\\ding\{81\}/g, '\u2731'], + [/\\ding\{82\}/g, '\u2732'], + [/\\ding\{83\}/g, '\u2733'], + [/\\ding\{84\}/g, '\u2734'], + [/\\ding\{85\}/g, '\u2735'], + [/\\ding\{86\}/g, '\u2736'], + [/\\ding\{87\}/g, '\u2737'], + [/\\ding\{88\}/g, '\u2738'], + [/\\ding\{89\}/g, '\u2739'], + [/\\ding\{90\}/g, '\u273A'], + [/\\ding\{91\}/g, '\u273B'], + [/\\ding\{92\}/g, '\u273C'], + [/\\ding\{93\}/g, '\u273D'], + [/\\ding\{94\}/g, '\u273E'], + [/\\ding\{95\}/g, '\u273F'], + [/\\ding\{96\}/g, '\u2740'], + [/\\ding\{97\}/g, '\u2741'], + [/\\ding\{98\}/g, '\u2742'], + [/\\ding\{99\}/g, '\u2743'], + [/\\ding\{100\}/g, '\u2744'], + [/\\ding\{101\}/g, '\u2745'], + [/\\ding\{102\}/g, '\u2746'], + [/\\ding\{103\}/g, '\u2747'], + [/\\ding\{104\}/g, '\u2748'], + [/\\ding\{105\}/g, '\u2749'], + [/\\ding\{106\}/g, '\u274A'], + [/\\ding\{107\}/g, '\u274B'], + [/\\ding\{109\}/g, '\u274D'], + [/\\ding\{111\}/g, '\u274F'], + [/\\ding\{112\}/g, '\u2750'], + [/\\ding\{113\}/g, '\u2751'], + [/\\ding\{114\}/g, '\u2752'], + [/\\ding\{118\}/g, '\u2756'], + [/\\ding\{120\}/g, '\u2758'], + [/\\ding\{121\}/g, '\u2759'], + [/\\ding\{122\}/g, '\u275A'], + [/\\ding\{123\}/g, '\u275B'], + [/\\ding\{124\}/g, '\u275C'], + [/\\ding\{125\}/g, '\u275D'], + [/\\ding\{126\}/g, '\u275E'], + [/\\ding\{161\}/g, '\u2761'], + [/\\ding\{162\}/g, '\u2762'], + [/\\ding\{163\}/g, '\u2763'], + [/\\ding\{164\}/g, '\u2764'], + [/\\ding\{165\}/g, '\u2765'], + [/\\ding\{166\}/g, '\u2766'], + [/\\ding\{167\}/g, '\u2767'], + [/\\ding\{182\}/g, '\u2776'], + [/\\ding\{183\}/g, '\u2777'], + [/\\ding\{184\}/g, '\u2778'], + [/\\ding\{185\}/g, '\u2779'], + [/\\ding\{186\}/g, '\u277A'], + [/\\ding\{187\}/g, '\u277B'], + [/\\ding\{188\}/g, '\u277C'], + [/\\ding\{189\}/g, '\u277D'], + [/\\ding\{190\}/g, '\u277E'], + [/\\ding\{191\}/g, '\u277F'], + [/\\ding\{192\}/g, '\u2780'], + [/\\ding\{193\}/g, '\u2781'], + [/\\ding\{194\}/g, '\u2782'], + [/\\ding\{195\}/g, '\u2783'], + [/\\ding\{196\}/g, '\u2784'], + [/\\ding\{197\}/g, '\u2785'], + [/\\ding\{198\}/g, '\u2786'], + [/\\ding\{199\}/g, '\u2787'], + [/\\ding\{200\}/g, '\u2788'], + [/\\ding\{201\}/g, '\u2789'], + [/\\ding\{202\}/g, '\u278A'], + [/\\ding\{203\}/g, '\u278B'], + [/\\ding\{204\}/g, '\u278C'], + [/\\ding\{205\}/g, '\u278D'], + [/\\ding\{206\}/g, '\u278E'], + [/\\ding\{207\}/g, '\u278F'], + [/\\ding\{208\}/g, '\u2790'], + [/\\ding\{209\}/g, '\u2791'], + [/\\ding\{210\}/g, '\u2792'], + [/\\ding\{211\}/g, '\u2793'], + [/\\ding\{212\}/g, '\u2794'], + [/\\ding\{216\}/g, '\u2798'], + [/\\ding\{217\}/g, '\u2799'], + [/\\ding\{218\}/g, '\u279A'], + [/\\ding\{219\}/g, '\u279B'], + [/\\ding\{220\}/g, '\u279C'], + [/\\ding\{221\}/g, '\u279D'], + [/\\ding\{222\}/g, '\u279E'], + [/\\ding\{223\}/g, '\u279F'], + [/\\ding\{224\}/g, '\u27A0'], + [/\\ding\{225\}/g, '\u27A1'], + [/\\ding\{226\}/g, '\u27A2'], + [/\\ding\{227\}/g, '\u27A3'], + [/\\ding\{228\}/g, '\u27A4'], + [/\\ding\{229\}/g, '\u27A5'], + [/\\ding\{230\}/g, '\u27A6'], + [/\\ding\{231\}/g, '\u27A7'], + [/\\ding\{232\}/g, '\u27A8'], + [/\\ding\{233\}/g, '\u27A9'], + [/\\ding\{234\}/g, '\u27AA'], + [/\\ding\{235\}/g, '\u27AB'], + [/\\ding\{236\}/g, '\u27AC'], + [/\\ding\{237\}/g, '\u27AD'], + [/\\ding\{238\}/g, '\u27AE'], + [/\\ding\{239\}/g, '\u27AF'], + [/\\ding\{241\}/g, '\u27B1'], + [/\\ding\{242\}/g, '\u27B2'], + [/\\ding\{243\}/g, '\u27B3'], + [/\\ding\{244\}/g, '\u27B4'], + [/\\ding\{245\}/g, '\u27B5'], + [/\\ding\{246\}/g, '\u27B6'], + [/\\ding\{247\}/g, '\u27B7'], + [/\\ding\{248\}/g, '\u27B8'], + [/\\ding\{249\}/g, '\u27B9'], + [/\\ding\{250\}/g, '\u27BA'], + [/\\ding\{251\}/g, '\u27BB'], + [/\\ding\{252\}/g, '\u27BC'], + [/\\ding\{253\}/g, '\u27BD'], + [/\\ding\{254\}/g, '\u27BE'], + [/\\longleftarrow /g, '\u27F5'], + [/\\longrightarrow /g, '\u27F6'], + [/\\longleftrightarrow /g, '\u27F7'], + [/\\Longleftarrow /g, '\u27F8'], + [/\\Longrightarrow /g, '\u27F9'], + [/\\Longleftrightarrow /g, '\u27FA'], + [/\\longmapsto /g, '\u27FC'], + [/\\sim\\joinrel\\leadsto/g, '\u27FF'], + [/\\ElsevierGlyph\{E212\}/g, '\u2905'], + [/\\UpArrowBar /g, '\u2912'], + [/\\DownArrowBar /g, '\u2913'], + [/\\ElsevierGlyph\{E20C\}/g, '\u2923'], + [/\\ElsevierGlyph\{E20D\}/g, '\u2924'], + [/\\ElsevierGlyph\{E20B\}/g, '\u2925'], + [/\\ElsevierGlyph\{E20A\}/g, '\u2926'], + [/\\ElsevierGlyph\{E211\}/g, '\u2927'], + [/\\ElsevierGlyph\{E20E\}/g, '\u2928'], + [/\\ElsevierGlyph\{E20F\}/g, '\u2929'], + [/\\ElsevierGlyph\{E210\}/g, '\u292A'], + [/\\ElsevierGlyph\{E21C\}/g, '\u2933'], + [/\\ElsevierGlyph\{E21D\}/g, '\u2933-00338'], + [/\\ElsevierGlyph\{E21A\}/g, '\u2936'], + [/\\ElsevierGlyph\{E219\}/g, '\u2937'], + [/\\Elolarr /g, '\u2940'], + [/\\Elorarr /g, '\u2941'], + [/\\ElzRlarr /g, '\u2942'], + [/\\ElzrLarr /g, '\u2944'], + [/\\Elzrarrx /g, '\u2947'], + [/\\LeftRightVector /g, '\u294E'], + [/\\RightUpDownVector /g, '\u294F'], + [/\\DownLeftRightVector /g, '\u2950'], + [/\\LeftUpDownVector /g, '\u2951'], + [/\\LeftVectorBar /g, '\u2952'], + [/\\RightVectorBar /g, '\u2953'], + [/\\RightUpVectorBar /g, '\u2954'], + [/\\RightDownVectorBar /g, '\u2955'], + [/\\DownLeftVectorBar /g, '\u2956'], + [/\\DownRightVectorBar /g, '\u2957'], + [/\\LeftUpVectorBar /g, '\u2958'], + [/\\LeftDownVectorBar /g, '\u2959'], + [/\\LeftTeeVector /g, '\u295A'], + [/\\RightTeeVector /g, '\u295B'], + [/\\RightUpTeeVector /g, '\u295C'], + [/\\RightDownTeeVector /g, '\u295D'], + [/\\DownLeftTeeVector /g, '\u295E'], + [/\\DownRightTeeVector /g, '\u295F'], + [/\\LeftUpTeeVector /g, '\u2960'], + [/\\LeftDownTeeVector /g, '\u2961'], + [/\\UpEquilibrium /g, '\u296E'], + [/\\ReverseUpEquilibrium /g, '\u296F'], + [/\\RoundImplies /g, '\u2970'], + [/\\ElsevierGlyph\{E214\}/g, '\u297C'], + [/\\ElsevierGlyph\{E215\}/g, '\u297D'], + [/\\Elztfnc /g, '\u2980'], + [/\\ElsevierGlyph\{3018\}/g, '\u2985'], + [/\\Elroang /g, '\u2986'], + [/\\ElsevierGlyph\{E291\}/g, '\u2994'], + [/\\Elzddfnc /g, '\u2999'], + [/\\Angle /g, '\u299C'], + [/\\Elzlpargt /g, '\u29A0'], + [/\\ElsevierGlyph\{E260\}/g, '\u29B5'], + [/\\ElsevierGlyph\{E61B\}/g, '\u29B6'], + [/\\ElzLap /g, '\u29CA'], + [/\\Elzdefas /g, '\u29CB'], + [/\\LeftTriangleBar /g, '\u29CF'], + [/\\NotLeftTriangleBar /g, '\u29CF-00338'], + [/\\RightTriangleBar /g, '\u29D0'], + [/\\NotRightTriangleBar /g, '\u29D0-00338'], + [/\\ElsevierGlyph\{E372\}/g, '\u29DC'], + [/\\blacklozenge /g, '\u29EB'], + [/\\RuleDelayed /g, '\u29F4'], + [/\\Elxuplus /g, '\u2A04'], + [/\\ElzThr /g, '\u2A05'], + [/\\Elxsqcup /g, '\u2A06'], + [/\\ElzInf /g, '\u2A07'], + [/\\ElzSup /g, '\u2A08'], + [/\\ElzCint /g, '\u2A0D'], + [/\\clockoint /g, '\u2A0F'], + [/\\ElsevierGlyph\{E395\}/g, '\u2A10'], + [/\\sqrint /g, '\u2A16'], + [/\\ElsevierGlyph\{E25A\}/g, '\u2A25'], + [/\\ElsevierGlyph\{E25B\}/g, '\u2A2A'], + [/\\ElsevierGlyph\{E25C\}/g, '\u2A2D'], + [/\\ElsevierGlyph\{E25D\}/g, '\u2A2E'], + [/\\ElzTimes /g, '\u2A2F'], + [/\\ElsevierGlyph\{E25E\}/g, '\u2A34'], + [/\\ElsevierGlyph\{E25E\}/g, '\u2A35'], + [/\\ElsevierGlyph\{E259\}/g, '\u2A3C'], + [/\\amalg /g, '\u2A3F'], + [/\\ElzAnd /g, '\u2A53'], + [/\\ElzOr /g, '\u2A54'], + [/\\ElsevierGlyph\{E36E\}/g, '\u2A55'], + [/\\ElOr /g, '\u2A56'], + [/\\perspcorrespond /g, '\u2A5E'], + [/\\Elzminhat /g, '\u2A5F'], + [/\\ElsevierGlyph\{225A\}/g, '\u2A63'], + [/\\stackrel\{*\}\{=\}/g, '\u2A6E'], + [/\\Equal /g, '\u2A75'], + [/\\leqslant /g, '\u2A7D'], + [/\\nleqslant /g, '\u2A7D-00338'], + [/\\geqslant /g, '\u2A7E'], + [/\\ngeqslant /g, '\u2A7E-00338'], + [/\\lessapprox /g, '\u2A85'], + [/\\gtrapprox /g, '\u2A86'], + [/\\lneq /g, '\u2A87'], + [/\\gneq /g, '\u2A88'], + [/\\lnapprox /g, '\u2A89'], + [/\\gnapprox /g, '\u2A8A'], + [/\\lesseqqgtr /g, '\u2A8B'], + [/\\gtreqqless /g, '\u2A8C'], + [/\\eqslantless /g, '\u2A95'], + [/\\eqslantgtr /g, '\u2A96'], + [/\\Pisymbol\{ppi020\}\{117\}/g, '\u2A9D'], + [/\\Pisymbol\{ppi020\}\{105\}/g, '\u2A9E'], + [/\\NestedLessLess /g, '\u2AA1'], + [/\\NotNestedLessLess /g, '\u2AA1-00338'], + [/\\NestedGreaterGreater /g, '\u2AA2'], + [/\\NotNestedGreaterGreater /g, '\u2AA2-00338'], + [/\\preceq /g, '\u2AAF'], + [/\\not\\preceq /g, '\u2AAF-00338'], + [/\\succeq /g, '\u2AB0'], + [/\\not\\succeq /g, '\u2AB0-00338'], + [/\\precneqq /g, '\u2AB5'], + [/\\succneqq /g, '\u2AB6'], + [/\\precapprox /g, '\u2AB7'], + [/\\succapprox /g, '\u2AB8'], + [/\\precnapprox /g, '\u2AB9'], + [/\\succnapprox /g, '\u2ABA'], + [/\\subseteqq /g, '\u2AC5'], + [/\\nsubseteqq /g, '\u2AC5-00338'], + [/\\supseteqq /g, '\u2AC6'], + [/\\nsupseteqq/g, '\u2AC6-00338'], + [/\\subsetneqq /g, '\u2ACB'], + [/\\supsetneqq /g, '\u2ACC'], + [/\\ElsevierGlyph\{E30D\}/g, '\u2AEB'], + [/\\Elztdcol /g, '\u2AF6'], + [/\\ElsevierGlyph\{300A\}/g, '\u300A'], + [/\\ElsevierGlyph\{300B\}/g, '\u300B'], + [/\\ElsevierGlyph\{3018\}/g, '\u3018'], + [/\\ElsevierGlyph\{3019\}/g, '\u3019'], + [/\\openbracketleft /g, '\u301A'], + [/\\openbracketright /g, '\u301B'], +] + +export default BibtexParser +if (typeof module !== 'undefined' && module.exports) { + module.exports = BibtexParser +} diff --git a/services/references/buildscript.txt b/services/references/buildscript.txt new file mode 100644 index 00000000000..ee42e1b57f8 --- /dev/null +++ b/services/references/buildscript.txt @@ -0,0 +1,6 @@ +references +--dependencies= +--env-add= +--env-pass-through= +--node-version=24.13.0 +--public-repo=False diff --git a/services/references/config/settings.defaults.cjs b/services/references/config/settings.defaults.cjs new file mode 100644 index 00000000000..2551f99f096 --- /dev/null +++ b/services/references/config/settings.defaults.cjs @@ -0,0 +1,9 @@ +module.exports = { + internal: { + references: { + port: 3056, + host: process.env.REFERENCES_HOST || '127.0.0.1', + }, + }, +} + diff --git a/services/references/docker-compose.ci.yml b/services/references/docker-compose.ci.yml new file mode 100644 index 00000000000..9c93e99a355 --- /dev/null +++ b/services/references/docker-compose.ci.yml @@ -0,0 +1,38 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +services: + test_unit: + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + user: node + command: npm run test:unit:_run + environment: + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + + + test_acceptance: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + environment: + ELASTIC_SEARCH_DSN: es:9200 + MONGO_HOST: mongo + POSTGRES_HOST: postgres + MOCHA_GREP: ${MOCHA_GREP} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + depends_on: + mongo: + condition: service_started + user: node + command: npm run test:acceptance + + + tar: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + volumes: + - ./:/tmp/build/ + command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . + user: root diff --git a/services/references/docker-compose.yml b/services/references/docker-compose.yml new file mode 100644 index 00000000000..c05741778d8 --- /dev/null +++ b/services/references/docker-compose.yml @@ -0,0 +1,40 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +services: + test_unit: + image: node:24.13.0 + volumes: + - .:/overleaf/services/references + - ../../node_modules:/overleaf/node_modules + - ../../libraries:/overleaf/libraries + working_dir: /overleaf/services/references + environment: + MOCHA_GREP: ${MOCHA_GREP} + LOG_LEVEL: ${LOG_LEVEL:-} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + command: npm run --silent test:unit + user: node + + test_acceptance: + image: node:20.18.2 + volumes: + - .:/overleaf/services/references + - ../../node_modules:/overleaf/node_modules + - ../../libraries:/overleaf/libraries + working_dir: /overleaf/services/references + environment: + ELASTIC_SEARCH_DSN: es:9200 + MONGO_HOST: mongo + POSTGRES_HOST: postgres + MOCHA_GREP: ${MOCHA_GREP} + LOG_LEVEL: ${LOG_LEVEL:-} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + user: node + depends_on: + mongo: + condition: service_started + command: npm run --silent test:acceptance diff --git a/services/references/package.json b/services/references/package.json new file mode 100644 index 00000000000..80480287b5e --- /dev/null +++ b/services/references/package.json @@ -0,0 +1,20 @@ +{ + "name": "@overleaf/references", + "description": "An API for providing citation-keys", + "private": true, + "type": "module", + "main": "app.js", + "scripts": { + "start": "node app.js" + }, + "version": "0.1.0", + "dependencies": { + "@overleaf/settings": "*", + "@overleaf/logger": "*", + "@overleaf/metrics": "*", + "async": "^3.2.5", + "express": "^4.22.1" + }, + "devDependencies": { + } +} diff --git a/services/references/tsconfig.json b/services/references/tsconfig.json new file mode 100644 index 00000000000..d3fdd3022ac --- /dev/null +++ b/services/references/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.backend.json", + "include": [ + "app.js", + "app/js/**/*", + "benchmarks/**/*", + "config/**/*", + "scripts/**/*", + "test/**/*", + "types" + ] +} diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.mjs b/services/web/app/src/Features/Authentication/AuthenticationController.mjs index 16acb68605d..00ef3f7f990 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.mjs +++ b/services/web/app/src/Features/Authentication/AuthenticationController.mjs @@ -83,6 +83,7 @@ const AuthenticationController = { analyticsId: user.analyticsId || user._id, alphaProgram: user.alphaProgram || undefined, // only store if set betaProgram: user.betaProgram || undefined, // only store if set + externalAuth: user.externalAuth || false, } if (user.isAdmin) { lightUser.isAdmin = true diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs index 849c10d5c58..8a227b10b80 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs @@ -145,6 +145,7 @@ const transferOwnershipSchema = z.object({ }), body: z.object({ user_id: zz.objectId(), + skipEmails: z.boolean().optional(), }), }) @@ -161,6 +162,7 @@ async function transferOwnership(req, res, next) { allowTransferToNonCollaborators: hasAdminAccess(sessionUser), sessionUserId: new ObjectId(sessionUser._id), ipAddress: req.ip, + skipEmails: body.skipEmails } ) res.sendStatus(204) diff --git a/services/web/app/src/Features/Compile/ClsiManager.mjs b/services/web/app/src/Features/Compile/ClsiManager.mjs index 5da6c55c39f..d69e8b4cf4a 100644 --- a/services/web/app/src/Features/Compile/ClsiManager.mjs +++ b/services/web/app/src/Features/Compile/ClsiManager.mjs @@ -1018,7 +1018,7 @@ async function _getContentFromMongo(projectId) { function _finaliseRequest(projectId, options, project, docs, files) { const resources = [] - let flags + let flags = [] let rootResourcePath = options.rootResourcePath let rootResourcePathOverride = null let hasMainFile = false @@ -1087,6 +1087,10 @@ function _finaliseRequest(projectId, options, project, docs, files) { flags = ['-file-line-error'] } + if (process.env.TEX_COMPILER_EXTRA_FLAGS) { + flags.push(...process.env.TEX_COMPILER_EXTRA_FLAGS.split(/\s+/).filter(Boolean)) + } + return { compile: { options: { diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs index 8a0a465a9d8..9f3d4cf6841 100644 --- a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs +++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs @@ -189,7 +189,13 @@ export default LinkedFilesController = { refreshLinkedFile: expressify(refreshLinkedFile), handleError(error, req, res, next) { - if (error instanceof AccessDeniedError) { + if ( + error instanceof AccessDeniedError && + error.message === 'Zotero account not linked' + ) { + res.status(400) + plainTextResponse(res, 'Your account is not linked with Zotero') + } else if (error instanceof AccessDeniedError) { res.status(403) plainTextResponse( res, @@ -231,8 +237,7 @@ export default LinkedFilesController = { } else { plainTextResponse( res, - `Your URL could not be reached (${ - error.info?.status || error.cause?.info?.status + `Your URL could not be reached (${error.info?.status || error.cause?.info?.status } status code). Please check it and try again.` ) } diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs index 33d785e80fe..965bf42561e 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs @@ -72,6 +72,7 @@ async function getUserForPasswordResetToken(token) { 'overleaf.id': 1, email: 1, must_reconfirm: 1, + hashedPassword: 1, }) await assertUserPermissions(user, ['change-password']) diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 32e642f132d..3ef9437dccc 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -322,6 +322,16 @@ const _ProjectController = { ) : ProjectCreationHandler.promises.createBasicProject(userId, projectName)) + // If webdavConfig is provided at project creation, save it (no connection test) + const webdavConfig = req.body.webdavConfig + if (webdavConfig?.url) { + try { + await ProjectUpdateHandler.promises.setWebDAVConfig(project._id, webdavConfig) + } catch (err) { + // Non-fatal: project is still created even if WebDAV config fails + } + } + ProjectAuditLogHandler.addEntryIfManagedInBackground( project._id, 'project-created', @@ -542,6 +552,8 @@ const _ProjectController = { collaberator_refs: 1, // used for link sharing analytics pendingEditor_refs: 1, // used for link sharing analytics reviewer_refs: 1, + imageName: 1, + webdavConfig: 1, }), userIsMemberOfGroupSubscription: sessionUser ? (async () => @@ -617,6 +629,8 @@ const _ProjectController = { ) const imageNames = ProjectHelper.getAllowedImagesForUser(user) + if (!project.imageName) await EditorController.promises.setImageName(projectId, Settings.currentImageName) + const privilegeLevel = await AuthorizationManager.promises.getPrivilegeLevelForProject( userId, @@ -742,7 +756,7 @@ const _ProjectController = { } const isAdminOrTemplateOwner = - hasAdminAccess(user) || Settings.templates?.user_id === userId + hasAdminAccess(user) || Settings.templates?.nonAdminCanManage || Settings.templates?.user_id === userId const showTemplatesServerPro = Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner @@ -958,6 +972,13 @@ const _ProjectController = { debugPdfDetach, showSymbolPalette, symbolPaletteAvailable: Features.hasFeature('symbol-palette'), + webdavConfig: project.webdavConfig ? { + url: project.webdavConfig.url, + basePath: project.webdavConfig.basePath, + enabled: project.webdavConfig.enabled, + hasUsername: !!project.webdavConfig.username, + hasPassword: !!project.webdavConfig.password, + } : null, userRestrictions: Array.from(req.userRestrictions || []), showAiFeatures, onAiFreeTrial: @@ -1211,6 +1232,14 @@ const _ProjectController = { trashed, owner_ref: project.owner_ref, isV1Project: false, + webdavConfig: project.webdavConfig ? { + url: project.webdavConfig.url, + basePath: project.webdavConfig.basePath, + enabled: project.webdavConfig.enabled, + lastSyncDate: project.webdavConfig.lastSyncDate, + hasUsername: !!project.webdavConfig.username, + hasPassword: !!project.webdavConfig.password, + } : undefined, } if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) { model.owner_ref = null diff --git a/services/web/app/src/Features/Project/ProjectDeleter.mjs b/services/web/app/src/Features/Project/ProjectDeleter.mjs index cf8f0355168..f6f15cabc34 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.mjs +++ b/services/web/app/src/Features/Project/ProjectDeleter.mjs @@ -78,13 +78,13 @@ async function unmarkAsDeletedByExternalSource(projectId) { ).exec() } -async function deleteUsersProjects(userId) { +async function deleteUsersProjects(userId, options = {}) { const projects = await Project.find({ owner_ref: userId }).exec() logger.info( { userId, projectCount: projects.length }, 'found user projects to delete' ) - await promiseMapWithLimit(5, projects, project => deleteProject(project._id)) + await promiseMapWithLimit(5, projects, project => deleteProject(project._id, options)) logger.info({ userId }, 'deleted all user projects') await CollaboratorsHandler.promises.removeUserFromAllProjects(userId) } @@ -209,6 +209,7 @@ async function deleteProject(projectId, options = {}) { }) } + const deleterData = { deletedAt: new Date(), deleterId: @@ -250,6 +251,7 @@ async function deleteProject(projectId, options = {}) { { projectId, userId: project.owner_ref }, 'successfully deleted project' ) + return { deletedAt: deleterData.deletedAt, deleterId: deleterData.deleterId } } catch (err) { logger.warn({ err }, 'problem deleting project') throw err @@ -279,7 +281,7 @@ async function undeleteProject(projectId, options = {}) { // if we're undeleting, we want the document to show up restored.name = await ProjectDetailsHandler.promises.generateUniqueName( deletedProject.deleterData.deletedProjectOwnerId, - restored.name + ' (Restored)' + restored.name + (options.suffix ?? ' (Restored)') ) restored.archived = undefined @@ -303,6 +305,7 @@ async function undeleteProject(projectId, options = {}) { await db.projects.insertOne(restored) await DeletedProject.deleteOne({ _id: deletedProject._id }).exec() + return restored } async function expireDeletedProject(projectId) { diff --git a/services/web/app/src/Features/Project/ProjectDuplicator.mjs b/services/web/app/src/Features/Project/ProjectDuplicator.mjs index fdada472b0d..ef5198efcbe 100644 --- a/services/web/app/src/Features/Project/ProjectDuplicator.mjs +++ b/services/web/app/src/Features/Project/ProjectDuplicator.mjs @@ -100,6 +100,8 @@ async function duplicate( // remove any leading or trailing spaces newProjectName = newProjectName.trim() + attributes.imageName = originalProject.imageName + // Now create the new project, cleaning it up on failure if necessary const newProject = await ProjectCreationHandler.promises.createBlankProject( owner._id, diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.mjs b/services/web/app/src/Features/Project/ProjectEditorHandler.mjs index e6b0af442a1..6b0b01e0756 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.mjs @@ -3,7 +3,7 @@ import Path from 'node:path' let ProjectEditorHandler export default ProjectEditorHandler = { - trackChangesAvailable: false, + trackChangesAvailable: true, buildProjectModelView( project, @@ -26,7 +26,7 @@ export default ProjectEditorHandler = { deletedByExternalDataSource: project.deletedByExternalDataSource || false, imageName: project.imageName != null - ? Path.basename(project.imageName) + ? project.imageName : undefined, } diff --git a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.mjs b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.mjs index f26b8fb8761..1539431ca2c 100644 --- a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.mjs @@ -23,6 +23,7 @@ import FileWriter from '../../infrastructure/FileWriter.mjs' import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs' import { callbackifyMultiResult, callbackify } from '@overleaf/promise-utils' import { iterablePaths } from './IterablePath.mjs' +import Modules from '../../infrastructure/Modules.mjs' const LOCK_NAMESPACE = 'sequentialProjectStructureUpdateLock' const VALID_ROOT_DOC_EXTENSIONS = Settings.validRootDocExtensions @@ -379,6 +380,7 @@ const addFile = wrapWithLock({ { newFiles, newProject: project }, source ) + Modules.promises.hooks.fire('projectFileAdded', projectId).catch(() => {}) return { fileRef, folderId, createdBlob } }, }) @@ -826,6 +828,7 @@ const deleteEntity = wrapWithLock( entityType, subtreeEntityIds, }) + Modules.promises.hooks.fire('projectEntityDeleted', projectId, path.fileSystem).catch(() => {}) return entityId } @@ -939,13 +942,15 @@ const moveEntity = wrapWithLock( logger.error({ err }, 'error sending tpds update') } - return await DocumentUpdaterHandler.promises.updateProjectStructure( + const result = await DocumentUpdaterHandler.promises.updateProjectStructure( projectId, projectHistoryId, userId, changes, source ) + Modules.promises.hooks.fire('projectEntityMoved', projectId, startPath, endPath).catch(() => {}) + return result } ) @@ -998,13 +1003,15 @@ const renameEntity = wrapWithLock( } catch (err) { logger.error({ err }, 'error sending tpds update') } - return await DocumentUpdaterHandler.promises.updateProjectStructure( + const result = await DocumentUpdaterHandler.promises.updateProjectStructure( projectId, projectHistoryId, userId, changes, source ) + Modules.promises.hooks.fire('projectEntityMoved', projectId, startPath, endPath).catch(() => {}) + return result } ) @@ -1408,6 +1415,7 @@ const ProjectEntityUpdateHandler = { { oldFiles, newFiles, newProject }, source ) + Modules.promises.hooks.fire('projectFileAdded', projectId).catch(() => {}) return updatedFileRef }, diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index ef6c6682448..fe24606d0e6 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/services/web/app/src/Features/Project/ProjectListController.mjs @@ -651,7 +651,7 @@ async function _getProjects( const results = await Promise.all([ ProjectGetter.promises.findAllUsersProjects( userId, - 'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens' + 'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens webdavConfig' ), TagsHandler.promises.getAllTags(userId), ]) @@ -795,6 +795,18 @@ function _formatProjectInfo(project, accessLevel, source, userId) { source, archived, trashed, + ...(project.webdavConfig?.enabled + ? { + webdavConfig: { + url: project.webdavConfig.url, + basePath: project.webdavConfig.basePath, + enabled: project.webdavConfig.enabled, + lastSyncDate: project.webdavConfig.lastSyncDate, + hasUsername: !!project.webdavConfig.username, + hasPassword: !!project.webdavConfig.password, + }, + } + : {}), } } diff --git a/services/web/app/src/Features/Project/ProjectOptionsHandler.mjs b/services/web/app/src/Features/Project/ProjectOptionsHandler.mjs index 8fcc0855281..511e1b7a155 100644 --- a/services/web/app/src/Features/Project/ProjectOptionsHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectOptionsHandler.mjs @@ -40,14 +40,13 @@ const ProjectOptionsHandler = { if (!imageName || !Array.isArray(settings.allowedImageNames)) { return undefined } - imageName = imageName.toLowerCase() const isAllowed = settings.allowedImageNames.find( allowed => imageName === allowed.imageName ) if (!isAllowed) { throw new OError('invalid imageName', { imageName }) } - return settings.imageRoot + '/' + imageName + return imageName }, async setImageName(projectId, imageName) { diff --git a/services/web/app/src/Features/Project/ProjectUpdateHandler.mjs b/services/web/app/src/Features/Project/ProjectUpdateHandler.mjs index f4b366acc00..3ac299000c7 100644 --- a/services/web/app/src/Features/Project/ProjectUpdateHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectUpdateHandler.mjs @@ -1,5 +1,6 @@ import { Project } from '../../models/Project.mjs' import { callbackify } from 'node:util' +import Modules from '../../infrastructure/Modules.mjs' const ProjectUpdateHandler = { async markAsUpdated(projectId, lastUpdatedAt, lastUpdatedBy) { @@ -17,6 +18,7 @@ const ProjectUpdateHandler = { lastUpdatedBy, } await Project.updateOne(conditions, update, {}).exec() + Modules.promises.hooks.fire('projectUpdated', projectId).catch(() => {}) }, async markAsOpened(projectId) { @@ -36,6 +38,46 @@ const ProjectUpdateHandler = { const update = { active: true } await Project.updateOne(conditions, update, {}).exec() }, + + async setWebDAVConfig(projectId, webdavConfig) { + const basePath = (webdavConfig.basePath || '/overleaf').replace(/\/$/, '') + const existing = await Project.findById(projectId, 'webdavConfig').lean().exec() + const existingConfig = existing?.webdavConfig + const isBasePathChanged = !existingConfig || existingConfig.basePath !== basePath + + let finalUsername = '' + let finalPassword = '' + if (webdavConfig.useUsername) { + finalUsername = webdavConfig.username || existingConfig?.username || '' + } + if (webdavConfig.usePassword) { + finalPassword = webdavConfig.password || existingConfig?.password || '' + } + + await Project.updateOne( + { _id: projectId }, + { + $set: { + webdavConfig: { + url: webdavConfig.url, + username: finalUsername, + password: finalPassword, + basePath, + enabled: true, + lastSyncDate: isBasePathChanged ? null : (existingConfig?.lastSyncDate || null), + syncedFileHashes: isBasePathChanged ? {} : (existingConfig?.syncedFileHashes || {}), + }, + }, + } + ).exec() + }, + + async unsetWebDAVConfig(projectId) { + await Project.updateOne( + { _id: projectId }, + { $unset: { webdavConfig: '' } } + ).exec() + }, } export default { @@ -43,5 +85,7 @@ export default { markAsOpened: callbackify(ProjectUpdateHandler.markAsOpened), markAsInactive: callbackify(ProjectUpdateHandler.markAsInactive), markAsActive: callbackify(ProjectUpdateHandler.markAsActive), + setWebDAVConfig: callbackify(ProjectUpdateHandler.setWebDAVConfig), + unsetWebDAVConfig: callbackify(ProjectUpdateHandler.unsetWebDAVConfig), promises: ProjectUpdateHandler, } diff --git a/services/web/app/src/Features/Templates/TemplatesController.mjs b/services/web/app/src/Features/Templates/TemplatesController.mjs index 85fb1fb4b34..2c504cd132b 100644 --- a/services/web/app/src/Features/Templates/TemplatesController.mjs +++ b/services/web/app/src/Features/Templates/TemplatesController.mjs @@ -7,21 +7,22 @@ import { expressify } from '@overleaf/promise-utils' const TemplatesController = { async getV1Template(req, res) { - const templateVersionId = req.params.Template_version_id - const templateId = req.query.id - if (!/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)) { - logger.err( - { templateVersionId, templateId }, - 'invalid template id or version' - ) - return res.sendStatus(400) - } + const templateId = req.params.Template_version_id + const templateVersionId = req.query.version +// if (!/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)) { +// logger.err( +// { templateVersionId, templateId }, +// 'invalid template id or version' +// ) +// return res.sendStatus(400) +// } const data = { templateVersionId, templateId, - name: req.query.templateName, - compiler: ProjectHelper.compilerFromV1Engine(req.query.latexEngine), - imageName: req.query.texImage, + name: req.query.name, + compiler: req.query.compiler, + language: req.query.language, + imageName: req.query.imageName, mainFile: req.query.mainFile, brandVariationId: req.query.brandVariationId, } @@ -36,6 +37,7 @@ const TemplatesController = { async createProjectFromV1Template(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) + const project = await TemplatesManager.promises.createProjectFromV1Template( req.body.brandVariationId, req.body.compiler, @@ -44,7 +46,8 @@ const TemplatesController = { req.body.templateName, req.body.templateVersionId, userId, - req.body.imageName + req.body.imageName, + req.body.language ) delete req.session.templateData if (!project) { diff --git a/services/web/app/src/Features/Templates/TemplatesManager.mjs b/services/web/app/src/Features/Templates/TemplatesManager.mjs index 0781597cc5a..9a9ebefa523 100644 --- a/services/web/app/src/Features/Templates/TemplatesManager.mjs +++ b/services/web/app/src/Features/Templates/TemplatesManager.mjs @@ -20,6 +20,7 @@ import OError from '@overleaf/o-error' const { promises: ProjectRootDocManager } = ProjectRootDocManagerModule const { promises: ProjectOptionsHandler } = ProjectOptionsHandlerModule +const TIMEOUT = 30000 // 30 sec const TemplatesManager = { async createProjectFromV1Template( @@ -30,31 +31,33 @@ const TemplatesManager = { templateName, templateVersionId, userId, - imageName + imageName, + spellCheckLanguage ) { + compiler = ProjectOptionsHandler.normalizeCompiler(compiler || 'pdflatex') - imageName = ProjectOptionsHandler.normalizeImageName( - imageName || 'wl_texlive:2018.1' - ) - const zipUrl = `${settings.apis.v1.url}/api/v1/overleaf/templates/${templateVersionId}` + try { + imageName = ProjectOptionsHandler.normalizeImageName(imageName) + } catch { + logger.warn( { templateId, imageName }, 'cannot use the image required by the template, using the default image') + imageName = null + } + + const zipUrl = `${settings.apis.filestore.url}/template/${templateId}/v/${templateVersionId}/zip` const zipReq = await fetchStreamWithResponse(zipUrl, { - basicAuth: { - user: settings.apis.v1.user, - password: settings.apis.v1.pass, - }, - signal: AbortSignal.timeout(settings.apis.v1.timeout), + signal: AbortSignal.timeout(TIMEOUT), }) const projectName = ProjectDetailsHandler.fixProjectName(templateName) const dumpPath = `${settings.path.dumpFolder}/${crypto.randomUUID()}` const writeStream = fs.createWriteStream(dumpPath) + try { const attributes = { - fromV1TemplateId: templateId, - fromV1TemplateVersionId: templateVersionId, compiler, imageName, + spellCheckLanguage, } if (brandVariationId) attributes.brandVariationId = brandVariationId diff --git a/services/web/app/src/Features/Uploads/ArchiveManager.mjs b/services/web/app/src/Features/Uploads/ArchiveManager.mjs index 0982727484b..010080ad5e5 100644 --- a/services/web/app/src/Features/Uploads/ArchiveManager.mjs +++ b/services/web/app/src/Features/Uploads/ArchiveManager.mjs @@ -70,7 +70,7 @@ const ArchiveManager = { new InvalidZipFileError({ info: { totalSizeInBytes } }) ) } - const isTooLarge = totalSizeInBytes > ONE_MEG * 300 + const isTooLarge = totalSizeInBytes > ONE_MEG * 6 * Settings.maxUploadSize // 6 * 50 MB = 300 MB return callback(null, isTooLarge) }) }) diff --git a/services/web/app/src/Features/User/UserController.mjs b/services/web/app/src/Features/User/UserController.mjs index 37cc016f9c7..61593e7b11e 100644 --- a/services/web/app/src/Features/User/UserController.mjs +++ b/services/web/app/src/Features/User/UserController.mjs @@ -546,4 +546,5 @@ export default { expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration), ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware), ensureAffiliation, + doLogout, } diff --git a/services/web/app/src/Features/User/UserDeleter.mjs b/services/web/app/src/Features/User/UserDeleter.mjs index 3ba04c7ed66..e3f7bfb1519 100644 --- a/services/web/app/src/Features/User/UserDeleter.mjs +++ b/services/web/app/src/Features/User/UserDeleter.mjs @@ -63,7 +63,7 @@ async function deleteUser(userId, options) { logger.info({ userId }, 'creating deleted user record') await _createDeletedUser(user, options) logger.info({ userId }, 'deleting user projects') - await ProjectDeleter.promises.deleteUsersProjects(user._id) + await ProjectDeleter.promises.deleteUsersProjects(user._id, options) if (options.skipEmail) { logger.info({ userId }, 'skipping sending deletion email to user') } else { diff --git a/services/web/app/src/Features/User/UserPagesController.mjs b/services/web/app/src/Features/User/UserPagesController.mjs index ba308bb0324..9d9c181d30a 100644 --- a/services/web/app/src/Features/User/UserPagesController.mjs +++ b/services/web/app/src/Features/User/UserPagesController.mjs @@ -53,10 +53,8 @@ async function settingsPage(req, res) { const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed']) delete req.session.saml let shouldAllowEditingDetails = true - if (Settings.ldap && Settings.ldap.updateUserDetailsOnLogin) { - shouldAllowEditingDetails = false - } - if (Settings.saml && Settings.saml.updateUserDetailsOnLogin) { + const externalAuth = req.user.externalAuth + if (externalAuth && Settings[externalAuth].updateUserDetailsOnLogin) { shouldAllowEditingDetails = false } const oauthProviders = Settings.oauthProviders || {} diff --git a/services/web/app/src/Features/User/UserRegistrationHandler.mjs b/services/web/app/src/Features/User/UserRegistrationHandler.mjs index ab78de2bf72..71053fa992a 100644 --- a/services/web/app/src/Features/User/UserRegistrationHandler.mjs +++ b/services/web/app/src/Features/User/UserRegistrationHandler.mjs @@ -15,10 +15,12 @@ import OError from '@overleaf/o-error' const UserRegistrationHandler = { _registrationRequestIsValid(body) { const invalidEmail = AuthenticationManager.validateEmail(body.email || '') + if (invalidEmail) throw new OError('InvalidEmailError') const invalidPassword = AuthenticationManager.validatePassword( body.password || '', body.email ) + if (invalidPassword) throw new OError('InvalidPasswordError') return !(invalidEmail || invalidPassword) }, diff --git a/services/web/app/src/infrastructure/ExpressLocals.mjs b/services/web/app/src/infrastructure/ExpressLocals.mjs index 65bb04b485c..39045dda09c 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.mjs +++ b/services/web/app/src/infrastructure/ExpressLocals.mjs @@ -114,9 +114,9 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) { webRouter.use(function (req, res, next) { req.externalAuthenticationSystemUsed = - Features.externalAuthenticationSystemUsed + () => !!req?.user?.externalAuth res.locals.externalAuthenticationSystemUsed = - Features.externalAuthenticationSystemUsed + () => !!req?.user?.externalAuth req.hasFeature = res.locals.hasFeature = Features.hasFeature next() }) @@ -303,7 +303,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) { webRouter.use(function (req, res, next) { // TODO - if (Settings.overleaf != null) { + if (true) { res.locals.overallThemes = [ { name: 'Dark', @@ -413,7 +413,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) { labsEnabled: Settings.labs && Settings.labs.enable, wikiEnabled: Settings.overleaf != null || Settings.proxyLearn, templatesEnabled: - Settings.overleaf != null || Settings.templates?.user_id != null, + Settings.overleaf != null || Boolean(Settings.templates), cioWriteKey: Settings.analytics?.cio?.writeKey, cioSiteId: Settings.analytics?.cio?.siteId, linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId, diff --git a/services/web/app/src/infrastructure/Features.mjs b/services/web/app/src/infrastructure/Features.mjs index 229312f38c7..2c1393d6bc0 100644 --- a/services/web/app/src/infrastructure/Features.mjs +++ b/services/web/app/src/infrastructure/Features.mjs @@ -53,7 +53,7 @@ const Features = { case 'registration-page': return ( !Features.externalAuthenticationSystemUsed() || - Boolean(Settings.overleaf) + Boolean(Settings.overleaf) || Settings.oidc?.allowedOIDCEmailDomains ) case 'registration': return Boolean(Settings.overleaf) @@ -68,7 +68,7 @@ const Features = { case 'oauth': return Boolean(Settings.oauth) case 'templates-server-pro': - return Boolean(Settings.templates?.user_id) + return Boolean(Settings.templates) case 'affiliations': case 'analytics': return Boolean(_.get(Settings, ['apis', 'v1', 'url'])) diff --git a/services/web/app/src/infrastructure/mongodb.mjs b/services/web/app/src/infrastructure/mongodb.mjs index f0bde7efabc..8dadc55a6a8 100644 --- a/services/web/app/src/infrastructure/mongodb.mjs +++ b/services/web/app/src/infrastructure/mongodb.mjs @@ -80,6 +80,7 @@ export const db = { systemmessages: internalDb.collection('systemmessages'), tags: internalDb.collection('tags'), teamInvites: internalDb.collection('teamInvites'), + templates: internalDb.collection('templates'), tokens: internalDb.collection('tokens'), userAuditLogEntries: internalDb.collection('userAuditLogEntries'), users: internalDb.collection('users'), diff --git a/services/web/app/src/models/Project.mjs b/services/web/app/src/models/Project.mjs index 7692a1dd2d9..3a115aab7a8 100644 --- a/services/web/app/src/models/Project.mjs +++ b/services/web/app/src/models/Project.mjs @@ -105,6 +105,15 @@ export const ProjectSchema = new Schema( }, ], deferredTpdsFlushCounter: { type: Number }, + webdavConfig: { + url: { type: String }, + username: { type: String }, + password: { type: String }, + basePath: { type: String, default: '/overleaf' }, + enabled: { type: Boolean, default: false }, + lastSyncDate: { type: Date }, + syncedFileHashes: { type: Schema.Types.Mixed, default: {} }, + }, }, { minimize: false } ) diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 10ed348a6c8..642e7a53ec4 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -228,6 +228,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { CaptchaMiddleware.canSkipCaptcha ) + await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) + webRouter.get('/login', UserPagesController.loginPage) AuthenticationController.addEndpointToLoginWhitelist('/login') @@ -273,6 +275,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { '/read-only/one-time-login' ) + await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) + webRouter.get('/logout', UserPagesController.logout) webRouter.post('/logout', UserController.logout) @@ -1246,6 +1250,10 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { ) } + webRouter.get(['/learn*', '/blog*', '/latex*', '/for/*', '/contact*'], (req, res) => { + res.redirect(301, `https://www.overleaf.com${req.originalUrl}`) + }) + webRouter.get('/unsupported-browser', renderUnsupportedBrowserPage) webRouter.get('*', ErrorController.notFound) diff --git a/services/web/app/views/admin/index.pug b/services/web/app/views/admin/index.pug index 71d85a7bcba..0d44dffec0b 100644 --- a/services/web/app/views/admin/index.pug +++ b/services/web/app/views/admin/index.pug @@ -14,6 +14,7 @@ block content .nav-tabs-container ul.nav.nav-tabs.align-left(role='tablist') +bookmarkable-tabset-header('system-messages', 'System Messages', true) + +bookmarkable-tabset-header('active-projects', 'Active Projects') +bookmarkable-tabset-header('open-sockets', 'Open Sockets') +bookmarkable-tabset-header('open-close-editor', 'Open/Close Editor') +bookmarkable-tabset-header('privileges-matrix', 'Privileges Matrix') @@ -42,6 +43,10 @@ block content input(name='_csrf' type='hidden' value=csrfToken) button.btn.btn-danger(type='submit') Clear all messages + .tab-pane(role='tabpanel' id='active-projects') + .row-spaced + include ../../../modules/admin-tools/app/views/active-projects.pug + .tab-pane(role='tabpanel' id='open-sockets') .row-spaced ul diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug index d99e4db5802..c2386be71b0 100644 --- a/services/web/app/views/layout/navbar-marketing.pug +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -66,7 +66,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg( +dropdown-menu-link-item(href='/admin') Manage Site +dropdown-menu-link-item(href='/admin/user') Manage Users if canDisplayProjectUrlLookup - +dropdown-menu-link-item(href='/admin/project') Project URL Lookup + +dropdown-menu-link-item(href='/admin/project') Manage Projects if canDisplayAdminRedirect +dropdown-menu-link-item(href=settings.adminUrl) Switch to Admin if canDisplaySplitTestMenu @@ -149,6 +149,18 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg( // logged out if !getSessionUser() + // templates link + if settings.templates + +nav-item + +nav-link( + href="/templates" + event-tracking="menu-click" + event-tracking-action="clicked" + event-tracking-trigger="click" + event-tracking-mb="true" + event-segmentation={ page: currentUrl, item: 'templates', location: 'top-menu' } + ) #{translate('templates')} + // register link if hasFeature('registration-page') +nav-item.primary diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index c6e303b87b5..a16f3b736e4 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -15,6 +15,7 @@ meta(name="ol-wikiEnabled" data-type="boolean" content=settings.proxyLearn) meta(name="ol-capabilities" data-type="json" content=capabilities) meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl) meta(name="ol-gitBridgeEnabled" data-type="boolean" content=gitBridgeEnabled) +meta(name="ol-webdavConfig" data-type="json" content=webdavConfig) meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain) //- enable doc hash checking for all projects //- used in public/js/libs/sharejs.js @@ -57,7 +58,7 @@ meta(name="ol-translationIoNotLoaded" data-type="string" content=translate("coul meta(name="ol-translationLoadErrorMessage" data-type="string" content=translate("could_not_load_translations")) meta(name="ol-translationUnableToJoin" data-type="string" content=translate("could_not_connect_to_collaboration_server")) -if (settings.overleaf != null) - meta(name="ol-overallThemes" data-type="json" content=overallThemes) +//- if (settings.overleaf != null) +meta(name="ol-overallThemes" data-type="json" content=overallThemes) != moduleIncludes("editor:meta", locals) diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index a5dc3ff33c3..6d359131412 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -29,8 +29,10 @@ block content input(type="hidden" name="templateVersionId" value=templateVersionId) input(type="hidden" name="templateName" value=name) input(type="hidden" name="compiler" value=compiler) - input(type="hidden" name="imageName" value=imageName) + if imageName + input(type="hidden" name="imageName" value=imageName) input(type="hidden" name="mainFile" value=mainFile) + input(type="hidden" name="language" value=language) if brandVariationId input(type="hidden" name="brandVariationId" value=brandVariationId) input(hidden type="submit") diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 5e8a23b0172..7b082e70512 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -26,8 +26,9 @@ block content label(for='email') #{translate("email")} input#email.form-control( name='email' - type='email' + type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email' required + placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com' autofocus='true' autocomplete='username' ) @@ -47,3 +48,21 @@ block content if login_support_text hr p.text-center !{login_support_text} + if settings.saml && settings.saml.enable + .actions(style='margin-top: 30px;') + a.button.btn-secondary.btn( + href='/saml/login', + style="width: 100%;" + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.saml.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + if settings.oidc && settings.oidc.enable + .actions(style='margin-top: 30px;') + a.button.btn-secondary.btn( + href='/oidc/login', + style="width: 100%;" + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… diff --git a/services/web/app/views/user/passwordReset.pug b/services/web/app/views/user/passwordReset.pug index ca2483c9a89..36afc189731 100644 --- a/services/web/app/views/user/passwordReset.pug +++ b/services/web/app/views/user/passwordReset.pug @@ -50,7 +50,7 @@ block content +notification({ariaLive: 'assertive', type: 'error', className: 'mb-3', content: translate(error)}) div(data-ol-custom-form-message='no-password-allowed-due-to-sso' hidden) - +notification({ariaLive: 'polite', type: 'error', className: 'mb-3', content: translate('you_cant_reset_password_due_to_sso', {}, [{name: 'a', attrs: {href: '/sso-login'}}])}) + +notification({ariaLive: 'polite', type: 'error', className: 'mb-3', content: translate('you_cant_reset_password_due_to_ldap_or_sso')}) input(name='_csrf' type='hidden' value=csrfToken) .form-group.mb-3 label.form-label(for='email') #{translate("email")} diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index eee3905ca2f..2e6e599b1d4 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -266,9 +266,15 @@ module.exports = { contacts: { url: `http://${process.env.CONTACTS_HOST || '127.0.0.1'}:3036`, }, + linkedUrlProxy: { + url: `http://${process.env.LINKED_URL_PROXY_HOST || '127.0.0.1'}:3066`, + }, notifications: { url: `http://${process.env.NOTIFICATIONS_HOST || '127.0.0.1'}:3042`, }, + references: { + url: `http://${process.env.REFERENCES_HOST || '127.0.0.1'}:3056`, + }, webpack: { url: `http://${process.env.WEBPACK_HOST || '127.0.0.1'}:3808`, }, @@ -370,7 +376,9 @@ module.exports = { process.env.PROJECT_UPLOAD_TIMEOUT || '120000', 10 ), - maxUploadSize: 50 * 1024 * 1024, // 50 MB + maxUploadSize: process.env.MAX_UPLOAD_SIZE + ? parseInt(process.env.MAX_UPLOAD_SIZE, 10) * 1024 * 1024 + : 50 * 1024 * 1024, // 50 MB multerOptions: { preservePath: process.env.MULTER_PRESERVE_PATH, }, @@ -985,14 +993,39 @@ module.exports = { // // Restart webpack after making changes. // - createFileModes: [], + createFileModes: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/zotero-create-file' + ), + ], devToolbar: [], gitBridge: [], publishModal: [], - tprFileViewInfo: [], - tprFileViewRefreshError: [], - tprFileViewRefreshButton: [], - tprFileViewNotOriginalImporter: [], + tprFileViewInfo: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/tpr-file-view-info' + ), + ], + tprFileViewRefreshError: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/tpr-file-view-refresh-error' + ), + ], + tprFileViewRefreshButton: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/tpr-file-view-refresh-button' + ), + ], + tprFileViewNotOriginalImporter: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/tpr-file-view-not-original-importer' + ), + ], contactUsModal: [], sourceEditorExtensions: [], sourceEditorComponents: [], @@ -1002,7 +1035,12 @@ module.exports = { pdfPreviewPromotions: [], diagnosticActions: [], sourceEditorCompletionSources: [], - sourceEditorSymbolPalette: [], + sourceEditorSymbolPalette: [ + Path.resolve( + __dirname, + '../modules/symbol-palette/frontend/components/symbol-palette' + ), + ], sourceEditorToolbarComponents: [], sourceEditorToolbarEndButtons: [], rootContextProviders: [], @@ -1011,13 +1049,38 @@ module.exports = { langFeedbackLinkingWidgets: [], labsExperiments: [], integrationLinkingWidgets: [], - referenceLinkingWidgets: [], + referenceLinkingWidgets: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/zotero-widget' + ), + ], importProjectFromGithubModalWrapper: [], importProjectFromGithubMenu: [], - editorLeftMenuSync: [], - editorLeftMenuManageTemplate: [], - menubarExtraComponents: [], - oauth2Server: [], + editorLeftMenuSync: [ + Path.resolve( + __dirname, + '../modules/git-bridge/frontend/js/card/components/git-modal.tsx' + ), + ], + editorLeftMenuManageTemplate: [ + Path.resolve( + __dirname, + '../modules/template-gallery/frontend/js/features/template/components/actions-manage-template' + ), + ], + menubarExtraComponents: [ + Path.resolve( + __dirname, + '../modules/template-gallery/frontend/js/features/template/components/menubar-manage-template' + ), + ], + oauth2Server: [ + Path.resolve( + __dirname, + '../modules/git-bridge/frontend/js/widget/components/git-integration-widget.tsx' + ) + ], managedGroupSubscriptionEnrollmentNotification: [], managedGroupEnrollmentInvite: [], ssoCertificateInfo: [], @@ -1054,7 +1117,16 @@ module.exports = { '../modules/full-project-search/frontend/js/components/full-project-search.tsx' ), ], - integrationPanelComponents: [], + integrationPanelComponents: [ + Path.resolve( + __dirname, + '../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx' + ), + Path.resolve( + __dirname, + '../modules/webdav/frontend/js/components/webdav-integration-card' + ), + ], referenceSearchSetting: [], errorLogsComponents: [], referenceIndices: [], @@ -1066,7 +1138,17 @@ module.exports = { 'history-v1', 'launchpad', 'server-ce-scripts', - 'user-activate', + 'sandboxed-compiles', + 'symbol-palette', + 'track-changes', + 'authentication/ldap', + 'authentication/saml', + 'authentication/oidc', + 'admin-tools', // import after authentication + 'template-gallery', + 'git-bridge', + 'zotero', + 'webdav', ], viewIncludes: {}, @@ -1080,7 +1162,6 @@ module.exports = { 'app/views/project/ide-react': [`img-src 'self' data: blob:`], }, }, - unsupportedBrowsers: { ie: '<=11', safari: '<=14', @@ -1093,6 +1174,31 @@ module.exports = { managedUsers: { enabled: false, }, + + oauthProviders: { + ...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && { + [process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc']: { + name: process.env.OVERLEAF_OIDC_PROVIDER_NAME || 'OIDC Provider', + descriptionKey: process.env.OVERLEAF_OIDC_PROVIDER_DESCRIPTION, + descriptionOptions: { link: process.env.OVERLEAF_OIDC_PROVIDER_INFO_LINK }, + hideWhenNotLinked: process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED ? + process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED.toLowerCase() === 'true' : undefined, + linkPath: '/oidc/login', + }, + }), + }, + + splitTestOverrides: { + ...(process.env.OVERLEAF_THEMED_DASHBOARD?.toLowerCase() === 'true' ? { + 'themed-project-dashboard': 'enabled', + } : {}), + ...(process.env.OVERLEAF_HISTORY_RESTORE?.toLowerCase() === 'true' ? { + 'history-ranges-support': 'enabled', + 'revert-file': 'enabled', + 'revert-project': 'enabled', + } : {}), + }, + } module.exports.mergeWith = function (overrides) { diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 3e887029411..0cf4d18e7a1 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -23,17 +23,28 @@ "a_new_reference_was_added_to_file_from_provider": "", "a_new_version_of_the_rolling_texlive_build_released": "", "about_to_archive_projects": "", + "about_to_delete_accounts": "", "about_to_delete_cert": "", "about_to_delete_projects": "", "about_to_delete_tag": "", + "about_to_delete_template": "", "about_to_delete_the_following_project": "", "about_to_delete_the_following_projects": "", "about_to_delete_user_preamble": "", "about_to_enable_managed_users": "", "about_to_leave_project": "", "about_to_leave_projects": "", + "about_to_permanently_delete_accounts": "", + "about_to_permanently_delete_projects": "", "about_to_remove_user_preamble": "", + "about_to_resend_activation_email": "", + "about_to_restore_accounts": "", + "about_to_restore_projects": "", + "about_to_resume_accounts": "", + "about_to_set_admin_accounts": "", + "about_to_suspend_accounts": "", "about_to_trash_projects": "", + "about_to_unset_admin_accounts": "", "abstract": "", "accept_all_cookies": "", "accept_and_continue": "", @@ -58,12 +69,14 @@ "account_has_been_link_to_institution_account": "", "account_has_past_due_invoice_change_plan_warning": "", "account_help": "", + "account_information": "", "account_managed_by_group_administrator": "", "account_managed_by_group_teamname": "", "account_not_linked_to_dropbox": "", "account_settings": "", "acct_linked_to_institution_acct_2": "", "actions": "", + "activation_link": "", "active": "", "add": "", "add_a_recovery_email_address": "", @@ -140,6 +153,8 @@ "all_project_activity_description": "", "all_projects": "", "all_projects_will_be_transferred_immediately": "", + "all_templates": "", + "all_users": "", "all_these_experiments_are_available_exclusively": "", "allocate_license": "", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "", @@ -176,6 +191,7 @@ "at_most_x_libraries_can_be_selected": "", "attach_image_or_pdf": "", "audit_logs": "", + "author": "", "auto_close_brackets": "", "auto_compile": "", "auto_complete": "", @@ -193,6 +209,7 @@ "back_to_editor": "", "back_to_my_projects": "", "back_to_subscription": "", + "back_to_user_list": "", "back_to_your_projects": "", "basic_compile_time": "", "before_you_use_error_assistant": "", @@ -249,6 +266,8 @@ "card_must_be_authenticated_by_3dsecure": "", "card_payment": "", "careers": "", + "categories": "", + "category": "", "category_arrows": "", "category_greek": "", "category_misc": "", @@ -299,6 +318,9 @@ "clone_with_git": "", "close": "", "close_dialog": "", + "cloud_storage": "", + "cloud_storage_optional": "", + "cloud_storage_settings": "", "clsi_maintenance": "", "clsi_unavailable": "", "code_check_failed_explanation": "", @@ -401,6 +423,7 @@ "cut": "", "dark_mode_pdf_preview": "", "dark_themes": "", + "date": "", "date_and_owner": "", "date_and_time": "", "dealing_with_errors": "", @@ -409,6 +432,7 @@ "delete_account": "", "delete_account_confirmation_label": "", "delete_account_warning_message_3": "", + "delete_accounts": "", "delete_acct_no_existing_pw": "", "delete_and_leave": "", "delete_and_leave_projects": "", @@ -426,10 +450,12 @@ "delete_message_confirmation": "", "delete_permanently": "", "delete_projects": "", + "delete_remote_content_warning": "", "delete_row_or_column": "", "delete_sso_config": "", "delete_table": "", "delete_tag": "", + "delete_template": "", "delete_token": "", "delete_user": "", "delete_your_account": "", @@ -438,6 +464,7 @@ "deleted_by_id": "", "deleted_by_ip": "", "deleted_by_on": "", + "deleted_projects": "", "deleted_user": "", "deleting": "", "demonstrating_git_integration": "", @@ -535,6 +562,7 @@ "edit_sso_configuration": "", "edit_tag": "", "edit_tag_name": "", + "edit_template": "", "edit_your_custom_dictionary": "", "edited": "", "editing": "", @@ -639,6 +667,7 @@ "files_cannot_include_invalid_characters": "", "files_selected": "", "filter_projects": "", + "filter_users": "", "find": "", "find_and_check_citations": "", "find_out_more": "", @@ -887,6 +916,7 @@ "imported_from_zotero_at_date": "", "importing": "", "importing_and_merging_changes_in_github": "", + "inactive_projects": "", "in_order_to_match_institutional_metadata_2": "", "in_order_to_match_institutional_metadata_associated": "", "include_caption": "", @@ -970,6 +1000,7 @@ "jump_to_location_in_pdf": "", "justify": "", "kb_suggestions_enquiry": "", + "keep_cloud_storage_content": "", "keep_current_plan": "", "keep_personal_projects_separate": "", "keep_your_account_safe_add_another_email": "", @@ -987,6 +1018,7 @@ "last_name": "", "last_resort_trouble_shooting_guide": "", "last_suggested_fix": "", + "last_updated": "", "last_updated_date_by_x": "", "last_used": "", "latam_discount_modal_info": "", @@ -994,6 +1026,8 @@ "latex_in_thirty_minutes": "", "latex_places_figures_according_to_a_special_algorithm": "", "latex_places_tables_according_to_a_special_algorithm": "", + "latex_templates": "", + "latex_templates_for_journal_articles": "", "layout_options": "", "learn_more": "", "learn_more_about": "", @@ -1004,6 +1038,7 @@ "learn_more_about_roles_permissions": "", "leave": "", "leave_any_group_subscriptions": "", + "leave_blank_to_keep_current": "", "leave_group": "", "leave_now": "", "leave_project": "", @@ -1038,6 +1073,7 @@ "link_to_github_description": "", "link_to_mendeley": "", "link_to_papers": "", + "link_to_webdav": "", "link_to_zotero": "", "link_to_zotero_mendeley_papers_to_see_search_results_from_your_libraries_without_importing_files": "", "linked_accounts": "", @@ -1165,11 +1201,13 @@ "need_to_add_new_primary_before_remove": "", "need_to_leave": "", "neither_agree_nor_disagree": "", + "never": "", "new_compile_domain_notice": "", "new_compiles_in_this_project_will_automatically_use_the_newest_version": "", "new_file": "", "new_folder": "", "new_name": "", + "new_owner": "", "new_password": "", "new_project": "", "new_subscription_will_be_billed_immediately": "", @@ -1214,6 +1252,8 @@ "no_selection_select_file": "", "no_symbols_found": "", "no_thanks_cancel_now": "", + "no_templates_found": "", + "no_users": "", "normal": "", "normally_x_price_per_month": "", "normally_x_price_per_year": "", @@ -1224,6 +1264,7 @@ "notification_personal_and_group_subscriptions": "", "notification_project_invite_accepted_message": "", "notification_project_invite_message": "", + "notify_users_about_account_deletion": "", "number_of_users": "", "numbered_list": "", "oauth_orcid_description": "", @@ -1245,6 +1286,7 @@ "only_importer_can_refresh": "", "open_action_menu": "", "open_advanced_reference_search": "", + "open_as_template": "", "open_file": "", "open_link": "", "open_path": "", @@ -1269,12 +1311,15 @@ "overleaf_is_easy_to_use": "", "overleaf_labs": "", "overleaf_logo": "", + "overleaf_template_gallery": "", "overleafs_functionality_meets_my_needs": "", "overview": "", "overwrite": "", "overwriting_the_original_folder": "", "owned_by_x": "", + "owned_projects": "", "owner": "", + "ownership_of_projects_will_be_transferred": "", "page_current": "", "page_x_of_n": "", "pagination_navigation": "", @@ -1329,8 +1374,11 @@ "per_license": "", "per_month": "", "per_month_x_annually": "", + "per_page": "", "per_year": "", "percent_is_the_percentage_of_the_line_width": "", + "permanently_delete_accounts": "", + "permanently_delete_projects": "", "permanently_disables_the_preview": "", "personal_library": "", "pick_up_where_you_left_off": "", @@ -1340,6 +1388,7 @@ "please_ask_the_project_owner_to_upgrade_to_track_changes": "", "please_change_primary_to_remove": "", "please_compile_pdf_before_download": "", + "please_compile_pdf_before_publish_as_template": "", "please_confirm_primary_email_or_edit": "", "please_confirm_secondary_email_or_edit": "", "please_confirm_your_email_before_making_it_default": "", @@ -1375,6 +1424,7 @@ "premium_plan_label": "", "presentation_mode": "", "previous": "", + "prev": "", "previous_page": "", "price": "", "primarily_work_study_question": "", @@ -1439,6 +1489,7 @@ "publisher_account": "", "publishing": "", "pull_github_changes_into_sharelatex": "", + "purge": "", "push_sharelatex_changes_to_github": "", "push_to_github_pull_to_overleaf": "", "quoted_text": "", @@ -1532,6 +1583,7 @@ "repository_visibility": "", "republish": "", "resend": "", + "resend_activation_email": "", "resend_confirmation_code": "", "resend_group_invite": "", "resend_link_sso": "", @@ -1543,6 +1595,8 @@ "resolve_comment_error_title": "", "resolved_comments": "", "restore": "", + "restore_accounts": "", + "restore_projects": "", "restore_file": "", "restore_file_confirmation_message": "", "restore_file_confirmation_title": "", @@ -1552,6 +1606,8 @@ "restore_project_to_this_version": "", "restore_this_version": "", "restoring": "", + "resume": "", + "resume_account": "", "resync_completed": "", "resync_message": "", "resync_project_history": "", @@ -1637,6 +1693,7 @@ "select_access_levels": "", "select_all": "", "select_all_projects": "", + "select_all_users": "", "select_an_output_file": "", "select_an_output_file_figure_modal": "", "select_bib_file": "", @@ -1651,6 +1708,7 @@ "select_image_from_project_files": "", "select_project": "", "select_projects": "", + "select_users": "", "select_size": "", "select_tag": "", "select_tax_id_type": "", @@ -1661,6 +1719,7 @@ "send": "", "send_confirmation_code": "", "send_message": "", + "send_notification_emails_to_users": "", "send_request": "", "sending": "", "server_error": "", @@ -1672,12 +1731,15 @@ "session_expired_redirecting_to_login": "", "sessions": "", "set_as_main_document": "", + "set_admin": "", + "set_admin_account": "", "set_color": "", "set_column_width": "", "set_up_single_sign_on": "", "set_up_sso": "", "settings": "", "setup_another_account_under_a_personal_email_address": "", + "setup_cloud_storage": "", "share": "", "share_feedback": "", "share_project": "", @@ -1686,6 +1748,7 @@ "sharing_permissions": "", "shortcut_to_open_advanced_reference_search": "", "show_all_projects": "", + "show_all_users": "", "show_breadcrumbs": "", "show_document_preamble": "", "show_equation_preview": "", @@ -1699,6 +1762,7 @@ "show_outline": "", "show_version_history": "", "show_x_more_projects": "", + "show_x_more_users": "", "showing_1_result": "", "showing_1_result_of_total": "", "showing_pdf_preview_with_inverted_colors": "", @@ -1708,6 +1772,7 @@ "showing_x_results_of_total": "", "sidebar": "", "sign_up": "", + "signed_up": "", "simple_search_mode": "", "single_sign_on_sso": "", "size": "", @@ -1843,6 +1908,9 @@ "sure_you_want_to_change_plan": "", "sure_you_want_to_delete": "", "sure_you_want_to_leave_group": "", + "suspend": "", + "suspend_account": "", + "switch_between_dark_and_light_mode": "", "switch_compile_mode_for_faster_draft_compilation": "", "switch_to_editor": "", "switch_to_pdf": "", @@ -1852,6 +1920,7 @@ "sync": "", "sync_all_projects_with_dropbox": "", "sync_dropbox_github": "", + "sync_now": "", "sync_project_to_github_explanation": "", "sync_to_dropbox": "", "sync_to_github": "", @@ -1873,6 +1942,7 @@ "tell_the_project_owner_and_ask_them_to_upgrade": "", "template": "", "template_description": "", + "template_gallery": "", "template_title_taken_from_project_title": "", "templates": "", "temporarily_hides_the_preview": "", @@ -1925,6 +1995,7 @@ "they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "", "they_will_retain_their_existing_account_on_the_free_plan": "", "they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password": "", + "this_action_can_be_undone_within_limited_period": "", "this_action_cannot_be_reversed": "", "this_action_cannot_be_undone": "", "this_action_will_also_disable_domain_capture": "", @@ -2045,6 +2116,7 @@ "track_changes": "", "tracked_change_added": "", "tracked_change_deleted": "", + "transfer_all_projects_to": "", "transfer_management_of_your_account": "", "transfer_management_of_your_account_to_x": "", "transfer_management_resolve_following_issues": "", @@ -2056,6 +2128,7 @@ "trashed": "", "trashed_projects": "", "trashing_projects_wont_affect_collaborators": "", + "trashing_projects_wont_affect_user_collaborators": "", "trial_last_day": "", "trial_remaining_days": "", "tried_to_log_in_with_email": "", @@ -2096,6 +2169,9 @@ "unlink": "", "unlink_all_users": "", "unlink_all_users_explanation": "", + "unlink_and_delete": "", + "unlink_cloud_storage": "", + "unlink_cloud_storage_description": "", "unlink_dropbox_folder": "", "unlink_dropbox_warning": "", "unlink_from_sso": "", @@ -2116,6 +2192,8 @@ "unpausing": "", "unpublish": "", "unpublishing": "", + "unset_admin": "", + "unset_admin_account": "", "unsubscribe": "", "until_then_you_can_still": "", "untrash": "", @@ -2151,7 +2229,9 @@ "usage_metrics": "", "use_a_different_email": "", "use_a_different_password": "", + "use_password": "", "use_saml_metadata_to_configure_sso_with_idp": "", + "use_username": "", "use_your_own_machine": "", "used_latex_before": "", "used_latex_response_familiar": "", @@ -2159,6 +2239,17 @@ "used_latex_response_occasionally": "", "used_latex_response_often": "", "used_when_referring_to_the_figure_elsewhere_in_the_document": "", + "user_activity": "", + "user_categories": "", + "user_category_admin": "", + "user_category_all": "", + "user_category_deleted": "", + "user_category_inactive": "", + "user_category_ldap": "", + "user_category_local": "", + "user_category_oidc": "", + "user_category_saml": "", + "user_category_suspended": "", "user_deletion_error": "", "user_deletion_password_reset_tip": "", "user_email_attribute": "", @@ -2167,6 +2258,7 @@ "user_last_name_attribute": "", "user_management": "", "user_sessions": "", + "users_list": "", "using_latex": "", "using_premium_features": "", "using_the_overleaf_editor": "", @@ -2229,6 +2321,18 @@ "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "", "we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months": "", "we_will_use_your_existing_payment_method": "", + "webdav_base_path": "", + "webdav_base_path_hint": "", + "webdav_connection_failed": "", + "webdav_connection_success": "", + "webdav_connection_test": "", + "webdav_directory_not_empty": "", + "webdav_password": "", + "webdav_url": "", + "webdav_url_hint": "", + "webdav_url_required": "", + "webdav_username": "", + "webdav_sync_completed": "", "webinars": "", "website_status": "", "wed_love_you_to_stay": "", @@ -2301,6 +2405,7 @@ "you_can_select_or_invite_collaborator": "", "you_can_select_or_invite_collaborator_plural": "", "you_can_still_use_your_premium_features": "", + "you_cant_add_or_change_password_due_to_ldap_or_sso": "", "you_cant_add_or_change_password_due_to_sso": "", "you_cant_join_this_group_subscription": "", "you_currently_have_x_linked_with_your_overleaf_account": "", @@ -2386,9 +2491,12 @@ "zoom_out": "", "zoom_to": "", "zotero": "", + "zotero_account_linked_successfully": "", "zotero_dynamic_sync_description": "", "zotero_groups_loading_error": "", "zotero_groups_relink": "", + "zotero_imported_by_collaborator": "", + "zotero_imported_by_unknown": "", "zotero_integration": "", "zotero_is_premium": "", "zotero_reference_loading_error": "", diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 index 4ca1d4db783..b4758e8b21a 100644 Binary files a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 and b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 differ diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs index c5d8120aeb7..4c0a37f841a 100644 --- a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -5,6 +5,7 @@ export default /** @type {const} */ ([ 'account_balance', + 'add_moderator', 'arrow_back_ios_new', 'arrow_circle_up', 'auto_delete', @@ -25,6 +26,7 @@ export default /** @type {const} */ ([ 'delete', 'description', 'edit_square', + 'edit', 'error', 'experiment', 'find_replace', @@ -34,22 +36,28 @@ export default /** @type {const} */ ([ 'help', 'image', 'info', - 'info', + 'info_i', 'integration_instructions', 'lightbulb', 'lightbulb_2', 'lock', 'lock_open', + 'mail', 'more_vert', 'neurology', 'note_add', 'notifications', 'open_in_new', 'password', + 'pause_circle', + 'person_edit', 'picture_as_pdf', + 'play_circle', 'push_pin', 'rate_review', + 'remove_moderator', 'report', + 'restore_from_trash', 'search', 'settings', 'shuffle', diff --git a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx index 0c49f69c8d1..b06fe83b552 100644 --- a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx +++ b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx @@ -56,6 +56,10 @@ const moduleRailPopovers = ( }[] ).map(({ import: { default: element } }) => element) +const integrationPanelComponents = importOverleafModules( + 'integrationPanelComponents' +) as { import: { default: unknown }; path: string }[] + export const RailLayout = () => { const { sendEvent } = useEditorAnalytics() const { t } = useTranslation() @@ -112,7 +116,7 @@ export const RailLayout = () => { icon: 'integration_instructions', title: t('integrations'), component: , - hide: !isOverleaf && !gitBridgeEnabled, + hide: !isOverleaf && !gitBridgeEnabled && moduleRailEntries.length === 0 && integrationPanelComponents.length === 0, }, { key: 'review-panel', diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx index 048016c3bc7..486a5480f4f 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx @@ -19,6 +19,8 @@ import OLButton from '@/shared/components/ol/ol-button' import OLForm from '@/shared/components/ol/ol-form' import OLFormLabel from '@/shared/components/ol/ol-form-label' import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import { Collapse } from 'react-bootstrap' type NewProjectData = { project_id: string @@ -41,21 +43,44 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) { const { autoFocusedRef } = useRefWithAutoFocus() const [projectName, setProjectName] = useState('') const [redirecting, setRedirecting] = useState(false) + const [showWebDAVConfig, setShowWebDAVConfig] = useState(false) + const [webdavUrl, setWebdavUrl] = useState('') + const [webdavBasePath, setWebdavBasePath] = useState('/overleaf') + const [useUsername, setUseUsername] = useState(false) + const [webdavUsername, setWebdavUsername] = useState('') + const [usePassword, setUsePassword] = useState(false) + const [webdavPassword, setWebdavPassword] = useState('') const { isLoading, isError, error, runAsync } = useAsync() const location = useLocation() const createNewProject = () => { - runAsync( - postJSON('/project/new', { - body: { - projectName, - template, - }, - }) - ) + const body: { + projectName: string + template: string + webdavConfig?: { + url: string + basePath: string + useUsername: boolean + username: string + usePassword: boolean + password: string + } + } = { projectName, template } + + if (webdavUrl.trim()) { + body.webdavConfig = { + url: webdavUrl.trim(), + basePath: webdavBasePath.trim() || '/overleaf', + useUsername, + username: useUsername ? webdavUsername.trim() : '', + usePassword, + password: usePassword ? webdavPassword : '', + } + } + + runAsync(postJSON('/project/new', { body })) .then(data => { if (data.project_id) { - // prevents clicking on create again between async load of next page and pending state being finished setRedirecting(true) location.assign(`/project/${data.project_id}`) } @@ -97,6 +122,74 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) { value={projectName} /> + +
+ +
+ + +
+ + {t('webdav_url')} + setWebdavUrl(e.target.value)} + /> + + + ) => + setUseUsername(e.target.checked) + } + /> + {useUsername && ( + setWebdavUsername(e.target.value)} + className="mt-2" + /> + )} + + + ) => + setUsePassword(e.target.checked) + } + /> + {usePassword && ( + setWebdavPassword(e.target.value)} + className="mt-2" + /> + )} + + + {t('webdav_base_path')} + setWebdavBasePath(e.target.value)} + placeholder="/overleaf" + /> + +
+
diff --git a/services/web/frontend/js/features/project-list/util/api.ts b/services/web/frontend/js/features/project-list/util/api.ts index d8f9841e4ba..c4bb3166be3 100644 --- a/services/web/frontend/js/features/project-list/util/api.ts +++ b/services/web/frontend/js/features/project-list/util/api.ts @@ -80,3 +80,21 @@ export function unarchiveProject(projectId: string) { export function untrashProject(projectId: string) { return deleteJSON(`/project/${projectId}/trash`) } + +export function linkWebDAV( + projectId: string, + config: { url: string; username?: string; password?: string; basePath?: string } +) { + return postJSON(`/project/${projectId}/webdav/link`, { + body: config, + }) +} + +export function unlinkWebDAV( + projectId: string, + deleteRemoteContent: boolean = false +) { + return postJSON(`/project/${projectId}/webdav/unlink`, { + body: { deleteRemoteContent }, + }) +} diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-comment.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-comment.tsx index c7f60aac35f..15b494252d1 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-comment.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-comment.tsx @@ -44,7 +44,7 @@ export const ReviewPanelComment = memo<{ const handleResolveComment = useCallback(async () => { setProcessing(true) try { - await resolveThread(comment.op.t) + await resolveThread(comment.op.t, docId) } catch (err) { debugConsole.error(err) showGenericMessageModal( @@ -54,7 +54,7 @@ export const ReviewPanelComment = memo<{ } finally { setProcessing(false) } - }, [comment.op.t, resolveThread, showGenericMessageModal, t]) + }, [comment.op.t, docId, resolveThread, showGenericMessageModal, t]) const handleEditMessage = useCallback( async (commentId: CommentId, content: string) => { @@ -110,7 +110,7 @@ export const ReviewPanelComment = memo<{ async (commentId: ThreadId) => { setProcessing(true) try { - await deleteThread(commentId) + await deleteThread(commentId, docId) } catch (err) { debugConsole.error(err) showGenericMessageModal( @@ -121,7 +121,7 @@ export const ReviewPanelComment = memo<{ setProcessing(false) } }, - [deleteThread, showGenericMessageModal, t] + [deleteThread, docId, showGenericMessageModal, t] ) const handleSubmitReply = useCallback( diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-resolved-thread.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-resolved-thread.tsx index e5b4b372b81..0ba2cbfa65d 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-resolved-thread.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-resolved-thread.tsx @@ -17,7 +17,8 @@ export const ReviewPanelResolvedThread: FC<{ id: ThreadId comment: Change docName: string -}> = ({ id, comment, docName }) => { + docId: string +}> = ({ id, comment, docName, docId }) => { const { t } = useTranslation() const { reopenThread, deleteThread } = useThreadsActionsContext() const [processing, setProcessing] = useState(false) @@ -33,7 +34,7 @@ export const ReviewPanelResolvedThread: FC<{ const handleReopenThread = useCallback(async () => { setProcessing(true) try { - await reopenThread(id) + await reopenThread(id, docId) } catch (err) { debugConsole.error(err) showGenericMessageModal( @@ -43,12 +44,12 @@ export const ReviewPanelResolvedThread: FC<{ } finally { setProcessing(false) } - }, [id, reopenThread, showGenericMessageModal, t]) + }, [id, docId, reopenThread, showGenericMessageModal, t]) const handleDeleteThread = useCallback(async () => { setProcessing(true) try { - await deleteThread(id) + await deleteThread(id, docId) } catch (err) { debugConsole.error(err) showGenericMessageModal( @@ -58,7 +59,7 @@ export const ReviewPanelResolvedThread: FC<{ } finally { setProcessing(false) } - }, [id, deleteThread, showGenericMessageModal, t]) + }, [id, docId, deleteThread, showGenericMessageModal, t]) return (
{ const { projectRanges, loading } = useProjectRanges() const docNameForThread = useMemo(() => { - const docNameForThread = new Map() + const docNameForThread = new Map() const otMigrationStage = getMeta('ol-otMigrationStage') for (const [docId, ranges] of projectRanges?.entries() ?? []) { @@ -28,7 +28,7 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => { if (docName !== undefined) { for (const comment of ranges.comments) { const threadId = comment.op.t - docNameForThread.set(threadId, docName) + docNameForThread.set(threadId, { docId, docName }) } } } @@ -112,7 +112,8 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => { key={thread.id} id={thread.id as ThreadId} comment={comment} - docName={docNameForThread.get(thread.id) ?? t('unknown')} + docName={docNameForThread.get(thread.id)?.docName ?? t('unknown')} + docId={docNameForThread.get(thread.id)?.docId} /> ) })} diff --git a/services/web/frontend/js/features/review-panel/context/threads-context.tsx b/services/web/frontend/js/features/review-panel/context/threads-context.tsx index b3a5d784df1..0bf6fef2498 100644 --- a/services/web/frontend/js/features/review-panel/context/threads-context.tsx +++ b/services/web/frontend/js/features/review-panel/context/threads-context.tsx @@ -289,23 +289,24 @@ export const ThreadsProvider: FC = ({ children }) => { currentDocument.submitOp(op) }, - async resolveThread(threadId: string) { + async resolveThread(threadId: string, docId: string) { await postJSON( - `/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}/resolve` + `/project/${projectId}/doc/${docId}/thread/${threadId}/resolve` ) sendEvent('rp-comment-resolve', { view: reviewPanelView }) }, - async reopenThread(threadId: string) { + async reopenThread(threadId: string, docId: string) { await postJSON( - `/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}/reopen` + `/project/${projectId}/doc/${docId}/thread/${threadId}/reopen` ) sendEvent('rp-comment-reopen') }, - async deleteThread(threadId: string) { + async deleteThread(threadId: string, docId: string) { await deleteJSON( - `/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}` + `/project/${projectId}/doc/${docId}/thread/${threadId}` ) - currentDocument.ranges?.removeCommentId(threadId) + // Is looks like it's not necessary + // currentDocument.ranges?.removeCommentId(threadId) sendEvent('rp-comment-delete') }, async addMessage(threadId: ThreadId, content: string) { diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx index cd92ab614be..e1f53ef5ca9 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -200,7 +200,8 @@ function SSOLinkingWidgetContainer({ const { t } = useTranslation() const { unlink } = useSSOContext() - let description = '' + let description = subscription.provider.descriptionKey || + `${t('login_with_service', { service: subscription.provider.name, })}.` switch (subscription.providerId) { case 'collabratec': description = t('linked_collabratec_description') diff --git a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx index c80605aefbb..0769db713e4 100644 --- a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx +++ b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx @@ -4,6 +4,7 @@ import { FetchError } from '../../../../infrastructure/fetch-json' import IEEELogo from '../../../../shared/svgs/ieee-logo' import GoogleLogo from '../../../../shared/svgs/google-logo' import OrcidLogo from '../../../../shared/svgs/orcid-logo' +import OpenIDLogo from '../../../../shared/svgs/openid-logo' import LinkingStatus from './status' import OLButton from '@/shared/components/ol/ol-button' import { @@ -18,6 +19,7 @@ const providerLogos: { readonly [p: string]: JSX.Element } = { collabratec: , google: , orcid: , + oidc: , } type SSOLinkingWidgetProps = { @@ -67,7 +69,7 @@ export function SSOLinkingWidget({ return (
-
{providerLogos[providerId]}
+
{providerLogos[providerId] || providerLogos['oidc']}

{title}

diff --git a/services/web/frontend/js/features/settings/components/password-section.tsx b/services/web/frontend/js/features/settings/components/password-section.tsx index 9128119dc38..73054dae66c 100644 --- a/services/web/frontend/js/features/settings/components/password-section.tsx +++ b/services/web/frontend/js/features/settings/components/password-section.tsx @@ -39,11 +39,7 @@ function CanOnlyLogInThroughSSO() { return (

, - ]} + i18nKey="you_cant_add_or_change_password_due_to_ldap_or_sso" />

) diff --git a/services/web/frontend/js/shared/components/dropdown/dropdown-menu.tsx b/services/web/frontend/js/shared/components/dropdown/dropdown-menu.tsx index 36de1a3ccc2..1f865bfaf13 100644 --- a/services/web/frontend/js/shared/components/dropdown/dropdown-menu.tsx +++ b/services/web/frontend/js/shared/components/dropdown/dropdown-menu.tsx @@ -32,6 +32,7 @@ function DropdownItem( description, leadingIcon, trailingIcon, + unfilled, ...props }: DropdownItemProps, ref: React.ForwardedRef @@ -41,7 +42,7 @@ function DropdownItem( if (typeof leadingIcon === 'string') { leadingIconComponent = ( ) @@ -61,7 +62,7 @@ function DropdownItem( trailingIconComponent = ( ) diff --git a/services/web/frontend/js/shared/components/navbar/admin-menu.tsx b/services/web/frontend/js/shared/components/navbar/admin-menu.tsx index 9876e4a769c..d44ee6c1b37 100644 --- a/services/web/frontend/js/shared/components/navbar/admin-menu.tsx +++ b/services/web/frontend/js/shared/components/navbar/admin-menu.tsx @@ -45,7 +45,7 @@ export default function AdminMenu({ ) : null} {canDisplayProjectUrlLookup ? ( - Project URL Lookup + Manage Projects ) : null} {canDisplayAdminRedirect && adminUrl ? ( diff --git a/services/web/frontend/js/shared/components/navbar/logged-in-items.tsx b/services/web/frontend/js/shared/components/navbar/logged-in-items.tsx index f3d442bb3c7..be45dabcf1d 100644 --- a/services/web/frontend/js/shared/components/navbar/logged-in-items.tsx +++ b/services/web/frontend/js/shared/components/navbar/logged-in-items.tsx @@ -4,6 +4,7 @@ import type { NavbarSessionUser } from '@/shared/components/types/navbar' import NavLinkItem from '@/shared/components/navbar/nav-link-item' import { AccountMenuItems } from './account-menu-items' import { useSendProjectListMB } from '@/features/project-list/components/project-list-events' +import getMeta from '@/utils/meta' export default function LoggedInItems({ sessionUser, @@ -14,11 +15,18 @@ export default function LoggedInItems({ }) { const { t } = useTranslation() const sendProjectListMB = useSendProjectListMB() + const { templatesEnabled } = getMeta('ol-ExposedSettings') + return ( <> {t('projects')} + {templatesEnabled && ( + + {t('templates')} + + )} + {templatesEnabled && ( + + {t('templates')} + + )} {showSignUpLink ? ( void +} + +export default function Pagination({ + currentPage, + totalPages, + onPageChange, +}: PaginationProps) { + const { t } = useTranslation() + if (totalPages <= 1) return null + + const pageNumbers: (number | "...")[] = [] + let startPage = Math.max(1, currentPage - 4) + let endPage = Math.min(totalPages, currentPage + 4) + + if (startPage > 1) { + pageNumbers.push(1) + if (startPage > 2) { + pageNumbers.push("...") + } + } + + for (let i = startPage; i <= endPage; i++) { + pageNumbers.push(i) + } + + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + pageNumbers.push("...") + } + pageNumbers.push(totalPages) + } + + return ( + + ) +} diff --git a/services/web/frontend/js/shared/svgs/openid-logo.jsx b/services/web/frontend/js/shared/svgs/openid-logo.jsx new file mode 100644 index 00000000000..3de933820b4 --- /dev/null +++ b/services/web/frontend/js/shared/svgs/openid-logo.jsx @@ -0,0 +1,27 @@ +function OpenIDLogo() { + return ( + + + + + + + ) +} + +export default OpenIDLogo + diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 6b0b077ed3f..b79d0f94928 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -24,6 +24,7 @@ import { } from '../../../types/project/dashboard/notification' import { Survey } from '../../../types/project/dashboard/survey' import { GetProjectsResponseBody } from '../../../types/project/dashboard/api' +import { GetTemplatesResponseBody } from '../../../types/template/dashboard/api' import { Tag } from '../../../app/src/Features/Tags/types' import { Institution } from '../../../types/institution' import { @@ -237,6 +238,7 @@ export interface Meta { 'ol-postCheckoutRedirect': string 'ol-postUrl': string 'ol-prefetchedProjectsBlob': GetProjectsResponseBody | undefined + 'ol-prefetchedTemplatesBlob': GetTemplatesResponseBody | undefined 'ol-preventCompileOnLoad'?: boolean 'ol-primaryEmail': { email: string; confirmed: boolean } 'ol-project': any // TODO @@ -342,6 +344,13 @@ export interface Meta { 'ol-validationStatus': ValidationStatus 'ol-viaDomainCapture': boolean 'ol-wikiEnabled': boolean + 'ol-webdavConfig': { + url: string + basePath: string + enabled: boolean + hasUsername: boolean + hasPassword: boolean + } | null 'ol-writefullEnabled': boolean 'ol-writefullEnv': WritefullPublicEnv 'ol-wsUrl': string diff --git a/services/web/frontend/stylesheets/components/link.scss b/services/web/frontend/stylesheets/components/link.scss index 7a6b96b597f..66174eedc52 100644 --- a/services/web/frontend/stylesheets/components/link.scss +++ b/services/web/frontend/stylesheets/components/link.scss @@ -9,5 +9,6 @@ i { margin-right: var(--spacing-02); padding-bottom: 3px; + vertical-align: middle; } } diff --git a/services/web/frontend/stylesheets/modules/admin-tools/entries-per-page.scss b/services/web/frontend/stylesheets/modules/admin-tools/entries-per-page.scss new file mode 100644 index 00000000000..97f6a272cea --- /dev/null +++ b/services/web/frontend/stylesheets/modules/admin-tools/entries-per-page.scss @@ -0,0 +1,26 @@ +.entries-per-page-toggle { + cursor: pointer; + background-color: var(--bg-light-tertiary); + padding: 0 var(--spacing-02); + border-radius: var(--border-radius-medium); + &:hover { + background-color: var(--bg-accent-03); + } + & + .dropdown-menu { + min-width: auto; + width: auto; + } +} + +.entries-per-page-toggle::after { + display: none; +} + +@include theme('default') { + .entries-per-page-toggle { + background-color: var(--bg-dark-tertiary); + &:hover { + background-color: var(--green-70); + } + } +} diff --git a/services/web/frontend/stylesheets/modules/admin-tools/manage-projects-page.scss b/services/web/frontend/stylesheets/modules/admin-tools/manage-projects-page.scss new file mode 100644 index 00000000000..77402d3a4b0 --- /dev/null +++ b/services/web/frontend/stylesheets/modules/admin-tools/manage-projects-page.scss @@ -0,0 +1,16 @@ +.project-ds-nav-page { + .manage-projects-page { + .nav-item-projects { + display: initial !important; + } + } +} + +@include theme('default') { + #manage-projects-root { + .website-redesign-navbar, + .website-redesign .navbar-default { + @include navbar-dark; + } + } +} diff --git a/services/web/frontend/stylesheets/modules/admin-tools/manage-users-page.scss b/services/web/frontend/stylesheets/modules/admin-tools/manage-users-page.scss new file mode 100644 index 00000000000..67bb061162a --- /dev/null +++ b/services/web/frontend/stylesheets/modules/admin-tools/manage-users-page.scss @@ -0,0 +1,15 @@ +@include theme('default') { + #manage-users-root { + .website-redesign-navbar, + .website-redesign .navbar-default { + @include navbar-dark; + } + } + .user-ds-nav-page { + @include dark-dropdown-menu; + .table, + .table-container { + @include dark-table; + } + } +} diff --git a/services/web/frontend/stylesheets/modules/admin-tools/pagination-dark.scss b/services/web/frontend/stylesheets/modules/admin-tools/pagination-dark.scss new file mode 100644 index 00000000000..d18c0a58a89 --- /dev/null +++ b/services/web/frontend/stylesheets/modules/admin-tools/pagination-dark.scss @@ -0,0 +1,33 @@ +@include theme('default') { + .project-ds-nav-page, + .user-ds-nav-page { + .pagination { + > li { + > a, + > span, + > button { + color: var(--content-primary-inverse); + background-color: var(--bg-secondary); + border-color: var(--border-primary); + + &:hover, + &:focus { + color: var(--content-primary-inverse); + background-color: var(--bg-tertiary); + } + } + } + + > .active > a, + > .active > span, + > .active > button { + &, + &:hover, + &:focus { + color: var(--content-primary-inverse); + background-color: var(--green-70); + } + } + } + } +} diff --git a/services/web/frontend/stylesheets/modules/admin-tools/user-list-ds-nav.scss b/services/web/frontend/stylesheets/modules/admin-tools/user-list-ds-nav.scss new file mode 100644 index 00000000000..f5d900481f4 --- /dev/null +++ b/services/web/frontend/stylesheets/modules/admin-tools/user-list-ds-nav.scss @@ -0,0 +1,396 @@ +:root { + --ds-nav-active-bg: var(--bg-accent-03); + --ds-nav-active-color: var(--green-60); + --theme-toggle-selected-background: var(--green-20); + --ds-nav-content-bg-secondary: var(--bg-light-secondary); + --table-icon-bg-hover: 27 34 44; + --themed-dashboard-popover-bg: var(--bg-dark-primary); + --themed-dashboard-popover-color: var(--content-primary-dark); + --themed-dashboard-popover-link-color: var(--link-ui-dark); + --themed-dashboard-popover-link-hover-color: var(--link-ui-hover-dark); + --themed-dashboard-popover-link-visited-color: var(--link-ui-visited-dark); + --ds-nav-color-scheme: light; + + @include theme('default') { + --ds-nav-active-bg: var(--green-70); + --ds-nav-active-color: var(--green-10); + --theme-toggle-selected-background: var(--green-70); + --ds-nav-content-bg-secondary: var(--bg-dark-secondary); + --table-icon-bg-hover: 255 255 255; + --themed-dashboard-popover-bg: var(--bg-light-primary); + --themed-dashboard-popover-color: var(--content-primary); + --themed-dashboard-popover-link-color: var(--link-ui); + --themed-dashboard-popover-link-hover-color: var(--link-ui-hover); + --themed-dashboard-popover-link-visited-color: var(--link-ui-visited); + --ds-nav-color-scheme: dark; + } +} + +body { + --ds-nav-bg-color: var(--bg-primary-themed); + --ds-nav-hover-bg: var(--bg-secondary-themed); + --ds-nav-color: var(--content-secondary-themed); +} + +.user-ds-nav-page { + @include full-height-stacked-page; + + color-scheme: var(--ds-nav-color-scheme); + color: var(--content-secondary-themed); + + // NOTE-AC: This code can be eliminated when we remove sidebar-navigation-ui-update + --navbar-btn-padding-h: var(--spacing-06); + --navbar-subdued-padding: calc( + var(--navbar-btn-padding-v) + var(--navbar-btn-border-width) + ) + calc(var(--navbar-btn-padding-h) + 1px); + + .navbar-default { + position: relative; + + .navbar-header .navbar-logo { + @include media-breakpoint-up(md) { + position: relative; + top: var(--spacing-05); + } + } + + .nav-item-users { + display: none !important; + } + + @include media-breakpoint-up(lg) { + .nav-item-account, + .nav-item-help { + display: none; + } + } + + @include media-breakpoint-down(lg) { + --navbar-hamburger-submenu-item-hover-color: var(--content-primary); + --navbar-hamburger-submenu-item-hover-bg: var(--bg-light-secondary); + --navbar-subdued-hover-color: var(--content-primary); + --navbar-link-hover-color: var(--content-primary); + --navbar-link-hover-bg: var(--bg-light-secondary); + --navbar-subdued-hover-bg: var(--bg-light-secondary); + --navbar-hamburger-submenu-bg: none; + + .nav-item-help::before { + content: ''; + display: block; + border-top: 1px solid var(--border-divider-themed); + margin: var(--spacing-07) var(--spacing-06); + } + + #navbar-main-collapse { + padding: var(--spacing-09) var(--spacing-06) 0; + + .navbar-nav { + > li { + > a, + > .dropdown-toggle, + > .nav-link { + border-radius: var(--border-radius-medium); + + &.show { + background-color: var(--ds-nav-active-bg); + color: var(--ds-nav-active-color); + } + } + } + } + } + } + } + + .navbar-nav > li > .dropdown-toggle::after { + display: none; + } + + .user-list-wrapper { + background-color: var(--ds-nav-bg-color); + flex-grow: 1; + display: flex; + overflow-y: hidden; + + .user-list-sidebar-wrapper-react { + position: relative; + display: flex; + flex-direction: column; + flex: 0 0 15%; + max-width: 320px; + min-width: 200px; + margin-top: var(--spacing-03); + padding: var(--spacing-08) 0 0 0; + + .create-account-button-wrapper { + padding: 0 var(--spacing-08) var(--spacing-05) var(--spacing-05); + border-bottom: solid 1px transparent; + + &.show-shadow { + border-bottom-color: var(--border-divider-themed); + } + } + + nav { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + + .user-list-sidebar-scroll { + flex: 1 1 auto; + overflow: auto; + margin: 0 var(--spacing-02) 0 0; + padding: var(--spacing-05) var(--spacing-07) var(--spacing-05) + var(--spacing-05); + } + + .ds-nav-sidebar-lower { + padding: var(--spacing-05) var(--spacing-08) 0 var(--spacing-05); + border-top: solid 1px transparent; + + &.show-shadow { + border-top-color: var(--border-divider-themed); + } + } + } + + hr { + border-top: 1px solid var(--border-divider-themed); + } + + ul.user-list-filters { + margin-bottom: 0; + + hr { + margin: var(--spacing-05) 0; + } + + > li { + > button { + width: 100%; + text-align: left; + color: var(--ds-nav-color); + background: none; + border-radius: var(--border-radius-medium); + border: none; + padding: var(--spacing-04) var(--spacing-05); + } + + &:hover button { + background-color: var(--ds-nav-hover-bg); + } + + &.active button { + background-color: var(--ds-nav-active-bg); + color: var(--ds-nav-active-color); + font-weight: bold; + } + } + + .dropdown-header { + font-weight: bold; + color: var(--ds-nav-color); + } + + > li.tag { + button.tag-name { + padding-right: var(--spacing-08) !important; + } + } + + .tag-menu { + button.dropdown-toggle { + border-radius: var(--border-radius-full); + border: none; + color: var(--ds-nav-color); + height: 20px; + width: 20px; + padding: 0; + + &::after { + display: none; + } + + &:hover, + &:active, + &[aria-expanded='true'] { + background-color: var(--neutral-20); + } + } + } + } + + .user-ds-nav-main { + min-height: 100%; + padding: var(--spacing-08) var(--spacing-06); + + @include media-breakpoint-up(md) { + padding: var(--spacing-08); + } + } + + .user-dash-table { + .btn-link { + color: var(--content-secondary-themed); + height: var(--spacing-08); + width: var(--spacing-08); + border-radius: 100%; + padding: var(--spacing-01) 0 0; + vertical-align: middle; + + &:hover, + &:focus { + background-color: rgb(var(--table-icon-bg-hover) / 8%) !important; + } + } + + .dash-cell-name a { + color: var(--content-primary-themed) !important; + } + } + + .user-ds-nav-content-and-messages { + flex-grow: 1; + display: flex; + flex-direction: column; + + > * { + @include media-breakpoint-up(md) { + border-left: 1px solid var(--border-divider-themed); + } + } + + .user-ds-nav-content { + flex-grow: 1; + overflow-y: auto; + position: relative; + background-color: var(--ds-nav-content-bg-secondary); + + @include media-breakpoint-up(md) { + border-top-left-radius: var(--border-radius-large); + border-top: 1px solid var(--border-divider-themed); + } + } + + .cookie-banner { + position: static; + + // Remove the parts of the shadow that stick out of the sides + clip-path: inset(-13px 0 0 0); + + // Prevent the cookie banner being overlaid on top of the navigation + z-index: auto; + } + } + + .btn-link { + --link-color: var(--link-web-themed); + --link-hover-color: var(--link-web-hover-themed); + } + } + + .ds-nav-icon-dropdown { + .dropdown-toggle { + color: var(--ds-nav-color); + background: none; + height: 44px; + width: 44px; + padding: 0; + overflow: hidden; + + &:hover { + background-color: var(--ds-nav-hover-bg); + } + + &.show { + background-color: var(--ds-nav-active-bg); + color: var(--ds-nav-active-color); + } + + &::after { + display: none; + } + + > div { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + .material-symbols { + vertical-align: middle; + } + } + } + + .ds-nav-ds-name { + color: var(--ds-nav-color); + margin-bottom: var(--spacing-05); + + span { + text-transform: uppercase; + font-weight: bold; + + @include body-xs; + } + } + + .add-affiliation { + color: var(--ds-nav-color); + } +} + +.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); +} + +.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); + } +} diff --git a/services/web/frontend/stylesheets/modules/admin-tools/user-list.scss b/services/web/frontend/stylesheets/modules/admin-tools/user-list.scss new file mode 100644 index 00000000000..75c3856d0b3 --- /dev/null +++ b/services/web/frontend/stylesheets/modules/admin-tools/user-list.scss @@ -0,0 +1,546 @@ +.user-list-empty-col { + display: flex; + height: 100%; + flex-flow: column nowrap; + + .row:first-child { + flex-grow: 1; /* fill vertical space so notifications are pushed to bottom */ + } + + .card-body { + // h2 + .card-thin top padding + padding-bottom: calc(var(--line-height-03) + var(--line-height-03) / 2); + } +} + +.action-btn { + padding: 0 var(--spacing-02); +} + +#manage-users-root .user-notifications ul { + margin-bottom: 0; +} + +.user-list-sidebar-wrapper-react { + button { + white-space: normal; + word-wrap: anywhere; + + // prevents buttons from expanding sidebar width + } + + .create-account-button-wrapper { + width: 100%; + .create-account-button { + width: 100%; + } + } +} + +.create-account-button.dropdown-toggle::after { + display: none; +} + +.user-list-header-row { + display: flex; + align-items: center; + margin-bottom: var(--spacing-05); + min-height: 36px; + + .user-list-title { + @include heading-sm; + + color: var(--content-secondary-themed); + font-weight: bold; + min-width: 0; + } +} + +.user-tools { + flex-shrink: 0; + margin-left: auto; + min-height: 38px; + display: flex; + align-items: center; +} + +@include media-breakpoint-down(md) { + .user-tools { + float: left; + margin-left: initial; + } +} + +.users-toolbar { + display: flex; + align-items: center; + + .dropdown, + .dropdown-toggle { + max-width: 100%; + } + + .dropdown { + min-width: 0; + } +} + +.users-sort-dropdown { + flex-shrink: 0; + margin-left: auto; +} + +.user-menu-item-edit-btn { + position: absolute; + top: 0; + right: var(--spacing-09); + width: initial; + background-color: transparent; +} + +.user-menu-item-tag-name { + padding-right: var(--spacing-13); +} + +ul.user-list-filters { + .subdued { + color: var(--content-disabled); + } + + > li { + cursor: pointer; + position: relative; + + > button { + &:hover, + &:focus { + text-decoration: none; + } + } + + &.separator { + padding: var(--spacing-03) var(--spacing-06); + cursor: auto; + } + } + + .dropdown-header { + @include body-sm; + + padding: var(--spacing-05) var(--spacing-06); + text-transform: uppercase; + } + + > li.active { + border-radius: 0; + + > button { + font-weight: 700; + } + } + + h2 { + font-size: var(--font-size-02); + margin-bottom: var(--spacing-00); + color: var(--content-disabled); + text-transform: uppercase; + padding: var(--spacing-03) var(--spacing-00); + } + + > li.tag { + &.active, + &:focus-within { + .tag-menu { + display: block; + } + } + + &.untagged { + button.tag-name { + span.name { + font-style: italic; + padding-left: 0; + } + } + } + + &:hover { + .tag-menu { + display: block; + } + } + + button.tag-name { + position: relative; + padding: var(--spacing-03) var(--spacing-09) var(--spacing-03) + var(--spacing-06); + display: flex; + align-items: center; + word-wrap: anywhere; + + .tag-list-icon { + vertical-align: sub; + font-weight: bold; + } + + span.name { + padding-left: 0.5em; + line-height: 1.4; + } + } + } + + .tag-menu { + button.dropdown-toggle { + background-color: transparent; + display: flex; + align-items: center; + justify-content: center; + position: relative; + + &::after { + margin: 0; + } + } + + display: none; + width: auto; + position: absolute; + top: 50%; + margin-top: -8px; // Half the element height. + right: 4px; + + &.open { + display: block; + } + + button.tag-action { + border-radius: unset; + width: 100%; + background-color: transparent; + border-color: transparent; + color: var(--neutral-70); + text-align: left; + font-weight: normal; + + &:hover { + color: var(--content-primary-dark); + background-color: var(--bg-accent-01); + } + + &:active { + outline: none; + } + } + } +} + +.user-dash-table { + width: 100%; + table-layout: fixed; + + @include media-breakpoint-down(md) { + tr:not(:last-child) { + border-bottom: 1px solid var(--border-divider-themed); + } + + td { + border-bottom-width: 0; + } + } + + tbody { + tr.no-users:hover { + td { + box-shadow: none; + } + } + } + + .table-header-sort-btn { + border: 0; + text-align: left; + color: var(--content-secondary-themed); + background-color: transparent; + padding: 0; + font-weight: bold; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + text-decoration: none; + + &:hover, + &:focus { + color: var(--content-secondary-themed); + text-decoration: none; + } + + .material-symbols { + vertical-align: bottom; + font-size: var(--font-size-06); + } + } + + .dash-row-admin { + font-weight: bold; + a { + color: var(--content-danger) !important; + } + } + + .dash-cell-name { + hyphens: auto; + width: 30%; + word-break: break-word; + } + + .dash-cell-email { + width: 40%; + overflow: hidden; + text-overflow: ellipsis; + } + + .dash-cell-date-signup { + width: 25%; + } + .dash-cell-date-active { + width: 25%; + } + + .dash-cell-actions { + display: none; + text-align: right; + + .btn { + text-decoration: none; + } + } + + .dash-cell-email-date { + font-size: $font-size-sm; + + @include text-truncate; + } + + .dash-cell-tag, + .clone-user-tag { + .badge-tag { + margin-top: var(--spacing-06); + margin-bottom: var(--spacing-06); + + &:first-child { + margin-left: initial !important; + } + } + } + + @include media-breakpoint-up(sm) { + .dash-cell-checkbox { + width: 4%; + } + + .dash-cell-name { + width: 36%; + } + + .dash-cell-email { + width: 36%; + } + + .dash-cell-date-signup { + width: 12%; + } + + .dash-cell-date-active { + width: 12%; + } + + .dash-cell-actions { + width: 0%; + } + } + + @include media-breakpoint-up(md) { + .dash-cell-checkbox { + width: 3%; + } + + .dash-cell-name { + width: 23%; + } + + .dash-cell-email { + width: 28%; + } + + .dash-cell-date-signup { + width: 17%; + } + + .dash-cell-date-active { + width: 17%; + } + + .dash-cell-actions { + display: table-cell; + width: 12%; + } + + .user-tools { + float: none; + } + } + + @include media-breakpoint-up(lg) { + .dash-cell-checkbox { + width: 3%; + } + + .dash-cell-name { + width: 24%; + } + + .dash-cell-email { + width: 29%; + } + + .dash-cell-date-signup { + width: 17%; + } + + .dash-cell-date-active { + width: 17%; + } + + .dash-cell-actions { + width: 12%; + } + + tbody { + .dash-cell-actions { + white-space: nowrap; + } + } + } + + @include media-breakpoint-up(xl) { + .dash-cell-checkbox { + width: 3%; + } + + .dash-cell-name { + width: 24%; + } + + .dash-cell-email { + width: 29%; + } + + .dash-cell-date-signup { + width: 17%; + } + + .dash-cell-date-active { + width: 17%; + } + + .dash-cell-actions { + width: 12%; + } + } + + @include media-breakpoint-up(xxl) { + .dash-cell-checkbox { + width: 3%; + } + + .dash-cell-name { + width: 24%; + } + + .dash-cell-email { + width: 29%; + } + + .dash-cell-date-signup { + width: 17%; + } + + .dash-cell-date-active { + width: 17%; + } + + .dash-cell-actions { + width: 12%; + } + } + + @include media-breakpoint-down(md) { + tr { + position: relative; + display: flex; + flex-direction: column; + + td { + padding-top: var(--spacing-02); + padding-bottom: var(--spacing-02); + } + + td:not(.dash-cell-actions) { + padding-right: 55px; + } + } + + .dash-cell-name, + .dash-cell-email, + .dash-cell-date-signed, + .dash-cell-date-active, + .dash-cell-actions { + display: block; + width: auto; + } + + .dash-cell-actions { + position: absolute; + top: var(--spacing-04); + right: var(--spacing-04); + padding: 0 !important; + } + } +} + +/* stylelint-disable selector-class-pattern */ +.user-list-upload-user-modal-uppy-dashboard .uppy-Root { + .uppy-Dashboard-AddFiles-title { + display: flex; + flex-direction: column; + color: var(--neutral-60); + white-space: pre-line; + + @include body-base; + + button.uppy-Dashboard-browse { + @extend .btn; + @extend .btn-lg; + @extend .btn-primary; + + margin-bottom: var(--spacing-07); + } + } +} + +form.user-search { + .form-group { + margin-bottom: 0; + } +} + +@include theme('default') { + .dash-cell-email a, + .dash-cell-email-date a { + color: var(--green-40); + } + .dash-row-admin a { + color: var(--content-danger-dark) !important; + } +} diff --git a/services/web/frontend/stylesheets/modules/all.scss b/services/web/frontend/stylesheets/modules/all.scss index 01d58c8c20e..23462389e6e 100644 --- a/services/web/frontend/stylesheets/modules/all.scss +++ b/services/web/frontend/stylesheets/modules/all.scss @@ -9,3 +9,9 @@ @import 'symbol-palette'; @import 'writefull'; @import 'labs'; +@import 'admin-tools/entries-per-page'; +@import 'admin-tools/user-list'; +@import 'admin-tools/user-list-ds-nav'; +@import 'admin-tools/manage-projects-page'; +@import 'admin-tools/manage-users-page'; +@import 'admin-tools/pagination-dark'; diff --git a/services/web/frontend/stylesheets/modules/symbol-palette.scss b/services/web/frontend/stylesheets/modules/symbol-palette.scss index 66c9f7169e7..66062b7beef 100644 --- a/services/web/frontend/stylesheets/modules/symbol-palette.scss +++ b/services/web/frontend/stylesheets/modules/symbol-palette.scss @@ -8,6 +8,7 @@ --symbol-palette-selected-tab-color: var(--content-primary-dark); --symbol-palette-text-shadow-color: var(--bg-dark-primary); --symbol-palette-overlay-bg: #{rgb($bg-dark-tertiary, 0.75)}; + --symbol-palette-close-invert: 1; } @include theme('light') { @@ -20,6 +21,7 @@ --symbol-palette-selected-tab-color: var(--blue-50); --symbol-palette-text-shadow-color: var(--bg-light-tertiary); --symbol-palette-overlay-bg: #{rgb($bg-light-primary, 0.75)}; + --symbol-palette-close-invert: 0; } .symbol-palette-container { @@ -154,12 +156,12 @@ .symbol-palette-close-button-outer { display: flex; + align-items: center; + margin-right: var(--spacing-05); } .symbol-palette-close-button { - margin-top: var(--spacing-04); - margin-left: var(--spacing-05); - margin-right: var(--spacing-03); + filter: invert(var(--symbol-palette-close-invert)); .symbol-palette-unavailable & { visibility: hidden; diff --git a/services/web/frontend/stylesheets/pages/templates-v2.scss b/services/web/frontend/stylesheets/pages/templates-v2.scss index 881b2941584..2791812b0b5 100644 --- a/services/web/frontend/stylesheets/pages/templates-v2.scss +++ b/services/web/frontend/stylesheets/pages/templates-v2.scss @@ -25,6 +25,37 @@ } .gallery { + padding-top: calc($header-height + var(--spacing-10)) !important; + + .gallery-search-form-control { + padding-top: 0px; + padding-bottom: 0px; + } + + .gallery-header-sort-btn { + font-size: var(--font-size-02); + border: 0; + text-align: left; + color: var(--neutral-90); + background-color: transparent; + padding: 0; + font-weight: bold; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + text-decoration: none; + + &:hover { + color: var(--neutral-90); + text-decoration: underline; + } + + .material-symbols { + vertical-align: top; + font-size: var(--font-size-05); + } + } + .gallery-tagged-tags-container-spacing { padding: 0 var(--spacing-09); margin-bottom: var(--spacing-16); diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 6002323588e..4ee5a2cd8c9 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -16,15 +16,26 @@ "a_more_comprehensive_list_of_keyboard_shortcuts": "Eine vollstƤndige Liste der Tastaturbelegung befindet sich in <0>dieser __appName__ Projekt Vorlage", "about": "Über uns", "about_to_archive_projects": "Du bist im Begriff, die folgenden Projekte zu archivieren:", + "about_to_delete_accounts": "Du bist dabei, die Konten der folgenden Benutzer einschließlich aller ihrer Projekte zu lƶschen:", "about_to_delete_cert": "Du bist dabei, das folgende Zertifikat zu lƶschen:", "about_to_delete_projects": "Du bist kurz davor folgende Projekte zu lƶschen:", "about_to_delete_tag": "Du bist dabei, das folgende Stichwort zu lƶschen (darin enthaltene Projekte werden nicht gelƶscht):", + "about_to_delete_template": "Du bist dabei, die folgende Vorlage zu lƶschen:", "about_to_delete_the_following_project": "Du bist dabei, das folgende Projekt zu lƶschen", "about_to_delete_the_following_projects": "Du bist dabei, die folgenden Projekte zu lƶschen", "about_to_delete_user_preamble": "Du bist dabei, __userName__ (__userEmail__) zu lƶschen. Das bedeutet:", "about_to_enable_managed_users": "Wenn du die Funktion Verwaltete Benutzer aktivierst, werden alle bestehenden Mitglieder deines Gruppenabonnements eingeladen, verwaltet zu werden. Dadurch erhƤltst du Verwaltungsrechte für deren Konten. Du wirst auch die Mƶglichkeit haben, neue Mitglieder einzuladen, dem Abonnement beizutreten und verwaltet zu werden.", "about_to_leave_projects": "Du bist kurz davor folgende Projekte zu verlassen:", - "about_to_trash_projects": "Du bist dabei, die folgenden Projekte zu lƶschen:", + "about_to_permanently_delete_accounts": "Du bist dabei, die folgenden Konten endgültig zu lƶschen:", + "about_to_permanently_delete_projects": "Du bist dabei, die folgenden Projekte endgültig zu lƶschen:", + "about_to_resend_activation_email": "Du bist dabei, die Aktivierungs-E-Mail an die folgenden Nutzer erneut zu senden:", + "about_to_restore_accounts": "Du bist dabei, die folgenden Benutzerkonten wiederherzustellen:", + "about_to_restore_projects": "Du bist dabei, die folgenden Projekte wiederherzustellen:", + "about_to_resume_accounts": "Du bist dabei, den Zugriff für die folgenden Benutzer wieder freizugeben:", + "about_to_set_admin_accounts": "Du bist dabei, Administratorrechte für die folgenden Benutzer zu vergeben:", + "about_to_suspend_accounts": "Du bist dabei, die folgenden Konten zu sperren:", + "about_to_trash_projects": "Du bist dabei, die folgenden Projekte zu verwerfen:", + "about_to_unset_admin_accounts": "Du bist dabei, Administratorrechte bei den folgenden Benutzern zu entziehen:", "abstract": "Abstrakt", "accept": "Akzeptieren", "accept_invitation": "Einladung annehmen", @@ -34,6 +45,7 @@ "account": "Konto", "account_has_been_link_to_institution_account": "Dein __appName__-Konto auf __email__ wurde mit deinem institutionellen Konto __institutionName__ verknüpft.", "account_has_past_due_invoice_change_plan_warning": "Dein Konto weist derzeit eine überfƤllige Rechnung auf. Du kannst dein Abonnement nicht Ƥndern, bis dies behoben ist.", + "account_information": "Kontoinformationen", "account_linking": "Kontoverknüpfung", "account_managed_by_group_administrator": "Dein Account wird von deinem Gruppenverwalter (__admin__) verwaltet.", "account_not_linked_to_dropbox": "Dein Konto ist nicht mit Dropbox verknüpft", @@ -44,6 +56,7 @@ "activate": "Aktivieren", "activate_account": "Deaktiviere dein Konto", "activating": "Aktivierung", + "activation_link": "Aktivierungslink", "activation_token_expired": "Dein Aktivierungs-Token ist abgelaufen, bitte fordere einen neuen an.", "add": "Hinzufügen", "add_affiliation": "Mitgliedschaft hinzufügen", @@ -80,6 +93,7 @@ "all_prices_displayed_are_in_currency": "Alle Preise sind in __recommendedCurrency__ angezeigt.", "all_projects": "Alle Projekte", "all_templates": "Alle Vorlagen", + "all_users": "Alle Benutzer", "already_have_sl_account": "Hast du bereits ein __appName__-Konto?", "also": "Ebenfalls", "alternatively_create_new_institution_account": "Alternativ kannst du ein neues Konto mit deiner institutionellen E-Mail-Adresse (__email__) erstellen, indem du auf ā€ž__clickText__ā€œ klickst.", @@ -120,6 +134,7 @@ "back_to_editor": "Zurück zum Editor", "back_to_log_in": "Zurück zur Anmeldung", "back_to_subscription": "Zurück zum Abonnement", + "back_to_user_list": "Zurück zur Benutzerliste", "back_to_your_projects": "Zurück zu deinen Projekten", "become_an_advisor": "Werde ein __appName__-Berater", "beta": "Beta", @@ -161,6 +176,7 @@ "card_must_be_authenticated_by_3dsecure": "Deine Karte muss mit 3D Secure authentifiziert werden, bevor du fortfahren kannst", "card_payment": "Kartenzahlung", "careers": "Karriere", + "category": "Kategorie", "category_arrows": "Pfeile", "category_greek": "Griechisch", "category_misc": "Sonstiges", @@ -254,6 +270,7 @@ "coupon_code_is_not_valid_for_selected_plan": "Der Gutscheincode ist nicht gültig für das gewƤhlte Abonnement", "coupons_not_included": "Dies beinhaltet nicht deine aktuellen Rabatte, die automatisch vor deiner nƤchsten Zahlung angewandt werden", "create": "Erstellen", + "create_account": "Konto erstellen", "create_a_new_password_for_your_account": "Erstelle ein neues Passwort für dein Konto", "create_a_new_project": "Erstelle ein neues Projekt", "create_first_admin_account": "Erstelle das erste Admin-Konto", @@ -286,19 +303,22 @@ "delete_account": "Konto lƶschen", "delete_account_confirmation_label": "Ich verstehe, dass dadurch alle Projekte in meinem __appName__-Konto mit der E-Mail-Adresse <0>__userDefaultEmail__ gelƶscht werden", "delete_account_warning_message_3": "Du bist dabei, alle Kontodaten permanent zu lƶschen, inklusive Projekte und Einstellungen. Bitte gib die E-Mail-Adresse und das Passwort deines Kontos in die Felder ein um fortzufahren.", + "delete_accounts": "Konten lƶschen", "delete_acct_no_existing_pw": "Bitte verwende das Formular zum Zurücksetzen des Passworts, um ein Passwort festzulegen, bevor du dein Konto lƶschst", "delete_and_leave": "Lƶschen/Verlassen", "delete_and_leave_projects": "Projekte lƶschen und verlassen", "delete_authentication_token": "Zugangstoken lƶschen", "delete_authentication_token_info": "Du bist dabei einen Git Zugangstoken zu lƶschen. Sobald dieser gelƶscht ist, verliert er seine Gültigkeit und er kann nicht mehr für Git Aktionen verwendet werden.", "delete_figure": "Abbildung lƶschen", - "delete_projects": "Projekte archivieren", "delete_tag": "Stichwort lƶschen", + "delete_template": "Vorlage lƶschen", "delete_token": "Token lƶschen", + "delete_projects": "Projekte lƶschen", "delete_user": "Nutzer lƶschen", "delete_your_account": "Lƶsche dein Konto", "deleted_at": "Gelƶscht am", "deleted_by_on": "Gelƶscht von __name__ am __date__", + "deleted_projects": "Gelƶschte Projekte", "deleting": "Lƶschen", "demonstrating_git_integration": "Demonstration der Git-Integration", "department": "Abteilung", @@ -312,6 +332,7 @@ "do_not_have_acct_or_do_not_want_to_link": "Wenn du kein __appName__-Konto hast oder nicht mit deinem __institutionName__-Konto verknüpfen mƶchtest, klicke auf ā€ž__clickText__ā€œ.", "do_not_link_accounts": "Konten nicht verknüpfen", "do_you_want_to_change_your_primary_email_address_to": "Willst Du deine primƤre E-Mail-Adresse in __email__ Ƥndern?", + "do_you_want_to_overwrite_it": "Mƶchtest du es überschreiben?", "do_you_want_to_overwrite_them": "Willst Du sie überschreiben?", "documentation": "Dokumentation", "does_not_contain_or_significantly_match_your_email": "nicht mit Teilen deiner E-Mail-Adresse übereinstimmt", @@ -360,6 +381,7 @@ "edit_dictionary_remove": "Aus Wƶrterbuch entfernen", "edit_figure": "Abbildung bearbeiten", "edit_tag": "Schlagwort bearbeiten", + "edit_template": "Vorlage bearbeiten", "editing": "Bearbeitung", "editing_captions": "Beschriftungen bearbeiten", "editor_and_pdf": "Editor & PDF", @@ -368,6 +390,7 @@ "editor_theme": "Editor-Thema", "educational_percent_discount_applied": "__percent__% Bildungsrabatt angewandt!", "email": "E-Mail", + "email_address": "E-Mail-Adresse", "email_already_associated_with": "Die E-Mail-Adresse __email1__ ist bereits mit dem Konto __email2__ __appName__ verknüpft.", "email_already_registered": "Diese E-Mail-Adresse ist bereits registriert.", "email_already_registered_secondary": "Diese E-Mail-Adresse ist bereits als sekundƤre E-Mail-Adresse registriert", @@ -404,6 +427,7 @@ "expiry": "Ablaufdatum", "export_csv": "CSV-Datei exportieren", "export_project_to_github": "Projekt nach GitHub exportieren", + "failed_to_publish_as_a_template": "Verƶffentlichung als Vorlage fehlgeschlagen.", "failed_to_send_managed_user_invite_to_email": "Der Versand der Einladung für Verwaltete Benutzer an <0>__email__ hat nicht funktioniert. Bitte versuche es spƤter noch einmal.", "faq_how_does_free_trial_works_answer": "WƤhrend deines __len__-tƤgigen Probe-Abonnements erhƤltst du vollen Zugriff auf die Funktionen des von dir gewƤhlten __appName__-Abonnements. Es besteht keine Verpflichtung, über die Testperiode hinaus fortzufahren. Deine Karte wird am Ende des __len__-tƤgigen Testzeitraums belastet, sofern du nicht vorher gekündigt hast. Um zu kündigen, gehe zu deinen Abonnementeinstellungen in deinem Konto.", "fast": "Schnell", @@ -429,6 +453,8 @@ "file_too_large": "Datei zu groß", "files_cannot_include_invalid_characters": "Der Dateiname ist leer oder enthƤlt ungültige Zeichen", "files_selected": "Dateien ausgewƤhlt.", + "filter_projects": "Projekte filtern", + "filter_users": "Benutzer filtern", "filters": "Filter", "find_out_more": "Finde mehr heraus", "find_out_more_about_institution_login": "Erfahre mehr über den institutionellen Login", @@ -483,8 +509,11 @@ "git_authentication_token_create_modal_info_2": "<0>Du bekommst diesen Anmelde-Token nur einmal angezeigt, bitte kopiere ihn und bewahre ihn sicher auf. Für weitere Anweisungen zur Verwendung von Anmelde-Tokens, besuche unsere <1>Hilfe-Seite.", "git_bridge_modal_click_generate": "Klicke jetzt auf Token generieren um deinen ersten Anmeldungs-Token zu erstellen. Oder erstelle ihn spƤter in deinen Kontoeinstellungen.", "git_bridge_modal_enter_authentication_token": "Wenn Du nach einem Passwort gefragt wirst, gib deinen neuen Anmeldungs-Token ein:", + "git_bridge_modal_git_clone_your_project": "Klonen Dein Projekt über den untenstehenden Link und einen Git-Authentifizierungstoken", "git_bridge_modal_see_once": "Du siehst diesen Token nur einmal. Um ihn zu lƶschen oder einen weiteren zu generieren, besuche die Kontoeinstellungen. Für detaillierte Anweisungen und Problembehebung, besuche unsere <0>Hilfe-Seite.", "git_bridge_modal_use_previous_token": "Wenn Du nach einem Passwort gefragt wirst, kannst Du einen zuvor generierten Git-Anmeldungs-Token verwenden. Oder Du kannst einen Neuen in den Kontoeinstellungen generieren. Für mehr Hilfe, besuche unsere <0>Hilfe-Seite.", + "git_clone_project_command": "Git-Clone-Projektbefehl", + "git_clone_this_project": "Dieses Projekt mit Git klonen.", "git_integration": "Git-Integration", "git_integration_info": "Mit der Git-Integration kannst Du Overleaf-Projekte Git-clonen. Für weitere Anweisungen hierfür, besuche <0>unsere Hilfe-Seite.", "github_commit_message_placeholder": "Commit-Meldung für Ƅnderungen die in __appName__ gemacht wurden", @@ -606,6 +635,7 @@ "in_order_to_have_a_secure_account_make_sure_your_password": "Um dein Konto abzusichern, stelle sicher, dass dein Passwort", "in_order_to_match_institutional_metadata_2": "Um deine institutionellen Metadaten abzugleichen, haben wir dein Konto mit <0>__email__ verknüpft.", "in_order_to_match_institutional_metadata_associated": "Um deine institutionellen Metadaten abzugleichen, wird dein Konto mit der E-Mail-Adresse __email__ verknüpft.", + "inactive_projects": "Inaktive Projekte", "include_caption": "Beschriftung anzeigen", "include_label": "Label anzeigen", "increased_compile_timeout": "Zeitlimit beim Kompilieren erhƶhen", @@ -817,9 +847,11 @@ "need_anything_contact_us_at": "Wenn du irgendetwas benƶtigst, kannst du uns gern direkt kontaktieren über", "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?", + "never": "Nie", "new_file": "Neue Datei", "new_folder": "Neuer Ordner", "new_name": "Neuer Name", + "new_owner": "Neuer Besitzer", "new_password": "Neues Passwort", "new_project": "Neues Projekt", "new_snippet_project": "Ohne Titel", @@ -852,8 +884,10 @@ "no_search_results": "Keine Suchergebnisse", "no_selection_select_file": "Derzeit ist keine Datei ausgewƤhlt. Bitte wƤhle eine Datei aus dem Dateibaum aus.", "no_symbols_found": "Keine Symbole gefunden", + "no_templates_found": "Keine Vorlagen gefunden.", "no_thanks_cancel_now": "Nein, danke - Ich mƶchte nach wie vor jetzt stornieren", "no_update_email": "Nein, E-Mail-Adresse aktualisieren", + "no_users": "Keine Benutzer", "normal": "Normal", "normally_x_price_per_month": "Normalerweise __price__ pro Monat", "normally_x_price_per_year": "Normalerweise __price__ pro Jahr", @@ -866,6 +900,7 @@ "notification_project_invite": "__userName__ mƶchte, dass du __projectName__ beitrittst. Trete Projekt bei", "notification_project_invite_accepted_message": "Du bist __projectName__ beigetreten", "notification_project_invite_message": "__userName__ mƶchte, dass du __projectName__ beitrittst", + "notify_users_about_account_deletion": "Benutzer über die Kontolƶschung benachrichtigen", "november": "November", "number_collab": "Anzahl der Mitarbeiter", "number_of_users": "Nutzeranzahl", @@ -895,7 +930,9 @@ "overall_theme": "Gesamtthema", "overleaf_history_system": "Overleaf-Historie", "overview": "Überblick", + "owned_projects": "Eigene Projekte", "owner": "Besitzer", + "ownership_of_projects_will_be_transferred": "Der Besitz der folgenden Projekte wird auf einen anderen Benutzer übertragen:", "page_current": "Seite __page__, Aktuelle Seite", "page_not_found": "Seite nicht gefunden", "pagination_navigation": "Seitenumbruch-Navigation", @@ -923,8 +960,11 @@ "pending": "Ausstehend", "pending_additional_licenses": "Dein Abonnement wird geƤndert, um <0>__pendingAdditionalLicenses__ zusƤtzliche Lizenz(en) für insgesamt <1>__pendingTotalLicenses__ Lizenzen einzuschließen.", "per_month": "pro Monat", + "per_page": "pro Seite", "per_user_year": "pro Nutzer / Jahr", "per_year": "pro Jahr", + "permanently_delete_accounts": "Konten endgültig lƶschen", + "permanently_delete_projects": "Projekte endgültig lƶschen:", "personal": "Persƶnlich", "personalized_onboarding": "Personalisiertes Onboarding", "pl": "Polnisch", @@ -936,6 +976,7 @@ "please_change_primary_to_remove": "Bitte Ƥndere deine primƤre E-Mail-Adresse, um sie zu entfernen", "please_check_your_inbox_to_confirm": "Bitte überprüfe deinen E-Mail-Postfach, um deine Zugehƶrigkeit zu <0>__institutionName__ zu bestƤtigen.", "please_compile_pdf_before_download": "Bitte kompiliere dein Projekt, bevor du das PDF herunterlƤdst", + "please_compile_pdf_before_publish_as_template": "Bitte kompiliere dein Projekt, bevor du es als Vorlage verƶffentlichst.", "please_compile_pdf_before_word_count": "Bitte kompiliere dein Projekt, bevor du eine WortzƤhlung durchführst.", "please_confirm_email": "Bitte bestƤtige deine E-Mail-Adresse __emailAddress__, indem du auf den Link in der BestƤtigungs-E-Mail klickst", "please_confirm_your_email_before_making_it_default": "Bitte bestƤtige deine E-Mail-Adresse, bevor du sie zur primƤren machst.", @@ -971,6 +1012,7 @@ "processing_your_request": "Bitte warte, wƤhrend wir deine Anfrage bearbeiten.", "professional": "Professionell", "project_approaching_file_limit": "Dieses Projekt nƤhert sich dem Dateilimit", + "project_categories_tags": "Projektkategorien und Tags", "project_flagged_too_many_compiles": "Dieses Projekt wurde zu hƤufig zum Kompilieren vermerkt. Das Limit wird in Kürze aufgehoben.", "project_has_too_many_files": "Dieses Projekt hat das Limit von 2000 Dateien erreicht", "project_has_too_many_files_limit": "Dieses Projekt hat das Limit von __limit__ Dateien erreicht", @@ -986,12 +1028,14 @@ "project_too_much_editable_text": "Dieses Projekt hat zu viel bearbeitbaren Text, bitte versuche ihn zu reduzieren.", "project_url": "Betroffene Projekt-URL", "projects": "Projekte", + "projects_list": "Projektliste", "pt": "Portugiesisch", "public": "Ɩffentlich", "publish": "Verƶffentlichen", "publish_as_template": "Als Vorlage verƶffentlichen", "publishing": "Verƶffentlichen", "pull_github_changes_into_sharelatex": "GitHub-Ƅnderungen nach __appName__ ziehen", + "purge": "Vernichten", "push_sharelatex_changes_to_github": "__appName__-Ƅnderungen an GitHub senden", "raw_logs": "Raw Logs", "raw_logs_description": "Raw Logs vom LaTeX-Compiler", @@ -1055,6 +1099,7 @@ "requesting_password_reset": "Zurücksetzen des Passworts anfordern", "required": "Erforderlich", "resend": "Sende erneut", + "resend_activation_email": "Aktivierungs-E-Mail erneut senden", "resend_confirmation_email": "BestƤtigungs-E-Mail erneut senden", "resend_managed_user_invite": "Einladung zu Verwaltete Benutzer erneut senden", "reset_password": "Passwort zurücksetzen", @@ -1062,9 +1107,13 @@ "resolve": "Lƶsen", "resolved_comments": "Gelƶste Kommentare", "restore": "Wiederherstellen", + "restore_accounts": "Konten wiederherstellen", + "restore_projects": "Projekte wiederherzustellen:", "restoring": "Wiederherstellen", "restricted": "Geschützt", "restricted_no_permission": "Entschuldigung, du hast nicht die Berechtigung, diese Seite anzuzeigen.", + "resume": "Freigeben", + "resume_account": "Konto wieder freigeben", "return_to_login_page": "Zurück zur Login-Seite", "revert_pending_plan_change": "Abonnement-Ƅnderung rückgƤngig machen", "review": "Überprüfen", @@ -1095,17 +1144,23 @@ "secondary_email_password_reset": "Diese E-Mail-Adresse ist als sekundƤre E-Mail-Adresse hinterlegt. Bitte gib die primƤre E-Mail-Adresse für dein Konto an.", "security": "Sicherheit", "select_a_file": "Datei auswƤhlen", + "select_a_new_owner_for_projects": "Neuen Besitzer für die Projekte dieses Benutzers auswƤhlen", "select_a_project": "Projekt auswƤhlen", "select_all_projects": "Alle Projekte auswƤhlen", + "select_all_users": "Alle Benutzer auswƤhlen", "select_an_output_file": "Ausgabedatei auswƤhlen", "select_from_output_files": "aus Ausgabedateien auswƤhlen", "select_from_source_files": "aus Quelldateien auswƤhlen", "select_github_repository": "WƤhle ein GitHub-Repository, das du in __appName__ importieren mƶchtest.", "select_project": "__project__ auswƤhlen", + "select_projects": "Projekte auswƤhlen", + "select_user": "Benutzer auswƤhlen", + "select_users": "Benutzer auswƤhlen", "selected": "AusgewƤhlt", "selected_by_overleaf_staff": "AusgewƤhlt von Overleaf-Mitarbeitern", "send": "Absenden", "send_first_message": "Sende deine erste Nachricht", + "send_notification_emails_to_users": "Eine Benachrichtigung an den aktuellen und den neuen Besitzer senden", "send_test_email": "Test-Mail senden", "sending": "Wird gesendet", "september": "September", @@ -1115,6 +1170,8 @@ "session_error": "Sitzungsfehler. Bitte überprüfe, ob Cookies aktiviert sind. Wenn das Problem weiterhin besteht, versuche, deinen Cache und deine Cookies zu lƶschen.", "session_expired_redirecting_to_login": "Sitzung abgelaufen. Du wirst in __seconds__ Sekunden auf die Anmeldungsseite umgeleitet", "sessions": "Sessions", + "set_admin": "Admin setzen", + "set_admin_account": "Administratorrechte vergeben", "set_new_password": "Neues Passwort eingeben", "set_password": "Passwort setzen", "settings": "Einstellungen", @@ -1122,16 +1179,23 @@ "share_project": "Projekt teilen", "shared_with_you": "Mit dir geteilt", "sharelatex_beta_program": "__appName__ Beta-Programm", + "show_all_projects": "Alle Projekte anzeigen", + "show_all_users": "Alle Benutzer anzeigen", "show_hotkeys": "Zeige Hotkeys", "show_in_code": "Im Code anzeigen", "show_in_pdf": "Im PDF anzeigen", "show_less": "Weniger anzeigen", "show_outline": "Dateigliederung anzeigen", + "show_x_more_projects": "__x__ weitere Projekte anzeigen", + "show_x_more_users": "__x__ weitere Benutzer anzeigen", "showing_1_result": "1 Ergebnis wird angezeigt", "showing_1_result_of_total": "Zeige 1 Ergebnis von __total__", + "showing_x_out_of_n_projects": "Es werden __x__ von __n__ Projekten angezeigt.", + "showing_x_out_of_n_users": "Es werden __x__ von __n__ Benutzern angezeigt", "showing_x_results": "Es werden __x__ Ergebnisse angezeigt", "showing_x_results_of_total": "Es werden __x__ Ergebnisse von __total__ angezeigt", "site_description": "Ein einfach bedienbarer Online-LaTeX-Editor. Keine Installation notwendig, Zusammenarbeit in Echtzeit, Versionskontrolle, Hunderte von LaTeX-Vorlagen und mehr", + "signed_up": "Angemeldet", "skip_to_content": "Zum Inhalt springen", "something_went_wrong_canceling_your_subscription": "Beim Kündigen deines Abonnements ist etwas schief gelaufen. Bitte wende dich an den Support.", "something_went_wrong_loading_pdf_viewer": "Beim Laden des PDF-Betrachters ist ein Fehler aufgetreten. Dies kann durch Probleme wie <0>vorübergehende Netzwerkprobleme oder einen <0>veralteten Webbrowser verursacht werden. Bitte befolge die <1>Schritte zur Fehlerbehebung bei Zugriffs-, Lade- und Anzeigeproblemen. Wenn das Problem weiterhin besteht, <2>teile uns dies bitte mit.", @@ -1174,6 +1238,8 @@ "sure_you_want_to_change_plan": "Bist du sicher, dass du zum Abonnement <0>__planName__ wechseln mƶchtest?", "sure_you_want_to_delete": "Mƶchtest du die folgenden Dateien wirklich lƶschen?", "sure_you_want_to_leave_group": "Bist du sicher, dass du diese Gruppe verlassen mƶchtest?", + "suspend": "Sperren", + "suspend_account": "Konto sperren", "sv": "Schwedisch", "symbol_palette": "Symbolpalette", "sync": "Sync", @@ -1195,6 +1261,7 @@ "template_not_found_description": "Diese Methode zum Erstellen von Projekten aus Vorlagen wurde entfernt. Besuche unsere Vorlagengalerie, um weitere Vorlagen zu finden.", "template_title_taken_from_project_title": "Der Vorlagentitel wird automatisch aus dem Projekttitel übernommen", "template_top_pick_by_overleaf": "Diese Vorlage wurde von Overleaf-Mitarbeitern aufgrund ihrer hohen QualitƤt ausgewƤhlt", + "template_with_this_title_exists_and_owned_by_x": "Eine Vorlage mit diesem Namen existiert bereits, und der Besitzer ist: __x__.", "templates": "Vorlagen", "templates_admin_source_project": "Administration: Quellprojekt", "templates_page_title": "Vorlagen - Zeitschriften, LebenslƤufe, PrƤsentationen, Berichte und mehr", @@ -1219,6 +1286,7 @@ "then_x_price_per_year": "Danach __price__ pro Jahr", "there_was_an_error_opening_your_content": "Beim Erstellen deines Projekts ist ein Fehler aufgetreten", "thesis": "Doktorarbeit", + "this_action_can_be_undone_within_limited_period": "Diese Aktion kann nur innerhalb eines begrenzten Zeitraums rückgƤngig gemacht werden.", "this_action_cannot_be_undone": "Diese Aktion kann nicht rückgƤngig gemacht werden.", "this_field_is_required": "Dieses Feld wird benƶtigt", "this_grants_access_to_features_2": "Dadurch erhƤltst du Zugriff auf die <0>__featureType__ Funktionen von <0>__appName__.", @@ -1234,7 +1302,9 @@ "to_change_access_permissions": "Um Zugriffsberechtigungen zu Ƥndern, wende dich bitte an den Projektinhaber", "to_modify_your_subscription_go_to": "Um dein Abo zu Ƥndern, gehe zu", "toggle_compile_options_menu": "Menü der Kompilieroptionen umschalten", + "token": "Token", "token_access_failure": "Zugriff kann nicht gewƤhrt werden", + "token_limit_reached": "Du hast das Limit von 10 Tokens erreicht. Um einen neuen Authentifizierungstoken zu generieren, lƶsche bitte einen bestehenden.", "too_many_attempts": "Zu viele Versuche. Bitte warte eine Weile und versuche es erneut.", "too_many_files_uploaded_throttled_short_period": "Zu viele Dateien hochgeladen, deine Uploads wurden für kurze Zeit gedrosselt.", "too_many_login_requests_2_mins": "In dieses Konto wurde sich zu hƤufig eingeloggt. Bitte warte 2 Minuten, bevor du es noch einmal versuchst.", @@ -1253,10 +1323,12 @@ "track_changes": "Ƅnderungen verfolgen", "tracked_change_added": "Hinzugefügt", "tracked_change_deleted": "Gelƶscht", - "trash": "Lƶschen", - "trash_projects": "Lƶsche Projekte", - "trashed_projects": "Gelƶschte Projekte", - "trashing_projects_wont_affect_collaborators": "Das Lƶschen von Projekten wirkt sich nicht auf deine Mitarbeiter aus.", + "transfer_all_projects_to": "Alle Projekte der Benutzer übertragen an:", + "trash": "Verwerfen", + "trash_projects": "Projekte verwerfen", + "trashed_projects": "Verworfene Projekte", + "trashing_projects_wont_affect_collaborators": "Das Verwerfen von Projekten wirkt sich nicht auf deine Mitarbeiter aus.", + "trashing_projects_wont_affect_user_collaborators": "Das Verwerfen von Projekten wirkt sich nicht auf die Mitarbeitenden des Benutzers aus.", "tried_to_log_in_with_email": "Du hast versucht, dich mit __email__ anzumelden.", "tried_to_register_with_email": "Du hast versucht, dich mit __email__ zu registrieren, das bereits bei __appName__ als institutionelles Konto registriert ist.", "try_again": "Bitte versuche es erneut", @@ -1264,6 +1336,7 @@ "try_it_for_free": "Probiere es kostenlos aus", "try_now": "Jetzt versuchen", "try_premium_for_free": "Teste Premium kostenlos", + "try_recompile_project": "Versuche bitte, das Projekt von Grund auf neu zu kompilieren.", "try_recompile_project_or_troubleshoot": "Versuche bitte, das Projekt von Grund auf neu zu kompilieren. Wenn das Problem weiterhin besteht, findest Du im <0>Troubleshooting Guide weitere Hilfe", "try_to_compile_despite_errors": "Versuche, trotz Fehler zu kompilieren", "turn_off_link_sharing": "Deaktiviere die Linkfreigabe", @@ -1276,6 +1349,7 @@ "unconfirmed": "UnbestƤtigt", "unfold_line": "Zeile ausklappen", "university": "UniversitƤt", + "unknown": "Unbekannt", "unlimited": "Unbegrenzt", "unlimited_collabs": "UnbeschrƤnkt viele Mitarbeiter", "unlimited_projects": "Unbegrenzte Projekte", @@ -1291,6 +1365,8 @@ "unlinking": "Verknüpfung wird aufgehoben", "unpublish": "Verƶffentlichung aufheben", "unpublishing": "Verƶffentlichung aufheben", + "unset_admin": "Admin entfernen", + "unset_admin_account": "Administratorrechte entziehen", "unsubscribe": "Abbestellen", "unsubscribed": "Abbestellt", "unsubscribing": "Abbestellen lƤuft", @@ -1313,12 +1389,24 @@ "usage_metrics": "Nutzungsmetriken", "use_a_different_password": "Bitte verwende ein anderes Passwort", "use_your_own_machine": "Verwende deine eigene Maschine mit deinem eigenen Setup", + "user_activity": "BenutzeraktivitƤt", "user_already_added": "Nutzer bereits hinzugefügt", + "user_categories": "Benutzerkategorien", + "user_category_admin": "Administratoren", + "user_category_all": "Alle Benutzer", + "user_category_deleted": "Gelƶschte Benutzer", + "user_category_inactive": "Inaktive Benutzer", + "user_category_ldap": "LDAP-Benutzer", + "user_category_local": "Lokale Benutzer", + "user_category_oidc": "OIDC-Benutzer", + "user_category_saml": "SAML-Benutzer", + "user_category_suspended": "Gesperrte Benutzer", "user_deletion_error": "Entschuldigung, beim Lƶschen deines Kontos ist etwas schief gelaufen. Bitte versuche es in einer Minute erneut.", "user_deletion_password_reset_tip": "Wenn du dich nicht mehr an dein Passwort erinnern kannst oder wenn du Single-Sign-On mit einem anderen Anbieter verwendest, um dich anzumelden (z.B. ORCID oder Google), <0>setze dein Passwort zurück und versuche es erneut.", "user_management": "Nutzerverwaltung", "user_not_found": "Nutzer wurde nicht gefunden", "user_wants_you_to_see_project": "__username__ mƶchte, dass Du __projectname__ beitreten", + "users_list": "Benutzerliste", "validation_issue_entry_description": "Ein Validierungsproblem, das die Kompilierung dieses Projekts verhindert hat", "vat": "MwSt.", "vat_number": "Umsatzsteuernummer", @@ -1349,12 +1437,21 @@ "x_price_per_year": "__price__ pro Jahr", "year": "Jahr", "yes_that_is_correct": "Ja, das ist richtig", + "you": "Du", "you_can_now_log_in_sso": "Du kannst dich jetzt über deine Institution anmelden und mƶglicherweise <0>kostenlose __appName__ ā€žProfessionellā€œ-Funktionen erhalten!", "you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "Du kannst dich jederzeit auf dieser Seite für das Beta-Programm an- und abmelden", + "you_cant_overwrite_it": "Überschreiben ist nicht mƶglich.", "you_have_added_x_of_group_size_y": "Du hast <0>__addedUsersSize__ von <1>__groupSize__ verfügbaren Mitgliedern hinzugefügt", "you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "Du kannst uns jederzeit kontaktieren, um uns dein Feedback mitzuteilen", "your_affiliation_is_confirmed": "Deine Zugehƶrigkeit zu <0>__institutionName__ ist bestƤtigt.", "your_browser_does_not_support_this_feature": "Entschuldigung, dein Browser unterstützt diese Funktion nicht. Bitte aktualisiere deinen Browser auf die neueste Version.", + "your_git_access_info": "Deine Git-Authentifizierungstoken solltest Du eingeben, wenn Du nach einem Passwort gefragt wirst.", + "your_git_access_info_bullet_1": "Du kannst bis zu 10 Tokens haben.", + "your_git_access_info_bullet_2": "Wenn Du das Maximum erreicht hast, musst Du einen Token lƶschen, bevor Du einen neuen erstellen kannst.", + "your_git_access_info_bullet_3": "Du kannst einen Token mit dem <0>Token generieren-Button erstellen.", + "your_git_access_info_bullet_4": "Den vollstƤndigen Token kannst Du nach der ersten Erstellung nicht mehr sehen. Bitte kopiere ihn und bewahre ihn sicher auf.", + "your_git_access_info_bullet_5": "Bereits erstellte Tokens werden hier angezeigt.", + "your_git_access_tokens": "Deine Git-Authentifizierungstoken", "your_new_plan": "Dein neues Abonnement", "your_plan": "Dein Abo", "your_plan_is_changing_at_term_end": "Dein Abonnement Ƥndert sich am Ende des aktuellen Abrechnungszeitraums in <0>__pendingPlanName__.", @@ -1365,8 +1462,11 @@ "zh-CN": "Chinesisch", "zip_contents_too_large": "ZIP-Inhalt zu groß", "zotero": "Zotero", + "zotero_account_linked_successfully": "Zotero-Konto erfolgreich verknüpft.", "zotero_groups_loading_error": "Beim Laden von Gruppen von Zotero ist ein Fehler aufgetreten", "zotero_groups_relink": "Beim Zugriff auf die Zotero-Daten ist ein Fehler aufgetreten. Dies wurde wahrscheinlich durch fehlende Berechtigungen verursacht. Bitte verknüpfe dein Konto neu und versuche es erneut.", + "zotero_imported_by_collaborator": "Diese Datei wurde aus Zotero von deinem Mitautor importiert. Du kannst sie nicht aktualisieren.", + "zotero_imported_by_unknown": "Diese Datei wurde aus Zotero von einem unbekannten Benutzer importiert. Du kannst sie nicht aktualisieren.", "zotero_integration": "Zotero-Integration", "zotero_is_premium": "Zotero-Integration ist eine Premiumfunktion", "zotero_reference_loading_error": "Fehler, Referenzen konnten nicht von Mendeley geladen werden", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index b0837feef00..e22b4e74a3c 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -25,17 +25,28 @@ "a_new_version_of_the_rolling_texlive_build_released": "A new version of the Rolling TeX Live build has been released.", "about": "About", "about_to_archive_projects": "You are about to archive the following projects:", + "about_to_delete_accounts": "You are about to delete the accounts of the following users, including all their projects:", "about_to_delete_cert": "You are about to delete the following certificate:", "about_to_delete_projects": "You are about to delete the following projects:", "about_to_delete_tag": "You are about to delete the following tag (any projects in them will not be deleted):", + "about_to_delete_template": "You are about to delete the following template:", "about_to_delete_the_following_project": "You are about to delete the following project", "about_to_delete_the_following_projects": "You are about to delete the following projects", "about_to_delete_user_preamble": "You’re about to delete __userName__ (__userEmail__). Doing this will mean:", "about_to_enable_managed_users": "By enabling the Managed Users feature, all existing members of your group subscription will be invited to become managed. This will give you admin rights over their account. You will also have the option to invite new members to join the subscription and become managed.", "about_to_leave_project": "You are about to leave this project.", "about_to_leave_projects": "You are about to leave the following projects:", + "about_to_permanently_delete_accounts": "You are about to permanently delete the following accounts:", + "about_to_permanently_delete_projects": "You are about to permanently delete the following projects:", "about_to_remove_user_preamble": "You’re about to remove __userName__ (__userEmail__). Doing this will mean:", + "about_to_resend_activation_email": "You are about to resend the activation email to the following users:", + "about_to_restore_accounts": "You are about to restore the following user accounts:", + "about_to_restore_projects": "You are about to restore the following projects:", + "about_to_resume_accounts": "You are about to resume access for the following users:", + "about_to_set_admin_accounts": "You are about to grant admin privileges to the following users:", + "about_to_suspend_accounts": "You are about to suspend the following accounts:", "about_to_trash_projects": "You are about to trash the following projects:", + "about_to_unset_admin_accounts": "You are about to revoke admin privileges from the following users:", "abstract": "Abstract", "accept": "Accept", "accept_all_cookies": "Accept all cookies", @@ -63,6 +74,7 @@ "account_has_been_link_to_institution_account": "Your __appName__ account on __email__ has been linked to your __institutionName__ institutional account.", "account_has_past_due_invoice_change_plan_warning": "Your account currently has a past due invoice. You will not be able to change your plan until this is resolved.", "account_help": "Account and help", + "account_information": "Account information", "account_linking": "Account linking", "account_managed_by_group_administrator": "Your account is managed by your group administrator (__admin__)", "account_managed_by_group_teamname": "This __appName__ account is managed by <0>__teamName__.", @@ -74,6 +86,7 @@ "activate": "Activate", "activate_account": "Activate your account", "activating": "Activating", + "activation_link": "Activation link", "activation_token_expired": "Your activation token has expired, you will need to get another one sent to you.", "active": "Active", "add": "Add", @@ -169,12 +182,14 @@ "all_templates": "All templates", "all_the_pros_of_our_standard_plan_plus_unlimited_collab": "All the pros of our standard plan, plus unlimited collaborators per project.", "all_these_experiments_are_available_exclusively": "All these experiments are available exclusively to members of the Labs program. If you sign up, you can choose which experiments you want to try.", + "all_users": "All users", "allocate_license": "Allocate license", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Allows to search by author, title, etc. Possible to pull results directly from your reference manager (if connected).", "already_have_an_account": "Already have an account?", "already_have_sl_account": "Already have an __appName__ account?", "also": "Also", "alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.", + "alternatively_create_local_admin_account": "Alternatively, you can create __appName__ local admin account.", "an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__. Please wait and try again later.", "an_error_occured_while_restoring_project": "An error occured while restoring the project", "an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code", @@ -241,6 +256,7 @@ "back_to_log_in": "Back to log in", "back_to_my_projects": "Back to my projects", "back_to_subscription": "Back to subscription", + "back_to_user_list": "Back to user list", "back_to_your_projects": "Back to your projects", "basic": "Basic", "basic_compile_time": "Basic compile time", @@ -314,6 +330,7 @@ "card_payment": "Card payment", "careers": "Careers", "categories": "Categories", + "category": "Category", "category_arrows": "Arrows", "category_greek": "Greek", "category_misc": "Misc", @@ -531,6 +548,7 @@ "delete_account": "Delete account", "delete_account_confirmation_label": "I understand this will delete all projects in my __appName__ account with email address <0>__userDefaultEmail__", "delete_account_warning_message_3": "You are about to permanently delete all of your account data, including your projects and settings. Please type your account email address and password in the boxes below to proceed.", + "delete_accounts": "Delete accounts", "delete_acct_no_existing_pw": "Please use the password reset form to set a password before deleting your account", "delete_and_leave": "Delete / Leave", "delete_and_leave_projects": "Delete and Leave Projects", @@ -552,6 +570,7 @@ "delete_sso_config": "Delete SSO configuration", "delete_table": "Delete table", "delete_tag": "Delete Tag", + "delete_template": "Delete template", "delete_token": "Delete token", "delete_user": "Delete user", "delete_your_account": "Delete your account", @@ -560,6 +579,7 @@ "deleted_by_id": "Deleted By ID", "deleted_by_ip": "Deleted By IP", "deleted_by_on": "Deleted by __name__ on __date__", + "deleted_projects": "Deleted projects", "deleted_user": "Deleted user", "deleting": "Deleting", "demonstrating_git_integration": "Demonstrating Git integration", @@ -676,6 +696,7 @@ "edit_sso_configuration": "Edit SSO Configuration", "edit_tag": "Edit tag", "edit_tag_name": "Edit tag name", + "edit_template": "Edit template", "edit_your_custom_dictionary": "Edit your custom dictionary", "editing": "Editing", "editing_and_collaboration": "Editing and collaboration", @@ -778,6 +799,7 @@ "export_csv": "Export CSV", "export_project_to_github": "Export Project to GitHub", "failed_to_consent_to_workbench_terms": "Failed to consent to workbench terms. Please try again later.", + "failed_to_publish_as_a_template": "Failed to publish as a template.", "failed_to_send_group_invite_to_email": "Failed to send Group invite to <0>__email__. Please try again later.", "failed_to_send_managed_user_invite_to_email": "Failed to send Managed User invite to <0>__email__. Please try again later.", "failed_to_send_sso_link_invite_to_email": "Failed to send SSO invite reminder to <0>__email__. Please try again later.", @@ -818,6 +840,7 @@ "files_cannot_include_invalid_characters": "File name is empty or contains invalid characters", "files_selected": "files selected.", "filter_projects": "Filter projects", + "filter_users": "Filter users", "filters": "Filters", "find": "Find", "find_and_check_citations": "Find and check citations", @@ -1117,6 +1140,7 @@ "in_order_to_have_a_secure_account_make_sure_your_password": "To help keep your account secure, make sure your new password:", "in_order_to_match_institutional_metadata_2": "In order to match your institutional metadata, we’ve linked your account using <0>__email__.", "in_order_to_match_institutional_metadata_associated": "In order to match your institutional metadata, your account is associated with the email __email__.", + "inactive_projects": "Inactive projects", "include_caption": "Include caption", "include_label": "Include label", "include_results_from_your_reference_manager": "Include results from your reference manager", @@ -1335,6 +1359,7 @@ "loading_github_repositories": "Loading your GitHub repositories", "loading_prices": "loading prices", "loading_recent_github_commits": "Loading recent commits", + "local_account": "Local account", "log_entry_description": "Log entry with level: __level__", "log_entry_maximum_entries": "Maximum log entries limit hit", "log_entry_maximum_entries_enable_stop_on_first_error": "Try to fix the first error and recompile. Often one error causes many later error messages. You can <0>Enable ā€œStop on first errorā€ to focus on fixing errors. We recommend fixing errors as soon as possible; letting them accumulate may lead to hard-to-debug and fatal errors. <1>Learn more", @@ -1499,11 +1524,13 @@ "need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.", "need_to_leave": "Need to leave?", "neither_agree_nor_disagree": "Neither agree nor disagree", + "never": "Never", "new_compile_domain_notice": "Something might be blocking your browser from accessing Overleaf’s PDF download location, <0>__compilesUserContentDomain__. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide.", "new_compiles_in_this_project_will_automatically_use_the_newest_version": "New compiles in this project will automatically use the newest version. <0>Learn how to change compiler settings", "new_file": "New file", "new_folder": "New folder", "new_name": "New name", + "new_owner": "New owner", "new_password": "New password", "new_project": "New project", "new_snippet_project": "Untitled", @@ -1554,8 +1581,10 @@ "no_search_results": "No Search Results", "no_selection_select_file": "Currently, no file is selected. Please select a file from the file tree.", "no_symbols_found": "No symbols found", + "no_templates_found": "No templates found.", "no_thanks_cancel_now": "No thanks, I still want to cancel", "no_update_email": "No, update email", + "no_users": "No users", "non_deletable_entity": "The specified entity may not be deleted", "normal": "Normal", "normally_x_price_per_month": "Normally __price__ per month", @@ -1574,6 +1603,7 @@ "notification_personal_subscription_not_required_due_to_affiliation": " Good news! Your affiliated organization __institutionName__ has an Overleaf subscription, and you now have access to Overleaf’s Professional features through your affiliation. You can cancel your individual subscription without losing access to any features.", "notification_project_invite_accepted_message": "You’ve joined __projectName__", "notification_project_invite_message": "__userName__ would like you to join __projectName__", + "notify_users_about_account_deletion": "Notify users about account deletion", "november": "November", "number_collab": "Number of collaborators", "number_collab_info": "The number of people you can invite to work on a project with you. The limit is per project, so you can invite different people to each project.", @@ -1647,13 +1677,15 @@ "overleaf_labs": "Overleaf Labs", "overleaf_logo": "Overleaf logo", "overleaf_plans_and_pricing": "overleaf plans and pricing", - "overleaf_template_gallery": "overleaf template gallery", + "overleaf_template_gallery": "overleaf ce template gallery", "overleafs_functionality_meets_my_needs": "Overleaf’s functionality meets my needs.", "overview": "Overview", "overwrite": "Overwrite", "overwriting_the_original_folder": "Overwriting the original folder will delete it and all the files it contains.", "owned_by_x": "owned by __x__", + "owned_projects": "User's projects", "owner": "Owner", + "ownership_of_projects_will_be_transferred": "Ownership of the following projects will be transferred to another user:", "page_current": "Page __page__, Current Page", "page_not_found": "Page Not Found", "page_x_of_n": "Page __x__ of __n__", @@ -1724,9 +1756,12 @@ "per_month": "per month", "per_month_billed_annually": "per month, billedĀ annually", "per_month_x_annually": "per month, __price__ annually", + "per_page": "per page", "per_user_year": "per user / year", "per_year": "per year", "percent_is_the_percentage_of_the_line_width": "% is the percentage of the line width", + "permanently_delete_accounts": "Permanently delete accounts", + "permanently_delete_projects": "Permanently delete projects", "permanently_disables_the_preview": "Permanently disables the preview", "personal": "Personal", "personal_library": "Personal library", @@ -1742,6 +1777,7 @@ "please_ask_the_project_owner_to_upgrade_to_track_changes": "Please ask the project owner to upgrade to use track changes", "please_change_primary_to_remove": "Please change your primary email in order to remove", "please_compile_pdf_before_download": "Please compile your project before downloading the PDF", + "please_compile_pdf_before_publish_as_template": "Please compile your project before publishing it as a template", "please_confirm_email": "Please confirm your email __emailAddress__ by clicking on the link in the confirmation email ", "please_confirm_primary_email_or_edit": "Please confirm your primary email address __emailAddress__. To edit it, go to <0>Account settings.", "please_confirm_secondary_email_or_edit": "Please confirm your secondary email address __emailAddress__. To edit it, go to <0>Account settings.", @@ -1785,6 +1821,7 @@ "premium_plan_label": "You’re using Overleaf Premium", "presentation": "Presentation", "presentation_mode": "Presentation mode", + "prev": "Prev", "previous_page": "Previous page", "price": "Price", "pricing": "Pricing", @@ -1856,10 +1893,11 @@ "pt": "Portuguese", "public": "Public", "publish": "Publish", - "publish_as_template": "Manage Template", + "publish_as_template": "Publish as a Template", "publisher_account": "Publisher Account", "publishing": "Publishing", "pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__", + "purge": "Purge", "push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub", "push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__", "quoted_text": "Quoted text", @@ -1979,6 +2017,7 @@ "requesting_password_reset": "Requesting password reset", "required": "Required", "resend": "Resend", + "resend_activation_email": "Resend activation email", "resend_confirmation_code": "Resend confirmation code", "resend_group_invite": "Resend group invite", "resend_link_sso": "Resend SSO invite", @@ -1996,6 +2035,8 @@ "resolved_comments": "Resolved comments", "resources": "Resources", "restore": "Restore", + "restore_accounts": "Restore accounts", + "restore_projects": "Restore projects", "restore_file": "Restore file", "restore_file_confirmation_message": "Your current file will restore to the version from __date__ at __time__.", "restore_file_confirmation_title": "Restore this version?", @@ -2007,6 +2048,8 @@ "restoring": "Restoring", "restricted": "Restricted", "restricted_no_permission": "Restricted, sorry you don’t have permission to load this page.", + "resume": "Resume", + "resume_account": "Resume account", "resync_completed": "Resync completed!", "resync_message": "Resyncing project history can take several minutes depending on the size of the project.", "resync_project_history": "Resync Project History", @@ -2067,6 +2110,7 @@ "search_command_replace": "Replace", "search_in_all_projects": "Search in all projects", "search_in_archived_projects": "Search in archived projects", + "search_in_deleted_projects": "Search in deleted projects", "search_in_shared_projects": "Search in projects shared with you", "search_in_trashed_projects": "Search in trashed projects", "search_in_your_projects": "Search in your projects", @@ -2105,6 +2149,7 @@ "select_access_levels": "Select access levels", "select_all": "Select all", "select_all_projects": "Select all projects", + "select_all_users": "Select all users", "select_an_output_file": "Select an Output File", "select_an_output_file_figure_modal": "Select an output file", "select_bib_file": "Select .bib file", @@ -2124,10 +2169,12 @@ "select_tax_id_type": "Select tax ID type", "select_user": "Select user", "select_user_role": "Select user role", + "select_users": "Select users", "selected": "Selected", "selected_by_overleaf_staff": "Selected by Overleaf staff", "selection_deleted": "Selection deleted", "send": "Send", + "send_notification_emails_to_users": "Send notification emails to the current and the new owners", "send_confirmation_code": "Send confirmation code", "send_message": "Send message", "send_request": "Send request", @@ -2146,6 +2193,8 @@ "session_expired_redirecting_to_login": "Session Expired. Redirecting to login page in __seconds__ seconds", "sessions": "Sessions", "set_as_main_document": "Set as main document", + "set_admin": "Set admin", + "set_admin_account": "Grant admin privileges", "set_color": "set color", "set_column_width": "Set column width", "set_new_password": "Set new password", @@ -2162,6 +2211,7 @@ "sharing_permissions": "Sharing permissions", "shortcut_to_open_advanced_reference_search": "(__ctrlSpace__ or __altSpace__)", "show_all_projects": "Show all projects", + "show_all_users": "Show all users", "show_breadcrumbs": "Show breadcrumbs", "show_document_preamble": "Show document preamble", "show_equation_preview": "Show equation preview", @@ -2175,6 +2225,7 @@ "show_outline": "Show File outline", "show_version_history": "Show version history", "show_x_more_projects": "Show __x__ more projects", + "show_x_more_users": "Show __x__ more users", "showing_1_result": "Showing 1 result", "showing_1_result_of_total": "Showing 1 result of __total__", "showing_pdf_preview_with_inverted_colors": "Showing PDF preview with inverted colors", @@ -2186,6 +2237,7 @@ "sign_up": "Sign up", "sign_up_for_free": "Sign up for free", "sign_up_for_free_account": "Sign up for a free account and receive regular updates", + "signed_up": "Signed up", "simple_search_mode": "Simple search", "single_sign_on_sso": "Single Sign-On (SSO)", "site_description": "An online LaTeX editor that’s easy to use. No installation, real-time collaboration, version control, hundreds of LaTeX templates, and more.", @@ -2347,6 +2399,8 @@ "sure_you_want_to_change_plan": "Are you sure you want to change plan to <0>__planName__?", "sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?", "sure_you_want_to_leave_group": "Are you sure you want to leave this group?", + "suspend": "Suspend", + "suspend_account": "Suspend account", "sv": "Swedish", "switch_compile_mode_for_faster_draft_compilation": "Switch compile mode for faster draft compilation", "switch_to_editor": "Switch to editor", @@ -2387,6 +2441,7 @@ "template_not_found_description": "This way of creating projects from templates has been removed. Please visit our template gallery to find more templates.", "template_title_taken_from_project_title": "The template title will be taken automatically from the project title", "template_top_pick_by_overleaf": "This template was hand-picked by Overleaf staff for its high quality", + "template_with_this_title_exists_and_owned_by_x": "A template with this title already exists and is owned by __x__.", "templates": "Templates", "templates_admin_source_project": "Admin: Source Project", "templates_admin_v1_link": "Admin: V1 Published Version", @@ -2459,6 +2514,7 @@ "they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "They will retain ownership of projects currently owned by them and any collaborators on those projects will become read-only.", "they_will_retain_their_existing_account_on_the_free_plan": "They will retain their existing account on the __appName__ free plan.", "they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password": "They won’t be able to log in with SSO (if you have this enabled). They will need to set an __appName__ password.", + "this_action_can_be_undone_within_limited_period": "This action can be undone only within a limited period.", "this_action_cannot_be_reversed": "This action cannot be reversed.", "this_action_cannot_be_undone": "This action cannot be undone.", "this_action_will_also_disable_domain_capture": "This action will also disable domain capture.", @@ -2585,6 +2641,7 @@ "track_changes": "Track changes", "tracked_change_added": "Added", "tracked_change_deleted": "Deleted", + "transfer_all_projects_to": "Transfer all projects of the users to:", "transfer_management_of_your_account": "Transfer management of your Overleaf account", "transfer_management_of_your_account_to_x": "Transfer management of your Overleaf account to __groupName__", "transfer_management_resolve_following_issues": "To transfer the management of your account, you need to resolve the following issues:", @@ -2596,6 +2653,7 @@ "trashed": "Trashed", "trashed_projects": "Trashed projects", "trashing_projects_wont_affect_collaborators": "Trashing projects won’t affect your collaborators.", + "trashing_projects_wont_affect_user_collaborators": "Trashing projects won’t affect user collaborators.", "trial_last_day": "This is the last day of your Overleaf Premium trial", "trial_remaining_days": "__days__ more days on your Overleaf Premium trial", "tried_to_log_in_with_email": "You’ve tried to log in with __email__.", @@ -2606,6 +2664,7 @@ "try_now": "Try Now", "try_overleaf_ai": "Try Overleaf AI", "try_premium_for_free": "Try Premium for free", + "try_recompile_project": "Please try recompiling the project from scratch.", "try_recompile_project_or_troubleshoot": "Please try recompiling the project from scratch, and if that doesn’t help, follow our <0>troubleshooting guide.", "try_relinking_provider": "It looks like you need to re-link your __provider__ account.", "try_to_compile_despite_errors": "Try to compile despite errors", @@ -2663,6 +2722,8 @@ "unpausing": "Unpausing", "unpublish": "Unpublish", "unpublishing": "Unpublishing", + "unset_admin": "Unset admin", + "unset_admin_account": "Revoke admin privileges", "unsubscribe": "Unsubscribe", "unsubscribed": "Unsubscribed", "unsubscribing": "Unsubscribing", @@ -2711,8 +2772,19 @@ "used_latex_response_occasionally": "I’ve used it occasionally", "used_latex_response_often": "I use it often", "used_when_referring_to_the_figure_elsewhere_in_the_document": "Used when referring to the figure elsewhere in the document", + "user_activity": "User activity", "user_administration": "User administration", "user_already_added": "User already added", + "user_categories": "User categories", + "user_category_admin": "Administrators", + "user_category_all": "All users", + "user_category_deleted": "Deleted users", + "user_category_inactive": "Inactive users", + "user_category_ldap": "LDAP users", + "user_category_local": "Local users", + "user_category_oidc": "OIDC users", + "user_category_saml": "SAML users", + "user_category_suspended": "Suspended users", "user_deletion_error": "Sorry, something went wrong deleting your account. Please try again in a minute.", "user_deletion_password_reset_tip": "If you cannot remember your password, or if you are using Single-Sign-On with another provider to sign in (such as ORCID or Google), please <0>reset your password and try again.", "user_email_attribute": "User email attribute", @@ -2725,6 +2797,7 @@ "user_not_found": "User not found", "user_sessions": "User Sessions", "user_wants_you_to_see_project": "__username__ would like you to join __projectname__", + "users_list": "Users list", "using_latex": "Using LaTeX", "using_premium_features": "Using premium features", "using_the_overleaf_editor": "Using the __appName__ Editor", @@ -2873,8 +2946,11 @@ "you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.", "you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.", "you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.", + "you_cant_add_or_change_password_due_to_ldap_or_sso": "You can’t add or change your password because your group or organization uses LDAP or SSO.", "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO).", "you_cant_join_this_group_subscription": "You can’t join this group subscription", + "you_cant_overwrite_it": "You can’t overwrite it.", + "you_cant_reset_password_due_to_ldap_or_sso": "You can’t reset your password because your group or organization uses LDAP or SSO. Contact your system administrator.", "you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO.", "you_currently_have_x_linked_with_your_overleaf_account": "You currently have <0>__managers__ linked with your __appName__ account.", "you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.", @@ -2970,9 +3046,12 @@ "zoom_to": "Zoom to", "zotero": "Zotero", "zotero_and_mendeley_integrations": "<0>Zotero and <0>Mendeley integrations", + "zotero_account_linked_successfully": "Zotero account linked successfully", "zotero_dynamic_sync_description": "With the Zotero integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Zotero library directly from __appName__.", "zotero_groups_loading_error": "There was an error loading groups from Zotero", "zotero_groups_relink": "There was an error accessing your Zotero data. This was likely caused by lack of permissions. Please re-link your account and try again.", + "zotero_imported_by_collaborator": "This file was imported from Zotero by your collaborator. You cannot refresh it.", + "zotero_imported_by_unknown": "This file was imported from Zotero by an unknown user. You cannot refresh it.", "zotero_integration": "Zotero integration", "zotero_is_premium": "Zotero integration is a premium feature", "zotero_reference_loading_error": "Error, could not load references from Zotero", @@ -2980,5 +3059,31 @@ "zotero_reference_loading_error_forbidden": "Could not load references from Zotero, please re-link your account and try again", "zotero_sync_description": "With the Zotero integration you can import your references from Zotero into your __appName__ projects.", "zotero_upgrade_prompt_content": "Link your Zotero account to search and add your references from Zotero directly in your project—they’ll automatically be added to your .bib file. Or import them as a file into your __appName__ projects.", - "zotero_upgrade_prompt_title": "Cite from Zotero" -} + "zotero_upgrade_prompt_title": "Cite from Zotero", + "cloud_storage": "Cloud Storage", + "cloud_storage_optional": "Cloud Storage (Optional)", + "cloud_storage_settings": "Cloud Storage Settings", + "setup_cloud_storage": "Setup Cloud Storage", + "webdav_url": "WebDAV URL", + "webdav_url_hint": "For Nextcloud: https://your-server.com/remote.php/dav/files/username/", + "webdav_url_required": "WebDAV URL is required", + "webdav_username": "Username", + "webdav_password": "Password", + "webdav_base_path": "Base Path", + "webdav_base_path_hint": "The folder path on WebDAV where files will be synced (e.g., /overleaf)", + "webdav_connection_failed": "Connection failed", + "webdav_connection_success": "Connection successful", + "webdav_connection_test": "Test Connection", + "webdav_directory_not_empty": "The target WebDAV directory must be empty. Please clear it or choose a new directory.", + "link_to_webdav": "Link to WebDAV Cloud Storage", + "unlink_cloud_storage": "Disconnect Cloud Storage", + "unlink_cloud_storage_description": "Are you sure you want to disconnect from cloud storage? Choose whether to keep or delete the synced files on the remote server.", + "keep_cloud_storage_content": "Keep cloud storage content (only disconnect link)", + "delete_remote_content_warning": "Warning: This will permanently delete all synced files from the cloud storage. This action cannot be undone.", + "unlink_and_delete": "Disconnect and Delete", + "sync_now": "Sync Now", + "webdav_sync_completed": "Sync completed successfully", + "leave_blank_to_keep_current": "Leave blank to keep current password", + "use_username": "Use username", + "use_password": "Use password" +} \ No newline at end of file diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index 22ab2fd7cc0..dab2dfd962d 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -20,12 +20,24 @@ "a_more_comprehensive_list_of_keyboard_shortcuts": "Более полный лист Š³Š¾Ń€ŃŃ‡ŠøŃ… клавиш Š½Š°Ń…Š¾Š“ŠøŃ‚ŃŃ в <0>ŃŃ‚Š¾Š¼ шаблоне проекта __appName__", "about": "Šž", "about_to_archive_projects": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ Š°Ń€Ń…ŠøŠ²ŠøŃ€Š¾Š²Š°Ń‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ проекты:", + "about_to_delete_accounts": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚Ń‹ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŃ… ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹, Š²ŠŗŠ»ŃŽŃ‡Š°Ń все ŠøŃ… проекты:", "about_to_delete_cert": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¹ сертификат:", "about_to_delete_projects": "Š”Š»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ проекты Š±ŃƒŠ“ŃƒŃ‚ ŃƒŠ“Š°Š»ŠµŠ½Ń‹:", + "about_to_delete_template": "Š”Š»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¹ шаблон Š±ŃƒŠ“ет уГален:", "about_to_delete_the_following_project": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¹ проект:", "about_to_delete_the_following_projects": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ проекты:", "about_to_leave_project": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ ŠæŠ¾ŠŗŠøŠ½ŃƒŃ‚ŃŒ ŃŃ‚Š¾Ń‚ проект:", "about_to_leave_projects": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ ŠæŠ¾ŠŗŠøŠ½ŃƒŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ проекты:", + "about_to_permanently_delete_accounts": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ Š¾ŠŗŠ¾Š½Ń‡Š°Ń‚ŠµŠ»ŃŒŠ½Š¾ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚Ń‹:", + "about_to_permanently_delete_projects": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ Š¾ŠŗŠ¾Š½Ń‡Š°Ń‚ŠµŠ»ŃŒŠ½Š¾ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ проекты:", + "about_to_resend_activation_email": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ повторно Š¾Ń‚ŠæŃ€Š°Š²ŠøŃ‚ŃŒ письмо активации ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¼ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŃŠ¼:", + "about_to_restore_accounts": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ Š²Š¾ŃŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ ŃƒŃ‡ŠµŃ‚Š½Ń‹Šµ записи:", + "about_to_restore_projects": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ Š²Š¾ŃŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ проекты:", + "about_to_resume_accounts": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ Š²Š¾Š·Š¾Š±Š½Š¾Š²ŠøŃ‚ŃŒ Š“Š¾ŃŃ‚ŃƒŠæ Šŗ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¼ ŃƒŃ‡ŠµŃ‚Š½Ń‹Š¼ Š·Š°ŠæŠøŃŃŠ¼:", + "about_to_set_admin_accounts": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ ŠæŃ€ŠµŠ“Š¾ŃŃ‚Š°Š²ŠøŃ‚ŃŒ аГминистративные права ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¼ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŃŠ¼:", + "about_to_suspend_accounts": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ Š·Š°Š±Š»Š¾ŠŗŠøŃ€Š¾Š²Š°Ń‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ ŃƒŃ‡ŠµŃ‚Š½Ń‹Šµ записи:", + "about_to_trash_projects": "Š”Š»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ проекты Š±ŃƒŠ“ŃƒŃ‚ перемещены в ŠŗŠ¾Ń€Š·ŠøŠ½Ńƒ:", + "about_to_unset_admin_accounts": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ Š¾Ń‚Š¾Š·Š²Š°Ń‚ŃŒ аГминистративные права у ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŃ… ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹:", "abstract": "ŠŠ½Š½Š¾Ń‚Š°Ń†ŠøŃ", "accept": "ŠŸŃ€ŠøŠ½ŃŃ‚ŃŒ", "accept_and_continue": "ŠŸŃ€ŠøŠ½ŃŃ‚ŃŒ Šø ŠæŃ€Š¾Š“Š¾Š»Š¶ŠøŃ‚ŃŒ", @@ -33,14 +45,17 @@ "accept_invitation": "ŠŸŃ€ŠøŠ½ŃŃ‚ŃŒ ŠæŃ€ŠøŠ³Š»Š°ŃˆŠµŠ½ŠøŠµ", "accepting_invite_as": "Š’Ń‹ принимаете ŠæŃ€ŠøŠ³Š»Š°ŃˆŠµŠ½ŠøŠµ как", "account": "ŠŠŗŠŗŠ°ŃƒŠ½Ń‚", + "account_information": "Š˜Š½Ń„Š¾Ń€Š¼Š°Ń†ŠøŃ об Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚е", "account_not_linked_to_dropbox": "Š’Š°Ńˆ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚ не синхронизирован с Dropbox", "account_settings": "ŠŠ°ŃŃ‚Ń€Š¾Š¹ŠŗŠø ŠæŃ€Š¾Ń„ŠøŠ»Ń", "actions": "Š”ŠµŠ¹ŃŃ‚Š²ŠøŃ", "activate": "ŠŠŗŃ‚ŠøŠ²ŠøŃ€Š¾Š²Š°Ń‚ŃŒ", "activate_account": "ŠŠŗŃ‚ŠøŠ²ŠøŃ€ŃƒŠ¹Ń‚Šµ Š’Š°Ńˆ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚", "activating": "ŠŠŗŃ‚ŠøŠ²Š°Ń†ŠøŃ", + "activation_link": "Дсылка Š“Š»Ń активации", "activation_token_expired": "Дрок Š“ŠµŠ¹ŃŃ‚Š²ŠøŃ Š’Š°ŃˆŠµŠ³Š¾ ŠŗŠ»ŃŽŃ‡Š° истёк. Вам необхоГимо Š·Š°ŠæŃ€Š¾ŃŠøŃ‚ŃŒ новый ŠŗŠ»ŃŽŃ‡ активации.", "add": "Š”Š¾Š±Š°Š²ŠøŃ‚ŃŒ", + "add_another_token": "Š”Š¾Š±Š°Š²ŠøŃ‚ŃŒ ещё оГин токен", "add_your_first_group_member_now": "Š”Š¾Š±Š°Š²ŃŒŃ‚Šµ первых ŃƒŃ‡Š°ŃŃ‚Š½ŠøŠŗŠ¾Š² Š³Ń€ŃƒŠæŠæŃ‹ сейчас", "added": "Гобавлены", "adding": "Добавление", @@ -48,6 +63,7 @@ "admin": "аГминистратор", "all_projects": "Все проекты", "all_templates": "Все ŃˆŠ°Š±Š»Š¾Š½Ń‹", + "all_users": "Все ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Šø", "already_have_sl_account": "Уже ŠµŃŃ‚ŃŒ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚ __appName__?", "and": "Šø", "annual": "Цена за гоГ", @@ -57,6 +73,7 @@ "auto_complete": "АвтоГополнение", "autocomplete": "ŠŠ²Ń‚Š¾Š·Š°Š²ŠµŃ€ŃˆŠµŠ½ŠøŠµ", "autocomplete_references": "АвтоГополнение ссылок (Š²Š½ŃƒŃ‚Ń€Šø блока \\cite{})", + "back_to_user_list": "ŠŠ°Š·Š°Š“ Šŗ списку ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", "back_to_your_projects": "ŠŠ°Š·Š°Š“ Šŗ списку проектов", "basic": "Базовый", "basic_compile_timeout_on_fast_servers": "Базовый Ń‚Š°Š¹Š¼Š°ŃƒŃ‚ ŠŗŠ¾Š¼ŠæŠøŠ»ŃŃ†ŠøŠø на быстрых серверах", @@ -71,6 +88,7 @@ "cancel_your_subscription": "ŠžŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ поГписку", "cant_find_email": "Š˜Š·Š²ŠøŠ½ŠøŃ‚Šµ, Ганный аГрес не зарегистрирован.", "cant_find_page": "К ŃŠ¾Š¶Š°Š»ŠµŠ½ŠøŃŽ, страница не найГена", + "category": "ŠšŠ°Ń‚ŠµŠ³Š¾Ń€ŠøŃ", "change": "Š˜Š·Š¼ŠµŠ½ŠøŃ‚ŃŒ", "change_password": "Изменение ŠæŠ°Ń€Š¾Š»Ń", "change_plan": "Š”Š¼ŠµŠ½ŠøŃ‚ŃŒ тарифный план", @@ -86,6 +104,7 @@ "clear_sessions_success": "Дессии Š·Š°Š²ŠµŃ€ŃˆŠµŠ½Ń‹", "clearing": "ŠžŃ‡ŠøŃŃ‚ŠŗŠ°", "click_here_to_view_sl_in_lng": "ŠšŠ»ŠøŠŗŠ½ŠøŃ‚Šµ зГесь, Š“Š»Ń ŠøŃŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Š½ŠøŃ __appName__ на <0>__lngName__", + "clone_with_git": "ŠšŠ»Š¾Š½ŠøŃ€Š¾Š²Š°Ń‚ŃŒ при помощи git", "close": "Š—Š°ŠŗŃ€Ń‹Ń‚ŃŒ", "clsi_maintenance": "ŠŠ° сервере ŠŗŠ¾Š¼ŠæŠøŠ»ŃŃ†ŠøŠø ŠæŃ€Š¾Š²Š¾Š“ŃŃ‚ŃŃ ремонтные работы, Šø он Š±ŃƒŠ“ет вскоре Š“Š¾ŃŃ‚ŃƒŠæŠµŠ½ снова.", "cn": "ŠšŠøŃ‚Š°Š¹ŃŠŗŠøŠ¹ (ŃƒŠæŃ€Š¾Ń‰Ń‘Š½Š½Ń‹Š¹)", @@ -114,8 +133,10 @@ "country": "Дтрана", "coupon_code": "коГ купона", "create": "Š”Š¾Š·Š“Š°Ń‚ŃŒ", + "create_account": "Š”Š¾Š·Š“Š°Ń‚ŃŒ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚", "create_new_subscription": "Š”Š¾Š·Š“Š°Ń‚ŃŒ Š½Š¾Š²ŃƒŃŽ поГписку", "create_project_in_github": "Š”Š¾Š·Š“Š°Ń‚ŃŒ проект на GitHub", + "created_at": "ДозГано", "creating": "ДозГание", "credit_card": "Š±Š°Š½ŠŗŠ¾Š²ŃŠŗŠ°Ń карта", "cs": "Чешский", @@ -127,11 +148,16 @@ "delete": "Š£Š“Š°Š»ŠøŃ‚ŃŒ", "delete_account": "Š£Š“Š°Š»ŠøŃ‚ŃŒ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚", "delete_account_warning_message_3": "Š’Ń‹ ŃŠ¾Š±ŠøŃ€Š°ŠµŃ‚ŠµŃŃŒ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ все Ганные Š’Š°ŃˆŠµŠ³Š¾ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚Š°, Š²ŠŗŠ»ŃŽŃ‡Š°Ń все Š’Š°ŃˆŠø проекты Šø настройки. ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ввеГите аГрес ŃŠ»ŠµŠŗŃ‚Ń€Š¾Š½Š½Š¾Š¹ почты Šø ŠæŠ°Ń€Š¾Š»ŃŒ Š’Š°ŃˆŠµŠ³Š¾ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚Š° в Ń„Š¾Ń€Š¼Ńƒ внизу Š“Š»Ń ŠæŃ€Š¾Š“Š¾Š»Š¶ŠµŠ½ŠøŃ.", + "delete_accounts": "Š£Š“Š°Š»ŠøŃ‚ŃŒ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚Ń‹", "delete_and_leave_projects": "Š£Š“Š°Š»ŠøŃ‚ŃŒ или Š¾ŃŃ‚Š°Š²ŠøŃ‚ŃŒ проекты", "delete_projects": "Š£Š“Š°Š»ŠøŃ‚ŃŒ проекты", + "delete_template": "Š£Š“Š°Š»ŠøŃ‚ŃŒ шаблон", "delete_your_account": "Š£Š“Š°Š»ŠøŃ‚ŃŒ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚", + "deleted_at": "УГалено", + "deleted_projects": "УГаленные проекты", "deleting": "УГаление", "disconnected": "РазъеГинен", + "do_you_want_to_overwrite_it": "ŠŸŠµŃ€ŠµŠ·Š°ŠæŠøŃŠ°Ń‚ŃŒ?", "documentation": "Š”Š¾ŠŗŃƒŠ¼ŠµŠ½Ń‚Š°Ń†ŠøŃ", "doesnt_match": "ŠŠµ совпаГает", "done": "Готово", @@ -140,8 +166,10 @@ "download_zip_file": "Š”ŠŗŠ°Ń‡Š°Ń‚ŃŒ архив (.zip)", "dropbox_sync": "Š”ŠøŠ½Ń…Ń€Š¾Š½ŠøŠ·Š°Ń†ŠøŃ с Dropbox", "dropbox_sync_description": "Š”ŠøŠ½Ń…Ń€Š¾Š½ŠøŠ·ŠøŃ€ŃƒŠ¹Ń‚Šµ Š’Š°ŃˆŠø __appName__ проекты с Š’Š°ŃˆŠøŠ¼ Dropbox. Š˜Š·Š¼ŠµŠ½ŠµŠ½ŠøŃ в __appName__ автоматически ŃŠ¾Ń…Ń€Š°Š½ŃŃŽŃ‚ŃŃ в Š’Š°ŃˆŠµŠ¼ Dropbox, Šø наоборот.", + "edit_template": "Š ŠµŠ“Š°ŠŗŃ‚ŠøŃ€Š¾Š²Š°Ń‚ŃŒ шаблон", "editing": "РеГактор", - "email": "АГрес ŃŠ»ŠµŠŗŃ‚Ń€Š¾Š½Š½Š¾Š¹ почты", + "email": "E-mail", + "email_address": "АГрес ŃŠ»ŠµŠŗŃ‚Ń€Š¾Š½Š½Š¾Š¹ почты", "email_already_registered": "Этот аГрес уже зарегистрирован.", "email_link_expired": "Дрок Š“ŠµŠ¹ŃŃ‚Š²ŠøŃ ссылки истёк. ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, повторите запрос!", "email_or_password_wrong_try_again": "ŠŠµŠ²ŠµŃ€Š½Ń‹Š¹ аГрес ŃŠ»ŠµŠŗŃ‚Ń€Š¾Š½Š½Š¾Š¹ почты или ŠæŠ°Ń€Š¾Š»ŃŒ. ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŠæŠ¾ŠæŃ€Š¾Š±ŃƒŠ¹Ń‚Šµ снова", @@ -149,12 +177,16 @@ "es": "Испанский", "every": "кажГый", "example_project": "Š˜ŃŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŃŒ пример", + "expires": "Š˜ŃŃ‚ŠµŠŗŠ°ŠµŃ‚", "expiry": "Дрок Š“ŠµŠ¹ŃŃ‚Š²ŠøŃ", "export_project_to_github": "Экспорт проекта на GitHub", + "failed_to_publish_as_a_template": "ŠŠµ уГалось ŃŠ¾Š·Š“Š°Ń‚ŃŒ шаблон.", "fast": "быстрый", "features": "Возможности", "february": "Š¤ŠµŠ²Ń€Š°Š»ŃŒ", "files_cannot_include_invalid_characters": "Файлы не Š¼Š¾Š³ŃƒŃ‚ ŃŠ¾Š“ŠµŃ€Š¶Š°Ń‚ŃŒ символы ’*’ Šø ’/’", + "filter_projects": "Š¤ŠøŠ»ŃŒŃ‚Ń€Š¾Š²Š°Ń‚ŃŒ проекты", + "filter_users": "Š¤ŠøŠ»ŃŒŃ‚Ń€Š¾Š²Š°Ń‚ŃŒ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", "first_name": "Š˜Š¼Ń", "folders": "Папки", "font_size": "Размер ŃˆŃ€ŠøŃ„Ń‚Š°", @@ -165,8 +197,14 @@ "free_dropbox_and_history": "Бесплатные Dropbox Šø Š˜ŃŃ‚Š¾Ń€ŠøŃ", "full_doc_history": "ŠŸŠ¾Š»Š½Š°Ń ŠøŃŃ‚Š¾Ń€ŠøŃ изменений", "general": "ŠžŠ±Ń‰ŠøŠµ", + "generate_token": "Š”Š¾Š·Š“Š°Ń‚ŃŒ токен", "generic_something_went_wrong": "Š˜Š·Š²ŠøŠ½ŠøŃ‚Šµ, что-то пошло не так", "get_in_touch": "Š”Š²ŃŠ·Š°Ń‚ŃŒŃŃ с нами", + "git": "Git", + "git_bridge_modal_git_clone_your_project": "ŠšŠ»Š¾Š½ŠøŃ€ŃƒŠ¹Ń‚Šµ Š’Š°Ńˆ проект при помощи ссылки Šø токена Š°ŃƒŃ‚ентификации git", + "git_clone_project_command": "КоманГа git clone Š“Š»Ń проекта", + "git_clone_this_project": "ŠšŠ»Š¾Š½ŠøŃ€ŃƒŠ¹Ń‚Šµ проект при помощи git.", + "git_integration": "Š˜Š½Ń‚ŠµŠ³Ń€Š°Ń†ŠøŃ с Git", "github_commit_message_placeholder": "Дообщение о фиксации изменений в __appName__...", "github_is_premium": "Š”ŠøŠ½Ń…Ń€Š¾Š½ŠøŠ·Š°Ń†ŠøŃ с GitHub Š“Š¾ŃŃ‚ŃƒŠæŠ½Š° Ń‚Š¾Š»ŃŒŠŗŠ¾ в ŠæŃ€ŠµŠ¼ŠøŃƒŠ¼ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚Šµ", "github_merge_failed": "Š’Š°ŃˆŠø ŠøŠ·Š¼ŠµŠ½ŠµŠ½ŠøŃ в __appName__ Šø GitHub не были автоматически Š¾Š±ŃŠŠµŠ“инены. ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, Š²Ń€ŃƒŃ‡Š½ŃƒŃŽ Š¾Š±ŃŠŠµŠ“ŠøŠ½ŠøŃ‚Šµ Š²ŠµŃ‚ŠŗŃƒ <0>__sharelatex_branch__ с веткой по ŃƒŠ¼Š¾Š»Ń‡Š°Š½ŠøŃŽ в git. ŠŠ°Š¶Š¼ŠøŃ‚Šµ ниже, чтобы ŠæŃ€Š¾Š“Š¾Š»Š¶ŠøŃ‚ŃŒ после Ń€ŃƒŃ‡Š½Š¾Š³Š¾ Š¾Š±ŃŠŠµŠ“ŠøŠ½ŠµŠ½ŠøŃ.", @@ -191,8 +229,10 @@ "ill_take_it": "Š‘ŠµŃ€Ńƒ!", "import_from_github": "Š˜Š¼ŠæŠ¾Ń€Ń‚ с GitHub", "import_to_sharelatex": "Š˜Š¼ŠæŠ¾Ń€Ń‚ŠøŃ€Š¾Š²Š°Ń‚ŃŒ в __appName__", + "imported_from_zotero_at_date": "Š˜Š¼ŠæŠ¾Ń€Ń‚ŠøŃ€Š¾Š²Š°Š½Š¾ ŠøŠ· Zotero в __formattedDate__ __relativeDate__", "importing": "Š˜Š¼ŠæŠ¾Ń€Ń‚", "importing_and_merging_changes_in_github": "Š˜Š¼ŠæŠ¾Ń€Ń‚ Šø ŃŠ»ŠøŃŠ½ŠøŠµ изменений в GitHub", + "inactive_projects": "ŠŠµŠ°ŠŗŃ‚ŠøŠ²Š½Ń‹Šµ проекты", "info": "Š˜Š½Ń„Š¾Ń€Š¼Š°Ń†ŠøŃ", "institution": "ŠžŃ€Š³Š°Š½ŠøŠ·Š°Ń†ŠøŃ", "invalid_file_name": "ŠŠµŠ²ŠµŃ€Š½Š¾Šµ ŠøŠ¼Ń файла", @@ -213,6 +253,7 @@ "language": "Язык", "last_modified": "ПослеГнее изменение", "last_name": "Š¤Š°Š¼ŠøŠ»ŠøŃ", + "last_used": "ПослеГнее использование", "latex_templates": "Шаблоны", "learn_more": "Š£Š·Š½Š°Ń‚ŃŒ больше", "leave_group": "ŠŸŠ¾ŠŗŠøŠ½ŃƒŃ‚ŃŒ Š³Ń€ŃƒŠæŠæŃƒ", @@ -257,9 +298,11 @@ "nearly_activated": "Š’Ń‹ в оГном шаге от активации Š’Š°ŃˆŠµŠ³Š¾ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚Š° Š“Š»Ń __appName__!", "need_anything_contact_us_at": "Если у Вас ŠµŃŃ‚ŃŒ какие-либо вопросы Šø ŠæŠ¾Š¶ŠµŠ»Š°Š½ŠøŃ, ŠæŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŠæŠøŃˆŠøŃ‚Šµ нам по Š°Š“Ń€ŠµŃŃƒ", "need_to_leave": "Š£Š“Š°Š»ŠøŃ‚ŃŒ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚?", + "never": "ŠŠøŠŗŠ¾Š³Š“Š°", "new_file": "ŠŠ¾Š²Ń‹Š¹ файл", "new_folder": "ŠŠ¾Š²Š°Ń папка", "new_name": "ВвеГите название", + "new_owner": "ŠŠ¾Š²Ń‹Š¹ влаГелец", "new_password": "ŠŠ¾Š²Ń‹Š¹ ŠæŠ°Ń€Š¾Š»ŃŒ", "new_project": "Š”Š¾Š·Š“Š°Ń‚ŃŒ проект", "next_payment_of_x_collectected_on_y": "Š”Š»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¹ платёж в размере <0>__paymentAmmount__ Š±ŃƒŠ“ŠµŃ‚ списан <1>__collectionDate__", @@ -274,9 +317,12 @@ "no_projects": "ŠŠµŃ‚ проектов", "no_search_results": "ŠŠøŃ‡ŠµŠ³Š¾ не найГено", "no_thanks_cancel_now": "ŠŠµŃ‚, спасибо - я Ń…Š¾Ń‡Ńƒ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ сейчас", + "no_templates_found": "Шаблоны не найГены.", + "no_users": "ŠŠµŃ‚ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", "normal": "Š½Š¾Ń€Š¼Š°Š»ŃŒŠ½Ń‹Š¹", "not_now": "ŠŠµ сейчас", "notification_project_invite": "__userName__ ŠæŃ€ŠøŠ³Š»Š°ŃˆŠ°ŠµŃ‚ Вас ŠæŃ€ŠøŠ½ŃŃ‚ŃŒ ŃƒŃ‡Š°ŃŃ‚ŠøŠµ в проекте __projectName__,ŠŸŃ€ŠøŃŠ¾ŠµŠ“ŠøŠ½ŠøŃ‚ŃŒŃŃ", + "notify_users_about_account_deletion": "Š£Š²ŠµŠ“Š¾Š¼ŠøŃ‚ŃŒ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹ об уГалении Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚ов", "november": "ŠŠ¾ŃŠ±Ń€ŃŒ", "october": "ŠžŠŗŃ‚ŃŠ±Ń€ŃŒ", "off": "ŠžŃ‚ŠŗŠ».", @@ -288,7 +334,9 @@ "or": "или", "other_logs_and_files": "Š”Ń€ŃƒŠ³ŠøŠµ логи Šø файлы", "over": "ŃŠ²Ń‹ŃˆŠµ", + "owned_projects": "Добственные проекты", "owner": "ВлаГелец", + "ownership_of_projects_will_be_transferred": "ВлаГение ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¼Šø проектами Š±ŃƒŠ“ет переГано Š“Ń€ŃƒŠ³Š¾Š¼Ńƒ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŃŽ:", "page_not_found": "Дтраница не найГена", "password": "ŠŸŠ°Ń€Š¾Š»ŃŒ", "password_reset": "Š”Š±Ń€Š¾ŃŠøŃ‚ŃŒ ŠæŠ°Ń€Š¾Š»ŃŒ", @@ -296,13 +344,17 @@ "password_reset_token_expired": "Š’Š°Ńˆ коГ Š²Š¾ŃŃŃ‚Š°Š½Š¾Š²Š»ŠµŠ½ŠøŃ ŠæŠ°Ń€Š¾Š»Ń истёк. ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, запросите восстановление ŠæŠ°Ń€Š¾Š»Ń по почте ещё раз Šø перейГите по ссылке в письме.", "pdf_viewer": "ŠŸŃ€Š¾ŃŠ¼Š¾Ń‚Ń€Ń‰ŠøŠŗ PDF", "pending": "Š’ ожиГании", + "per_page": "на ŃŃ‚Ń€Š°Š½ŠøŃ†Ńƒ", + "permanently_delete_accounts": "ŠžŠŗŠ¾Š½Ń‡Š°Ń‚ŠµŠ»ŃŒŠ½Š¾ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚Ń‹", + "permanently_delete_projects": "ŠžŠŗŠ¾Š½Ń‡Š°Ń‚ŠµŠ»ŃŒŠ½Š¾ ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ проекты", "personal": "Личный", "pl": "Польский", "planned_maintenance": "ŠŸŠ»Š°Š½Š¾Š²Ń‹Šµ работы", "plans_amper_pricing": "Тарифы", "plans_and_pricing": "Тарифные планы", "please_compile_pdf_before_download": "ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŃŠŗŠ¾Š¼ŠæŠøŠ»ŠøŃ€ŃƒŠ¹Ń‚Šµ проект переГ Š·Š°Š³Ń€ŃƒŠ·ŠŗŠ¾Š¹ PDF", - "please_compile_pdf_before_word_count": "ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŃŠŗŠ¾Š¼ŠæŠøŠ»ŠøŃ€ŃƒŠ¹Ń‚Šµ проект, прежГе чем ŠæŠ¾Š“ŃŃ‡ŠøŃ‚Ń‹Š²Š°Ń‚ŃŒ количество слов!", + "please_compile_pdf_before_publish_as_template": "ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŃŠŗŠ¾Š¼ŠæŠøŠ»ŠøŃ€ŃƒŠ¹Ń‚Šµ проект, прежГе чем ŃŠ¾Š·Š“Š°Š²Š°Ń‚ŃŒ ŠøŠ· него шаблон", + "please_compile_pdf_before_word_count": "ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŃŠŗŠ¾Š¼ŠæŠøŠ»ŠøŃ€ŃƒŠ¹Ń‚Šµ проект, прежГе чем ŠæŠ¾Š“ŃŃ‡ŠøŃ‚Ń‹Š²Š°Ń‚ŃŒ количество слов.", "please_enter_email": "ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ввеГите аГрес ŃŠ»ŠµŠŗŃ‚Ń€Š¾Š½Š½Š¾Š¹ почты", "please_refresh": "ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, обновите ŃŃ‚Ń€Š°Š½ŠøŃ†Ńƒ Š“Š»Ń ŠæŃ€Š¾Š“Š¾Š»Š¶ŠµŠ½ŠøŃ", "please_set_a_password": "ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŃƒŠŗŠ°Š¶ŠøŃ‚Šµ ŠæŠ°Ń€Š¾Š»ŃŒ", @@ -317,6 +369,7 @@ "problem_with_subscription_contact_us": "Возникли проблемы с Š’Š°ŃˆŠµŠ¹ поГпиской. ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŃŠ²ŃŠ¶ŠøŃ‚ŠµŃŃŒ с нами, чтобы ŃƒŠ·Š½Š°Ń‚ŃŒ поГробности.", "processing": "обработка", "professional": "ŠŸŃ€Š¾Ń„ŠµŃŃŠøŠ¾Š½Š°Š»", + "project_categories_tags": "ŠšŠ°Ń‚ŠµŠ³Š¾Ń€ŠøŠø Šø теги проектов", "project_last_published_at": "Š’ послеГний раз проект был опубликован", "project_name": "ŠŠ°Š·Š²Š°Š½ŠøŠµ проекта", "project_not_linked_to_github": "Этот проект не ŃŠ²ŃŠ·Š°Š½ ни с оГним проектом на GitHub. Š’Ń‹ можете ŃŠ¾Š·Š“Š°Ń‚ŃŒ Š“Š»Ń него проект на GitHub:", @@ -324,12 +377,14 @@ "project_too_large_please_reduce": "Š’ ŃŃ‚Š¾Š¼ проекте слишком много текста. ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŠæŠ¾ŠæŃ€Š¾Š±ŃƒŠ¹Ń‚Šµ ŃƒŠ¼ŠµŠ½ŃŒŃˆŠøŃ‚ŃŒ количество.", "project_url": "URL проекта", "projects": "ŠŸŃ€Š¾ŠµŠŗŃ‚Ń‹", + "projects_list": "Дписок проектов", "pt": "ŠŸŠ¾Ń€Ń‚ŃƒŠ³Š°Š»ŃŒŃŠŗŠøŠ¹", "public": "ŠžŃ‚ŠŗŃ€Ń‹Ń‚Ń‹Š¹", "publish": "ŠžŠæŃƒŠ±Š»ŠøŠŗŠ¾Š²Š°Ń‚ŃŒ", "publish_as_template": "Š”Š¾Š·Š“Š°Ń‚ŃŒ шаблон", "publishing": "ŠŸŃƒŠ±Š»ŠøŠŗŠ°Ń†ŠøŃ", "pull_github_changes_into_sharelatex": "Š”ŠŗŠ°Ń‡Š°Ń‚ŃŒ ŠøŠ·Š¼ŠµŠ½ŠµŠ½ŠøŃ с GitHub в __appName__", + "purge": "Š£Š½ŠøŃ‡Ń‚Š¾Š¶ŠøŃ‚ŃŒ", "push_sharelatex_changes_to_github": "Š—Š°Š³Ń€ŃƒŠ·ŠøŃ‚ŃŒ ŠøŠ·Š¼ŠµŠ½ŠµŠ½ŠøŃ ŠøŠ· __appName__ на GitHub", "recent_commits_in_github": "ПослеГние коммиты на GitHub", "recompile": "ŠšŠ¾Š¼ŠæŠøŠ»ŠøŃ€Š¾Š²Š°Ń‚ŃŒ", @@ -353,13 +408,18 @@ "request_password_reset": "Š”Š±Ń€Š¾ŃŠøŃ‚ŃŒ ŠæŠ°Ń€Š¾Š»ŃŒ", "request_sent_thank_you": "Дпасибо, Š’Š°Ńˆ запрос отправлен!", "required": "Š¾Š±ŃŠ·Š°Ń‚ŠµŠ»ŃŒŠ½Š¾", - "resend": "ŠžŃ‚ŠæŃ€Š°Š²ŠøŃ‚ŃŒ еще раз", + "resend": "ŠžŃ‚ŠæŃ€Š°Š²ŠøŃ‚ŃŒ повторно", + "resend_activation_email": "ŠŸŠ¾Š²Ń‚Š¾Ń€Š½Š¾ Š¾Ń‚ŠæŃ€Š°Š²ŠøŃ‚ŃŒ письмо активации", "reset_password": "Š”Š±Ń€Š¾ŃŠøŃ‚ŃŒ ŠæŠ°Ń€Š¾Š»ŃŒ", "reset_your_password": "Š”Š±Ń€Š¾ŃŠøŃ‚ŃŒ ŠæŠ°Ń€Š¾Š»ŃŒ", "restore": "Š’Š¾ŃŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ", + "restore_accounts": "Š’Š¾ŃŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ ŃƒŃ‡ŠµŃ‚Š½Ń‹Šµ записи", + "restore_projects": "Š’Š¾ŃŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ проекты", "restoring": "Восстановление", "restricted": "Š”Š¾ŃŃ‚ŃƒŠæ ограничен", "restricted_no_permission": "Š˜Š·Š²ŠøŠ½ŠøŃ‚Šµ, у Вас неГостаточно прав Š“Š»Ń просмотра Ганной страницы.", + "resume": "Š’Š¾Š·Š¾Š±Š½Š¾Š²ŠøŃ‚ŃŒ", + "resume_account": "Š’Š¾Š·Š¾Š±Š½Š¾Š²ŠøŃ‚ŃŒ Š“Š¾ŃŃ‚ŃƒŠæ Šŗ ŃƒŃ‡ŠµŃ‚Š½Š¾Š¹ записи", "return_to_login_page": "Š’ŠµŃ€Š½ŃƒŃ‚ŃŒŃŃ на ŃŃ‚Ń€Š°Š½ŠøŃ†Ńƒ вхоГа", "revoke_invite": "ŠžŃ‚Š¾Š·Š²Š°Ń‚ŃŒ ŠæŃ€ŠøŠ³Š»Š°ŃˆŠµŠ½ŠøŠµ", "ro": "Š ŃƒŠ¼Ń‹Š½ŃŠŗŠøŠ¹", @@ -367,27 +427,46 @@ "ru": "Русский", "saving": "Дохранение", "saving_notification_with_seconds": "Дохранение __docname__... (__seconds__ секунГ с послеГнего ŃŠ¾Ń…Ń€Š°Š½ŠµŠ½ŠøŃ)", + "search": "Поиск", "search_bib_files": "Поиск по Š°Š²Ń‚Š¾Ń€Ńƒ, Š½Š°Š·Š²Š°Š½ŠøŃŽ, гоГу", "search_projects": "Поиск по проектам", "search_references": "Поиск .bib файлов в проекте", "security": "Š‘ŠµŠ·Š¾ŠæŠ°ŃŠ½Š¾ŃŃ‚ŃŒ", + "select_a_new_owner_for_projects": "Выберите нового Š²Š»Š°Š“ŠµŠ»ŃŒŃ†Š° Š“Š»Ń проектов ŃŃ‚Š¾Š³Š¾ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Ń", + "select_all_projects": "Š’Ń‹Š±Ń€Š°Ń‚ŃŒ все проекты", + "select_all_users": "Š’Ń‹Š±Ń€Š°Ń‚ŃŒ всех ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", "select_github_repository": "Выберите проект на GitHub Š“Š»Ń импорта в __appName__", + "select_project": "Š’Ń‹Š±Ń€Š°Ń‚ŃŒ __project__", + "select_projects": "Š’Ń‹Š±Ń€Š°Ń‚ŃŒ проекты", + "select_user": "Š’Ń‹Š±Ń€Š°Ń‚ŃŒ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Ń", + "select_users": "Š’Ń‹Š±Ń€Š°Ń‚ŃŒ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", "send_first_message": "ŠžŃ‚ŠæŃ€Š°Š²ŃŒŃ‚Šµ сообщение", + "send_notification_emails_to_users": "ŠžŃ‚ŠæŃ€Š°Š²ŠøŃ‚ŃŒ увеГовление Ń‚ŠµŠŗŃƒŃ‰ŠµŠ¼Ńƒ Šø новому Š²Š»Š°Š“ŠµŠ»ŃŒŃ†Š°Š¼", "september": "Š”ŠµŠ½Ń‚ŃŠ±Ń€ŃŒ", "server_error": "ŠžŃˆŠøŠ±ŠŗŠ° сервера", "services": "Дервисы", "session_created_at": "Š”ŠµŃŃŠøŃ созГана", "session_expired_redirecting_to_login": "Дрок сессии истёк. ŠŸŠµŃ€ŠµŠ½Š°ŠæŃ€Š°Š²Š»ŠµŠ½ŠøŠµ на ŃŃ‚Ń€Š°Š½ŠøŃ†Ńƒ вхоГа через __seconds__ секунГ(ы)", "sessions": "Дессии", + "set_admin": "Š”Š“ŠµŠ»Š°Ń‚ŃŒ аГмином", + "set_admin_account": "ŠŸŃ€ŠµŠ“Š¾ŃŃ‚Š°Š²ŠøŃ‚ŃŒ права аГминистратора", "set_new_password": "ВвеГите новый ŠæŠ°Ń€Š¾Š»ŃŒ", "set_password": "Š£ŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ ŠæŠ°Ń€Š¾Š»ŃŒ", "settings": "ŠŠ°ŃŃ‚Ń€Š¾Š¹ŠŗŠø", "share": "ŠžŃ‚ŠŗŃ€Ń‹Ń‚ŃŒ Š“Š¾ŃŃ‚ŃƒŠæ", "share_project": "ŠžŃ‚ŠŗŃ€Ń‹Ń‚ŃŒ Š“Š¾ŃŃ‚ŃƒŠæ Šŗ ŠæŃ€Š¾ŠµŠŗŃ‚Ńƒ", "shared_with_you": "Š”Š¾ŃŃ‚ŃƒŠæŠ½Ń‹Šµ мне", + "show_all_projects": "ŠŸŠ¾ŠŗŠ°Š·Š°Ń‚ŃŒ все проекты", + "show_all_users": "ŠŸŠ¾ŠŗŠ°Š·Š°Ń‚ŃŒ всех ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", "show_hotkeys": "ŠŸŠ¾ŠŗŠ°Š·Š°Ń‚ŃŒ Š³Š¾Ń€ŃŃ‡ŠøŠµ клавиши", + "show_x_more_projects": "ŠŸŠ¾ŠŗŠ°Š·Š°Ń‚ŃŒ ещё __x__ проектов", + "show_x_more_users": "ŠŸŠ¾ŠŗŠ°Š·Š°Ń‚ŃŒ ещё __x__ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", + "showing_x_out_of_n_projects": "Показано __x__ ŠøŠ· __n__ проектов", + "showing_x_out_of_n_users": "Показано __x__ ŠøŠ· __n__ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", + "signed_up": "Зарегистрирован", "site_description": "ŠŸŃ€Š¾ŃŃ‚Š¾Š¹ в использовании онлайн реГактор LaTeX. ŠŠµ Ń‚Ń€ŠµŠ±ŃƒŠµŃ‚ ŃƒŃŃ‚Š°Š½Š¾Š²ŠŗŠø, поГГерживает ŃŠ¾Š²Š¼ŠµŃŃ‚Š½ŃƒŃŽ Ń€Š°Š±Š¾Ń‚Ńƒ в Ń€ŠµŠ°Š»ŃŒŠ½Š¾Š¼ времени, ŠŗŠ¾Š½Ń‚Ń€Š¾Š»ŃŒ версий, сотни шаблонов LaTeX Šø многое Š“Ń€ŃƒŠ³Š¾Šµ.", - "somthing_went_wrong_compiling": "К ŃŠ¾Š¶Š°Š»ŠµŠ½ŠøŃŽ, что-то пошло не так Šø мы не смогли ŃŠŗŠ¾Š¼ŠæŠ˜Š»ŠøŃ€Š¾Š²Š°Ń‚ŃŒ Š’Š°Ńˆ проект. ŠŸŠ¾ŠæŃ€Š¾Š±ŃƒŠ¹Ń‚Šµ еще раз через ŠæŠ°Ń€Ńƒ Š¼ŠøŠ½ŃƒŃ‚.", + "something_went_wrong_compiling": "К ŃŠ¾Š¶Š°Š»ŠµŠ½ŠøŃŽ, что-то пошло не так Šø мы не смогли ŃŠŗŠ¾Š¼ŠæŠ˜Š»ŠøŃ€Š¾Š²Š°Ń‚ŃŒ Š’Š°Ńˆ проект. ŠŸŠ¾ŠæŃ€Š¾Š±ŃƒŠ¹Ń‚Šµ еще раз через ŠæŠ°Ń€Ńƒ Š¼ŠøŠ½ŃƒŃ‚.", + "something_went_wrong_server": "Š˜Š·Š²ŠøŠ½ŠøŃ‚Šµ, что-то пошло не так. ŠŸŠ¾ŠæŃ€Š¾Š±ŃƒŠ¹Ń‚Šµ еще раз.", "source": "Š˜ŃŃ…Š¾Š“Š½Ń‹Š¹ коГ", "spell_check": "ŠŸŃ€Š¾Š²ŠµŃ€ŠŗŠ° ŠæŃ€Š°Š²Š¾ŠæŠøŃŠ°Š½ŠøŃ", "start_free_trial": "ŠŸŠ¾ŠæŃ€Š¾Š±Š¾Š²Š°Ń‚ŃŒ бесплатно!", @@ -403,6 +482,8 @@ "sure_you_want_to_change_plan": "Š’Ń‹ ŃƒŠ²ŠµŃ€ŠµŠ½Ń‹, что хотите ŃŠ¼ŠµŠ½ŠøŃ‚ŃŒ тарифный план на <0>__planName__?", "sure_you_want_to_delete": "Š’Ń‹ ŃƒŠ²ŠµŃ€ŠµŠ½Ń‹, что хотите перманентно ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠµ файлы?", "sure_you_want_to_leave_group": "Š’Ń‹ ŃƒŠ²ŠµŃ€ŠµŠ½Ń‹, что хотите ŠæŠ¾ŠŗŠøŠ½ŃƒŃ‚ŃŒ Š³Ń€ŃƒŠæŠæŃƒ?", + "suspend": "Š—Š°Š±Š»Š¾ŠŗŠøŃ€Š¾Š²Š°Ń‚ŃŒ", + "suspend_account": "Š—Š°Š±Š»Š¾ŠŗŠøŃ€Š¾Š²Š°Ń‚ŃŒ ŃƒŃ‡ŠµŃ‚Š½ŃƒŃŽ запись", "sv": "ШвеГский", "sync": "Š”ŠøŠ½Ń…Ń€Š¾Š½ŠøŠ·Š°Ń†ŠøŃ", "sync_project_to_github_explanation": "Все ŠøŠ·Š¼ŠµŠ½ŠµŠ½ŠøŃ, сГеланные Вами в __appName__ Š±ŃƒŠ“ŃƒŃ‚ интегрированы (commit Šø merge) со всеми Š¾Š±Š½Š¾Š²Š»ŠµŠ½ŠøŃŠ¼Šø на GitHub.", @@ -411,6 +492,7 @@ "syntax_validation": "ŠŸŃ€Š¾Š²ŠµŃ€ŠŗŠ° коГа", "take_me_home": "Š’ŠµŃ€Š½ŃƒŃ‚ŃŒŃŃ в начало", "template_description": "ŠžŠæŠøŃŠ°Š½ŠøŠµ шаблона", + "template_with_this_title_exists_and_owned_by_x": "Уже ŠµŃŃ‚ŃŒ шаблон с таким названием, его влаГелец __x__.", "templates": "Шаблоны", "terminated": "ŠšŠ¾Š¼ŠæŠøŠ»ŃŃ†ŠøŃ отменена", "terms": "Š£ŃŠ»Š¾Š²ŠøŃ", @@ -421,6 +503,8 @@ "thanks_settings_updated": "Дпасибо, ŠøŠ·Š¼ŠµŠ½ŠµŠ½ŠøŃ сохранены", "theme": "Тема", "thesis": "Š”ŠøŃŃŠµŃ€Ń‚Š°Ń†ŠøŃ", + "this_action_can_be_undone_within_limited_period": "Эта Š¾ŠæŠµŃ€Š°Ń†ŠøŃ может Š±Ń‹Ń‚ŃŒ отменена Ń‚Š¾Š»ŃŒŠŗŠ¾ в течение ограниченного периоГа.", + "this_action_cannot_be_undone": "Š­Ń‚Ńƒ Š¾ŠæŠµŃ€Š°Ń†ŠøŃŽ Š½ŠµŠ»ŃŒŠ·Ń Š¾Ń‚Š¼ŠµŠ½ŠøŃ‚ŃŒ.", "this_is_your_template": "Это шаблон ŠøŠ· Š’Š°ŃˆŠµŠ³Š¾ проекта", "this_project_is_public": "Это открытый проект. ŠžŠ½ может Š±Ń‹Ń‚ŃŒ изменен Š»ŃŽŠ±Ń‹Š¼ человеком, Š·Š½Š°ŃŽŃ‰ŠøŠ¼ аГрес (URL)", "this_project_is_public_read_only": "Этот проект открыт Š“Š»Ń всех, у кого ŠµŃŃ‚ŃŒ ссылка (но без возможности Ń€ŠµŠ“Š°ŠŗŃ‚ŠøŃ€Š¾Š²Š°Š½ŠøŃ)", @@ -429,20 +513,31 @@ "timedout": "Š’Ń€ŠµŠ¼Ń Š¾Š¶ŠøŠ“Š°Š½ŠøŃ истекло", "title": "ŠŠ°Š·Š²Š°Š½ŠøŠµ", "to_modify_your_subscription_go_to": "Š”Š»Ń ŠøŠ·Š¼ŠµŠ½ŠµŠ½ŠøŃ поГписки перейГите по ссылке:", + "token": "Токен", + "token_limit_reached": "Š’Ń‹ Гостигли лимита в 10 токенов. Чтобы ŃŠ¾Š·Š“Š°Ń‚ŃŒ новый токен Š°ŃƒŃ‚ентификации, ŃƒŠ“Š°Š»ŠøŃ‚Šµ оГин ŠøŠ· ŠøŠ¼ŠµŃŽŃ‰ŠøŃ…ŃŃ.", "too_many_files_uploaded_throttled_short_period": "Длишком много файлов Š·Š°Š³Ń€ŃƒŠ¶ŠµŠ½Š¾ за раз - на короткое Š²Ń€ŠµŠ¼Ń Š·Š°Š³Ń€ŃƒŠ·ŠŗŠ° была приостановлена.", "too_many_login_requests_2_mins": "Было ŠæŃ€ŠµŠ“ŠæŃ€ŠøŠ½ŃŃ‚Š¾ слишком много попыток вхоГа. ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, поГожГите 2 Š¼ŠøŠ½ŃƒŃ‚Ń‹, прежГе чем ŠæŃ€Š¾Š±Š¾Š²Š°Ń‚ŃŒ снова", "too_recently_compiled": "Этот проект был скомпилирован совсем неГавно, ŠæŠ¾ŃŃ‚Š¾Š¼Ńƒ ŠŗŠ¾Š¼ŠæŠøŠ»ŃŃ†ŠøŃ была ŠæŃ€Š¾ŠæŃƒŃ‰ŠµŠ½Š°.", "total_words": "ŠšŠ¾Š»ŠøŃ‡ŠµŃŃ‚Š²Š¾ слов", "tr": "Š¢ŃƒŃ€ŠµŃ†ŠŗŠøŠ¹", + "transfer_all_projects_to": "ŠŸŠµŃ€ŠµŠ“Š°Ń‚ŃŒ все проекты ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹ новому Š²Š»Š°Š“ŠµŠ»ŃŒŃ†Ńƒ:", + "trash_projects": "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ проекты в ŠŗŠ¾Ń€Š·ŠøŠ½Ńƒ", + "trashed_projects": "ŠŸŃ€Š¾ŠµŠŗŃ‚Ń‹ в корзине", + "trashing_projects_wont_affect_collaborators": "ŠŸŠµŃ€ŠµŠ¼ŠµŃ‰ŠµŠ½ŠøŠµ проекта в ŠŗŠ¾Ń€Š·ŠøŠ½Ńƒ не ŠæŠ¾Š²Š»ŠøŃŠµŃ‚ на Š“Ń€ŃƒŠ³ŠøŃ… разработчиков проекта.", + "trashing_projects_wont_affect_user_collaborators": "ŠŸŠµŃ€ŠµŠ¼ŠµŃ‰ŠµŠ½ŠøŠµ проекта в ŠŗŠ¾Ń€Š·ŠøŠ½Ńƒ не ŠæŠ¾Š²Š»ŠøŃŠµŃ‚ на Š“Ń€ŃƒŠ³ŠøŃ… разработчиков проекта.", "try_now": "ŠŸŠ¾ŠæŃ€Š¾Š±ŃƒŠ¹Ń‚Šµ", + "try_recompile_project": "ŠŸŠ¾ŠæŃ€Š¾Š±ŃƒŠ¹Ń‚Šµ ŃŠŗŠ¾Š¼ŠæŠøŠ»ŠøŃ€Š¾Š²Š°Ń‚ŃŒ проект заново.", "uk": "Украинский", "university": "Университет", + "unknown": "ŠŠµŠøŠ·Š²ŠµŃŃ‚Š½Ń‹Š¹", "unlimited_collabs": "ŠŠµŠ¾Š³Ń€Š°Š½ŠøŃ‡ŠµŠ½Š½Š¾ число соавторов", "unlimited_projects": "ŠŠµŠ¾Š³Ń€Š°Š½ŠøŃ‡ŠµŠ½Š½Š¾Šµ число проектов", "unlink": "ŠžŃ‚ŃŠ¾ŠµŠ“ŠøŠ½ŠøŃ‚ŃŒ", "unlink_github_warning": "Все проекты, которые Š’Ń‹ синхронизировали с GitHub, Š±ŃƒŠ“ŃƒŃ‚ отсоеГинены Šø больше не Š±ŃƒŠ“ŃƒŃ‚ ŃŠøŠ½Ń…Ń€Š¾Š½ŠøŠ·ŠøŃ€Š¾Š²Š°Ń‚ŃŒŃŃ с GitHub. Š’Ń‹ ŃƒŠ²ŠµŃ€ŠµŠ½Ń‹, что хотите Š¾Ń‚ŃŠ¾ŠµŠ“ŠøŠ½ŠøŃ‚ŃŒ Š’Š°Ńˆ GitHub Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚?", "unpublish": "ŠžŃ‚Š¼ŠµŠ½ŠøŃ‚ŃŒ ŠæŃƒŠ±Š»ŠøŠŗŠ°Ń†ŠøŃŽ", "unpublishing": "ŠžŃ‚Š¼ŠµŠ½Š° ŠæŃƒŠ±Š»ŠøŠŗŠ°Ń†ŠøŠø", + "unset_admin": "Š£Š±Ń€Š°Ń‚ŃŒ аГминина", + "unset_admin_account": "ŠžŃ‚Š¾Š·Š²Š°Ń‚ŃŒ права аГминистратора", "unsubscribe": "ŠžŃ‚Š¼ŠµŠ½ŠøŃ‚ŃŒ поГписку", "unsubscribed": "ŠŠµ поГписан", "unsubscribing": "ŠžŃ‚Š¼ŠµŠ½Š° поГписки", @@ -457,18 +552,47 @@ "upload_file": "Š—Š°Š³Ń€ŃƒŠ·ŠøŃ‚ŃŒ файл", "upload_project": "Š—Š°Š³Ń€ŃƒŠ·ŠøŃ‚ŃŒ проект", "upload_zipped_project": "Š—Š°Š³Ń€ŃƒŠ·ŠøŃ‚ŃŒ архив проекта (*.zip)", + "user_activity": "ŠŠŗŃ‚ŠøŠ²Š½Š¾ŃŃ‚ŃŒ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Ń", + "user_categories": "ŠšŠ°Ń‚ŠµŠ³Š¾Ń€ŠøŠø ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", + "user_category_admin": "АГминистраторы", + "user_category_all": "Все ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Šø", + "user_category_deleted": "УГалённые ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Šø", + "user_category_inactive": "ŠŠµŠ°ŠŗŃ‚ŠøŠ²Š½Ń‹Šµ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Šø", + "user_category_ldap": "ŠŸŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Šø LDAP", + "user_category_local": "Š›Š¾ŠŗŠ°Š»ŃŒŠ½Ń‹Šµ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Šø", + "user_category_oidc": "ŠŸŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Šø OIDC", + "user_category_saml": "ŠŸŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Šø SAML", + "user_category_suspended": "Заблокированные ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Šø", "user_wants_you_to_see_project": "__username__ ŠæŃ€ŠøŠ³Š»Š°ŃˆŠ°ŠµŃ‚ вас Šŗ ŠæŃ€Š¾ŃŠ¼Š¾Ń‚Ń€Ńƒ проекта __projectname__", + "users_list": "Дписок ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¹", "vat_number": "ŠŠ¾Š¼ŠµŃ€ ŠæŠ»Š°Ń‚ŠµŠ»ŃŒŃ‰ŠøŠŗŠ° ŠŠ”Š”", "view_all": "ŠŸŠ¾ŠŗŠ°Š·Š°Ń‚ŃŒ все", "view_in_template_gallery": "ŠŸŠ¾ŃŠ¼Š¾Ń‚Ń€ŠµŃ‚ŃŒ в галерее шаблонов", "welcome_to_sl": "Добро ŠæŠ¾Š¶Š°Š»Š¾Š²Š°Ń‚ŃŒ в __appName__", "word_count": "ŠšŠ¾Š»ŠøŃ‡ŠµŃŃ‚Š²Š¾ слов", "year": "гоГ", + "you": "Š’Ń‹", + "you_cant_overwrite_it": "ŠŸŠµŃ€ŠµŠ·Š°ŠæŠøŃŠ°Ń‚ŃŒ Š½ŠµŠ»ŃŒŠ·Ń.", "you_have_added_x_of_group_size_y": "Š’Ń‹ Гобавили <0>__addedUsersSize__ ŠøŠ· <1>__groupSize__ Š“Š¾ŃŃ‚ŃƒŠæŠ½Ń‹Ń… ŃƒŃ‡Š°ŃŃ‚Š½ŠøŠŗŠ¾Š²", + "your_git_access_info": "ŠŸŃ€Šø запросе ŠæŠ°Ń€Š¾Š»Ń ŠøŃŠæŠ¾Š»ŃŒŠ·ŃƒŠ¹Ń‚Šµ ваш токен Š°ŃƒŃ‚ентификации Git.", + "your_git_access_info_bullet_1": "Š’Ń‹ можете ŠøŠ¼ŠµŃ‚ŃŒ Го 10 токенов.", + "your_git_access_info_bullet_2": "Если вы Гостигли максимума, необхоГимо ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ ŠøŠ¼ŠµŃŽŃ‰ŠøŠ¹ŃŃ токен, прежГе чем ŃŠ¾Š·Š“Š°Š²Š°Ń‚ŃŒ новый.", + "your_git_access_info_bullet_3": "Š’Ń‹ можете ŃŠ¾Š·Š“Š°Ń‚ŃŒ токен с ŠæŠ¾Š¼Š¾Ń‰ŃŒŃŽ кнопки <0>Š”Š¾Š·Š“Š°Ń‚ŃŒ токен.", + "your_git_access_info_bullet_4": "После того как вы созГаГите токен, у вас больше не Š±ŃƒŠ“ет возможности ŃƒŠ²ŠøŠ“ŠµŃ‚ŃŒ его снова. ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŃŠŗŠ¾ŠæŠøŃ€ŃƒŠ¹Ń‚Šµ его Šø сохраните в безопасности.", + "your_git_access_info_bullet_5": "Ранее созГанные токены Š±ŃƒŠ“ŃƒŃ‚ Š¾Ń‚Š¾Š±Ń€Š°Š¶Š°Ń‚ŃŒŃŃ зГесь.", + "your_git_access_tokens": "Š’Š°ŃˆŠø токены Š°ŃƒŃ‚ŠµŠ½Ń‚ŠøŃ„ŠøŠŗŠ°Ń†ŠøŠø Git", "your_plan": "Š’Š°Ńˆ тариф", "your_projects": "ДозГанные мной", "your_sessions": "Š’Š°ŃˆŠø сессии", "your_subscription_has_expired": "Дрок Š’Š°ŃˆŠµŠ¹ поГписки истёк.", "your_subscriptions": "Š’Š°ŃˆŠø поГписки", - "zh-CN": "ŠšŠøŃ‚Š°Š¹ŃŠŗŠøŠ¹" + "zh-CN": "ŠšŠøŃ‚Š°Š¹ŃŠŗŠøŠ¹", + "zotero_account_linked_successfully": "Š’Š°Ńˆ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚ Zotero успешно ŠæŠ¾Š“ŠŗŠ»ŃŽŃ‡Ń‘Š½.", + "zotero_groups_loading_error": "ŠŸŃ€Šø Š·Š°Š³Ń€ŃƒŠ·ŠŗŠµ Š³Ń€ŃƒŠæŠæ ŠøŠ· Zotero ŠæŃ€Š¾ŠøŠ·Š¾ŃˆŠ»Š° ошибка", + "zotero_imported_by_collaborator": "Этот файл был импортирован ŠøŠ· Zotero вашим соавтором. Š’Ń‹ не можете Š¾Š±Š½Š¾Š²ŠøŃ‚ŃŒ файл.", + "zotero_imported_by_unknown": "Этот файл был импортирован ŠøŠ· Zotero неизвестным ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»ŠµŠ¼. Š’Ń‹ не можете Š¾Š±Š½Š¾Š²ŠøŃ‚ŃŒ файл.", + "zotero_reference_loading_error": "ŠžŃˆŠøŠ±ŠŗŠ°: не уГалось Š·Š°Š³Ń€ŃƒŠ·ŠøŃ‚ŃŒ ссылки ŠøŠ· Zotero", + "zotero_reference_loading_error_expired": "Дрок Š“ŠµŠ¹ŃŃ‚Š²ŠøŃ токена Zotero истёк, ŠæŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, повторно ŠæŠ¾Š“ŠŗŠ»ŃŽŃ‡ŠøŃ‚Šµ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚", + "zotero_reference_loading_error_forbidden": "ŠŠµ уГалось Š·Š°Š³Ń€ŃƒŠ·ŠøŃ‚ŃŒ ссылки ŠøŠ· Zotero, ŠæŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, повторно ŠæŠ¾Š“ŠŗŠ»ŃŽŃ‡ŠøŃ‚Šµ Š°ŠŗŠŗŠ°ŃƒŠ½Ń‚ Šø ŠæŠ¾ŠæŃ€Š¾Š±ŃƒŠ¹Ń‚Šµ снова", + "zotero_sync_description": "Š˜ŃŠæŠ¾Š»ŃŒŠ·ŃƒŃ ŠøŠ½Ń‚ŠµŠ³Ń€Š°Ń†ŠøŃŽ с Zotero вы можете ŠøŠ¼ŠæŠ¾Ń€Ń‚ŠøŃ€Š¾Š²Š°Ń‚ŃŒ свои ссылки ŠøŠ· Zotero в проекты __appName__." } diff --git a/services/web/modules/admin-tools/app/src/AdminToolsController.mjs b/services/web/modules/admin-tools/app/src/AdminToolsController.mjs new file mode 100644 index 00000000000..c51f4006c3c --- /dev/null +++ b/services/web/modules/admin-tools/app/src/AdminToolsController.mjs @@ -0,0 +1,101 @@ +import { expressify } from '@overleaf/promise-utils' +import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' +import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' +import request from 'request' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' + +// Helper function to get all active projects from real-time service +async function getActiveProjectsFromRealTime() { + return new Promise((resolve, reject) => { + const realTimeUrl = Settings.apis.realTime?.url || 'http://127.0.0.1:3026' + const url = `${realTimeUrl}/clients` + const user = Settings.apis.realTime?.user || process.env.WEB_API_USER || 'overleaf' + const pass = Settings.apis.realTime?.pass || process.env.WEB_API_PASSWORD || '' + + logger.info({ url, user }, 'Fetching active clients from real-time service') + + request.get({ + url, + auth: { user, pass, sendImmediately: true }, + json: true, + timeout: 10000 + }, async (error, response, body) => { + if (error) { + logger.error({ err: error }, 'Error getting active clients from real-time') + return resolve([]) + } + if (response.statusCode !== 200) { + logger.warn({ statusCode: response.statusCode }, 'Unexpected response from real-time service') + return resolve([]) + } + if (!Array.isArray(body)) { + logger.warn({ body }, 'Unexpected response body from real-time service') + return resolve([]) + } + + // Group flat client list by project_id + const projectMap = new Map() + for (const client of body) { + if (!projectMap.has(client.project_id)) { + projectMap.set(client.project_id, []) + } + projectMap.get(client.project_id).push(client) + } + + logger.info({ projectCount: projectMap.size }, 'Got active projects from real-time') + + try { + const enrichedProjects = await Promise.all( + [...projectMap.entries()].map(async ([projectId, clients]) => { + try { + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + _id: 1, + owner_ref: 1, + }) + if (!project) { + logger.warn({ projectId }, 'Project not found in database') + return null + } + const owner = await UserGetter.promises.getUser(project.owner_ref, { + email: 1, + first_name: 1, + last_name: 1, + }) + return { + id: project._id.toString(), + name: project.name, + owner: owner ? { + email: owner.email, + name: `${owner.first_name || ''} ${owner.last_name || ''}`.trim() || owner.email, + } : null, + activeUsers: clients.map(c => ({ + name: `${c.first_name} ${c.last_name}`.trim() || c.email || 'Unknown', + email: c.email, + })), + connectionCount: clients.length, + } + } catch (err) { + logger.error({ err, projectId }, 'Error enriching project data') + return null + } + }) + ) + resolve(enrichedProjects.filter(p => p !== null)) + } catch (err) { + logger.error({ err }, 'Error processing active projects') + resolve([]) + } + }) + }) +} + +async function activeProjects(req, res) { + const activeProjects = await getActiveProjectsFromRealTime() + res.json(activeProjects) +} + +export default { + activeProjects: expressify(activeProjects), +} diff --git a/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs new file mode 100644 index 00000000000..f994a0142fd --- /dev/null +++ b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs @@ -0,0 +1,87 @@ +import logger from '@overleaf/logger' +import UserListController from './UserListController.mjs' +import ProjectListController from './ProjectListController.mjs' +import AdminToolsController from './AdminToolsController.mjs' +import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init AdminTools router') + + webRouter.get('/user/activate', UserListController.activateAccountPage) + AuthenticationController.addEndpointToLoginWhitelist('/user/activate') + + webRouter.get('/admin/user', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.manageUsersPage + ) + webRouter.post( + '/admin/user/create', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.registerNewUser + ) + webRouter.post('/admin/user/:userId/send-activation', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.sendActivationEmail + ) + webRouter.get('/admin/user/:userId/info', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.getAdditionalUserInfo, + ) + webRouter.post('/admin/users', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.getUsersJson + ) + webRouter.post('/admin/user/:userId/delete', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.deleteUser + ) + webRouter.post('/admin/user/:userId/update', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.updateUser, + ) + webRouter.delete('/admin/user/:userId', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.purgeDeletedUser + ) + webRouter.post('/admin/user/:userId/restore', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.restoreDeletedUser + ) + webRouter.post('/admin/user/:userId/projects', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.getProjectsJson + ) + + webRouter.get('/admin/project', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.manageProjectsPage + ) + webRouter.post('/admin/project/:project_id/trash', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.trashProjectForUser + ) + webRouter.post('/admin/project/:project_id/untrash', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.untrashProjectForUser + ) + webRouter.delete('/admin/project/:project_id/purge', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.purgeDeletedProject + ) + webRouter.delete('/admin/project/:project_id', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.deleteProject + ) + webRouter.post('/admin/project/:project_id/undelete', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.undeleteProject + ) + + webRouter.get('/admin/active-projects', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminToolsController.activeProjects, + ) + }, +} diff --git a/services/web/modules/admin-tools/app/src/ProjectListController.mjs b/services/web/modules/admin-tools/app/src/ProjectListController.mjs new file mode 100644 index 00000000000..2c85fde71c5 --- /dev/null +++ b/services/web/modules/admin-tools/app/src/ProjectListController.mjs @@ -0,0 +1,275 @@ +import _ from 'lodash' +import Path from 'node:path' +import { fileURLToPath } from 'node:url' +import { expressify } from '@overleaf/promise-utils' +import logger from '@overleaf/logger' +import Metrics from '@overleaf/metrics' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs' +import ProjectHelper from '../../../../app/src/Features/Project/ProjectHelper.mjs' +import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' +import ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.mjs' +import UserSettingsHelper from '../../../../app/src/Features/Project/UserSettingsHelper.mjs' +import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' +import { User } from '../../../../app/src/models/User.mjs' +import { Project } from '../../../../app/src/models/Project.mjs' +import { DeletedProject } from '../../../../app/src/models/DeletedProject.mjs' +import { OError } from '../../../../app/src/Features/Errors/Errors.js' +import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs' +import SplitTestHandler from '../../../../app/src/Features/SplitTests/SplitTestHandler.mjs' + +const __dirname = Path.dirname(fileURLToPath(import.meta.url)) + +function cleanupSession(req) { + // cleanup redirects at the end of the redirect chain + delete req.session.postCheckoutRedirect + delete req.session.postLoginRedirect + delete req.session.postOnboardingRedirect +} + +async function manageProjectsPage(req, res, next) { + cleanupSession(req) + + const projectsBlobPending = _getProjects().catch(err => { + logger.err({ err }, 'projects listing in background failed') + return undefined + }) + + const prefetchedProjectsBlob = await projectsBlobPending + + Metrics.inc('project-list-prefetch-projects', 1, { + status: prefetchedProjectsBlob ? 'success' : 'error', + }) + + const userId = SessionManager.getLoggedInUserId(req.session) + const user = await User.findById(userId, 'ace') + + const userSettings = await UserSettingsHelper.buildUserSettings( + req, + res, + user + ) + + await SplitTestHandler.promises.getAssignment( + req, + res, + 'themed-project-dashboard' + ) + + res.render(Path.resolve(__dirname, '../views/manage-projects-react'), { + title: 'Manage Projects', + userSettings, + prefetchedProjectsBlob, + }) +} + +async function getProjectsJson(req, res) { + const { filters, page, sort } = req.body + const { userId } = req.params + + const projectsPage = await _getProjects(userId, filters, sort, page) + res.json(projectsPage) +} + +async function _getProjects( + userId = null, + filters = {}, + sort = { by: 'lastUpdated', order: 'desc' }, + page = { size: 20 } +) { + + const projection = { + _id: 1, + name: 1, + lastUpdated: 1, + lastUpdatedBy: 1, + lastOpened: 1, + trashed: 1, + owner_ref: 1, + } + + const actualProjects = await Project.find( + userId == null ? {} : { owner_ref: userId }, + projection, + ).lean().exec() + + const delProjection = Object.fromEntries( + Object.keys(projection).map(k => [`project.${k}`, 1]) + ) + delProjection['deleterData.deletedAt'] = 1 + delProjection['deleterData.deleterId'] = 1 + + const deletedProjects = await DeletedProject.find( + userId == null ? { project: { $type: 'object' } } : { 'project.owner_ref': userId }, + delProjection + ).lean().exec() + + const formattedActualProjects = _formatProjects(actualProjects, _formatProjectInfo) + const formattedDeletedProjects = _formatProjects(deletedProjects, _formatDeletedProjectInfo) + const formattedProjects = [...formattedActualProjects, ...formattedDeletedProjects] + const filteredProjects = _applyFilters(formattedProjects, filters) + const projects = _sortAndPaginate(filteredProjects, sort, page) + + return { + totalSize: filteredProjects.length, + projects, + } +} + +function _formatProjects(projects, formatProjectInfo) { + const yearAgo = new Date() + yearAgo.setFullYear(yearAgo.getFullYear() - 1) + const formattedProjects = [] + + for (const project of projects) { + formattedProjects.push( + formatProjectInfo(project, yearAgo) + ) + } + return formattedProjects +} + +function _applyFilters(projects, filters) { + if (!_hasActiveFilter(filters)) { + return projects + } + return projects.filter(project => _matchesFilters(project, filters)) +} + +function _sortAndPaginate(projects, sort, page) { + if ( + (sort.by && !['lastUpdated', 'title', 'deletedAt', 'owner'].includes(sort.by)) || + (sort.order && !['asc', 'desc'].includes(sort.order)) + ) { + throw new OError('Invalid sorting criteria', { sort }) + } + +// sorting by owner is not implemented, it is not needed + const sortedProjects = + sort.by === 'title' + ? [...projects].sort((a, b) => + (a.name ?? '\uffff').localeCompare(b.name ?? '\uffff') + ) + : _.orderBy( + projects, + [sort.by || 'lastUpdated'], + [sort.order || 'desc'] + ) + return sortedProjects +} + +function _formatProjectInfo(project, maxDate) { + const owner_ref = project.owner_ref + const trashed = owner_ref ? ProjectHelper.isTrashed(project, owner_ref) : false + + return { + id: project._id.toString(), + name: project.name, + owner: project.owner_ref, + lastUpdated: project.lastUpdated?.toISOString(), + lastUpdatedBy: project.lastUpdatedBy, + inactive: project.lastOpened < maxDate, + trashed, + deleted: false, + } +} + +function _formatDeletedProjectInfo(deletedProject, maxDate) { + const project = deletedProject.project + const owner_ref = project.owner_ref + const trashed = owner_ref ? ProjectHelper.isTrashed(project, owner_ref) : false + + return { + id: project._id.toString(), + name: project.name, + owner: owner_ref, + lastUpdated: project.lastUpdated?.toISOString(), + lastUpdatedBy: project.lastUpdatedBy, + inactive: project.lastOpened < maxDate, + trashed, + deleted: true, + deletedAt: deletedProject.deleterData?.deletedAt?.toISOString(), + deleterId: deletedProject.deleterData?.deleterId, + } +} + +function _matchesFilters(project, filters) { + if (filters.owned && (project.trashed || project.deleted)) { + return false + } + if (filters.trashed && (!project.trashed || project.deleted)) { + return false + } + if (filters.deleted && !project.deleted) { + return false + } + if (filters.inactive && (project.trashed || project.deleted || !project.inactive)) { + return false + } + if ( + filters.search?.length && + project.name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 + ) { + return false + } + return true +} + +function _hasActiveFilter(filters) { + return Boolean( + filters.owned || + filters.inactive || + filters.trashed || + filters.deleted || + filters.search?.length + ) +} + +async function trashProjectForUser(req, res) { + const projectId = req.params.project_id + const { userId } = req.body + await ProjectDeleter.promises.trashProject(projectId, userId) + res.sendStatus(200) +} + +async function untrashProjectForUser(req, res) { + const projectId = req.params.project_id + const { userId } = req.body + await ProjectDeleter.promises.untrashProject(projectId, userId) + res.sendStatus(200) +} + +async function deleteProject(req, res) { + const projectId = req.params.project_id + const deleterId = SessionManager.getLoggedInUserId(req.session) + const options = { deleterUser: {_id: deleterId } } + const deletedProjectData = await ProjectDeleter.promises.deleteProject(projectId, options) + return res.json(deletedProjectData) +} + +async function undeleteProject(req, res) { + const projectId = req.params.project_id + const { userId } = req.body + const undelededProject = await ProjectDeleter.promises.undeleteProject(projectId, { userId }) + await ProjectDeleter.promises.untrashProject(projectId, userId) + + return res.json({ + name: undelededProject.name, + }) +} + +async function purgeDeletedProject(req, res) { + const projectId = req.params.project_id + await ProjectDeleter.promises.expireDeletedProject(projectId) + res.sendStatus(200) +} + +export default { + manageProjectsPage: expressify(manageProjectsPage), + getProjectsJson: expressify(getProjectsJson), + deleteProject: expressify(deleteProject), + undeleteProject: expressify(undeleteProject), + purgeDeletedProject: expressify(purgeDeletedProject), + trashProjectForUser: expressify(trashProjectForUser), + untrashProjectForUser: expressify(untrashProjectForUser), +} diff --git a/services/web/modules/admin-tools/app/src/UserListController.mjs b/services/web/modules/admin-tools/app/src/UserListController.mjs new file mode 100644 index 00000000000..101cd82dc89 --- /dev/null +++ b/services/web/modules/admin-tools/app/src/UserListController.mjs @@ -0,0 +1,623 @@ +import Path from 'node:path' +import { fileURLToPath } from 'node:url' +import _ from 'lodash' +import crypto from 'crypto' +import Settings from '@overleaf/settings' +import Metrics from '@overleaf/metrics' +import logger from '@overleaf/logger' +import { User } from '../../../../app/src/models/User.mjs' +import { DeletedUser } from '../../../../app/src/models/DeletedUser.mjs' +import { DeletedProject } from '../../../../app/src/models/DeletedProject.mjs' +import { expressify, promiseMapWithLimit } from '@overleaf/promise-utils' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs' +import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.mjs' +import OneTimeTokenHandler from '../../../../app/src/Features/Security/OneTimeTokenHandler.mjs' +import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' +import UserUpdater from '../../../../app/src/Features/User/UserUpdater.mjs' +import UserDeleter from '../../../../app/src/Features/User/UserDeleter.mjs' +import UserSettingsHelper from '../../../../app/src/Features/Project/UserSettingsHelper.mjs' +import ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.mjs' +import OwnershipTransferHandler from '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs' +import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs' +import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs' +import Errors, { OError } from '../../../../app/src/Features/Errors/Errors.js' +import { db } from '../../../../app/src/infrastructure/mongodb.mjs' +import SplitTestHandler from '../../../../app/src/Features/SplitTests/SplitTestHandler.mjs' + +const __dirname = Path.dirname(fileURLToPath(import.meta.url)) + +const externalAuth = process.env.EXTERNAL_AUTH ? + process.env.EXTERNAL_AUTH.split(/\s+/).filter(m => m && m !== 'none') : [] +const availableAuthMethods = ['local', ...externalAuth] + +const userIsAdminUpdatedOnLogin = Object.fromEntries( + availableAuthMethods.map(m => [ + m, + Boolean(Settings[m]?.attAdmin) && Boolean(Settings[m]?.valAdmin) + ]) +) +const userDetailsUpdatedOnLogin = Object.fromEntries( + availableAuthMethods.map(m => [ + m, + Boolean(Settings[m]?.updateUserDetailsOnLogin) + ]) +) + +async function _sendActivationEmail(idString) { + const user = await User.findById(idString, { email: 1, _id: 0 }).lean() + if (!user) { + throw new OError('User does not exist, cannot send activation email', { userId: idString }) + } + const ONE_WEEK = 7 * 24 * 60 * 60 // seconds + const token = await OneTimeTokenHandler.promises.getNewToken( + 'password', + { user_id: idString, email: user.email }, + { expiresIn: ONE_WEEK } + ) + const setNewPasswordUrl = `${Settings.siteUrl}/user/activate?token=${token}&user_id=${idString}` + await EmailHandler.promises.sendEmail('registered', { to: user.email, setNewPasswordUrl }) + .catch(error => { + throw new OError('Failed to send activation email', { error: error.message, email: user.email }) + }) + return setNewPasswordUrl +} + +function cleanupSession(req) { + // cleanup redirects at the end of the redirect chain + delete req.session.postCheckoutRedirect + delete req.session.postLoginRedirect + delete req.session.postOnboardingRedirect +} + +async function manageUsersPage(req, res, next) { + cleanupSession(req) + + const usersBlobPending = _getUsers().catch(err => { + logger.err({ err }, 'users listing in background failed') + return undefined + }) + + const prefetchedUsersBlob = await usersBlobPending + + Metrics.inc('user-list-prefetch-users', 1, { + status: prefetchedUsersBlob ? 'success' : 'error', + }) + + await SplitTestHandler.promises.getAssignment( + req, + res, + 'themed-project-dashboard' + ) + + const userId = SessionManager.getLoggedInUserId(req.session) + const user = await User.findById(userId, 'ace') + + const userSettings = await UserSettingsHelper.buildUserSettings( + req, + res, + user + ) + + res.render(Path.resolve(__dirname, '../views/manage-users-react'), { + title: 'Manage Users', + userSettings, + prefetchedUsersBlob, + availableAuthMethods, + userDetailsUpdatedOnLogin, + userIsAdminUpdatedOnLogin, + }) +} + +async function registerNewUser(req, res, next) { + const { email, isExternal, isAdmin } = req.body + if (email == null || email === '') { + return HttpErrorHandler.unprocessableEntity(req, res, 'Email address is empty') + } + delete req.body.isExternal + req.body.password = crypto.randomBytes(32).toString('hex') + + let user + try { + user = await UserRegistrationHandler.promises.registerNewUser(req.body) + } catch (err) { + if (err.message == 'EmailAlreadyRegistered') { + return HttpErrorHandler.conflict(req, res, 'email_already_registered') + } + if (err.message === 'InvalidEmailError') { + return HttpErrorHandler.unprocessableEntity(req, res, 'email_address_is_invalid') + } + if (err.message === 'InvalidPasswordError') { + return HttpErrorHandler.unprocessableEntity(req, res, 'try_again') + } + OError.tag(err, 'error user registration', { + email, + }) + throw err + } + + try { + const reversedHostname = user.email + .split('@')[1] + .split('') + .reverse() + .join('') + const update = { + $set: { isAdmin, emails: [{ email, reversedHostname, confirmedAt: Date.now() }] }, + } + if (isExternal) { + update.$unset = { hashedPassword: "" } + } else { + await _sendActivationEmail(user._id.toString()) + } + await User.updateOne({ _id: user._id }, update).exec() + } catch (err) { + OError.tag(err, 'error finishing user registration', { + email: user.email, + }) + throw err + } + + const authMethods = isExternal ? [] : ['local'] + const { id, first_name, last_name, signUpDate } = user + const newUser = { id, email, firstName: first_name, lastName: last_name, isAdmin, signUpDate, inactive: true, deleted: false, authMethods } + res.json({ user: newUser }) +} + +async function sendActivationEmail(req, res, next) { + const { userId } = req.params + try { + await _sendActivationEmail(userId) + } catch (err) { + logger.warn({ err }) + return HttpErrorHandler.unprocessableEntity(req, res, 'Error sending activation email') + } + res.sendStatus(200) +} + +async function getUsersJson(req, res) { + const { filters, page, sort } = req.body + const usersPage = await _getUsers(filters, sort, page) + res.json(usersPage) +} + + +async function activateAccountPage(req, res, next) { + // An 'activation' is actually just a password reset on an account that + // was set with a random password originally. + if (req.query.user_id == null || req.query.token == null) { + return ErrorController.notFound(req, res) + } + + if (typeof req.query.user_id !== 'string') { + return ErrorController.forbidden(req, res) + } + + const user = await UserGetter.promises.getUser(req.query.user_id, { + email: 1, + }) + + if (!user) { + return ErrorController.notFound(req, res) + } + + req.session.doLoginAfterPasswordReset = true + + res.render(Path.resolve(__dirname, '../views/activate'), { + title: 'activate_account', + email: user.email, + token: req.query.token, + }) +} + +async function _getUsers( + filters = {}, + sort = { by: 'name', order: 'asc' }, + page = { size: 20 } +) { + const projection = { + _id: 1, + email: 1, + first_name: 1, + last_name: 1, + lastActive: 1, + lastLoggedIn: 1, + signUpDate: 1, + loginCount: 1, + isAdmin: 1, + hashedPassword: 1, + samlIdentifiers: 1, + thirdPartyIdentifiers: 1, + suspended: 1, + } + const projectionDeleted = {}; + for (const key of Object.keys(projection)) { + projectionDeleted[key] = `$user.${key}` + } + projectionDeleted.deletedAt = '$deleterData.deletedAt' + + const activeUsers = await UserGetter.promises.getUsers({}, projection) + const deletedUsers = await DeletedUser.aggregate([ + { $match: { user: { $type: 'object' } } }, + { $project: projectionDeleted }, + ]) + + const allUsers = [...activeUsers, ...deletedUsers] + + const formattedUsers = _formatUsers(allUsers) + const filteredUsers = _applyFilters(formattedUsers, filters) + const users = _sortAndPaginate(filteredUsers, sort, page) + + return { + totalSize: filteredUsers.length, + users, + } +} + +function _formatUsers(users) { + const formattedUsers = [] + const yearAgo = new Date() + yearAgo.setFullYear(yearAgo.getFullYear() - 1) + + for (const user of users) { + formattedUsers.push( + _formatUserInfo(user, yearAgo) + ) + } + + return formattedUsers +} + +function _applyFilters(users, filters) { + if (!_hasActiveFilter(filters)) { + return users + } + return users.filter(user => _matchesFilters(user, filters)) +} + +function _sortAndPaginate(users, sort, page) { + if ( + (sort.by && !['lastActive', 'signUpDate', 'email', 'name', 'deletedAt'].includes(sort.by)) || + (sort.order && !['asc', 'desc'].includes(sort.order)) + ) { + throw new OError('Invalid sorting criteria', { sort }) + } + + const LAST = '\uffff' + const sortedUsers = + sort.by === 'name' + ? [...users].sort((a, b) => + (a.lastName ?? LAST).localeCompare(b.lastName ?? LAST, undefined, { sensitivity: 'base' }) || + (a.firstName ?? LAST).localeCompare(b.firstName ?? LAST, undefined, { sensitivity: 'base' }) || + (a.email ?? LAST).localeCompare(b.email ?? LAST, undefined, { sensitivity: 'base' }) + ) + : _.orderBy( + users, + [u => { + const key = sort.by || 'signUpDate' + const value = u[key] + return typeof value === 'string' ? (value ?? LAST).toLowerCase() : value + }], + [sort.order || 'desc'] + ) + return sortedUsers +} + +function _formatUserInfo(user, maxDate) { + let authMethods = [] + if (availableAuthMethods.includes('local') && user.hashedPassword) authMethods.push('local') + if (availableAuthMethods.includes('saml') && user.samlIdentifiers?.length) authMethods.push('saml') + if (availableAuthMethods.includes('oidc') && user.thirdPartyIdentifiers?.length) authMethods.push('oidc') +// If none of the above, mark as LDAP + if (availableAuthMethods.includes('ldap') && authMethods.length === 0 && user.loginCount !== 0) authMethods.push('ldap') + +// if not all user's authentications methods update a property on login, allow admin to update that property + const allowUpdateDetails = authMethods.length === 0 || !authMethods.every(m => userDetailsUpdatedOnLogin[m]) + const allowUpdateIsAdmin = authMethods.length === 0 || !authMethods.every(m => userIsAdminUpdatedOnLogin[m]) + + return { + id: user._id.toString(), + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + isAdmin: user.isAdmin, + loginCount: user.loginCount, + signUpDate: user.signUpDate, + lastActive: user.lastActive, + lastLoggedIn: user.lastLoggedIn, + authMethods, + allowUpdateDetails, + allowUpdateIsAdmin, + ...(user.suspended && { suspended: user.suspended }), + inactive: !user.lastActive || user.lastActive < maxDate, + ...(user.deletedAt && { deletedAt: user.deletedAt }), + deleted: Boolean(user.deletedAt), + } +} + +function _matchesFilters(user, filters) { + if ( + filters.search?.length && + user.email.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 && + user.firstName?.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 && + user.lastName?.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 + ) { return false } + // Deleted users only match the 'deleted' filter + if (user.deleted) return Boolean(filters.deleted) + if (filters.all) return true + if (filters.admin) return user.isAdmin + if (filters.inactive && !user.inactive) return false + if (filters.suspended && !user.suspended) return false + for (const method of availableAuthMethods) { + if (filters[method] && !user.authMethods.includes(method)) { + return false + } + } + return true +} + +function _hasActiveFilter(filters) { + return Boolean( + filters.deleted || + filters.all || + filters.admin || + filters.inactive || + filters.suspended || + filters.local || + filters.saml || + filters.oidc || + filters.ldap || + filters.search?.length + ) +} + +async function deleteUser(req, res, next) { + const deleterUserId = SessionManager.getLoggedInUserId(req.session) + const { userId } = req.params + const { sendEmail, toUserId } = req.body + + logger.debug({ deleterUserId, userId }, 'admin is trying to delete user account') + + if (toUserId) { + try { + await OwnershipTransferHandler.promises.transferAllProjectsToUser({ + fromUserId: userId, + toUserId, + ipAddress: '0.0.0.0', + }) + } catch (err) { + logger.warn({ userId, toUserId }, err.message) + const message = 'Failed to transfer projects ownership' + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + } + + try { + await UserDeleter.promises.deleteUser(userId, { + deleterUser: { '_id': deleterUserId }, + ipAddress: req.ip, + skipEmail: !sendEmail, + }) + } catch (err) { + logger.warn({ deleterUser, userId }, err.message) + if (toUserId) { + try { // failed to delete user, try to transfer all projects back + await OwnershipTransferHandler.promises.transferAllProjectsToUser({ + toUserId, + fromUserId: userId, + ipAddress: '0.0.0.0', + }) + } catch (e) { + logger.warn({ toUserId, userId }, 'Failed to transfer the projects ownership back: ' + e.message) + } + } + const message = 'Something went wrong. Does the account still exist?' + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + + const deletedUser = await DeletedUser.findOne( + { 'user._id': userId }, { 'deleterData.deletedAt': 1 } + ).lean() + + res.json({ deletedAt: deletedUser.deleterData.deletedAt }) +} + +async function purgeDeletedUser(req, res, next) { + const deleterUserId = SessionManager.getLoggedInUserId(req.session) + const userId = req.params.userId + + logger.debug({ deleterUserId, userId }, 'admin is trying to purge deleted user account') + try { + UserDeleter.promises.expireDeletedUser(userId) + } catch (err) { + logger.warn({ restorerId, userId }, err.message) + const message = 'Something went wrong. The user is already deleted?' + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + res.sendStatus(200) +} + +async function restoreDeletedUser(req, res, next) { + const restorerId = SessionManager.getLoggedInUserId(req.session) + const userId = req.params.userId + + logger.debug({ restorerId, userId }, 'admin is trying to restore deleted user') + + let userData + try { + const deletedEntry = await DeletedUser.findOne( { "user._id": userId }).lean() + userData = deletedEntry?.user + if (!userData) { + const message = 'Something went wrong. The user is purged?' + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + + const exists = await User.findOne({ email: userData.email }, { _id: 1 }).lean() + if (exists) { + const message = req.i18n.translate('email_already_registered') + return HttpErrorHandler.conflict(req, res, message) + } + + userData.suspended = false + await User.create(userData) + await DeletedUser.deleteOne({ "user._id": userId }) + + } catch (err) { + const message = req.i18n.translate('generic_something_went_wrong') + return HttpErrorHandler.legacyInternal( + req, res, message, + OError.tag(err, 'problem restoring deleted user', { + userId, + }) + ) + } + + try { + const projects = await DeletedProject.find({ "project.owner_ref": userId }).exec() + logger.info( + { userId, projectCount: projects.length }, + 'found user projects to restore' + ) + await promiseMapWithLimit(5, projects, project => + ProjectDeleter.promises.undeleteProject(project.deleterData.deletedProjectId, { suffix: "" })) + } catch (err) { + logger.info({ userId }, err.message) + } + + return res.json({ + restoredId: userData._id.toString(), + email: userData.email, + }) +} + +async function updateUser(req, res, next) { + const userId = req.params.userId + const actorUserId = SessionManager.getLoggedInUserId(req.session) + req.logger.addFields({ actorUserId }) + const { body } = req + + const updatesInput = { ...body } + if ('firstName' in updatesInput) { + updatesInput.first_name = updatesInput.firstName + delete updatesInput.firstName + } + if ('lastName' in updatesInput) { + updatesInput.last_name = updatesInput.lastName + delete updatesInput.lastName + } + + const projection = Object.fromEntries(Object.keys(updatesInput).map(k => [k, 1])) + const user = await User.findById(userId, projection).exec() + + if (!user) { + throw new OError('problem updating user settings', { userId }) + } + + let emailIsUpdated = false + const newEmail = updatesInput.email?.trim().toLowerCase() + if (newEmail != null && newEmail !== user.email) { // email is updated + if (newEmail.indexOf('@') === -1) { + const message = req.i18n.translate('email_address_is_invalid') + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + const auditLog = { initiatorId: actorUserId, ipAddress: req.ip } + + try { + await UserUpdater.promises.changeEmailAddress(userId, newEmail, auditLog) + emailIsUpdated = true + } catch (err) { + if (err instanceof Errors.EmailExistsError) { + const message = req.i18n.translate('email_already_registered') + return HttpErrorHandler.conflict(req, res, message) + } else { + const message = req.i18n.translate('problem_changing_email_address') + return HttpErrorHandler.legacyInternal( + req, res, message, + OError.tag(err, 'problem changing email address', { + userId, + newEmail, + }) + ) + } + } + if (userId == actorUserId) { + SessionManager.setInSessionUser(req.session, { email: newEmail }) + } + } + + const update = {} + + for (let [key, value] of Object.entries(updatesInput)) { + if (key === 'email') continue + + const newValue = typeof value === 'string' ? value.trim() : value + if (newValue === user[key]) continue + + update[key] = newValue + } + + Object.assign(user, update) + try { + await user.save() + } catch { + throw new OError('problem updating user settings', { userId }) + } + + if (userId == actorUserId) { + const sessionUpdate = {} + if (update.first_name != null) sessionUpdate.first_name = update.first_name + if (update.last_name != null) sessionUpdate.last_name = update.last_name + SessionManager.setInSessionUser(req.session, sessionUpdate) + } + + if (emailIsUpdated) update.email = newEmail + + if (update.first_name != null) { + update.firstName = update.first_name + delete update.first_name + } + if (update.last_name != null) { + update.lastName = update.last_name + delete update.last_name + } + + return res.json(update) +} + +async function _getActivationLink(userId) { + try { + const tokenDoc = await db.tokens.findOne({ + use: 'password', + 'data.user_id': userId, + expiresAt: { $gt: new Date() }, + usedAt: { $exists: false }, + peekCount: { $not: { $gte: OneTimeTokenHandler.MAX_PEEKS } }, + }) + if (!tokenDoc) { + return null + } + return `${Settings.siteUrl}/user/activate?token=${tokenDoc.token}&user_id=${userId}` + } catch (err) { + logger.warn({ userId }, 'Failed to get activation link' + err.message) + return null + } +} + +async function getAdditionalUserInfo(req, res, next) { + const { userId } = req.params + const activationLink = await _getActivationLink(userId) + res.json({ activationLink }) +} + +export default { + manageUsersPage: expressify(manageUsersPage), + getUsersJson: expressify(getUsersJson), + getAdditionalUserInfo: expressify(getAdditionalUserInfo), + registerNewUser: expressify(registerNewUser), + activateAccountPage: expressify(activateAccountPage), + sendActivationEmail: expressify(sendActivationEmail), + deleteUser: expressify(deleteUser), + restoreDeletedUser: expressify(restoreDeletedUser), + purgeDeletedUser: expressify(purgeDeletedUser), + updateUser: expressify(updateUser), +} diff --git a/services/web/modules/admin-tools/app/src/tsconfig.json b/services/web/modules/admin-tools/app/src/tsconfig.json new file mode 100644 index 00000000000..ddee721c50e --- /dev/null +++ b/services/web/modules/admin-tools/app/src/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../../../../tsconfig.backend.json" } diff --git a/services/web/modules/admin-tools/app/views/activate.pug b/services/web/modules/admin-tools/app/views/activate.pug new file mode 100644 index 00000000000..2fa9ec9d545 --- /dev/null +++ b/services/web/modules/admin-tools/app/views/activate.pug @@ -0,0 +1,75 @@ +extends ../../../../app/views/layout-website-redesign + +block vars + - isWebsiteRedesign = true + +include ../../../../app/views/_mixins/material_symbol + +block content + main#main-content.content.content-alt + .container + .col-lg-6.col-xl-4.m-auto + .notification-list + .notification.notification-type-success(aria-live='off' role='alert') + .notification-content-and-cta + .notification-icon + +material-symbol('check_circle') + .notification-content + p + | #{translate("nearly_activated")} + + h1.h3 #{translate("please_set_a_password")} + + form( + name='activationForm' + data-ol-async-form + action='/user/password/set' + method='POST' + ) + +formMessages + + +customFormMessage('token-expired', 'danger') + | #{translate("activation_token_expired")} + + +customFormMessage('invalid-password', 'danger') + | #{translate('invalid_password')} + + +customFormMessage('password-must-be-different', 'danger') + | #{translate('password_change_password_must_be_different')} + + input(name='_csrf' type='hidden' value=csrfToken) + input(name='passwordResetToken' type='hidden' value=token) + + .form-group + label(for='emailField') #{translate("email")} + input#emailField.form-control( + name='email' + aria-label='email' + type='email' + placeholder='email@example.com' + autocomplete='username' + value=email + required + disabled + ) + .form-group + label(for='passwordField') #{translate("password")} + input#passwordField.form-control( + name='password' + type='password' + placeholder='********' + autocomplete='new-password' + autofocus + required + minlength=settings.passwordStrengthOptions.length.min + ) + .actions + button.btn.btn-primary( + type='submit' + data-ol-disabled-inflight + aria-label=translate('activate') + ) + span(data-ol-inflight='idle') + | #{translate('activate')} + span(hidden data-ol-inflight='pending') + | #{translate('activating')}… diff --git a/services/web/modules/admin-tools/app/views/active-projects.pug b/services/web/modules/admin-tools/app/views/active-projects.pug new file mode 100644 index 00000000000..bd96be21e9d --- /dev/null +++ b/services/web/modules/admin-tools/app/views/active-projects.pug @@ -0,0 +1,8 @@ +meta(name="ol-dummy", content="placeholder") + +h3 Active Projects (Currently Being Edited) +p.text-muted Real-time information from the editor service showing who is actively working on which projects. +#active-projects-container + +each file in entrypointScripts('modules/admin-tools/pages/active-projects') + script(type='text/javascript' nonce=scriptNonce src=file) diff --git a/services/web/modules/admin-tools/app/views/manage-projects-react.pug b/services/web/modules/admin-tools/app/views/manage-projects-react.pug new file mode 100644 index 00000000000..8be5bfa442f --- /dev/null +++ b/services/web/modules/admin-tools/app/views/manage-projects-react.pug @@ -0,0 +1,30 @@ +extends ../../../../app/views/layout-react + +block entrypointVar + - entrypoint = 'modules/admin-tools/pages/manage-projects' + +block vars + - const suppressNavContentLinks = true + - const suppressNavbar = true + - const suppressFooter = true + - const suppressPugCookieBanner = true + +block append meta + meta( + name='ol-prefetchedProjectsBlob' + data-type='json' + content=prefetchedProjectsBlob + ) + meta(name='ol-userSettings' data-type='json' content=userSettings) + meta(name='ol-overallThemes' data-type='json' content=overallThemes) + if suggestedLanguageSubdomainConfig + meta( + name='ol-suggestedLanguage' + data-type='json' + content=Object.assign(suggestedLanguageSubdomainConfig, { + lngName: translate(suggestedLanguageSubdomainConfig.lngCode), + imgUrl: buildImgPath('flags/24/' + suggestedLanguageSubdomainConfig.lngCode + '.png'), + }) + ) +block content + #manage-projects-root diff --git a/services/web/modules/admin-tools/app/views/manage-users-react.pug b/services/web/modules/admin-tools/app/views/manage-users-react.pug new file mode 100644 index 00000000000..9b9f1e8ae72 --- /dev/null +++ b/services/web/modules/admin-tools/app/views/manage-users-react.pug @@ -0,0 +1,31 @@ +extends ../../../../app/views/layout-react + +block entrypointVar + - entrypoint = 'modules/admin-tools/pages/manage-users' + +block vars + - const suppressNavContentLinks = true + - const suppressNavbar = true + - const suppressFooter = true + - const suppressPugCookieBanner = true + +block append meta + meta(name='ol-availableAuthMethods' data-type='json' content=availableAuthMethods) + meta( + name='ol-prefetchedUsersBlob' + data-type='json' + content=prefetchedUsersBlob + ) + meta(name='ol-userSettings' data-type='json' content=userSettings) + meta(name='ol-overallThemes' data-type='json' content=overallThemes) + if suggestedLanguageSubdomainConfig + meta( + name='ol-suggestedLanguage' + data-type='json' + content=Object.assign(suggestedLanguageSubdomainConfig, { + lngName: translate(suggestedLanguageSubdomainConfig.lngCode), + imgUrl: buildImgPath('flags/24/' + suggestedLanguageSubdomainConfig.lngCode + '.png'), + }) + ) +block content + #manage-users-root diff --git a/services/web/modules/admin-tools/frontend/js/manage-projects-root.tsx b/services/web/modules/admin-tools/frontend/js/manage-projects-root.tsx new file mode 100644 index 00000000000..f586ecc053a --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/manage-projects-root.tsx @@ -0,0 +1,31 @@ +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 { SplitTestProvider } from '@/shared/context/split-test-context' +import { UserSettingsProvider } from '@/shared/context/user-settings-context' +import { UserListProvider } from './user-list/context/user-list-context' +import { ProjectListProvider } from './project-list/context/project-list-context' +import ProjectListRoot from './project-list/components/project-list-root' + +function ManageProjectsRoot() { + const { isReady } = useWaitForI18n() + + if (!isReady) return null + + return ( + + + + + + + + + + ) +} + +export default withErrorBoundary(ManageProjectsRoot, () => ( + +)) + diff --git a/services/web/modules/admin-tools/frontend/js/manage-users-root.tsx b/services/web/modules/admin-tools/frontend/js/manage-users-root.tsx new file mode 100644 index 00000000000..61729031990 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/manage-users-root.tsx @@ -0,0 +1,45 @@ +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 { SplitTestProvider } from '@/shared/context/split-test-context' +import { UserSettingsProvider } from '@/shared/context/user-settings-context' +import { UsersPageProvider, useUsersPageContext } from './users-page-context.tsx' +import { UserListProvider } from './user-list/context/user-list-context' +import UserListRoot from './user-list/components/user-list-root' +import { ProjectListProvider } from './project-list/context/project-list-context' +import ProjectListRoot from './project-list/components/project-list-root' + +function UsersPageSelector() { + const { page } = useUsersPageContext() + + if (page.type === 'projects') { + return ( + + + + ) + } + return +} + +function ManageUsersRoot() { + const { isReady } = useWaitForI18n() + + if (!isReady) return null + + return ( + + + + + + + + + + ) +} + +export default withErrorBoundary(ManageUsersRoot, () => ( + +)) diff --git a/services/web/modules/admin-tools/frontend/js/pages/active-projects.js b/services/web/modules/admin-tools/frontend/js/pages/active-projects.js new file mode 100644 index 00000000000..c9254d0dcd0 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/pages/active-projects.js @@ -0,0 +1,101 @@ +document.addEventListener('DOMContentLoaded', () => { + const container = document.getElementById('active-projects-container') + const tabLink = document.querySelector('a[href="#active-projects"]') + const tabPane = document.getElementById('active-projects') + + if (!container || !tabPane) return + + let isLoaded = false + let isLoading = false + + function loadActiveProjects() { + if (isLoaded || isLoading) return + + isLoading = true + + // show loading only if tab is visible + if (tabPane.classList.contains('active')) { + container.innerHTML = '

Loading...

' + } + + fetch('/admin/active-projects') + .then(res => res.json()) + .then(data => { + container.innerHTML = renderTable(data) + isLoaded = true + }) + .catch(() => { + container.innerHTML = + '

Failed to load active projects

' + }) + .finally(() => { + isLoading = false + }) + } + + // preload immediately when /admin page opens + loadActiveProjects() + + // ensure data is loaded when tab is opened + if (tabLink) { + tabLink.addEventListener('shown.bs.tab', () => { + if (!isLoaded) loadActiveProjects() + }) + } +}) + +function renderTable(data) { + if (!Array.isArray(data) || data.length === 0) { + return ` +
+ āœ” + Great news! + No projects are currently being edited. +
+ ` + } + + const rows = data.map(project => { + const owner = project.owner || {} + const users = project.activeUsers || [] + + const usersHtml = users.length + ? `
    ${users.map(u => + `
  • ${u.name}${u.email ? ` (${u.email})` : ''}
  • ` + ).join('')}
` + : 'None detected' + + return ` + + ${project.name} + ${owner.name || 'Unknown'} + ${ + owner.email + ? `${owner.email}` + : 'N/A' + } + ${usersHtml} + ${project.connectionCount} + + ` + }).join('') + + return ` +

+ ${data.length} project(s) currently being edited +

+ + + + + + + + + + + + ${rows} +
Project NameOwnerOwner EmailActive UsersConnections
+ ` +} diff --git a/services/web/modules/admin-tools/frontend/js/pages/manage-projects.tsx b/services/web/modules/admin-tools/frontend/js/pages/manage-projects.tsx new file mode 100644 index 00000000000..1c0f3112174 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/pages/manage-projects.tsx @@ -0,0 +1,8 @@ +import { createRoot } from 'react-dom/client' +import ManageProjectsRoot from '../manage-projects-root' + +const element = document.getElementById('manage-projects-root') +if (element) { + const root = createRoot(element) + root.render() +} diff --git a/services/web/modules/admin-tools/frontend/js/pages/manage-users.tsx b/services/web/modules/admin-tools/frontend/js/pages/manage-users.tsx new file mode 100644 index 00000000000..29a982abb0e --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/pages/manage-users.tsx @@ -0,0 +1,8 @@ +import { createRoot } from 'react-dom/client' +import ManageUsersRoot from '../manage-users-root' + +const element = document.getElementById('manage-users-root') +if (element) { + const root = createRoot(element) + root.render() +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/actions-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/actions-dropdown.tsx new file mode 100644 index 00000000000..2ae610ae5d1 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/actions-dropdown.tsx @@ -0,0 +1,144 @@ +import { useTranslation } from 'react-i18next' +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import DownloadProjectButton from '../table/cells/action-buttons/download-project-button' +import TrashProjectButton from '../table/cells/action-buttons/trash-project-button' +import UntrashProjectButton from '../table/cells/action-buttons/untrash-project-button' +import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button' +import RestoreProjectButton from '../table/cells/action-buttons/restore-project-button' +import PurgeProjectButton from '../table/cells/action-buttons/purge-project-button' +import TransferProjectButton from '../table/cells/action-buttons/transfer-project-button' +import { Project } from '../../../../../types/project/api' +import MaterialIcon from '@/shared/components/material-icon' +import OLSpinner from '@/shared/components/ol/ol-spinner' + +type ActionDropdownProps = { + project: Project +} + +function ActionsDropdown({ project }: ActionDropdownProps) { + const { t } = useTranslation() + + return ( + + + + + + + {(text, downloadProject) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, untrashProject) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + + + + + +
    +
    + ) +} + +export default ActionsDropdown diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/projects-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/projects-dropdown.tsx new file mode 100644 index 00000000000..ca8a1c05b12 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/projects-dropdown.tsx @@ -0,0 +1,92 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { + Filter, + useProjectListContext, +} from '../../context/project-list-context' +import { + Dropdown, + DropdownHeader, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import BackToUserList from '../back-to-user-list' +import ProjectsFilterMenu from '../projects-filter-menu' + +type ItemProps = { + filter: Filter + text: string + onClick?: () => void +} + +export function Item({ filter, text, onClick }: ItemProps) { + const { selectFilter } = useProjectListContext() + const handleClick = () => { + selectFilter(filter) + onClick?.() + } + + return ( + + {isActive => ( + + {text} + + )} + + ) +} + +function ProjectsDropdown() { + const { t } = useTranslation() + const [title, setTitle] = useState(() => t('all_projects')) + const { filter } = useProjectListContext() + const filterTranslations = useRef>({ + owned: t('all_projects'), + inactive: t('inactive_projects'), + trashed: t('trashed_projects'), + deleted: t('deleted_projects'), + }) + + useEffect(() => { + setTitle(filterTranslations.current[filter]) + }, [filter, t]) + + return ( + + + + {title} + + + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    +
    + ) +} + +export default ProjectsDropdown diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/sort-by-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/sort-by-dropdown.tsx new file mode 100644 index 00000000000..fee9e3dd9be --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/sort-by-dropdown.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import useSort from '../../hooks/use-sort' +import withContent, { SortBtnProps } from '../sort/with-content' +import { useProjectListContext } from '../../context/project-list-context' +import { Sort } from '../../../../../types/project/api' +import { + Dropdown, + DropdownHeader, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' + +function Item({ onClick, text, iconType }: SortBtnProps) { + return ( + + {text} + + ) +} + +const ItemWithContent = withContent(Item) + +function SortByDropdown() { + const { t } = useTranslation() + const [title, setTitle] = useState(() => t('last_modified')) + const { filter, sort } = useProjectListContext() + const { handleSort } = useSort() + const sortByTranslations = useRef>({ + title: t('title'), + lastUpdated: t('last_modified'), + deletedAt: t('deleted_at'), + }) + + const handleClick = (by: Sort['by']) => { + setTitle(sortByTranslations.current[by]) + handleSort(by) + } + + useEffect(() => { + setTitle(sortByTranslations.current[sort.by]) + }, [sort.by]) + + return ( + + + + {title} + + + + + {t('sort_by')}: + + handleClick('title')} + /> + handleClick('owner')} + /> + { filter !== 'deleted' ? ( + handleClick('lastUpdated')} + /> + ) : ( + handleClick('deletedAt')} + /> + )} + + + ) +} + +export default SortByDropdown diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/delete-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/delete-project-modal.tsx new file mode 100644 index 00000000000..631d825c1e0 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/delete-project-modal.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Notification from '@/shared/components/notification' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' + +type DeleteProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function DeleteProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: DeleteProjectModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + return ( + +

    {t('about_to_delete_projects')}

    + + +
    + ) +} + +export default DeleteProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-action-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-action-modal.tsx new file mode 100644 index 00000000000..1c68f7a714f --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-action-modal.tsx @@ -0,0 +1,142 @@ +import { memo, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Project } from '../../../../../types/project/api' +import { getUserFacingMessage } from '@/infrastructure/fetch-json' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import * as eventTracking from '@/infrastructure/event-tracking' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import Notification from '@/shared/components/notification' +import OLButton from '@/shared/components/ol/ol-button' +import { + OLModal, + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' + +type ProjectsActionModalProps = { + title?: string + action: 'transfer' | 'trash' | 'delete' | 'restore' | 'purge' + actionHandler: (project: Project, options?: any) => Promise + handleCloseModal: () => void + projects: Array + showModal: boolean + options?: any + children?: React.ReactNode +} + +const greenActions = new Set(['restore', 'transfer']) +const redActions = new Set(['trash', 'delete', 'purge']) + +function ProjectsActionModal({ + title, + action, + actionHandler, + handleCloseModal, + showModal, + projects, + options, + children, +}: ProjectsActionModalProps) { + const { t } = useTranslation() + const [errors, setErrors] = useState>([]) + const [isProcessing, setIsProcessing] = useState(false) + const isMounted = useIsMounted() + + const variant = + redActions.has(action) ? 'danger' : + greenActions.has(action) ? 'primary' : 'secondary' + + const actionLabel = + action === 'transfer' ? t('change_owner') : + t(action) + + async function handleActionForProjects(projects: Array, options?: any) { + const errored = [] + setIsProcessing(true) + setErrors([]) + + for (const project of projects) { + try { + await actionHandler(project, options) + } catch (e) { + errored.push({ projectName: project.name, error: e }) + } + } + + if (isMounted.current) { + setIsProcessing(false) + } + + if (errored.length === 0) { + handleCloseModal() + } else { + setErrors(errored) + } + } + + useEffect(() => { + if (!showModal) { + setErrors([]) + setIsProcessing(false) + } + }, [showModal]) + + useEffect(() => { + if (options) { + setErrors([]) + } + }, [options]) + + useEffect(() => { + if (showModal) { + eventTracking.sendMB('admin-user-project-list-page-interaction', { + action, + isSmallDevice, + }) + } + }, [action, showModal]) + + return ( + + + {title} + + + {children} + {!isProcessing && + errors.length > 0 && + errors.map((error, i) => ( +
    + +
    + ))} +
    + + + {t('cancel')} + + handleActionForProjects(projects, options)} + disabled={isProcessing} + > + {actionLabel} + + +
    + ) +} + +export default memo(ProjectsActionModal) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-list.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-list.tsx new file mode 100644 index 00000000000..6b75fd87b3f --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-list.tsx @@ -0,0 +1,28 @@ +import classnames from 'classnames' +import { Project } from '../../../../../types/project/api' + +type ProjectsToDisplayProps = { + projects: Project[] + projectsToDisplay: Project[] +} + +function ProjectsList({ projects, projectsToDisplay }: ProjectsToDisplayProps) { + return ( +
      + {projectsToDisplay.map(project => ( +
    • id === project.id + ), + })} + > + {project.name} +
    • + ))} +
    + ) +} + +export default ProjectsList diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/purge-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/purge-project-modal.tsx new file mode 100644 index 00000000000..c4c8efa3916 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/purge-project-modal.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Notification from '@/shared/components/notification' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' + +type PurgeProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function PurgeProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: PurgeProjectModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + return ( + +

    {t('about_to_permanently_delete_projects')}

    + + +
    + ) +} + +export default PurgeProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/restore-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/restore-project-modal.tsx new file mode 100644 index 00000000000..6d92b15c9be --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/restore-project-modal.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' + +type RestoreProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function RestoreProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: RestoreProjectModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + return ( + +

    {t('about_to_restore_projects')}

    + +
    + ) +} + +export default RestoreProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx new file mode 100644 index 00000000000..384ac884425 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx @@ -0,0 +1,121 @@ +import { useMemo, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' +import SelectOwnerForm from '../select-owner-form' +import { useUserListContext } from '../../../user-list/context/user-list-context' +import { useProjectListContext } from '../../context/project-list-context' +import { User } from '../../../../../types/user/api' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import OLSpinner from '@/shared/components/ol/ol-spinner' + +type TransferProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function TransferProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: TransferProjectModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + const { loadedUsers } = useUserListContext() + const { projectsOwnerId } = useProjectListContext() + + const potentialOwners = useMemo(() => { + if (!loadedUsers) return null; + const result: UserRef[] = [] + for (const user of loadedUsers) { + if (!user.deleted && user.id !== projectsOwnerId) { + result.push({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email + }) + } + } + return result + }, [loadedUsers, projectsOwnerId]) + + const [newOwner, setNewOwner] = useState(null) + const [sendEmails, setSendEmails] = useState(false) + + const options = useMemo(() => { + if (!newOwner) return null + + return { + user_id: newOwner.id, + skipEmails: !sendEmails, + } + }, [newOwner, sendEmails]) + + useEffect(() => { + if (showModal) { + setNewOwner(null) + setSendEmails(false) + } + }, [showModal]) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + const handleCheckboxChange = (e: React.ChangeEvent) => { + setSendEmails(e.currentTarget.checked) + } + + return ( + +

    {t('ownership_of_projects_will_be_transferred')}

    + + + + + + + + + + + +
    + ) +} + +export default TransferProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/trash-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/trash-project-modal.tsx new file mode 100644 index 00000000000..6f5d60b87d2 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/trash-project-modal.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' + +type TrashProjectPropsModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function TrashProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: TrashProjectPropsModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + return ( + +

    {t('about_to_trash_projects')}

    + +

    + {t('trashing_projects_wont_affect_user_collaborators')} +

    +
    + ) +} + +export default TrashProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx new file mode 100644 index 00000000000..f54854aad44 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx @@ -0,0 +1,129 @@ +import { useTranslation } from 'react-i18next' +import ProjectListTable from './table/project-list-table' +import SearchForm from './search-form' +import ProjectsDropdown from './dropdown/projects-dropdown' +import SortByDropdown from './dropdown/sort-by-dropdown' +import ProjectTools from './table/project-tools/project-tools' +import ProjectListTitle from './title/project-list-title' +import LoadMore from './load-more' +import OLCol from '@/shared/components/ol/ol-col' +import OLRow from '@/shared/components/ol/ol-row' +import { TableContainer } from '@/shared/components/table' +import DashApiError from '@/features/project-list/components/dash-api-error' +import getMeta from '@/utils/meta' +import DefaultNavbar from '@/shared/components/navbar/default-navbar' +import Footer from '@/shared/components/footer/footer' +import SidebarDsNav from './sidebar/sidebar-ds-nav' +import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg' +import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg' +import { getUserName } from '../util/user' +import { useProjectListContext } from '../context/project-list-context' +import { useUserIdentityContext } from '../../user-list/context/user-identity-context' +import Pagination from '@/shared/components/pagination-cep' +import ProjectListSummary from './project-list-summary' +import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme' + +export function ProjectListDsNav() { + + const navbarProps = getMeta('ol-navbar') + const footerProps = getMeta('ol-footer') + + const { t } = useTranslation() + const { + error, + searchText, + setSearchText, + selectedProjects, + filter, + projectsOwnerId, + currentPage, + setCurrentPage, + totalPages, + } = useProjectListContext() + const { getUserNameById } = useUserIdentityContext() + const activeOverallTheme = useActiveOverallTheme('themed-project-dashboard') + + const userName = projectsOwnerId ? getUserNameById(projectsOwnerId) : t('all_users') + const tableTopArea = ( +
    +
    + {userName} +
    + +
    + ) + + return ( +
    +
    + +
    +
    + +
    +
    +
    + {error ? : ''} +
    +
    + +
    +
    + {selectedProjects.length !== 0 && } +
    +
    +
    +
    + + + + + +
    +
    + + +
    +
    +
    + + {tableTopArea} + + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-root.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-root.tsx new file mode 100644 index 00000000000..7e0ba796996 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-root.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { useRef } from 'react' +import { + useProjectListContext, +} from '../context/project-list-context' +import * as eventTracking from '@/infrastructure/event-tracking' +import { useTranslation } from 'react-i18next' +import LoadingBranded from '@/shared/components/loading-branded' +import { ProjectListDsNav } from './project-list-ds-nav' +import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav' +import useThemedPage from '@/shared/hooks/use-themed-page' + +export default function ProjectListRoot() { + + useThemedPage('themed-project-dashboard') + const { isLoading, loadProgress } = useProjectListContext() + + const { t } = useTranslation() + + useEffect(() => { + eventTracking.sendMB('loads_v2_dash', {}) + }, []) + + if (isLoading) { + return ( + + ) + } + + return ( + + + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-summary.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-summary.tsx new file mode 100644 index 00000000000..f5ed58db691 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-summary.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from 'react-i18next' +import { + Dropdown, + DropdownMenu, + DropdownItem, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import { useProjectListContext } from '../context/project-list-context' + +const OPTIONS = [20, 40, 80] + +export default function ProjectListSummary() { + const { + visibleProjects, + hiddenProjectsCount, + projectsPerPage, + setProjectsPerPage, + } = useProjectListContext() + + const { t } = useTranslation() + + return ( +
    + + {t('showing_x_out_of_n_projects', { + x: visibleProjects.length, + n: visibleProjects.length + hiddenProjectsCount, + })} + + + Ā· + + + + + + {projectsPerPage} + + + + {OPTIONS.map((value) => ( + setProjectsPerPage(value)} + > + {value} + + ))} + + + + {t('per_page')} + +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/projects-filter-menu.ts b/services/web/modules/admin-tools/frontend/js/project-list/components/projects-filter-menu.ts new file mode 100644 index 00000000000..fb1e17af464 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/projects-filter-menu.ts @@ -0,0 +1,15 @@ +import { Filter, useProjectListContext } from '../context/project-list-context' + +type ProjectsMenuFilterType = { + children: (isActive: boolean) => React.ReactElement + filter: Filter +} + +function ProjectsFilterMenu({ children, filter }: ProjectsMenuFilterType) { + const { filter: activeFilter } = useProjectListContext() + const isActive = filter === activeFilter + + return children(isActive) +} + +export default ProjectsFilterMenu diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx new file mode 100644 index 00000000000..085a77203a4 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import classnames from 'classnames' +import * as eventTracking from '@/infrastructure/event-tracking' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import OLCol from '@/shared/components/ol/ol-col' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import MaterialIcon from '@/shared/components/material-icon' +import { MergeAndOverride } from '../../../../../../types/utils' + +type SearchFormOwnProps = { + inputValue: string + setInputValue: (input: string) => void +} + +type SearchFormProps = MergeAndOverride< + React.ComponentProps, + SearchFormOwnProps +> + +function SearchForm({ + inputValue, + setInputValue, + className, + ...props +}: SearchFormProps) { + const { t } = useTranslation() + const placeholder = t('search_projects')+'…' + + const [localValue, setLocalValue] = useState(inputValue) + const debounceRef = useRef | null>(null) + + useEffect(() => { + setLocalValue(inputValue) + }, [inputValue]) + + const handleChange: React.ComponentProps< + typeof OLFormControl + >['onChange'] = e => { + eventTracking.sendMB('admin-project-list-page-interaction', { + action: 'search', + isSmallDevice, + }) + + const value = e.target.value + setLocalValue(value) + + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + + debounceRef.current = setTimeout(() => { + setInputValue(value) + }, 300) + } + + const handleClear = () => { + setLocalValue('') + setInputValue('') + } + + return ( + e.preventDefault()} + {...props} + > + + + } + append={ + localValue.length > 0 && ( + + ) + } + /> + + + + ) +} + +export default SearchForm diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/select-owner-form.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/select-owner-form.tsx new file mode 100644 index 00000000000..c62da1f2b07 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/select-owner-form.tsx @@ -0,0 +1,194 @@ +import classnames from 'classnames' +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useCombobox } from 'downshift' +import MaterialIcon from '@/shared/components/material-icon' +import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLSpinner from '@/shared/components/ol/ol-spinner' +import { UserRef } from '../../../../types/project/api' +import { getUserName } from '../util/user' + +const FILTER_DELAY_MS = 200 +const MAX_RESULTS = 100 + +function getDisplayName(user: UserRef) { + return `${getUserName(user)} <${user.email}>` +} + +const SelectOwnerForm = React.forwardRef< + HTMLInputElement, + { + loading: boolean + users: UserRef[] + value: UserRef | null + onChange: (user: UserRef | null) => void + } +>(function SelectOwnerForm( + { loading, users, value, onChange }, + forwardedRef +) { + const { t } = useTranslation() + const inputRef = useRef(null) + + useEffect(() => { + if (!forwardedRef) return + + if (typeof forwardedRef === 'function') { + forwardedRef(inputRef.current) + } else { + forwardedRef.current = inputRef.current + } + }, [forwardedRef]) + + const lastSelectedRef = useRef(value) + + const [inputValue, setInputValue] = useState( + value ? getDisplayName(value) : '' + ) + const [debouncedInput, setDebouncedInput] = useState(inputValue) + + useEffect(() => { + lastSelectedRef.current = value + }, [value]) + + useEffect(() => { + const id = window.setTimeout(() => { + setDebouncedInput(inputValue) + }, FILTER_DELAY_MS) + + return () => { + window.clearTimeout(id) + } + }, [inputValue]) + + const filteredOptions = useMemo(() => { + if (!debouncedInput) { + return users.slice(0, MAX_RESULTS) + } + + const query = debouncedInput.toLowerCase() + const result: UserRef[] = [] + + for (const user of users) { + const label = getDisplayName(user).toLowerCase() + + if (label.includes(query)) { + result.push(user) + if (result.length >= MAX_RESULTS) { + break + } + } + } + + return result + }, [users, debouncedInput]) + + const focusInput = useCallback(() => { + inputRef.current?.focus() + }, []) + + const { + isOpen, + highlightedIndex, + getInputProps, + getItemProps, + getMenuProps, + getLabelProps, + } = useCombobox({ + items: filteredOptions, + selectedItem: value, + inputValue, + itemToString: item => (item ? getDisplayName(item) : ''), + defaultHighlightedIndex: 0, + + onInputValueChange: ({ inputValue }) => { + setInputValue(inputValue ?? '') + }, + + onSelectedItemChange: ({ selectedItem }) => { + if (selectedItem) { + lastSelectedRef.current = selectedItem + onChange(selectedItem) + setInputValue(getDisplayName(selectedItem)) + } + }, + }) + + return ( +
    + + {t('new_owner')} + {loading && } + + +
    +
    + {value && } + + { + const last = lastSelectedRef.current + if (last) { + setInputValue(getDisplayName(last)) + } else { + setInputValue('') + onChange(null) + } + }, + onKeyDown: e => { + if (e.key === 'Enter' && highlightedIndex === -1) { + e.preventDefault() + } + }, + })} + /> +
    + +
      + {isOpen && filteredOptions.length === 0 && ( +
    • + {t('No results')} +
    • + )} + + {isOpen && + filteredOptions.map((item, index) => ( +
    • + + {getDisplayName(item)} + +
    • + ))} +
    +
    +
    + ) +}) + +export default SelectOwnerForm diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-ds-nav.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-ds-nav.tsx new file mode 100644 index 00000000000..e27f1a5add9 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-ds-nav.tsx @@ -0,0 +1,158 @@ +import classnames from 'classnames' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Dropdown } from 'react-bootstrap' +import { User as UserIcon } from '@phosphor-icons/react' +import { usePersistedResize } from '@/shared/hooks/use-resize' +import getMeta from '@/utils/meta' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { AccountMenuItems } from '@/shared/components/navbar/account-menu-items' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import SidebarFilters from './sidebar-filters' +import { getUserName } from '../../util/user' +import { useUserIdentityContext } from '../../../user-list/context/user-identity-context' +import { useProjectListContext } from '../../context/project-list-context' + +import { useScrolled } from '@/features/project-list/components/sidebar/use-scroll' +import { useSendProjectListMB } from '@/features/project-list/components/project-list-events' + +function SidebarDsNav() { + const { t } = useTranslation() + const [showAccountDropdown, setShowAccountDropdown] = useState(false) + const [showHelpDropdown, setShowHelpDropdown] = useState(false) + const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({ + name: 'users-and-projects-sidebar', + }) + const sendMB = useSendProjectListMB() + const { sessionUser } = getMeta('ol-navbar') + const { containerRef, scrolledUp } = useScrolled() + const themedDsNav = useFeatureFlag('themed-project-dashboard') + + const { getUserNameById } = useUserIdentityContext() + const { projectsOwnerId } = useProjectListContext() + + const ownerName = projectsOwnerId ? getUserNameById(projectsOwnerId) : t('all_users') + const handleBack = () => { + if (history.length > 1) { + history.back() + } else { + history.replaceState({ type: 'users' }, '') + } + } + + return ( +
    + +
    + +
    + Extended CE +
    +
    +
    +
    + ) +} + +export default SidebarDsNav diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-filters.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-filters.tsx new file mode 100644 index 00000000000..ee76ddc502c --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-filters.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import { + Filter, + useProjectListContext, +} from '../../context/project-list-context' +import ProjectsFilterMenu from '../projects-filter-menu' + +type SidebarFilterProps = { + filter: Filter + text: React.ReactNode +} + +export function SidebarFilter({ filter, text }: SidebarFilterProps) { + const { selectFilter } = useProjectListContext() + + return ( + + {isActive => ( +
  • + +
  • + )} +
    + ) +} + +export default function SidebarFilters() { + const { t } = useTranslation() + + return ( +
      + + + + + +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/sort/with-content.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/sort/with-content.tsx new file mode 100644 index 00000000000..a029004581d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/sort/with-content.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { Sort } from '../../../../../types/project/api' + +type SortBtnOwnProps = { + column: string + sort: Sort + text: string + onClick: () => void +} + +type WithContentProps = { + iconType?: string + screenReaderText: string +} + +export type SortBtnProps = SortBtnOwnProps & WithContentProps + +function withContent( + WrappedComponent: React.ComponentType +) { + function WithContent(hocProps: T) { + const { t } = useTranslation() + const { column, text, sort } = hocProps + let iconType + + let screenReaderText = t('sort_by_x', { x: text }) + + if (column === sort.by) { + iconType = + sort.order === 'asc' ? 'arrow_upward_alt' : 'arrow_downward_alt' + screenReaderText = t('reverse_x_sort_order', { x: text }) + } + + return ( + + ) + } + + return WithContent +} + +export default withContent diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/delete-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/delete-project-button.tsx new file mode 100644 index 00000000000..4acefb382d3 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/delete-project-button.tsx @@ -0,0 +1,88 @@ +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Project } from '../../../../../../../types/project/api' +import DeleteProjectModal from '../../../modals/delete-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { deleteProject } from '../../../../util/api' +import { useProjectListContext } from '../../../../context/project-list-context' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type DeleteProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function DeleteProjectButton({ project, children }: DeleteProjectButtonProps) { + + if (!project.trashed || project.deleted) return null + + const { toggleSelectedProject, updateProjectViewData } = useProjectListContext() + const { t } = useTranslation() + const text = t('delete') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleDeleteProject = useCallback(async () => { + return deleteProject(project.id).then(data => { + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + ...data, + deleted: true, + }) + }) + }, [project, toggleSelectedProject, updateProjectViewData]) + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const DeleteProjectButtonTooltip = memo(function DeleteProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(DeleteProjectButton) +export { DeleteProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/download-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/download-project-button.tsx new file mode 100644 index 00000000000..ec8dbeb0286 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/download-project-button.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback } from 'react' +import { Project } from '../../../../../../../types/project/api' +import * as eventTracking from '@/infrastructure/event-tracking' +import { useLocation } from '@/shared/hooks/use-location' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type DownloadProjectButtonProps = { + project: Project + children: (text: string, downloadProject: () => void) => React.ReactElement +} + +function DownloadProjectButton({ + project, + children, +}: DownloadProjectButtonProps) { + + if (project.deleted) return null + + const { t } = useTranslation() + const text = t('download_zip_file') + const location = useLocation() + + const downloadProject = useCallback(() => { + eventTracking.sendMB('admin-user-project-list-page-interaction', { + action: 'downloadZip', + projectId: project.id, + isSmallDevice, + }) + location.assign(`/project/${project.id}/download/zip`) + }, [project, location]) + + return children(text, downloadProject) +} + +const DownloadProjectButtonTooltip = memo( + function DownloadProjectButtonTooltip({ + project, + }: Pick) { + return ( + + {(text, downloadProject) => ( + + + + )} + + ) + } +) + +export default memo(DownloadProjectButton) +export { DownloadProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/purge-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/purge-project-button.tsx new file mode 100644 index 00000000000..c7eb023a357 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/purge-project-button.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback, useState } from 'react' +import getMeta from '@/utils/meta' +import { Project } from '../../../../../../../types/project/api' +import PurgeProjectModal from '../../../modals/purge-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { purgeProject } from '../../../../util/api' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type PurgeProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function PurgeProjectButton({ project, children }: PurgeProjectButtonProps) { + + if (!project.deleted) return null + + const { removeProjectFromView } = useProjectListContext() + const { t } = useTranslation() + const text = t('purge') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handlePurgeProject = useCallback(async () => { + await purgeProject(project.id) + removeProjectFromView(project) + }, [project, removeProjectFromView]) + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const PurgeProjectButtonTooltip = memo(function PurgeProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(PurgeProjectButton) +export { PurgeProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/restore-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/restore-project-button.tsx new file mode 100644 index 00000000000..c288c7da51f --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/restore-project-button.tsx @@ -0,0 +1,92 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { Project } from '../../../../../../../types/project/api' +import RestoreProjectModal from '../../../modals/restore-project-modal' +import { undeleteProject } from '../../../../util/api' + +type RestoreProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function RestoreProjectButton({ + project, + children, +}: RestoreProjectButtonProps) { + const { toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + const { t } = useTranslation() + const text = t('restore') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleRestoreProject = useCallback(() => { + return undeleteProject(project.id, project.owner).then(data => { + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + ...data, + deleted: false, + trashed: false, + }) + }) + }, [project, toggleSelectedProject, updateProjectViewData]) + + if (!project.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const RestoreProjectButtonTooltip = memo(function RestoreProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(RestoreProjectButton) +export { RestoreProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/transfer-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/transfer-project-button.tsx new file mode 100644 index 00000000000..9118acb3610 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/transfer-project-button.tsx @@ -0,0 +1,95 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { Project } from '../../../../../../../types/project/api' +import TransferProjectModal from '../../../modals/transfer-project-modal' +import { TransferOwnershipOptions, transferProjectOwnership } from '../../../../util/api' + +type TransferProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function TransferProjectButton({ project, children }: TransferProjectButtonProps) { + + if (project.deleted) return null + + const { + removeProjectFromView, + updateProjectViewData, + toggleSelectedProject, + projectsOwnerId } + = useProjectListContext() + const { t } = useTranslation() + const text = t('change_owner') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleTransferProject = useCallback(async (project: Project, options: TransferOwnershipOptions ) => { + await transferProjectOwnership(project.id, options) + if (!projectsOwnerId) { + updateProjectViewData({ + ...project, + owner: options.user_id, + }) + toggleSelectedProject(project.id, false) + } else { + removeProjectFromView(project) + } + }, [project, removeProjectFromView, updateProjectViewData]) + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const TransferProjectButtonTooltip = memo(function TransferProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(TransferProjectButton) +export { TransferProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/trash-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/trash-project-button.tsx new file mode 100644 index 00000000000..caf6cae30d5 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/trash-project-button.tsx @@ -0,0 +1,88 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Project } from '../../../../../../../types/project/api' +import TrashProjectModal from '../../../modals/trash-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { trashProjectForUser } from '../../../../util/api' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type TrashProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function TrashProjectButton({ project, children }: TrashProjectButtonProps) { + + if (project.trashed || project.deleted ) return null + + const { toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + const { t } = useTranslation() + const text = t('trash') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleTrashProject = useCallback(async () => { + await trashProjectForUser(project.id, project.owner) + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + trashed: true, + archived: false, + }) + }, [project, toggleSelectedProject, updateProjectViewData]) + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const TrashProjectButtonTooltip = memo(function TrashProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(TrashProjectButton) +export { TrashProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/untrash-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/untrash-project-button.tsx new file mode 100644 index 00000000000..8a0a016324f --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/untrash-project-button.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback } from 'react' +import getMeta from '@/utils/meta' +import { Project } from '../../../../../../../types/project/api' +import { useProjectListContext } from '../../../../context/project-list-context' +import { untrashProjectForUser } from '../../../../util/api' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type UntrashProjectButtonProps = { + project: Project + children: ( + text: string, + untrashProject: () => Promise + ) => React.ReactElement +} + +function UntrashProjectButton({ + project, + children, +}: UntrashProjectButtonProps) { + + if (!project.trashed || project.deleted) return null + + const { t } = useTranslation() + const text = t('untrash') + const { toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + + const handleUntrashProject = useCallback(async () => { + await untrashProjectForUser(project.id, project.owner) + toggleSelectedProject(project.id, false) + updateProjectViewData({ ...project, trashed: false }) + }, [project, toggleSelectedProject, updateProjectViewData]) + + return children(text, handleUntrashProject) +} + +const UntrashProjectButtonTooltip = memo(function UntrashProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleUntrashProject) => ( + + + + )} + + ) +}) + +export default memo(UntrashProjectButton) +export { UntrashProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/actions-cell.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/actions-cell.tsx new file mode 100644 index 00000000000..e362d2206c3 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/actions-cell.tsx @@ -0,0 +1,26 @@ +import { Project } from '../../../../../../types/project/api' +import { DownloadProjectButtonTooltip } from './action-buttons/download-project-button' +import { TransferProjectButtonTooltip } from './action-buttons/transfer-project-button' +import { TrashProjectButtonTooltip } from './action-buttons/trash-project-button' +import { UntrashProjectButtonTooltip } from './action-buttons/untrash-project-button' +import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button' +import { RestoreProjectButtonTooltip } from './action-buttons/restore-project-button' +import { PurgeProjectButtonTooltip } from './action-buttons/purge-project-button' + +type ActionsCellProps = { + project: Project +} + +export default function ActionsCell({ project }: ActionsCellProps) { + return ( + <> + + + + + + + + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/date-cell.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/date-cell.tsx new file mode 100644 index 00000000000..d685d863a63 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/date-cell.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' +import { formatDate, fromNowDate } from '@/utils/dates' +import OLTooltip from '@/shared/components/ol/ol-tooltip' + +type DateCellProps = { + projectId: string + actorName: string + date: string +} + +export default function DateCell({ projectId, actorName, date }: DateCellProps) { + const fromNow = fromNowDate(date) + const tooltipText = formatDate(date) + const { t } = useTranslation() + + return ( + + + {t('last_updated_date_by_x', { + lastUpdatedDate: fromNow, + person: actorName, + })} + + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-checkbox.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-checkbox.tsx new file mode 100644 index 00000000000..3951b4191b8 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-checkbox.tsx @@ -0,0 +1,32 @@ +import { ChangeEvent, memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useProjectListContext } from '../../context/project-list-context' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' + +export const ProjectCheckbox = memo<{ projectId: string; projectName: string }>( + ({ projectId, projectName }) => { + const { t } = useTranslation() + const { selectedProjectIds, toggleSelectedProject } = + useProjectListContext() + + const handleCheckboxChange = useCallback( + (event: ChangeEvent) => { + toggleSelectedProject(projectId, event.target.checked) + }, + [projectId, toggleSelectedProject] + ) + + return ( + + ) + } +) + +ProjectCheckbox.displayName = 'ProjectCheckbox' diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-owner-name.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-owner-name.tsx new file mode 100644 index 00000000000..29b8a9ec75e --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-owner-name.tsx @@ -0,0 +1,10 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' + +export const ProjectListOwnerName = memo<{ ownerName: string }>( + ({ ownerName }) => { + const { t } = useTranslation() + return — {t('owned_by_x', { x: ownerName })} + } +) +ProjectListOwnerName.displayName = 'ProjectListOwnerName' diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table-row.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table-row.tsx new file mode 100644 index 00000000000..91ee9db2726 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table-row.tsx @@ -0,0 +1,62 @@ +import { memo } from 'react' +import OwnerCell from './cells/owner-cell' +import DateCell from './cells/date-cell' +import { Filter } from '../../context/project-list-context' +import ActionsCell from './cells/actions-cell' +import ActionsDropdown from '../dropdown/actions-dropdown' +import { Project } from '../../../../../types/project/api' +import { ProjectCheckbox } from './project-checkbox' +import { ProjectListOwnerName } from './project-list-owner-name' +import { useUserIdentityContext } from '../../../user-list/context/user-identity-context' + +type ProjectListTableRowProps = { + project: Project + selected: boolean + filter: Filter +} +function ProjectListTableRow({ project, selected, filter }: ProjectListTableRowProps) { + const { getUserNameById } = useUserIdentityContext() + const ownerName = getUserNameById(project.owner) + const actorName = filter !== 'deleted' ? + getUserNameById(project.lastUpdatedBy) : + getUserNameById(project.deleterId) + const eventDate = filter !== 'deleted' ? project.lastUpdated : project.deletedAt + + return ( + + + + + + {project.name} + + + + + + + {ownerName} + + + + + +
    + +
    +
    + +
    + + + ) +} +export default memo(ProjectListTableRow) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table.tsx new file mode 100644 index 00000000000..535c21996bf --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table.tsx @@ -0,0 +1,189 @@ +import { useCallback, useRef, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ProjectListTableRow from './project-list-table-row' +import { useProjectListContext } from '../../context/project-list-context' +import useSort from '../../hooks/use-sort' +import withContent, { SortBtnProps } from '../sort/with-content' +import OLTable from '@/shared/components/ol/ol-table' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import MaterialIcon from '@/shared/components/material-icon' + +function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) { + return ( + + ) +} + +const SortByButton = withContent(SortBtn) + +function ProjectListTable() { + const { t } = useTranslation() + const { + visibleProjects, + sort, + searchText, + selectedProjects, + selectOrUnselectAllProjects, + filter, + currentPage, + setCurrentPage, + } = useProjectListContext() + const { handleSort } = useSort() + const checkAllRef = useRef(null) + + const handleAllProjectsCheckboxChange = useCallback( + (event: React.ChangeEvent) => { + selectOrUnselectAllProjects(event.target.checked) + }, + [selectOrUnselectAllProjects] + ) + + useEffect(() => { + if (checkAllRef.current) { + checkAllRef.current.indeterminate = + selectedProjects.length > 0 && + selectedProjects.length !== visibleProjects.length + } + }, [selectedProjects, visibleProjects]) + + const [lastNonSearchPage, setLastNonSearchPage] = useState(1) + const [isSearching, setIsSearching] = useState(false) + + return ( + + {t('projects_list')} + + + + + + + handleSort('title')} + /> + + + {t('date_and_owner')} + + + handleSort('owner')} + /> + + {filter !== 'deleted' ? ( + + handleSort('lastUpdated')} + /> + + ) : ( + + handleSort('deletedAt')} + /> + + )} + + {t('actions')} + + + + + {visibleProjects.length > 0 ? ( + visibleProjects.map(p => ( + id === p.id)} + key={p.id} + filter={filter} + /> + )) + ) : ( + + + {t('no_projects')} + + + )} + + + ) +} + +export default ProjectListTable diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/delete-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/delete-projects-button.tsx new file mode 100644 index 00000000000..cb09ead662e --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/delete-projects-button.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import OLButton from '@/shared/components/ol/ol-button' +import { useTranslation } from 'react-i18next' +import DeleteProjectModal from '../../../modals/delete-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { deleteProject } from '../../../../util/api' +import { Project } from '../../../../../../../types/project/api' + +function DeleteProjectsButton() { + const { t } = useTranslation() + const { + selectedProjects, + toggleSelectedProject, + updateProjectViewData, + } = useProjectListContext() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const handleDeleteProject = async (project: Project) => { + return deleteProject(project.id).then(data => { + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + ...data, + deleted: true, + }) + }) + } + + return ( + <> + + {t('delete')} + + {showModal && ( + + )} + + ) +} + +export default DeleteProjectsButton diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/download-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/download-projects-button.tsx new file mode 100644 index 00000000000..222df01dfdc --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/download-projects-button.tsx @@ -0,0 +1,47 @@ +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import * as eventTracking from '@/infrastructure/event-tracking' +import { useProjectListContext } from '../../../../context/project-list-context' +import { useLocation } from '@/shared/hooks/use-location' +import { isSmallDevice } from '@/infrastructure/event-tracking' + +function DownloadProjectsButton() { + const { selectedProjects, selectOrUnselectAllProjects } = + useProjectListContext() + const { t } = useTranslation() + const text = t('download') + const location = useLocation() + + const projectIds = selectedProjects.map(p => p.id) + + const handleDownloadProjects = useCallback(() => { + eventTracking.sendMB('admin-user-project-list-page-interaction', { + action: 'downloadZips', + isSmallDevice, + }) + + location.assign(`/project/download/zip?project_ids=${projectIds.join(',')}`) + + const selected = false + selectOrUnselectAllProjects(selected) + }, [projectIds, selectOrUnselectAllProjects, location]) + + return ( + + + + ) +} + +export default memo(DownloadProjectsButton) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/purge-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/purge-projects-button.tsx new file mode 100644 index 00000000000..20a41cee07a --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/purge-projects-button.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import OLButton from '@/shared/components/ol/ol-button' +import { useTranslation } from 'react-i18next' +import PurgeProjectModal from '../../../modals/purge-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { purgeProject } from '../../../../util/api' +import { Project } from '../../../../../../../types/project/api' + +function PurgeProjectsButton() { + const { t } = useTranslation() + const { + selectedProjects, + removeProjectFromView + } = useProjectListContext() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const handlePurgeProject = async (project: Project) => { + await purgeProject(project.id) + removeProjectFromView(project) + } + + return ( + <> + + {t('purge')} + + {showModal && ( + + )} + + ) +} + +export default PurgeProjectsButton diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/restore-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/restore-projects-button.tsx new file mode 100644 index 00000000000..072c25af489 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/restore-projects-button.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import OLButton from '@/shared/components/ol/ol-button' +import { useProjectListContext } from '../../../../context/project-list-context' +import { undeleteProject } from '../../../../util/api' +import RestoreProjectModal from '../../../modals/restore-project-modal' +import { Project } from '../../../../../../../types/project/api' + +function RestoreProjectsButton() { + const { t } = useTranslation() + const { + selectedProjects, + toggleSelectedProject, + updateProjectViewData, + } = useProjectListContext() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const handleRestoreProject = (project: Project) => { +// const ownerId = project.owner ?? getMeta('ol-user_id') + const ownerId = project.owner ?? getMeta('ol-user_id') + return undeleteProject(project.id, project.owner).then(data => { + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + ...data, + deleted: false, + trashed: false, + }) + }) + } + + return ( + <> + + {t('restore')} + + {showModal && ( + + )} + + ) +} + +export default RestoreProjectsButton diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/transfer-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/transfer-projects-button.tsx new file mode 100644 index 00000000000..090dae81fe8 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/transfer-projects-button.tsx @@ -0,0 +1,75 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import TransferProjectModal from '../../../modals/transfer-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { TransferOwnershipOptions, transferProjectOwnership } from '../../../../util/api' +import { Project } from '../../../../../../../types/project/api' + +function TransferProjectsButton() { + const { + selectedProjects, + removeProjectFromView, + updateProjectViewData, + toggleSelectedProject, + projectsOwnerId } + = useProjectListContext() + + const { t } = useTranslation() + const text = t('change_owner') + + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleTransferProject = async (project: Project, options: TransferOwnershipOptions ) => { + await transferProjectOwnership(project.id, options) + if (!projectsOwnerId) { + updateProjectViewData({ + ...project, + owner: options.user_id, + }) + toggleSelectedProject(project.id, false) + } else { + removeProjectFromView(project) + } + } + + return ( + <> + + + + {showModal && ( + + )} + + ) +} + +export default memo(TransferProjectsButton) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/trash-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/trash-projects-button.tsx new file mode 100644 index 00000000000..3bcdb60f000 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/trash-projects-button.tsx @@ -0,0 +1,67 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import TrashProjectModal from '../../../modals/trash-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { trashProjectForUser } from '../../../../util/api' +import { Project } from '../../../../../../../types/project/api' + +function TrashProjectsButton() { + const { selectedProjects, toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + const { t } = useTranslation() + const text = t('trash') + + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleTrashProject = async (project: Project) => { + await trashProjectForUser(project.id, project.owner) + + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + trashed: true, + archived: false, + }) + } + + return ( + <> + + + + {showModal && ( + + )} + + ) +} + +export default memo(TrashProjectsButton) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx new file mode 100644 index 00000000000..d8c3143fbcd --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx @@ -0,0 +1,37 @@ +import { memo } from 'react' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import { useTranslation } from 'react-i18next' +import { useProjectListContext } from '../../../../context/project-list-context' +import { untrashProjectForUser } from '../../../../util/api' + +export default function UntrashProjectsButton() { + const { selectedProjects, toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + const { t } = useTranslation() + const text = t('restore') + + const handleUntrashProjects = async () => { + for (const project of selectedProjects) { + await untrashProjectForUser(project.id, project.owner) + toggleSelectedProject(project.id, false) + updateProjectViewData({ ...project, trashed: false }) + } + } + + return ( + + + + ) +} + diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/project-tools.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/project-tools.tsx new file mode 100644 index 00000000000..e2097b37e13 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/project-tools.tsx @@ -0,0 +1,52 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { useProjectListContext } from '../../../context/project-list-context' +import TransferProjectsButton from './buttons/transfer-projects-button' +import DownloadProjectsButton from './buttons/download-projects-button' +import TrashProjectsButton from './buttons/trash-projects-button' +import UntrashProjectsButton from './buttons/untrash-projects-button' +import DeleteProjectsButton from './buttons/delete-projects-button' +import RestoreProjectsButton from './buttons/restore-projects-button' +import PurgeProjectsButton from './buttons/purge-projects-button' +import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar' +import OLButtonGroup from '@/shared/components/ol/ol-button-group' + +function ProjectTools() { + const { t } = useTranslation() + const { filter } = useProjectListContext() + + return ( + + + {filter !== 'deleted' && } + {filter !== 'deleted' && } + {filter !== 'deleted' && filter !== 'trashed' && } + {filter === 'trashed' && } + + {filter === 'trashed' && ( + + + + )} + + {(filter === 'deleted') && ( + <> + + + + + + + + )} + + ) +} + +export default memo(ProjectTools) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/title/project-list-title.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/title/project-list-title.tsx new file mode 100644 index 00000000000..e406e24ef76 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/title/project-list-title.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import classnames from 'classnames' +import { Filter } from '../../context/project-list-context' + +function ProjectListTitle({ + filter, + className, +}: { + filter: Filter + className?: string +}) { + const { t } = useTranslation() + let message = t('projects') + let extraProps = {} + + switch (filter) { + case 'owned': + message = t('all_projects') + break + case 'inactive': + message = t('inactive_projects') + break + case 'trashed': + message = t('trashed_projects') + break + case 'deleted': + message = t('deleted_projects') + break + } + + return ( +

    + {message} +

    + ) +} + +export default ProjectListTitle diff --git a/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx b/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx new file mode 100644 index 00000000000..138f8bcbdb7 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx @@ -0,0 +1,373 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import getMeta from '@/utils/meta' +import { debugConsole } from '@/utils/debugging' +import useAsync from '@/shared/hooks/use-async' +import usePersistedState from '@/shared/hooks/use-persisted-state' +import { + GetProjectsResponseBody, + Project, + Sort, +} from '../../../../types/project/api' +import { getProjects } from '../util/api' +import { useUserIdentityContext } from '../../user-list/context/user-identity-context' +import sortProjects from '../util/sort-projects' + +export type Filter = 'owned' | 'trashed' | 'deleted' | 'inactive' + +type FilterMap = { + [key in Filter]: Partial | ((project: Project) => boolean) +} + +const filters: FilterMap = { + owned: (project) => + project.deleted === false && + project.trashed === false && + project.owner != null, + + trashed: (project) => + project.deleted === false && + project.trashed === true && + project.owner != null, + + deleted: (project) => + project.deleted === true, + + inactive: (project) => + project.deleted === false && + project.trashed === false && + project.inactive === true, +} + +export type ProjectListContextValue = { + error: Error | null + filter: Filter + hiddenProjectsCount: number + isLoading: ReturnType['isLoading'] + loadProgress: number + removeProjectFromView: (project: Project) => void + selectFilter: (filter: Filter) => void + selectedProjectIds: Set + selectedProjects: Project[] + selectOrUnselectAllProjects: React.Dispatch> + searchText: string + setSearchText: React.Dispatch> + setSelectedProjectIds: React.Dispatch>> + setSort: React.Dispatch> + sort: Sort + toggleSelectedProject: (projectId: string, selected?: boolean) => void + totalProjectsCount: number + projectsOwnerId: string | null + updateProjectViewData: (newProjectData: Project) => void + visibleProjects: Project[] + currentPage: number + setCurrentPage: React.Dispatch> + totalPages: number + projectsPerPage: number, + setProjectsPerPage: React.Dispatch>, +} + +export const ProjectListContext = createContext< + ProjectListContextValue | undefined +>(undefined) + +type ProjectListProviderProps = { + projectsOwnerId: string | null + children: ReactNode +} + +export function ProjectListProvider({ projectsOwnerId, children }: ProjectListProviderProps) { + const { getUserById } = useUserIdentityContext() + + const prefetchedProjectsBlob = projectsOwnerId ? null : getMeta('ol-prefetchedProjectsBlob') + const [loadedProjects, setLoadedProjects] = useState( + prefetchedProjectsBlob?.projects ?? [] + ) + + const [loadProgress, setLoadProgress] = useState( + prefetchedProjectsBlob ? 100 : 20 + ) + + const [totalProjectsCount, setTotalProjectsCount] = useState( + prefetchedProjectsBlob?.totalSize ?? 0 + ) + + const [filter, setFilter] = usePersistedState( + 'admin-project-list-filter', + 'owned' + ) + const [sort, setSort] = useState({ + by: filter === 'deleted' ? 'deletedAt' : 'lastUpdated', + order: 'desc', + }) + const prevSortRef = useRef(sort) + + const [searchText, setSearchTextState] = useState('') + const lastNonSearchPageRef = useRef(1) + const isSearchingRef = useRef(false) + + const [currentPage, setCurrentPage] = useState(1) + + const setSearchText: React.Dispatch> = value => { + setSearchTextState(prev => { + const nextValue = + typeof value === 'function' ? value(prev) : value + + const wasSearching = isSearchingRef.current + const willSearch = nextValue.length > 0 + + if (!wasSearching && willSearch) { + lastNonSearchPageRef.current = currentPage + isSearchingRef.current = true + setCurrentPage(1) + } else if (wasSearching && !willSearch) { + isSearchingRef.current = false + setCurrentPage(lastNonSearchPageRef.current) + } + + return nextValue + }) + } + + const { + isLoading: loading, + isIdle, + error, + runAsync, + } = useAsync({ + status: prefetchedProjectsBlob ? 'resolved' : 'pending', + data: prefetchedProjectsBlob, + }) + + const isLoading = isIdle ? true : loading + + useEffect(() => { + if (prefetchedProjectsBlob) return + + setLoadProgress(40) + runAsync(getProjects({ userId: projectsOwnerId, by: 'lastUpdated', order: 'desc' })) + .then(data => { + setLoadedProjects(data.projects) + setTotalProjectsCount(data.totalSize) + }) + .catch(debugConsole.error) + .finally(() => { + setLoadProgress(100) + }) + }, [projectsOwnerId, runAsync, prefetchedProjectsBlob]) + + const sortedProjects = useMemo(() => { + if ( + prevSortRef.current.by === sort.by && + prevSortRef.current.order === sort.order + ) { + return loadedProjects + } + + const sorted = sortProjects(loadedProjects, sort, getUserById) + prevSortRef.current = sort + return sorted + }, [loadedProjects, sort, getUserById]) + + const filteredProjects = useMemo(() => { + const predicate = filters[filter] + const hasSearch = searchText.length > 0 + const lower = hasSearch ? searchText.toLowerCase() : null + + return sortedProjects.filter(project => { + if (hasSearch) { + if (!project.name.toLowerCase().includes(lower!)) { + return false + } + } + + return typeof predicate === 'function' + ? predicate(project) + : true + }) + }, [sortedProjects, searchText, filter]) + + const [projectsPerPage, setProjectsPerPage] = useState(20) + const previousProjectsPerPageRef = useRef(projectsPerPage) + + useEffect(() => { + const previousProjectsPerPage = previousProjectsPerPageRef.current + + if (previousProjectsPerPage !== projectsPerPage) { + const oldStartIndex = (currentPage - 1) * previousProjectsPerPage + const newPage = Math.floor(oldStartIndex / projectsPerPage) + 1 + setCurrentPage(newPage) + previousProjectsPerPageRef.current = projectsPerPage + } + }, [projectsPerPage]) + + const totalPages = Math.ceil(filteredProjects.length / projectsPerPage) + const startIndex = (currentPage - 1) * projectsPerPage + + const visibleProjects = useMemo(() => { + return filteredProjects.slice(startIndex, startIndex + projectsPerPage) + }, [filteredProjects, startIndex, projectsPerPage]) + + const hiddenProjectsCount = Math.max( + filteredProjects.length - visibleProjects.length, + 0 + ) + + const [selectedProjectIds, setSelectedProjectIds] = useState( + () => new Set() + ) + + const toggleSelectedProject = useCallback( + (projectId: string, selected?: boolean) => { + setSelectedProjectIds(prevSelectedProjectIds => { + const selectedProjectIds = new Set(prevSelectedProjectIds) + if (selected === true) { + selectedProjectIds.add(projectId) + } else if (selected === false) { + selectedProjectIds.delete(projectId) + } else if (selectedProjectIds.has(projectId)) { + selectedProjectIds.delete(projectId) + } else { + selectedProjectIds.add(projectId) + } + return selectedProjectIds + }) + }, + [] + ) + + const selectedProjects = useMemo(() => { + return visibleProjects.filter(project => selectedProjectIds.has(project.id)) + }, [selectedProjectIds, visibleProjects]) + + const selectOrUnselectAllProjects = useCallback( + (checked: boolean) => { + setSelectedProjectIds(prevSelectedProjectIds => { + const selectedProjectIds = new Set(prevSelectedProjectIds) + for (const project of visibleProjects) { + if (checked) { + selectedProjectIds.add(project.id) + } else { + selectedProjectIds.delete(project.id) + } + } + return selectedProjectIds + }) + }, + [visibleProjects] + ) + + const selectFilter = useCallback( + (filter: Filter) => { + setFilter(filter) + selectOrUnselectAllProjects(false) + setCurrentPage(1) + + setSort(prev => { + if (filter === 'deleted' && prev.by === 'lastUpdated') { + return { ...prev, by: 'deletedAt' } + } + if (filter !== 'deleted' && prev.by === 'deletedAt') { + return { ...prev, by: 'lastUpdated' } + } + return prev + }) + }, + [selectOrUnselectAllProjects] + ) + + const updateProjectViewData = useCallback((newProjectData: Project) => { + setLoadedProjects(loadedProjects => { + return loadedProjects.map(p => + p.id === newProjectData.id ? { ...newProjectData } : p + ) + }) + }, []) + + const removeProjectFromView = useCallback((project: Project) => { + setLoadedProjects(loadedProjects => { + return loadedProjects.filter(p => p.id !== project.id) + }) + }, []) + + const value = useMemo( + () => ({ + error, + filter, + hiddenProjectsCount, + isLoading, + loadProgress, + removeProjectFromView, + selectFilter, + selectedProjects, + selectedProjectIds, + selectOrUnselectAllProjects, + searchText, + setSearchText, + setSelectedProjectIds, + setSort, + sort, + toggleSelectedProject, + totalProjectsCount, + updateProjectViewData, + projectsOwnerId, + visibleProjects, + currentPage, + setCurrentPage, + totalPages, + projectsPerPage, + setProjectsPerPage + }), + [ + error, + filter, + hiddenProjectsCount, + isLoading, + loadProgress, + removeProjectFromView, + selectFilter, + selectedProjectIds, + selectedProjects, + selectOrUnselectAllProjects, + searchText, + setSearchText, + setSelectedProjectIds, + setSort, + sort, + toggleSelectedProject, + totalProjectsCount, + projectsOwnerId, + updateProjectViewData, + visibleProjects, + currentPage, + setCurrentPage, + totalPages, + projectsPerPage, + setProjectsPerPage + ] + ) + + return ( + + {children} + + ) +} + +export function useProjectListContext() { + const context = useContext(ProjectListContext) + if (!context) { + throw new Error( + 'ProjectListContext is only available inside ProjectListProvider' + ) + } + return context +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/hooks/use-sort.ts b/services/web/modules/admin-tools/frontend/js/project-list/hooks/use-sort.ts new file mode 100644 index 00000000000..1a03c13b2f6 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/hooks/use-sort.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { useProjectListContext } from '../context/project-list-context' +import { Sort } from '../../../../types/project/api' +import { SortingOrder } from '../../../../../../types/sorting-order' + +const toggleSort = (order: SortingOrder): SortingOrder => + order === 'asc' ? 'desc' : 'asc' + +function useSort() { + const { filter, sort, setSort, setCurrentPage } = useProjectListContext() + + const handleSort = (by: Sort['by']) => { + setCurrentPage(1) + setSort(prev => ({ + by, + order: prev.by === by ? toggleSort(prev.order) : prev.order, + })) + } + + useEffect(() => { + if (filter === 'deleted' && sort.by === 'lastUpdated') { + setCurrentPage(1) + setSort(prev => ({ ...prev, by: 'deletedAt' })) + } + + if (filter !== 'deleted' && sort.by === 'deletedAt') { + setCurrentPage(1) + setSort(prev => ({ ...prev, by: 'lastUpdated' })) + } + }, [filter, sort.by, setSort, setCurrentPage]) + + return { handleSort } +} + +export default useSort diff --git a/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts b/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts new file mode 100644 index 00000000000..0ae064554ce --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts @@ -0,0 +1,43 @@ +import { + GetProjectsResponseBody, + Sort, +} from '../../../../types/project/api' +import { deleteJSON, postJSON } from '@/infrastructure/fetch-json' + +export type TransferOwnershipOptions = { + user_id: string + skipEmails: boolean +} + +export function getProjects( + params: { + userId: string, + by: Sort['by'] + order: Sort['order'] + }): Promise { + const { userId, ...sort } = params + return postJSON(`/admin/user/${userId}/projects`, { body: { sort } }) +} + +export function deleteProject(projectId: string) { + return deleteJSON(`/admin/project/${projectId}`) +} + +export function purgeProject(projectId: string) { + return deleteJSON(`/admin/project/${projectId}/purge`) +} + +export function undeleteProject(projectId: string, userId: string) { + return postJSON(`/admin/project/${projectId}/undelete`, { body: { userId } }) +} + +export function trashProjectForUser(projectId: string, userId: string) { + return postJSON(`/admin/project/${projectId}/trash`, { body: { userId } }) +} + +export function untrashProjectForUser(projectId: string, userId: string) { + return postJSON(`/admin/project/${projectId}/untrash`, { body: { userId } }) +} +export function transferProjectOwnership(projectId: string, options: TransferOwnershipOptions) { + return postJSON(`/project/${projectId}/transfer-ownership`, { body: { ...options } }) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/util/sort-projects.ts b/services/web/modules/admin-tools/frontend/js/project-list/util/sort-projects.ts new file mode 100644 index 00000000000..45d9fe5df3b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/util/sort-projects.ts @@ -0,0 +1,95 @@ +import { Project, Sort } from '../../../../types/project/api' +import { SortingOrder } from '../../../../../../types/sorting-order' +import { Compare } from '../../../../../../types/helpers/array/sort' +import { User } from '../../../../types/user/api' + +const order = (order: SortingOrder, projects: Project[]) => { + return order === 'asc' ? [...projects] : projects.reverse() +} + +function cmp(a, b) { + const aEmpty = a == null || a === "" + const bEmpty = b == null || b === "" + if (aEmpty) return Compare.SORT_A_AFTER_B + if (bEmpty) return Compare.SORT_A_BEFORE_B + return a.localeCompare(b) +} + +export const ownerNameComparator = + (getUserById: (userId: string) => User | null) => + (v1: Project, v2: Project) => { + const user1 = getUserById(v1.owner) + const user2 = getUserById(v2.owner) + + if (!user1) { + if (!user2) { + return v1.lastUpdated < v2.lastUpdated + ? Compare.SORT_A_BEFORE_B + : Compare.SORT_A_AFTER_B + } + return Compare.SORT_A_AFTER_B + } + + if (!user2) { + return Compare.SORT_A_BEFORE_B + } + + const lastNameCmp = cmp(user1.lastName, user2.lastName) + if (lastNameCmp !== Compare.SORT_KEEP_ORDER) return lastNameCmp + + const firstNameCmp = cmp(user1.firstName, user2.firstName) + if (firstNameCmp !== Compare.SORT_KEEP_ORDER) return firstNameCmp + + return v1.lastUpdated < v2.lastUpdated + ? Compare.SORT_A_BEFORE_B + : Compare.SORT_A_AFTER_B + } + +export const defaultComparator = ( + v1: Project, + v2: Project, + key: 'name' | 'lastUpdated' | 'deletedAt' +) => { + const value1 = v1[key]?.toLowerCase() + const value2 = v2[key]?.toLowerCase() + + if (value1 !== value2) { + if (value1 === undefined) return Compare.SORT_A_BEFORE_B + if (value2 === undefined) return Compare.SORT_A_AFTER_B + return value1.localeCompare(value2) + } + + return Compare.SORT_KEEP_ORDER +} + +export default function sortProjects( + projects: Project[], + sort: Sort, + getUserById: (userId: string) => string +) { + let sorted = [...projects] + + if (sort.by === 'title') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'name') + }) + } + + if (sort.by === 'lastUpdated') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'lastUpdated') + }) + } + + if (sort.by === 'deletedAt') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'deletedAt') + }) + } + + if (sort.by === 'owner') { + sorted.sort(ownerNameComparator(getUserById)) + } + + return order(sort.order, sorted) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/util/user.ts b/services/web/modules/admin-tools/frontend/js/project-list/util/user.ts new file mode 100644 index 00000000000..8a2e3641920 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/util/user.ts @@ -0,0 +1,15 @@ +import { User } from '../../../../types/user/api' +export function getUserName(user: User) { + + if (!user) return '[N/A]' + + const { firstName, lastName, email } = user + if (firstName || lastName) { + return [firstName, lastName].filter(n => n != null).join(' ') + } + if (email) { + return email + } + + return '[Noname]' +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button.tsx new file mode 100644 index 00000000000..2305300c1b1 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button.tsx @@ -0,0 +1,61 @@ +import OLButton from '@/shared/components/ol/ol-button' +import Button from '@/shared/components/button/button' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { sendMB } from '@/infrastructure/event-tracking' +import { useSendUserListMB } from './user-list-events' +import CreateAccountModal from './create-account-button/create-account-modal' + +type Segmentation = { + action: string +} + +type CreateAccountButtonProps = { + id: string + buttonText?: string + className?: string + trackingKey?: string +} + +function CreateAccountButton({ + id, + buttonText, + className, + trackingKey, +}: CreateAccountButtonProps) { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const sendUserListMB = useSendUserListMB() + + const handleButtonClick = useCallback(() => { + if (trackingKey) { + const segmentation: Segmentation = { + action: 'create-account-click', + } + sendMB(trackingKey, segmentation) + } + + sendUserListMB('create-account-click') + setShowModal(true) + }, [sendUserListMB, trackingKey]) + + return ( +
    + + {buttonText || t('create_account')} + + + {showModal && ( + setShowModal(false)} /> + )} +
    + ) +} + +export default CreateAccountButton + diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/create-account-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/create-account-modal.tsx new file mode 100644 index 00000000000..99923114e09 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/create-account-modal.tsx @@ -0,0 +1,22 @@ +import ModalContentNewUserForm from './modal-content-new-user-form' +import { OLModal } from '@/shared/components/ol/ol-modal' + +type CreateAccountModalProps = { + onHide: () => void +} + +function CreateAccountModal({ onHide }: CreateAccountModalProps) { + return ( + + + + ) +} + +export default CreateAccountModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/modal-content-new-user-form.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/modal-content-new-user-form.tsx new file mode 100644 index 00000000000..a785b328cf1 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/modal-content-new-user-form.tsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import useAsync from '@/shared/hooks/use-async' +import { debugConsole } from '@/utils/debugging' +import { + getUserFacingMessage, + postJSON, +} from '@/infrastructure/fetch-json' +import { useRefWithAutoFocus } from '@/shared/hooks/use-ref-with-auto-focus' +import Notification from '@/shared/components/notification' +import { + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import getMeta from '@/utils/meta' +import { useUserListContext } from '../../context/user-list-context' +import { User } from '../../../../../types/user/api' + +type CreateUserResult = { + user: User +} + +type Props = { + handleCloseModal: () => void +} + +const availableAuthMethods = getMeta("ol-availableAuthMethods") +const onlyLocalAuthEnabled = (availableAuthMethods.length === 1 && availableAuthMethods[0] === 'local') + +function ModalContentNewUserForm({ handleCloseModal }: Props) { + const { t } = useTranslation() + const { autoFocusedRef } = useRefWithAutoFocus() + const [userData, setUserData] = useState({ + email: '', + firstName: '', + lastName: '', + isAdmin: false, + isExternal: false, + }) + + const { refreshUsers, addUserToView } = useUserListContext() + const [redirecting, setRedirecting] = useState(false) + const { isLoading, isError, error, runAsync } = useAsync() + + const createAccount = () => { + runAsync( + postJSON('/admin/user/create', { + body: { + email: userData.email.trim(), + first_name: userData.firstName.trim(), + last_name: userData.lastName.trim(), + isAdmin: userData.isAdmin, + isExternal: userData.isExternal, + } + }) + ) + .then(data => { + addUserToView(data.user) + handleCloseModal() + }) + .catch(debugConsole.error) + } + + const handleTextChange = (e: React.ChangeEvent) => { + const { name, value } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: value })) + } + + const handleCheckboxChange = (e: React.ChangeEvent) => { + const { name, checked } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: checked })) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + createAccount() + } + + return ( + <> + + {t('create_account')} + + + + {isError && ( +
    + +
    + )} + + + + {t('email_address')} + + + + {t('first_name')} + + + + {t('last_name')} + + + + + + + + + {(!onlyLocalAuthEnabled && + + + + + + )} + + +
    + + + + {t('cancel')} + + + {t('create')} + + + + ) +} + +export default ModalContentNewUserForm diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/actions-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/actions-dropdown.tsx new file mode 100644 index 00000000000..ee2aaf690b8 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/actions-dropdown.tsx @@ -0,0 +1,161 @@ +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import MaterialIcon from '@/shared/components/material-icon' +import OLSpinner from '@/shared/components/ol/ol-spinner' +import FlagUserButton from '../table/cells/action-buttons/flag-user-button' +import DeleteUserButton from '../table/cells/action-buttons/delete-user-button' +import UpdateUserButton from '../table/cells/action-buttons/update-user-button' +import RestoreUserButton from '../table/cells/action-buttons/restore-user-button' +import PurgeUserButton from '../table/cells/action-buttons/purge-user-button' +import ShowUserInfoButton from '../table/cells/action-buttons/show-user-info-button' +import SendRegEmailButton from '../table/cells/action-buttons/send-reg-email-button' +import { User } from '../../../../../types/user/api' + +const flagActions = [ + { action: 'suspend', icon: 'pause', unfilled: false }, + { action: 'resume', icon: 'resume', unfilled: false }, +] + +type ActionDropdownProps = { + user: User +} + +function ActionsDropdown({ user }: ActionDropdownProps) { + const { t } = useTranslation() + const isSelf = getMeta('ol-user_id') === user.id + + return ( + + + + + + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + {!isSelf && ( + <> + {flagActions.map(({ action, icon, unfilled }) => ( + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + ))} + + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + {(user.authMethods.includes('local') && !user.suspended) && ( + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + )} + + )} +
    +
    + ) +} + +export default ActionsDropdown diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/menu-item-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/menu-item-button.tsx new file mode 100644 index 00000000000..200cdecf95c --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/menu-item-button.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from 'react' + +type MenuItemButtonProps = { + children: ReactNode + onClick?: (e?: React.MouseEvent) => void + className?: string + afterNode?: React.ReactNode +} + +export default function MenuItemButton({ + children, + onClick, + className, + afterNode, + ...buttonProps +}: MenuItemButtonProps) { + return ( +
  • + + {afterNode} +
  • + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/sort-by-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/sort-by-dropdown.tsx new file mode 100644 index 00000000000..3e2351e1301 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/sort-by-dropdown.tsx @@ -0,0 +1,107 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { + Dropdown, + DropdownHeader, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import { useUserListContext } from '../../context/user-list-context' +import useSort from '../../hooks/use-sort' +import withContent, { SortBtnProps } from '../sort/with-content' +import { Sort } from '../../../../../types/user/api' + +function Item({ onClick, text, iconType }: SortBtnProps) { + return ( + + {text} + + ) +} + +const ItemWithContent = withContent(Item) + +function SortByDropdown() { + const { t } = useTranslation() + const [title, setTitle] = useState(() => t('last_modified')) + const { filter, sort } = useUserListContext() + const { handleSort } = useSort() + const sortByTranslations = useRef>({ + name: t('name'), + email: t('email'), + signUpDate: t('signed_up'), + lastActive: t('last_active'), + deletedAt: t('deleted_at'), + }) + + const handleClick = (by: Sort['by']) => { + setTitle(sortByTranslations.current[by]) + handleSort(by) + } + + useEffect(() => { + setTitle(sortByTranslations.current[sort.by]) + }, [sort.by]) + + return ( + + + + {title} + + + + + {t('sort_by')}: + + handleClick('name')} + /> + handleClick('email')} + /> + + { filter !== 'deleted' ? ( + handleClick('signUpDate')} + /> + ) : ( + handleClick('deletedAt')} + /> + )} + handleClick('lastActive')} + /> + + + ) +} + +export default SortByDropdown diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/users-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/users-dropdown.tsx new file mode 100644 index 00000000000..559091e4b5d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/users-dropdown.tsx @@ -0,0 +1,74 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + Filter, + useUserListContext, +} from '../../context/user-list-context' +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import UsersFilterMenu from '../users-filter-menu' + +type ItemProps = { + filter: Filter + text: string + onClick?: () => void +} + +export function Item({ filter, text, onClick }: ItemProps) { + const { selectFilter } = useUserListContext() + const handleClick = () => { + selectFilter(filter) + onClick?.() + } + + return ( + + {isActive => ( + + {text} + + )} + + ) +} + +function UsersDropdown() { + const { t } = useTranslation() + const { filter, filterTranslations } = useUserListContext() + + const title = filterTranslations.get(filter) ?? t('user_category_all') + + return ( + + + + {title} + + + + {[...filterTranslations.entries()].map(([key, text]) => ( +
  • + +
  • + ))} +
    +
    + ) +} + +export default UsersDropdown diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/delete-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/delete-user-modal.tsx new file mode 100644 index 00000000000..270db0578fb --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/delete-user-modal.tsx @@ -0,0 +1,135 @@ +import React, { useEffect, useState, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' +import Notification from '@/shared/components/notification' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import OLForm from '@/shared/components/ol/ol-form' +import SelectOwnerForm from '../../../project-list/components/select-owner-form' +import { useUserListContext } from '../../../user-list/context/user-list-context' +import { UserRef } from '../../../../../types/project/api' + +type DeleteUserModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function DeleteUserModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: DeleteUserModalProps) { + const { t } = useTranslation() + const { loadedUsers } = useUserListContext() + + const [usersToDisplay, setUsersToDisplay] = useState([]) + const [sendEmail, setSendEmail] = useState(false) + const [transferProjects, setTransferProjects] = useState(false) + const [newOwner, setNewOwner] = useState(null) + + const selectOwnerInputRef = useRef(null) + + const potentialOwners = useMemo(() => { + if (!loadedUsers) return [] + const excludeIds = new Set(users.map(u => u.id)) + return loadedUsers.filter( + user => !user.deleted && !excludeIds.has(user.id) + ) + }, [loadedUsers, users]) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => displayUsers.length ? displayUsers : users) + setSendEmail(false) + setTransferProjects(false) + setNewOwner(null) + } else { + setUsersToDisplay([]) + } + }, [showModal, users]) + + useEffect(() => { + if (transferProjects) { + selectOwnerInputRef.current?.focus() + } + }, [transferProjects]) + + const handleSendEmailChange = (e: React.ChangeEvent) => { + setSendEmail(e.currentTarget.checked) + } + + const handleTransferProjectsChange = (e: React.ChangeEvent) => { + setTransferProjects(e.currentTarget.checked) + if (!e.currentTarget.checked) { + setNewOwner(null) + } + } + + const options = useMemo(() => { + return { + sendEmail, + toUserId: transferProjects && newOwner ? newOwner.id : null, + } + }, [sendEmail, transferProjects, newOwner]) + + return ( + +

    {t('about_to_delete_accounts')}

    + + + + + + + + + + + + + {transferProjects && ( + + + + )} + +
    + ) +} + +export default DeleteUserModal + diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/flag-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/flag-user-modal.tsx new file mode 100644 index 00000000000..af724f86ea1 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/flag-user-modal.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' + +type FlagUserModalProps = Pick< + React.ComponentProps, + 'users' | 'action' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function FlagUserModal({ + users, + action, + actionHandler, + showModal, + handleCloseModal, +}: FlagUserModalProps) { + const { t } = useTranslation() + const [usersToDisplay, setUsersToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => { + return displayUsers.length ? displayUsers : users + }) + } else { + setUsersToDisplay([]) + } + }, [showModal, users]) + + let userData + switch (action) { + case 'set_admin': + userData = { isAdmin: true } + break + case 'unset_admin': + userData = { isAdmin: false } + break + case 'suspend': + userData = { suspended: true } + break + case 'resume': + userData = { suspended: false } + break + default: + return + } + +return ( + +

    {t(`about_to_${action}_accounts`)}

    + +
    + ) +} + +export default FlagUserModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/purge-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/purge-user-modal.tsx new file mode 100644 index 00000000000..e455c01dc8c --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/purge-user-modal.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' +import Notification from '@/shared/components/notification' + +type PurgeUserModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function PurgeUserModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: PurgeUserModalProps) { + const { t } = useTranslation() + const [usersToDisplay, setUsersToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => { + return displayUsers.length ? displayUsers : users + }) + } else { + setUsersToDisplay([]) + } + }, [showModal, users]) + + return ( + +

    {t('about_to_permanently_delete_accounts')}

    + + +
    + ) +} + +export default PurgeUserModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/restore-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/restore-user-modal.tsx new file mode 100644 index 00000000000..460a92924ce --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/restore-user-modal.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' + +type RestoreUserModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function RestoreUserModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: RestoreUserModalProps) { + const { t } = useTranslation() + const [usersToDisplay, setUsersToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => { + return displayUsers.length ? displayUsers : users + }) + } else { + setUsersToDisplay([]) + } + }, [showModal, users]) + + return ( + +

    {t('about_to_restore_accounts')}

    + +
    + ) +} + +export default RestoreUserModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/send-reg-email-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/send-reg-email-modal.tsx new file mode 100644 index 00000000000..c0f64d4f1dd --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/send-reg-email-modal.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' +import { useUserListContext } from '../../context/user-list-context' + +type SendRegEmailModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function SendRegEmailModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: SendRegEmailModalProps) { + const { t } = useTranslation() + const { selectedUsers, toggleSelectedUser } = useUserListContext() + + const localUsers = useMemo( + () => users.filter(user => user.authMethods?.includes('local') && !user.suspended), + [users] + ) + + const [usersToDisplay, setUsersToDisplay] = useState([]) + + useEffect(() => { + if (!showModal) return + + selectedUsers.forEach(user => { + if (!user.authMethods?.includes('local') || user.suspended) { + toggleSelectedUser(user.id, false) + } + }) + // intentionally depends only on showModal + }, [showModal]) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => { + return displayUsers.length ? displayUsers : localUsers + }) + } else { + setUsersToDisplay([]) + } + }, [showModal, localUsers]) + + return ( + +

    {t('about_to_resend_activation_email')}

    + +
    + ) +} + +export default SendRegEmailModal + diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/show-user-info-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/show-user-info-modal.tsx new file mode 100644 index 00000000000..d46c2ae0aa1 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/show-user-info-modal.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useState } from 'react' +import { Card } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLCard from '@/shared/components/ol/ol-card' +import OLBadge from '@/shared/components/ol/ol-badge' +import { formatDate } from '@/utils/dates' +import UsersActionModal from './users-action-modal' +import { getAdditionalUserInfo } from '../../util/api' + +type ShowUserInfoModalProps = Pick< + React.ComponentProps, + 'users' | 'showModal' | 'handleCloseModal' +> + +function InfoRow({ + label, + value, +}: { + label: string + value: React.ReactNode +}) { + return ( + + + {label} + + {value} + + ) +} + +function ShowUserInfoModal({ + users, + showModal, + handleCloseModal, +}: ShowUserInfoModalProps) { + const { t } = useTranslation() + + if (users.length !== 1) return null + const user = users[0] + + const [activationLink, setActivationLink] = useState(null) + const [copiedId, setCopiedId] = useState(false) + const [copiedActivationLink, setCopiedActivationLink] = useState(false) + + useEffect(() => { + if (!showModal) return + + getAdditionalUserInfo(user.id) + .then(({ activationLink }) => { + setActivationLink(activationLink) + }) + .catch(() => { + setActivationLink(null) + }) + }, [showModal, user.id]) + + const markCopied = (setter: React.Dispatch>) => { + setter(true) + setTimeout(() => setter(false), 1500) + } + + const handleCopy = (text: string, setter: React.Dispatch>) => { + if (!text) return + + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(() => markCopied(setter)) + return + } + + // fallback for older browsers + const tempInput = document.createElement('input') + tempInput.value = text + tempInput.style.position = 'fixed' + tempInput.style.opacity = '0' + document.body.appendChild(tempInput) + tempInput.select() + document.execCommand('copy') + document.body.removeChild(tempInput) + + markCopied(setter) + } + + return ( + + + {(Body) => ( + <> + {t('Account')} + + handleCopy(user.id, setCopiedId)} + > + {user.id} + {copiedId && ( + + ({t('copied')}) + + )} + + } + /> + + + + + {user.isAdmin && ( + + {t('user_category_admin')} + + } + /> + )} + {activationLink && ( + handleCopy(activationLink, setCopiedActivationLink)} + > + {activationLink} + {copiedActivationLink && ( + + ({t('copied')}) + + )} + + } + /> + )} + + + )} + + + + {(Body) => ( + <> + {t('user_activity')} + + + + + + + + )} + + + ) +} + +export default ShowUserInfoModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx new file mode 100644 index 00000000000..52f480adc83 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' +import Notification from '@/shared/components/notification' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import OLButton from '@/shared/components/ol/ol-button' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import { useRefWithAutoFocus } from '@/shared/hooks/use-ref-with-auto-focus' + +type UpdateUserModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> +const pickUserFields = ({ firstName, lastName, email, isAdmin }) => ({ firstName, lastName, email, isAdmin }) + +function UpdateUserModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: UpdateUserModalProps) { + const { t } = useTranslation() + + const { autoFocusedRef } = useRefWithAutoFocus() + + if (users.length !== 1) return null + const [userData, setUserData] = useState(pickUserFields(users[0])) + const isSelf = getMeta('ol-user_id') === users[0].id + const allowUpdateDetails = users[0].allowUpdateDetails + const allowUpdateIsAdmin = users[0].allowUpdateIsAdmin + + useEffect(() => { + if (showModal) { + setUserData(pickUserFields(users[0])) + } + }, [showModal, users]) + + const handleTextChange = (e: React.ChangeEvent) => { + const { name, value } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: value })) + } + + const handleCheckboxChange = (e: React.ChangeEvent) => { + const { name, checked } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: checked })) + } + + return ( + + + {t('email_address')} + + + + {t('first_name')} + + + + {t('last_name')} + + + + + + + + + + + ) +} + +export default UpdateUserModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-action-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-action-modal.tsx new file mode 100644 index 00000000000..0d2a258c25e --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-action-modal.tsx @@ -0,0 +1,136 @@ +import { memo, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import * as eventTracking from '@/infrastructure/event-tracking' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import { getUserFacingMessage } from '@/infrastructure/fetch-json' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import OLButton from '@/shared/components/ol/ol-button' +import { + OLModal, + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import Notification from '@/shared/components/notification' +import { User } from '../../../../../types/user/api' + +type UsersActionModalProps = { + title?: string + action: 'info' | 'update' | 'delete' | 'purge' | 'restore' | 'suspend' | 'resume' | 'set_admin' | 'unset_admin' | 'resend' + actionHandler?: (user: User, options?: any) => Promise + handleCloseModal: () => void + users: Array + options?: any + showModal: boolean + actionIsDisabled?: boolean + children?: React.ReactNode +} + +const greenActions = new Set(['update', 'restore', 'resume', 'unset_admin', 'resend']) +const redActions = new Set(['delete', 'purge', 'set_admin', 'suspend']) + +function UsersActionModal({ + title, + action, + actionHandler, + handleCloseModal, + showModal, + actionIsDisabled, + users, + options, + children, +}: UsersActionModalProps) { + const { t } = useTranslation() + const [errors, setErrors] = useState>([]) + const [isProcessing, setIsProcessing] = useState(false) + const isMounted = useIsMounted() + + const variant = + redActions.has(action) ? 'danger' : + greenActions.has(action) ? 'primary' : 'secondary' + + const actionLabel = + action === 'update' ? t('confirm') : + action === 'info' ? t('close') : + t(action) + + async function handleActionForUsers(users: Array, options?: any) { + const errored = [] + setIsProcessing(true) + setErrors([]) + + if (actionHandler) { + for (const user of users) { + try { + await actionHandler(user, options) + } catch (e) { + errored.push({ userName: user.email, error: e }) + } + } + } + + if (isMounted.current) { + setIsProcessing(false) + } + + if (errored.length === 0) { + handleCloseModal() + } else { + setErrors(errored) + } + } + + useEffect(() => { + if (showModal) { + eventTracking.sendMB('admin-user-list-page-interaction', { + action, + isSmallDevice, + }) + } + }, [action, showModal]) + + return ( + + + {title} + + + {children} + {!isProcessing && + errors.length > 0 && + errors.map((error, i) => ( +
    + +
    + ))} +
    + + {action !== 'info' && ( + + {t('cancel')} + + )} + handleActionForUsers(users, options)} + disabled={isProcessing || actionIsDisabled} + > + {actionLabel} + + +
    + ) +} + +export default memo(UsersActionModal) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-list.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-list.tsx new file mode 100644 index 00000000000..5a5fcba3999 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-list.tsx @@ -0,0 +1,29 @@ +import classnames from 'classnames' +import { User } from '../../../../../types/user/api' +import { getUserName } from '../../../project-list/util/user' + +type UsersToDisplayProps = { + users: User[] + usersToDisplay: User[] +} + +function UsersList({ users, usersToDisplay }: UsersToDisplayProps) { + return ( +
      + {usersToDisplay.map(user => ( +
    • id === user.id + ), + })} + > + {`${getUserName(user)} <${user.email}>`} +
    • + ))} +
    + ) +} + +export default UsersList diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/search-form.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/search-form.tsx new file mode 100644 index 00000000000..c1d7ff92092 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/search-form.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import classnames from 'classnames' +import * as eventTracking from '@/infrastructure/event-tracking' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import OLCol from '@/shared/components/ol/ol-col' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import MaterialIcon from '@/shared/components/material-icon' +import { MergeAndOverride } from '../../../../../../types/utils' + +type SearchFormOwnProps = { + inputValue: string + setInputValue: (input: string) => void +} + +type SearchFormProps = MergeAndOverride< + React.ComponentProps, + SearchFormOwnProps +> + +function SearchForm({ + inputValue, + setInputValue, + className, + ...props +}: SearchFormProps) { + const { t } = useTranslation() + const placeholder = t('search')+'…' + + const [localValue, setLocalValue] = useState(inputValue) + const debounceRef = useRef | null>(null) + + useEffect(() => { + setLocalValue(inputValue) + }, [inputValue]) + + const handleChange: React.ComponentProps< + typeof OLFormControl + >['onChange'] = e => { + eventTracking.sendMB('admin-user-list-page-interaction', { + action: 'search', + isSmallDevice, + }) + + const value = e.target.value + setLocalValue(value) + + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + + debounceRef.current = setTimeout(() => { + setInputValue(value) + }, 300) + } + + const handleClear = () => { + setLocalValue('') + setInputValue('') + } + + return ( + e.preventDefault()} + {...props} + > + + + } + append={ + localValue.length > 0 && ( + + ) + } + /> + + + + ) +} + +export default SearchForm diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-ds-nav.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-ds-nav.tsx new file mode 100644 index 00000000000..01e9656033b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-ds-nav.tsx @@ -0,0 +1,130 @@ +import classnames from 'classnames' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Dropdown } from 'react-bootstrap' +import { User as UserIcon } from '@phosphor-icons/react' +import { usePersistedResize } from '@/shared/hooks/use-resize' +import getMeta from '@/utils/meta' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { AccountMenuItems } from '@/shared/components/navbar/account-menu-items' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import SidebarFilters from './sidebar-filters' +import CreateAccountButton from '../create-account-button' +import { useSendUserListMB } from '../user-list-events' +import { useScrolled } from '@/features/project-list/components/sidebar/use-scroll' + +function SidebarDsNav() { + const { t } = useTranslation() + const [showAccountDropdown, setShowAccountDropdown] = useState(false) + const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({ + name: 'users-and-projects-sidebar', + }) + const sendMB = useSendUserListMB() + const { sessionUser } = getMeta('ol-navbar') + const { containerRef, scrolledUp, scrolledDown } = useScrolled() + const themedDsNav = useFeatureFlag('themed-project-dashboard') + return ( +
    + +
    + +
    + Extended CE +
    +
    +
    +
    + ) +} + +export default SidebarDsNav diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-filters.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-filters.tsx new file mode 100644 index 00000000000..2dcd69ac8f1 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-filters.tsx @@ -0,0 +1,42 @@ +import { useMemo } from 'react' +import { + Filter, + useUserListContext, +} from '../../context/user-list-context' +import UsersFilterMenu from '../users-filter-menu' + +type SidebarFilterProps = { + filter: Filter + text: React.ReactNode +} + +export function SidebarFilter({ filter, text }: SidebarFilterProps) { + const { selectFilter } = useUserListContext() + + return ( + + {isActive => ( +
  • + +
  • + )} +
    + ) +} + +export default function SidebarFilters() { + const { filterTranslations } = useUserListContext() + + return ( +
      + {[...filterTranslations.entries()].map(([key, text]) => ( + + ))} + +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/sort/with-content.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/sort/with-content.tsx new file mode 100644 index 00000000000..6c287714132 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/sort/with-content.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { Sort } from '../../../../../types/user/api' + +type SortBtnOwnProps = { + column: string + sort: Sort + text: string + onClick: () => void +} + +type WithContentProps = { + iconType?: string + screenReaderText: string +} + +export type SortBtnProps = SortBtnOwnProps & WithContentProps + +function withContent( + WrappedComponent: React.ComponentType +) { + function WithContent(hocProps: T) { + const { t } = useTranslation() + const { column, text, sort } = hocProps + let iconType + + let screenReaderText = t('sort_by_x', { x: text }) + + if (column === sort.by) { + iconType = + sort.order === 'asc' ? 'arrow_upward_alt' : 'arrow_downward_alt' + screenReaderText = t('reverse_x_sort_order', { x: text }) + } + + return ( + + ) + } + + return WithContent +} + +export default withContent diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/delete-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/delete-user-button.tsx new file mode 100644 index 00000000000..73873e7f0c3 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/delete-user-button.tsx @@ -0,0 +1,82 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import DeleteUserModal from '../../../modals/delete-user-modal' +import { performDeleteUser, PostActions } from '../../../../util/user-actions' + +type DeleteUserButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function DeleteUserButton({ user, children }: DeleteUserButtonProps) { + const { t } = useTranslation() + const text = t('delete') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleDeleteUser = useCallback((user: User, options: any) => { + return performDeleteUser(user, postActions, options) + }, [postActions]) + + if (user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const DeleteUserButtonTooltip = memo(function DeleteUserButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(DeleteUserButton) +export { DeleteUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/flag-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/flag-user-button.tsx new file mode 100644 index 00000000000..21213df454f --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/flag-user-button.tsx @@ -0,0 +1,105 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import FlagUserModal from '../../../modals/flag-user-modal' +import { performUpdateUser, PostActions } from '../../../../util/user-actions' + +type FlagUserButtonProps = { + user: User + action: string + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function FlagUserButton({ user, action, children }: FlagUserButtonProps) { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + const text = t(action) + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleFlagUser = useCallback((user: User, options: any) => { + return performUpdateUser(user, postActions, options) + }, [postActions]) + + if (user.deleted) return null + if (action === "suspend" && user.suspended) return null + if (action === "resume" && !user.suspended) return null + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const FlagUserButtonTooltip = memo(function FlagUserButtonTooltip({ + user, flag +}: Pick) { + + let action + let icon + let unfilled + switch (flag) { + case 'isAdmin': + action = user.isAdmin ? 'unset_admin' : 'set_admin' + icon = user.isAdmin ? 'remove_moderator' : 'add_moderator' + unfilled = true + break + case 'suspended': + action = user.suspended ? 'resume' : 'suspend' + icon = user.suspended ? 'resume' : 'pause' + unfilled = false + break + default: + return null + } + + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(FlagUserButton) +export { FlagUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/purge-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/purge-user-button.tsx new file mode 100644 index 00000000000..3cbf132d92b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/purge-user-button.tsx @@ -0,0 +1,81 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import PurgeUserModal from '../../../modals/purge-user-modal' +import { performPurgeUser, PostActions } from '../../../../util/user-actions' + +type PurgeUserButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function PurgeUserButton({ user, children }: PurgeUserButtonProps) { + const { t } = useTranslation() + const text = t('purge') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { removeUserFromView } = useUserListContext() + const postActions: PostActions = { removeUserFromView } + const handlePurgeUser = useCallback((user: User) => { + return performPurgeUser(user, postActions) + }, [user, postActions]) + + if (!user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const PurgeUserButtonTooltip = memo(function PurgeUserButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(PurgeUserButton) +export { PurgeUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/restore-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/restore-user-button.tsx new file mode 100644 index 00000000000..4cccb535886 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/restore-user-button.tsx @@ -0,0 +1,81 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import RestoreUserModal from '../../../modals/restore-user-modal' +import { performRestoreUser, PostActions } from '../../../../util/user-actions' + +type RestoreUserButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function RestoreUserButton({ user, children }: RestoreUserButtonProps) { + const { t } = useTranslation() + const text = t('restore') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleRestoreUser = useCallback((user: User) => { + return performRestoreUser(user, postActions) + }, [user, postActions]) + + if (!user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const RestoreUserButtonTooltip = memo(function RestoreUserButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(RestoreUserButton) +export { RestoreUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/send-reg-email-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/send-reg-email-button.tsx new file mode 100644 index 00000000000..8c726ae67d4 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/send-reg-email-button.tsx @@ -0,0 +1,85 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import SendRegEmailModal from '../../../modals/send-reg-email-modal' +import { performSendRegEmail, PostActions } from '../../../../util/user-actions' + +type SendRegEmailButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function SendRegEmailButton({ user, children }: SendRegEmailButtonProps) { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + const text = t('resend') + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser } + const handleSendRegEmail = useCallback((user: User) => { + return performSendRegEmail(user, postActions) + }, [postActions]) + + if (user.deleted) return null + + const isHidden = !user.authMethods.includes('local') || user.suspended + + return ( + + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const SendRegEmailButtonTooltip = memo(function SendRegEmailButtonTooltip({ + user, +}: Pick) { + + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(SendRegEmailButton) +export { SendRegEmailButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/show-user-info-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/show-user-info-button.tsx new file mode 100644 index 00000000000..50905953850 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/show-user-info-button.tsx @@ -0,0 +1,79 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import ShowUserInfoModal from '../../../modals/show-user-info-modal' + +type ShowUserInfoButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function ShowUserInfoButton({ user, children }: ShowUserInfoButtonProps) { + const { t } = useTranslation() + const text = t('info') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const handleShowUserInfo = useCallback((user: User) => { + return performShowUserInfo(user) + }, []) + + if (user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const ShowUserInfoButtonTooltip = memo(function ShowUserInfoButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(ShowUserInfoButton) +export { ShowUserInfoButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/update-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/update-user-button.tsx new file mode 100644 index 00000000000..6b93027a340 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/update-user-button.tsx @@ -0,0 +1,82 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import UpdateUserModal from '../../../modals/update-user-modal' +import { performUpdateUser, PostActions } from '../../../../util/user-actions' + +type UpdateUserButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function UpdateUserButton({ user, children }: UpdateUserButtonProps) { + const { t } = useTranslation() + const text = t('update') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleUpdateUser = useCallback((user: User, options: any) => { + return performUpdateUser(user, postActions, options) + }, [postActions]) + + if (user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + {showModal && ( + + )} + + ) +} + +const UpdateUserButtonTooltip = memo(function UpdateUserButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(UpdateUserButton) +export { UpdateUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/actions-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/actions-cell.tsx new file mode 100644 index 00000000000..6c096c046ca --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/actions-cell.tsx @@ -0,0 +1,34 @@ +import getMeta from '@/utils/meta' +import { DeleteUserButtonTooltip } from './action-buttons/delete-user-button' +import { RestoreUserButtonTooltip } from './action-buttons/restore-user-button' +import { PurgeUserButtonTooltip } from './action-buttons/purge-user-button' +import { FlagUserButtonTooltip } from './action-buttons/flag-user-button' +import { UpdateUserButtonTooltip } from './action-buttons/update-user-button' +import { ShowUserInfoButtonTooltip } from './action-buttons/show-user-info-button' +import { SendRegEmailButtonTooltip } from './action-buttons/send-reg-email-button' +import { User } from '../../../../../../types/user/api' + +type ActionsCellProps = { + user: User +} + +export default function ActionsCell({ user }: ActionsCellProps) { + const isSelf = getMeta('ol-user_id') === user.id + return ( +
    + + + + + + + + + + + + + +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/deleted-at-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/deleted-at-cell.tsx new file mode 100644 index 00000000000..9c694125807 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/deleted-at-cell.tsx @@ -0,0 +1,22 @@ +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { formatDate, fromNowDate } from '@/utils/dates' +import { User } from '../../../../../../types/user/api' + +type DeletedAtProps = { + user: User +} + +export default function deletedAtCell({ user }: deletedAtCellProps) { + const deletedAt = user.deletedAt ? fromNowDate(user.deletedAt) : 'Not deleted' + const tooltipText = user.deletedAt ? formatDate(user.deletedAt) : 'Not deleted' + return ( + + {deletedAt} + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/email-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/email-cell.tsx new file mode 100644 index 00000000000..81f2b8617d5 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/email-cell.tsx @@ -0,0 +1,14 @@ +import { useTranslation } from 'react-i18next' +import { User } from '../../../../../../types/user/api' + +type EmailCellProps = { + user: User +} + +export default function EmailCell({ user }: EmailCellProps) { + return ( + + {user.email} + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/last-active-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/last-active-cell.tsx new file mode 100644 index 00000000000..0629fd365bd --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/last-active-cell.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { formatDate, fromNowDate } from '@/utils/dates' +import { User } from '../../../../../../types/user/api' + +type LastActiveProps = { + user: User +} + +export default function lastActiveCell({ user }: LastActiveCellProps) { + const { t } = useTranslation() + const lastActiveDate = user.lastActive ? fromNowDate(user.lastActive) : t('never') + const tooltipText = user.lastActive ? formatDate(user.lastActive) : t('never') + return ( + + {lastActiveDate} + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/sign-up-date-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/sign-up-date-cell.tsx new file mode 100644 index 00000000000..0a1bda9ceaf --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/sign-up-date-cell.tsx @@ -0,0 +1,22 @@ +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { formatDate, fromNowDate } from '@/utils/dates' +import { User } from '../../../../../../types/user/api' + +type SignUpDateCellProps = { + user: User +} + +export default function signUpDateCell({ user }: SignUpDateCellProps) { + const signUpDate = fromNowDate(user.signUpDate) + const tooltipText = formatDate(user.signUpDate) + return ( + + {signUpDate} + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-checkbox.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-checkbox.tsx new file mode 100644 index 00000000000..074ae3f6717 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-checkbox.tsx @@ -0,0 +1,38 @@ +import { ChangeEvent, memo, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { useUserListContext } from '../../context/user-list-context' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' + +export const UserCheckbox = memo<{ userId: string; userName: string }>( + ({ userId, userName }) => { + const { t } = useTranslation() + const { selectedUserIds, toggleSelectedUser } = + useUserListContext() + + const isSelf = useMemo(() => { + return getMeta('ol-user_id') === userId + }, [userId]) + + const handleCheckboxChange = useCallback( + (event: ChangeEvent) => { + toggleSelectedUser(userId, event.target.checked) + }, + [userId, toggleSelectedUser] + ) + + return ( + + ) + } +) + +UserCheckbox.displayName = 'UserCheckbox' diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table-row.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table-row.tsx new file mode 100644 index 00000000000..eafdc8d059e --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table-row.tsx @@ -0,0 +1,70 @@ +import { memo, useState } from 'react' +import EmailCell from './cells/email-cell' +import LastActiveCell from './cells/last-active-cell' +import SignUpDateCell from './cells/sign-up-date-cell' +import DeletedAtCell from './cells/deleted-at-cell' +import ActionsCell from './cells/actions-cell' +import ActionsDropdown from '../dropdown/actions-dropdown' +import { User } from '../../../../../types/user/api' +import { UserCheckbox } from './user-checkbox' +import { useUsersPageContext } from '../../../users-page-context' +import { getUserName } from '../../../project-list/util/user' + +type UserListTableRowProps = { + user: User + selected: boolean + filter: string +} +function UserListTableRow({ user, selected, filter }: UserListTableRowProps) { + const fullName = getUserName(user) + const rowClassName = `${selected ? 'table-active' : ''} ${user.isAdmin ? 'dash-row-admin' : ''}`.trim() + const { showProjects } = useUsersPageContext() + + return ( + + + + + + { + showProjects(user.id) + }} + style={{ cursor: 'pointer' }} + > + {fullName} + + + + — + + + + + {filter !== 'deleted' ? ( + + + + ) : ( + + + + )} + + + + +
    + +
    +
    + +
    + + + ) +} +export default memo(UserListTableRow) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx new file mode 100644 index 00000000000..cef3bcf8b33 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx @@ -0,0 +1,205 @@ +import { useCallback, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import UserListTableRow from './user-list-table-row' +import { useUserListContext } from '../../context/user-list-context' +import useSort from '../../hooks/use-sort' +import withContent, { SortBtnProps } from '../sort/with-content' +import OLTable from '@/shared/components/ol/ol-table' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import MaterialIcon from '@/shared/components/material-icon' + +function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) { + return ( + + ) +} + +const SortByButton = withContent(SortBtn) + +function UserListTable() { + const { t } = useTranslation() + const { + visibleUsers, + sort, + searchText, + selectedUsers, + selectOrUnselectAllUsers, + selfVisibleCount, + filter, + currentPage, + setCurrentPage, + } = useUserListContext() + const { handleSort } = useSort() + const checkAllRef = useRef(null) + + const handleAllUsersCheckboxChange = useCallback( + (event: React.ChangeEvent) => { + selectOrUnselectAllUsers(event.target.checked) + }, + [selectOrUnselectAllUsers] + ) + + useEffect(() => { + if (checkAllRef.current) { + checkAllRef.current.indeterminate = + selectedUsers.length > 0 && + selectedUsers.length + selfVisibleCount !== visibleUsers.length + } + }, [selectedUsers, visibleUsers]) + + return ( + + {t('users_list')} + + + + + + + handleSort('name')} + /> + + + {t('date_and_owner')} + + + handleSort('email')} + /> + + {filter !== 'deleted' ? ( + + handleSort('signUpDate')} + /> + + ) : ( + + handleSort('deletedAt')} + /> + + )} + + handleSort('lastActive')} + /> + + + {t('actions')} + + + + + {visibleUsers.length > 0 ? ( + visibleUsers.map(u => ( + id === u.id)} + key={u.id} + filter={filter} + /> + )) + ) : ( + + + {t('no_users')} + + + )} + + + ) +} + +export default UserListTable diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/delete-users-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/delete-users-button.tsx new file mode 100644 index 00000000000..8260db1eb68 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/delete-users-button.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLButton from '@/shared/components/ol/ol-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import DeleteUserModal from '../../../modals/delete-user-modal' +import { performDeleteUser, PostActions } from '../../../../util/user-actions' + +type DeleteUserResult = { + success: boolean + message: string + user: Partial +} + +function DeleteUsersButton() { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const { selectedUsers, toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: Partial = { toggleSelectedUser, updateUserViewData } + + const handleDeleteUser = (user: User, options: any) => { + return performDeleteUser(user, postActions, options) + } + + return ( + <> + + {t('delete')} + + {showModal && ( + + )} + + ) +} + +export default DeleteUsersButton diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/flag-users-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/flag-users-button.tsx new file mode 100644 index 00000000000..f88edd26432 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/flag-users-button.tsx @@ -0,0 +1,86 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import FlagUserModal from '../../../modals/flag-user-modal' +import { performUpdateUser, PostActions } from '../../../../util/user-actions' + +function FlagUsersButton({ action }: { action: string }) { + const { selectedUsers, toggleSelectedUser, updateUserViewData } = + useUserListContext() + const { t } = useTranslation() + const text = t(action) + + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleFlagUser = async (user: User, options: any) => { + await performUpdateUser(user, postActions, options) + } + + let icon + let unfilled + switch (action) { + case 'set_admin': + icon = 'add_moderator' + unfilled = true + break + case 'unset_admin': + icon = 'remove_moderator' + unfilled = true + break + case 'suspend': + icon = 'pause' + unfilled = false + break + case 'resume': + icon = 'resume' + unfilled = false + break + default: + return null + } + + return ( + <> + + + + {showModal && ( + + )} + + ) +} + +export default memo(FlagUsersButton) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/purge-users-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/purge-users-button.tsx new file mode 100644 index 00000000000..5d9b618d6ac --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/purge-users-button.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLButton from '@/shared/components/ol/ol-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import PurgeUserModal from '../../../modals/purge-user-modal' +import { performPurgeUser, postActions } from '../../../../util/user-actions' + +function PurgeUsersButton() { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const { selectedUsers, removeUserFromView } = useUserListContext() + const postActions: PostActions = { removeUserFromView } + + const handlePurgeUser = (user: User) => { + return performPurgeUser(user, postActions) + } + + return ( + <> + + {t('purge')} + + {showModal && ( + + )} + + ) +} + +export default PurgeUsersButton diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/restore-users-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/restore-users-button.tsx new file mode 100644 index 00000000000..3a73470f643 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/restore-users-button.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLButton from '@/shared/components/ol/ol-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import RestoreUserModal from '../../../modals/restore-user-modal' +import { performRestoreUser, postActions } from '../../../../util/user-actions' + +function RestoreUsersButton() { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const { selectedUsers, toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + + const handleRestoreUser = (user: User) => { + return performRestoreUser(user, postActions) + } + + return ( + <> + + {t('restore')} + + {showModal && ( + + )} + + ) +} + +export default RestoreUsersButton diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/send-reg-emails-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/send-reg-emails-button.tsx new file mode 100644 index 00000000000..2a66e7c43df --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/send-reg-emails-button.tsx @@ -0,0 +1,62 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import SendRegEmailModal from '../../../modals/send-reg-email-modal' +import { performSendRegEmail } from '../../../../util/user-actions' + +function SendRegEmailsButton({ action }: { action: string }) { + const { selectedUsers, toggleSelectedUser } = + useUserListContext() + const { t } = useTranslation() + const text = t(action) + + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const postActions: PostActions = { toggleSelectedUser } + const handleSendRegEmail = async (user: User) => { + await performSendRegEmail(user, postActions) + } + + return ( + <> + + + + {showModal && ( + + )} + + ) +} + +export default memo(SendRegEmailsButton) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/user-tools.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/user-tools.tsx new file mode 100644 index 00000000000..c11f7d3258c --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/user-tools.tsx @@ -0,0 +1,56 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { useUserListContext } from '../../../context/user-list-context' +import DeleteUsersButton from './buttons/delete-users-button' +import PurgeUsersButton from './buttons/purge-users-button' +import RestoreUsersButton from './buttons/restore-users-button' +import FlagUsersButton from './buttons/flag-users-button' +import SendRegEmailsButton from './buttons/send-reg-emails-button' +import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar' +import OLButtonGroup from '@/shared/components/ol/ol-button-group' + +function UserTools() { + const { t } = useTranslation() + const { filter, selectedUsers } = useUserListContext() + + return ( + + + {(filter === 'deleted') && ( + + + + )} + + {(filter !== 'deleted') && ( + + {filter !== 'suspended' && } + + {filter !== 'suspended' && } + + )} + + {(filter !== 'deleted') && ( + + {(filter !== 'admin' && filter !== 'suspended') && } + + + )} + + {(filter !== 'deleted') && ( + + + + )} + + {(filter === 'deleted') && ( + + + + )} + + + ) +} + +export default memo(UserTools) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/title/user-list-title.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/title/user-list-title.tsx new file mode 100644 index 00000000000..2162c9a37b1 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/title/user-list-title.tsx @@ -0,0 +1,29 @@ +import { useMemo } from 'react' +import classnames from 'classnames' +import { Filter, useUserListContext } from '../../context/user-list-context' + +function UserListTitle({ + filter, + className, +}: { + filter: Filter + className?: string +}) { + const { filterTranslations } = useUserListContext() + + let message = filterTranslations.get(filter) + let extraProps = {} + + return ( +

    + {message} +

    + ) +} + +export default UserListTitle diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/user-filter-menu.ts b/services/web/modules/admin-tools/frontend/js/user-list/components/user-filter-menu.ts new file mode 100644 index 00000000000..48f482f5503 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/user-filter-menu.ts @@ -0,0 +1,15 @@ +import { Filter, useUserListContext } from '../context/user-list-context' + +type UsersMenuFilterType = { + children: (isActive: boolean) => React.ReactElement + filter: Filter +} + +function UsersFilterMenu({ children, filter }: UsersMenuFilterType) { + const { filter: activeFilter } = useUserListContext() + const isActive = (filter === activeFilter) + + return children(isActive) +} + +export default UsersFilterMenu diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-ds-nav.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-ds-nav.tsx new file mode 100644 index 00000000000..4f658eef1cf --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-ds-nav.tsx @@ -0,0 +1,124 @@ +import { useUserListContext } from '../context/user-list-context' +import { useTranslation } from 'react-i18next' +import CreateAccountButton from './create-account-button' +import UserListTable from './table/user-list-table' +import SearchForm from './search-form' +import UsersDropdown from './dropdown/users-dropdown' +import SortByDropdown from './dropdown/sort-by-dropdown' +import UserTools from './table/user-tools/user-tools' +import UserListTitle from './title/user-list-title' +import CountUsers from './count-users' +import OLCol from '@/shared/components/ol/ol-col' +import OLRow from '@/shared/components/ol/ol-row' +import { TableContainer } from '@/shared/components/table' +import DashApiError from '@/features/project-list/components/dash-api-error' +import getMeta from '@/utils/meta' +import DefaultNavbar from '@/shared/components/navbar/default-navbar' +import Footer from '@/shared/components/footer/footer' +import SidebarDsNav from './sidebar/sidebar-ds-nav' +import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg' +import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg' +import CookieBanner from '@/shared/components/cookie-banner' +import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme' +import Pagination from '@/shared/components/pagination-cep' +import UserListSummary from './user-list-summary' + +export function UserListDsNav() { + const navbarProps = getMeta('ol-navbar') + const footerProps = getMeta('ol-footer') + + const { t } = useTranslation() + const { + error, + searchText, + setSearchText, + selectedUsers, + filter, + currentPage, + setCurrentPage, + totalPages, + } = useUserListContext() + const activeOverallTheme = useActiveOverallTheme('themed-project-dashboard') + + const tableTopArea = ( +
    + + +
    + ) + + return ( +
    + +
    + +
    +
    +
    + {error ? : ''} +
    +
    + +
    +
    + {selectedUsers.length !== 0 && } +
    +
    +
    +
    + + + + + +
    +
    + + +
    +
    +
    + + {tableTopArea} + + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-events.ts b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-events.ts new file mode 100644 index 00000000000..b9180e2261b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-events.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react' +import { sendMB } from '@/infrastructure/event-tracking' + +export type ExtraSegmentations = { + 'menu-expand': { + item: 'help' | 'account' | 'features' | 'admin' + location: 'top-menu' | 'sidebar' + } + 'menu-click': { + item: + | 'login' + | 'register' + | 'why-latex' + | 'learn' + | 'contact' + | 'templates' + location: 'top-menu' | 'sidebar' + destinationURL?: string + } + 'create-account-click': undefined +} + +export const useSendUserListMB = () => { + return useCallback( + ( + event: T, + payload: ExtraSegmentations[T] + ) => sendMB(event, payload), + [] + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-root.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-root.tsx new file mode 100644 index 00000000000..6c0cc65f9a3 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-root.tsx @@ -0,0 +1,33 @@ +import { ReactNode, useEffect, useRef } from 'react' +import { + useUserListContext, +} from '../context/user-list-context' +import * as eventTracking from '@/infrastructure/event-tracking' +import { useTranslation } from 'react-i18next' +import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav' +import LoadingBranded from '@/shared/components/loading-branded' +import useThemedPage from '@/shared/hooks/use-themed-page' +import { UserListDsNav } from './user-list-ds-nav' + +export default function UserListRoot() { + useThemedPage('themed-project-dashboard') + const { isLoading, loadProgress } = useUserListContext() + + useEffect(() => { + eventTracking.sendMB('loads_v2_dash', {}) + }, []) + + const { t } = useTranslation() + + if (isLoading) { + const loadingComponent = ( + + ) + return loadingComponent + } + return ( + + + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-summary.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-summary.tsx new file mode 100644 index 00000000000..558d548556e --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-summary.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from 'react-i18next' +import { + Dropdown, + DropdownMenu, + DropdownItem, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import { useUserListContext } from '../context/user-list-context' + +const OPTIONS = [20, 40, 80] + +export default function UserListSummary() { + const { + visibleUsers, + hiddenUsersCount, + usersPerPage, + setUsersPerPage, + } = useUserListContext() + const { t } = useTranslation() + + return ( +
    + + {t('showing_x_out_of_n_users', { + x: visibleUsers.length, + n: visibleUsers.length + hiddenUsersCount, + })} + + + Ā· + + + + + + {usersPerPage} + + + + {OPTIONS.map((value) => ( + setUsersPerPage(value)} + > + {value} + + ))} + + + + + {t('per_page')} + +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/users-filter-menu.ts b/services/web/modules/admin-tools/frontend/js/user-list/components/users-filter-menu.ts new file mode 100644 index 00000000000..48f482f5503 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/users-filter-menu.ts @@ -0,0 +1,15 @@ +import { Filter, useUserListContext } from '../context/user-list-context' + +type UsersMenuFilterType = { + children: (isActive: boolean) => React.ReactElement + filter: Filter +} + +function UsersFilterMenu({ children, filter }: UsersMenuFilterType) { + const { filter: activeFilter } = useUserListContext() + const isActive = (filter === activeFilter) + + return children(isActive) +} + +export default UsersFilterMenu diff --git a/services/web/modules/admin-tools/frontend/js/user-list/context/user-identity-context.tsx b/services/web/modules/admin-tools/frontend/js/user-list/context/user-identity-context.tsx new file mode 100644 index 00000000000..dde1f22047d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/context/user-identity-context.tsx @@ -0,0 +1,76 @@ +import { + createContext, + ReactNode, + useContext, + useMemo, + useCallback, +} from 'react' +import { User } from '../../../../types/user/api' +import { getUserName } from '../../project-list/util/user' + +export type UserIdentityContextValue = { + getUserById: (userId: string) => User | undefined + getUserNameById: (userId: string) => string +} + +const UserIdentityContext = createContext< + UserIdentityContextValue | undefined +>(undefined) + +type UserIdentityProviderProps = { + users: User[] + children: ReactNode +} + +export function UserIdentityProvider({ + users, + children, +}: UserIdentityProviderProps) { + + const usersById = useMemo(() => { + const map = new Map() + for (const user of users) { + map.set(user.id, user) + } + return map + }, [users]) + + const getUserById = useCallback( + (userId: string) => { + return usersById.get(userId) + }, + [usersById] + ) + + const getUserNameById = useCallback( + (userId: string) => { + const user = usersById.get(userId) + return getUserName(user) + }, + [usersById] + ) + + const value = useMemo( + () => ({ + getUserById, + getUserNameById, + }), + [getUserById, getUserNameById] + ) + + return ( + + {children} + + ) +} + +export function useUserIdentityContext() { + const context = useContext(UserIdentityContext) + if (!context) { + throw new Error( + 'UserIdentityContext is only available inside UserIdentityProvider' + ) + } + return context +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx new file mode 100644 index 00000000000..251b1241503 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx @@ -0,0 +1,412 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, + useRef, +} from 'react' +import { + filter as arrayFilter, +} from 'lodash' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { debugConsole } from '@/utils/debugging' +import useAsync from '@/shared/hooks/use-async' +import usePersistedState from '@/shared/hooks/use-persisted-state' +import { + GetUsersResponseBody, + User, + Sort, +} from '../../../../types/user/api' +import { getUsers } from '../util/api' +import sortUsers from '../util/sort-users' + +import { UserIdentityProvider } from './user-identity-context' + +type AuthMethods = 'local' | 'ldap' | 'saml' | 'oidc' +export type Filter = 'all' | 'admin' | 'suspended' | 'inactive' | AuthMethods | 'deleted' + +const selfId = getMeta('ol-user_id') +const availableAuthMethods: AuthMethods[] = getMeta('ol-availableAuthMethods') ?? [] + +type FilterMap = { + [key in Filter]: Partial | ((user: User) => boolean) +} + +const filters: FilterMap = { + all: { deleted: false }, + admin: { isAdmin: true, deleted: false }, + suspended: { suspended: true, deleted: false }, + inactive: { inactive: true, deleted: false }, + deleted: { deleted: true }, + ...Object.fromEntries( + availableAuthMethods.map(method => [ + method, + (user: User) => user.authMethods.includes(method) && !user.deleted + ]) + ) +} + +// if there is only one authentication source we won't show "users by authentication" lists +const filterKeys: Filter[] = [ + 'all', + 'admin', + 'suspended', + 'inactive', + ...(availableAuthMethods.length === 1 ? [] : availableAuthMethods), + 'deleted' +] + +export type UserListContextValue = { + addUserToView: (user: Partial) => void + error: Error | null + filter: Filter + filterTranslations: Map + hiddenUsersCount: number + isLoading: ReturnType['isLoading'] + loadProgress: number + removeUserFromView: (user: User) => void + searchText: string + selectFilter: (filter: Filter) => void + selectedUserIds: Set + selectedUsers: User[] + selectOrUnselectAllUsers: React.Dispatch> + selfVisibleCount: number + setSearchText: React.Dispatch> + setSelectedUserIds: React.Dispatch>> + setSort: React.Dispatch> + sort: Sort + toggleSelectedUser: (userId: string, selected?: boolean) => void + totalUsersCount: number + updateUserViewData: (newUserData: User) => void + loadedUsers: User[] + visibleUsers: User[] + currentPage: number + setCurrentPage: React.Dispatch> + totalPages: number + usersPerPage: number, + setUsersPerPage: React.Dispatch>, +} + +export const UserListContext = createContext< + UserListContextValue | undefined +>(undefined) + +type UserListProviderProps = { + children: ReactNode +} + +export function UserListProvider({ children }: UserListProviderProps) { + const prefetchedUsersBlob = getMeta('ol-prefetchedUsersBlob') + const [loadedUsers, setLoadedUsers] = useState( + prefetchedUsersBlob?.users ?? [] + ) + + const [loadProgress, setLoadProgress] = useState( + prefetchedUsersBlob ? 100 : 20 + ) + const [totalUsersCount, setTotalUsersCount] = useState( + prefetchedUsersBlob?.totalSize ?? 0 + ) + const [sort, setSort] = useState({ + by: 'name', + order: 'asc', + }) + + const { t } = useTranslation() + + const filterTranslations = useMemo( + () => new Map(filterKeys.map(key => [key, t(`user_category_${key}`)])), + [t] + ) + + const [filter, setFilter] = usePersistedState( + 'user-list-filter', + 'all' + ) + + const [currentPage, setCurrentPage] = useState(1) + const [searchText, setSearchTextState] = useState('') + const lastNonSearchPageRef = useRef(1) + const isSearchingRef = useRef(false) + + const setSearchText: React.Dispatch> = value => { + setSearchTextState(prev => { + const nextValue = + typeof value === 'function' ? value(prev) : value + + const wasSearching = isSearchingRef.current + const willSearch = nextValue.length > 0 + + if (!wasSearching && willSearch) { + lastNonSearchPageRef.current = currentPage + isSearchingRef.current = true + setCurrentPage(1) + } else if (wasSearching && !willSearch) { + isSearchingRef.current = false + setCurrentPage(lastNonSearchPageRef.current) + } + + return nextValue + }) + } + + const { + isLoading: loading, + isIdle, + error, + runAsync, + } = useAsync({ + status: prefetchedUsersBlob ? 'resolved' : 'pending', + data: prefetchedUsersBlob, + }) + const isLoading = isIdle ? true : loading + + + useEffect(() => { + if (prefetchedUsersBlob) return + setLoadProgress(40) + runAsync(getUsers({ by: 'name', order: 'asc' })) + .then(data => { + setLoadedUsers(data.users) + setTotalUsersCount(data.totalSize) + }) + .catch(debugConsole.error) + .finally(() => { + setLoadProgress(100) + }) + }, [prefetchedUsersBlob, runAsync]) + + const addUserToView = useCallback((newUser: Partial) => { + setLoadedUsers(prev => [newUser as User, ...prev]) + }, []) + + const isDefaultSort = + sort.by === 'name' && sort.order === 'asc' + + const filteredUsers = useMemo(() => { + let users = loadedUsers + + if (searchText.length) { + const searchTextLowerCase = searchText.toLowerCase() + users = users.filter(user => + user.email?.toLowerCase().includes(searchTextLowerCase) || + user.firstName?.toLowerCase().includes(searchTextLowerCase) || + user.lastName?.toLowerCase().includes(searchTextLowerCase) + ) + } + + users = arrayFilter(users, filters[filter]) + + return users + }, [loadedUsers, searchText, filter]) + + const processedUsers = useMemo(() => { + if (isDefaultSort) { + return filteredUsers + } + + return sortUsers(filteredUsers, sort) + }, [filteredUsers, sort, isDefaultSort]) + + const [usersPerPage, setUsersPerPage] = useState(20) + const previousUsersPerPageRef = useRef(usersPerPage) + + useEffect(() => { + const previousUsersPerPage = previousUsersPerPageRef.current + + if (previousUsersPerPage !== usersPerPage) { + const oldStartIndex = (currentPage - 1) * previousUsersPerPage + const newPage = Math.floor(oldStartIndex / usersPerPage) + 1 + setCurrentPage(newPage) + previousUsersPerPageRef.current = usersPerPage + } + }, [usersPerPage]) + + const totalPages = useMemo( + () => Math.ceil(processedUsers.length / usersPerPage), + [processedUsers.length, usersPerPage] + ) + const startIndex = (currentPage - 1) * usersPerPage + + const visibleUsers = useMemo(() => { + return processedUsers.slice(startIndex, startIndex + usersPerPage) + }, [processedUsers, startIndex, usersPerPage]) + + const hiddenUsersCount = Math.max( + processedUsers.length - visibleUsers.length, + 0 + ) + + const selfVisibleCount = useMemo(() => { + return visibleUsers.some(u => u.id === selfId) ? 1 : 0 + }, [visibleUsers]) + + const [selectedUserIds, setSelectedUserIds] = useState( + () => new Set() + ) + + const toggleSelectedUser = useCallback( + (userId: string, selected?: boolean) => { + setSelectedUserIds(prevSelectedUserIds => { + const selectedUserIds = new Set(prevSelectedUserIds) + if (selected === true) { + selectedUserIds.add(userId) + } else if (selected === false) { + selectedUserIds.delete(userId) + } else if (selectedUserIds.has(userId)) { + selectedUserIds.delete(userId) + } else { + selectedUserIds.add(userId) + } + return selectedUserIds + }) + }, + [] + ) + + const selectedUsers = useMemo(() => { + return loadedUsers.filter(user => selectedUserIds.has(user.id)) + }, [selectedUserIds, loadedUsers]) + + const selectOrUnselectAllUsers = useCallback( + (checked: any) => { + setSelectedUserIds(prevSelectedUserIds => { + const selectedUserIds = new Set(prevSelectedUserIds) + for (const user of visibleUsers) { + if (user.id === selfId) { + selectedUserIds.delete(user.id) + continue + } + if (checked) { + selectedUserIds.add(user.id) + } else { + selectedUserIds.delete(user.id) + } + } + return selectedUserIds + }) + }, + [visibleUsers] + ) + + const selectFilter = useCallback( + (filter: Filter) => { + setFilter(filter) + + setSort(prev => { + if (filter === 'deleted' && prev.by === 'signUpDate') { + return { ...prev, by: 'deletedAt' } + } + if (filter !== 'deleted' && prev.by === 'deletedAt') { + return { ...prev, by: 'signUpDate' } + } + return prev + }) + + selectOrUnselectAllUsers(false) + setCurrentPage(1) + }, + [selectOrUnselectAllUsers] + ) + + const updateUserViewData = useCallback((newUserData: User) => { + setLoadedUsers(loadedUsers => { + return loadedUsers.map(u => + u.id === newUserData.id ? { ...newUserData } : u + ) + }) + }, []) + + const removeUserFromView = useCallback((user: User) => { + setLoadedUsers(loadedUsers => { + return loadedUsers.filter(u => u.id !== user.id) + }) + }, []) + + const value = useMemo( + () => ({ + addUserToView, + error, + filter, + filterTranslations, + hiddenUsersCount, + isLoading, + loadProgress, + removeUserFromView, + searchText, + selectFilter, + selectedUserIds, + selectedUsers, + selectOrUnselectAllUsers, + selfVisibleCount, + setSearchText, + setSelectedUserIds, + setSort, + sort, + toggleSelectedUser, + totalUsersCount, + updateUserViewData, + loadedUsers, + visibleUsers, + currentPage, + setCurrentPage, + totalPages, + usersPerPage, + setUsersPerPage, + }), + [ + addUserToView, + error, + filter, + filterTranslations, + hiddenUsersCount, + isLoading, + loadProgress, + removeUserFromView, + searchText, + selectFilter, + selectedUserIds, + selectedUsers, + selectOrUnselectAllUsers, + selfVisibleCount, + setSearchText, + setSelectedUserIds, + setSort, + sort, + toggleSelectedUser, + totalUsersCount, + updateUserViewData, + loadedUsers, + visibleUsers, + currentPage, + setCurrentPage, + totalPages, + usersPerPage, + setUsersPerPage, + ] + ) + + if (isLoading) return null + + return ( + + + {children} + + + ) +} + +export function useUserListContext() { + const context = useContext(UserListContext) + if (!context) { + throw new Error( + 'UserListContext is only available inside UserListProvider' + ) + } + return context +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/hooks/use-sort.ts b/services/web/modules/admin-tools/frontend/js/user-list/hooks/use-sort.ts new file mode 100644 index 00000000000..876e9fdeb1b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/hooks/use-sort.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react' +import { Sort } from '../../../../types/user/api' +import { SortingOrder } from '../../../../../../types/sorting-order' +import { useUserListContext } from '../context/user-list-context' + +const toggleSort = (order: SortingOrder): SortingOrder => { + return order === 'asc' ? 'desc' : 'asc' +} + +function useSort() { + const { filter, sort, setSort, setCurrentPage } = useUserListContext() + + const handleSort = (by: Sort['by']) => { + setCurrentPage(1) + setSort(prev => ({ + by, + order: prev.by === by ? toggleSort(sort.order) : sort.order, + })) + } + + useEffect(() => { + if (filter === 'deleted' && sort.by === 'signUpDate') { + setCurrentPage(1) + setSort(prev => ({ ...prev, by: 'deletedAt' })) + } + + if (filter !== 'deleted' && sort.by === 'deletedAt') { + setCurrentPage(1) + setSort(prev => ({ ...prev, by: 'signUpDate' })) + } + }, [filter, sort.by, setSort, setCurrentPage]) + + return { handleSort } +} + +export default useSort diff --git a/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts b/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts new file mode 100644 index 00000000000..033ae3a06e9 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts @@ -0,0 +1,36 @@ +import { GetUsersResponseBody, Sort } from '../../../../types/user/api' +import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json' + +export function getUsers(sortBy: Sort): Promise { + return postJSON('/admin/users', { body: { sort: sortBy } }) +} + +export function updateUser(userId: string, userData: Partial) { + return postJSON(`/admin/user/${userId}/update`, { body: userData }) +} + +export function deleteUser( + userId: string, + options: { + sendEmail: boolean + toUserId: string | null + } +) { + return postJSON(`/admin/user/${userId}/delete`, { body: options } ) +} + +export function restoreUser(userId: string) { + return postJSON(`/admin/user/${userId}/restore`) +} + +export function purgeUser(userId: string) { + return deleteJSON(`/admin/user/${userId}`) +} + +export function getAdditionalUserInfo(userId: string) { + return getJSON(`/admin/user/${userId}/info`) +} + +export function sendRegEmail(userId: string) { + return postJSON(`/admin/user/${userId}/send-activation`) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/util/sort-users.ts b/services/web/modules/admin-tools/frontend/js/user-list/util/sort-users.ts new file mode 100644 index 00000000000..deabb5cdfda --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/util/sort-users.ts @@ -0,0 +1,73 @@ +import { User, Sort } from '../../../../types/user/api' +import { SortingOrder } from '../../../../../../types/sorting-order' +import { Compare } from '../../../../../../types/helpers/array/sort' + +const order = (order: SortingOrder, users: User[]) => { + return order === 'asc' ? [...users] : users.reverse() +} + +function cmp(a, b) { + const aEmpty = a == null || a === "" + const bEmpty = b == null || b === "" + if (aEmpty && bEmpty) return Compare.SORT_KEEP_ORDER + if (aEmpty) return Compare.SORT_A_AFTER_B + if (bEmpty) return Compare.SORT_A_BEFORE_B + return a.localeCompare(b) +} + +export const userNameComparator = (v1: User, v2: User) => { + const res = cmp(v1.lastName, v2.lastName) + if (res !== Compare.SORT_KEEP_ORDER) return res + return cmp(v1.firstName, v2.firstName) +} + +export const defaultComparator = ( + v1: User, + v2: User, + key: 'lastActive' | 'signUpDate' | 'deletedAt' | 'email' +) => { + const value1 = v1[key]?.toLowerCase() + const value2 = v2[key]?.toLowerCase() + + if (value1 !== value2) { + if (value1 === undefined) return Compare.SORT_A_BEFORE_B + if (value2 === undefined) return Compare.SORT_A_AFTER_B + return value1 < value2 ? Compare.SORT_A_BEFORE_B : Compare.SORT_A_AFTER_B + } + return Compare.SORT_KEEP_ORDER +} + +export default function sortUsers(users: User[], sort: Sort) { + let sorted = [...users] + if (sort.by === 'lastActive') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'lastActive') + }) + } + + if (sort.by === 'signUpDate') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'signUpDate') + }) + } + + if (sort.by === 'deletedAt') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'deletedAt') + }) + } + + if (sort.by === 'email') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'email') + }) + } + + if (sort.by === 'name') { + sorted = sorted.sort((...args) => { + return userNameComparator(...args) + }) + } + + return order(sort.order, sorted) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/util/user-actions.ts b/services/web/modules/admin-tools/frontend/js/user-list/util/user-actions.ts new file mode 100644 index 00000000000..0379a54ab09 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/util/user-actions.ts @@ -0,0 +1,81 @@ +import { User } from '../../../../types/user/api' +import { updateUser, deleteUser, purgeUser, restoreUser, sendRegEmail } from './api' + +export type PostActions = { + toggleSelectedUser?: (id: string, selected: boolean) => void + updateUserViewData?: (user: User) => void + removeUserFromView?: (user: User) => void +} + +export async function performDeleteUser( + user: User, + postActions: PostActions, + options: { sendEmail: boolean, toUserId: string | null }, +) { + return deleteUser(user.id, options).then(data => { + postActions.toggleSelectedUser(user.id, false) + postActions.updateUserViewData({ + ...user, + ...data, + deleted: true, + }) + }) +} + +export function performUpdateUser( + user: User, + postActions: PostActions, + options: { userData: Partial }, +) { + + const dataToUpdate = { ...options.userData} + if (!user.allowUpdateDetails) { + delete dataToUpdate.firstName + delete dataToUpdate.lastName + } + if (!user.allowUpdateIsAdmin) { + delete dataToUpdate.isAdmin + } + + return updateUser(user.id, dataToUpdate).then(data => { + postActions.toggleSelectedUser(user.id, false) + postActions.updateUserViewData({ + ...user, + ...data + }) + }) +} + +export function performRestoreUser( + user: User, + postActions: PostActions, +) { + + return restoreUser(user.id).then(() => { + postActions.toggleSelectedUser(user.id, false) + postActions.updateUserViewData({ + ...user, + deletedAt: undefined, + deleted: false, + }) + }) +} + +export function performPurgeUser( + user: User, + postActions: PostActions +) { + return purgeUser(user.id).then(() => { + postActions.removeUserFromView(user) + }) +} + +export function performSendRegEmail( + user: User, + postActions: PostActions, +) { + + return sendRegEmail(user.id).then(() => { + postActions.toggleSelectedUser(user.id, false) + }) +} diff --git a/services/web/modules/admin-tools/frontend/js/users-page-context.tsx b/services/web/modules/admin-tools/frontend/js/users-page-context.tsx new file mode 100644 index 00000000000..03fee7d9c0a --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/users-page-context.tsx @@ -0,0 +1,101 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useMemo, + ReactNode, +} from 'react' + +export type PageState = + | { type: 'users' } + | { type: 'projects'; userId: string | null } + +type UsersPageContextValue = { + page: PageState + showUsers: () => void + showProjects: (userId: string) => void +} + +const UsersPageContext = + createContext(null) + +type ProviderProps = { + children: ReactNode +} + +function isPageState(value: unknown): value is PageState { + if (typeof value !== 'object' || value === null) return false + const v = value as any + if (v.type === 'users') return true + if (v.type === 'projects') return v.userId === null || typeof v.userId === 'string' + return false +} + +export function UsersPageProvider({ children }: ProviderProps) { + const [page, setPage] = useState(() => { + return isPageState(history.state) + ? history.state + : { type: 'users' } + }) + + useEffect(() => { + if (!isPageState(history.state)) { + history.replaceState(page, '') + } + + const onPopState = (e: PopStateEvent) => { + setPage( + isPageState(e.state) + ? e.state + : { type: 'users' } + ) + } + + window.addEventListener('popstate', onPopState) + return () => window.removeEventListener('popstate', onPopState) + }, []) + + const navigate = useCallback((state: PageState) => { + history.pushState(state, '') + setPage(state) + }, []) + + const showUsers = useCallback(() => { + navigate({ type: 'users' }) + }, [navigate]) + + const showProjects = useCallback((userId: string) => { + navigate({ type: 'projects', userId }) + }, [navigate]) + + const value = useMemo( + () => ({ + page, + showUsers, + showProjects, + }), + [ + page, + showUsers, + showProjects, + ] + ) + + return ( + + {children} + + ) +} + +export function useUsersPageContext(): UsersPageContextValue { + const context = useContext(UsersPageContext) + if (!context) { + throw new Error('UsersPageContext is only available inside UsersPageProvider') + } + return context +} diff --git a/services/web/modules/admin-tools/index.mjs b/services/web/modules/admin-tools/index.mjs new file mode 100644 index 00000000000..812d8d293c4 --- /dev/null +++ b/services/web/modules/admin-tools/index.mjs @@ -0,0 +1,12 @@ +import AdminToolsRouter from './app/src/AdminToolsRouter.mjs' + +/** + * @import { WebModule } from "../../types/web-module" + */ + +/** @type {WebModule} */ +const AdminToolsModule = { + router: AdminToolsRouter, +} + +export default AdminToolsModule diff --git a/services/web/modules/admin-tools/types/project/api.d.ts b/services/web/modules/admin-tools/types/project/api.d.ts new file mode 100644 index 00000000000..a9fd8df07f0 --- /dev/null +++ b/services/web/modules/admin-tools/types/project/api.d.ts @@ -0,0 +1,59 @@ +import { SortingOrder } from '../../../../types/sorting-order' +import { MergeAndOverride } from '../../../../types/utils' +import { User } from '../user/api' + +export type Page = { + size: number + lastId?: string +} + +export type Sort = { + by: 'lastUpdated' | 'title' | 'deletedAt' | 'owner' + order: SortingOrder +} + +export type Filters = { + owned?: boolean + trashed?: boolean + deleted?: boolean + search?: string +} + +export type GetProjectsRequestBody = { + page: Page + sort: Sort + filters: Filters +} + +export type UserRef = Pick< + User, + 'id' | 'email' | 'firstName' | 'lastName' +> + + +export type ProjectApi = { + id: string + name: string + owner?: string + lastUpdated: Date + lastUpdatedBy: string | null + trashed: boolean + deleted: boolean + inactive?: boolean + deleterId?: string + deletedAt?: Date +} + +export type Project = MergeAndOverride< + ProjectApi, + { + lastUpdated: string + deletedAt?: string + selected?: boolean + } +> + +export type GetProjectsResponseBody = { + totalSize: number + projects: Project[] +} diff --git a/services/web/modules/admin-tools/types/user/api.d.ts b/services/web/modules/admin-tools/types/user/api.d.ts new file mode 100644 index 00000000000..46abb9bedb9 --- /dev/null +++ b/services/web/modules/admin-tools/types/user/api.d.ts @@ -0,0 +1,66 @@ +import { SortingOrder } from '../../../../types/sorting-order' +import { MergeAndOverride } from '../../../../types/utils' + +export type Page = { + size: number + lastId?: string +} + +export type Sort = { + by: 'lastLoggedIn' | 'lastActive' | 'signUpDate' | 'deletedAt' | 'email' | 'name' + order: SortingOrder +} + +export type Filters = { + all?: boolean + admin?: boolean + inactive?: boolean + suspended?: boolean + deleted?: boolean + local?: boolean + saml?: boolean + oidc?: boolean + ldap?: boolean + search?: string +} + +export type GetUsersRequestBody = { + page: Page + sort: Sort + filters: Filters +} + +export type UserApi = { + id: string + email: string + firstName: string + lastName: string + isAdmin: boolean + loginCount: number + signUpDate: Date + lastActive?: Date + lastLoggedIn?: Date + authMethods: string[] + allowUpdateDetails: boolean + allowUpdateIsAdmin: boolean + suspended: boolean + inactive: boolean + deleted?: boolean + deletedAt?: Date +} + +export type User = MergeAndOverride< + UserApi, + { + signUpDate: string + lastActive?: string + lastLoggedIn?: string + deletedAt?: string + selected?: boolean + } +> + +export type GetUsersResponseBody = { + totalSize: number + users: User[] +} diff --git a/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs new file mode 100644 index 00000000000..425389c2a19 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs @@ -0,0 +1,112 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import EmailHelper from '../../../../../app/src/Features/Helpers/EmailHelper.mjs' +import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.mjs' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import LDAPAuthenticationManager from './LDAPAuthenticationManager.mjs' + +const LDAPAuthenticationController = { + passportLogin(req, res, next) { + // This function is middleware which wraps the passport.authenticate middleware, + // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, + // and send a `{redir: ""}` response on success + passport.authenticate( + 'ldapauth', + { keepSessionInfo: true }, + async function (err, user, info, status) { + if (err) { //we cannot be here as long as errors are treated as fails + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'LDAP password login', + }) + + try { + await AuthenticationController.promises.finishLogin(user, req, res) + res.status(200) + return + } catch (err) { + return next(err) + } + } else { + if (status != 401) { + logger.warn(status, 'LDAP: ' + info.message) + } + if (EmailHelper.parseEmail(req.body.email)) return next() //Try local authentication + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(status || info.status || 401) + delete info.status + info.type = 'error' + info.key = 'invalid-password-retry-or-reset' + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportLogin(req, profile, done) { + let user, info + try { + ;({ user, info } = await LDAPAuthenticationController._doPassportLogin( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'LDAP password login', fromKnownDevice }, + } + + let user, isPasswordReused + try { + user = await LDAPAuthenticationManager.promises.findOrCreateUser(profile, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { + return { + user: false, + info: { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }, + } + } else if (user) { + user.externalAuth = 'ldap' + return { user, info: undefined } + } else { //we cannot be here, something is terribly wrong + logger.debug({ email : profile.mail }, 'failed LDAP log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, +} + +export default LDAPAuthenticationController diff --git a/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs new file mode 100644 index 00000000000..25947de1489 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs @@ -0,0 +1,74 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.mjs' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.mjs' +import { User } from '../../../../../app/src/models/User.mjs' +import { splitFullName } from '../../../utils.mjs' + +const LDAPAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + //user is already authenticated in LDAP + const { + attEmail, + attFirstName, + attLastName, + attName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.ldap + + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + let nameParts = ["",""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = splitFullName(profile[attName] || "") + } + const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0] + let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1] + if (!firstName && !lastName) lastName = email + let isAdmin = false + if( attAdmin && valAdmin ) { + isAdmin = Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin + } + let user = await User.findOne({ 'email': email }).exec() + + if( !user ) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + await User.updateOne( + { _id: user._id }, + { $set : { 'emails.0.confirmedAt' : Date.now() } } + ).exec() //email of ldap user is confirmed + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if( attAdmin && valAdmin ) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, + { + $inc: { loginEpoch: 1 }, + $set: userDetails, + $unset: { hashedPassword: "" }, + } + ).exec() + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + promises: LDAPAuthenticationManager, +} diff --git a/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs b/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs new file mode 100644 index 00000000000..28f4928ee94 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs @@ -0,0 +1,120 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { promisify } from 'util' +import passport from 'passport' +import ldapjs from 'ldapjs' +import UserGetter from '../../../../../app/src/Features/User/UserGetter.mjs' +import { splitFullName } from '../../../utils.mjs' + +function _searchLDAP(client, baseDN, options) { + return new Promise((resolve, reject) => { + const searchEntries = [] + client.search(baseDN, options, (error, res) => { + if (error) { + reject(error) + } else { + res.on('searchEntry', entry => searchEntries.push(entry.object)) + res.on('error', reject) + res.on('end', () => resolve(searchEntries)) + } + }) + }) +} + +async function fetchLDAPContacts(userId, contacts) { + if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { + return [] + } + + const ldapOptions = passport._strategy('ldapauth').options.server + const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap + const { + url, + timeout, + connectTimeout, + tlsOptions, + starttls, + bindDN, + bindCredentials + } = ldapOptions + const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOptions.searchBase + const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' + const ldapConfig = { url, timeout, connectTimeout, tlsOptions } + + let ldapUsers + let client + + try { + await new Promise((resolve, reject) => { + client = ldapjs.createClient(ldapConfig) + client.on('error', (error) => { reject(error) }) + client.on('connectTimeout', (error) => { reject(error) }) + client.on('connect', () => { resolve() }) + }) + + if (starttls) { + const starttlsAsync = promisify(client.starttls).bind(client) + await starttlsAsync(tlsOptions, null) + } + const bindAsync = promisify(client.bind).bind(client) + await bindAsync(bindDN, bindCredentials) + + async function createContactsSearchFilter(client, ldapOptions, userId, contactsFilter) { + const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY + if (!searchProperty) { + return contactsFilter + } + const email = await UserGetter.promises.getUserEmail(userId) + const searchOptions = { + scope: ldapOptions.searchScope, + attributes: [searchProperty], + filter: `(${Settings.ldap.attEmail}=${email})` + } + const searchBase = ldapOptions.searchBase + const ldapUser = (await _searchLDAP(client, searchBase, searchOptions))[0] + const searchPropertyValue = ldapUser ? ldapUser[searchProperty] + : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' + return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) + } + + const filter = await createContactsSearchFilter(client, ldapOptions, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) + const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } + + ldapUsers = await _searchLDAP(client, searchBase, searchOptions) + } catch (error) { + logger.warn({ error }, 'Error in fetchLDAPContacts') + return [] + } finally { + client?.unbind() + } + + const newLDAPContacts = ldapUsers.reduce((acc, ldapUser) => { + const email = Array.isArray(ldapUser[attEmail]) + ? ldapUser[attEmail][0]?.toLowerCase() + : ldapUser[attEmail]?.toLowerCase() + if (!email) return acc + if (!contacts.some(contact => contact.email === email)) { + let nameParts = ["", ""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = splitFullName(ldapUser[attName] || "") + } + const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] + const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] + acc.push({ + first_name: firstName, + last_name: lastName, + email: email, + type: 'user' + }) + } + return acc + }, []) + + return newLDAPContacts.sort((a, b) => + a.last_name.localeCompare(b.last_name) || + a.first_name.localeCompare(b.first_name) || + a.email.localeCompare(b.email) + ) +} + +export default fetchLDAPContacts diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs new file mode 100644 index 00000000000..f2beccad802 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -0,0 +1,108 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import { Strategy as LDAPStrategy } from 'passport-ldapauth' +import Settings from '@overleaf/settings' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.mjs' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import LDAPAuthenticationController from './LDAPAuthenticationController.mjs' +import fetchLDAPContacts from './LDAPContacts.mjs' + +const LDAPModuleManager = { + initSettings() { + Settings.ldap = { + enable: true, + placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', + attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', + attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, + attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, + attName: process.env.OVERLEAF_LDAP_NAME_ATT, + attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, + valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN), + } + }, + passportSetup(passport, callback) { + const ldapOptions = { + url: process.env.OVERLEAF_LDAP_URL, + bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", + bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", + bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, + searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, + searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, + searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', + searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || null), + cache: boolFromEnv(process.env.OVERLEAF_LDAP_CACHE), + timeout: numFromEnv(process.env.OVERLEAF_LDAP_TIMEOUT), + connectTimeout: numFromEnv(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT), + starttls: boolFromEnv(process.env.OVERLEAF_LDAP_STARTTLS), + tlsOptions: { + ca: readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), + rejectUnauthorized: boolFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH), + } + } + try { + passport.use( + new LDAPStrategy( + { + server: ldapOptions, + passReqToCallback: true, + usernameField: 'email', + passwordField: 'password', + handleErrorsAsFailures: true, + }, + LDAPAuthenticationController.doPassportLogin + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + + async getContacts(userId, contacts, callback) { + try { + const newContacts = await fetchLDAPContacts(userId, contacts) + callback(null, newContacts) + } catch (error) { + callback(error) + } + }, + + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + PermissionsManager.registerCapability('use-ai', { default : false }) + } catch (error) { + logger.info({}, error.message) + } + const ldapPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'ldap' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'ldapPolicy', + { 'change-password' : false }, + { validator: ldapPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'ldapPolicy' : true }, + subscription : null + }).then(userValidationMap => { + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, { groupPolicy }) + }).catch(error => { + callback(error) + }) + }, +} + +export default LDAPModuleManager diff --git a/services/web/modules/authentication/ldap/app/src/LDAPRateLimitMiddleware.mjs b/services/web/modules/authentication/ldap/app/src/LDAPRateLimitMiddleware.mjs new file mode 100644 index 00000000000..b0a2e3c92a2 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPRateLimitMiddleware.mjs @@ -0,0 +1,24 @@ +export function prepareLdapLoginForRateLimitEmail(field = 'email') { + return function (req, res, next) { + const value = req.body[field] + if (!value) return next() + + if (!value.includes('@')) { + req._originalLogin = value + req.body[field] = `${value}@ldapfake.invalid` + } + + next() + } +} + +export function restoreLdapLoginAfterRateLimitEmail(field = 'email') { + return function (req, res, next) { + if (req._originalLogin !== undefined) { + req.body[field] = req._originalLogin + delete req._originalLogin + } + next() + } +} + diff --git a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs new file mode 100644 index 00000000000..f36de96add2 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs @@ -0,0 +1,22 @@ +import logger from '@overleaf/logger' +import RateLimiterMiddleware from '../../../../../app/src/Features/Security/RateLimiterMiddleware.mjs' +import CaptchaMiddleware from '../../../../../app/src/Features/Captcha/CaptchaMiddleware.mjs' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import { overleafLoginRateLimiter } from '../../../../../app/src/infrastructure/RateLimiter.mjs' +import LDAPAuthenticationController from './LDAPAuthenticationController.mjs' +import { prepareLdapLoginForRateLimitEmail, restoreLdapLoginAfterRateLimitEmail } from './LDAPRateLimitMiddleware.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init LDAP router') + webRouter.post('/login', + RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s) + prepareLdapLoginForRateLimitEmail(), // for logins with uid + RateLimiterMiddleware.loginRateLimitEmail('email'), // rate limit email (10 / 120s) + restoreLdapLoginAfterRateLimitEmail(), + CaptchaMiddleware.validateCaptcha('login'), + LDAPAuthenticationController.passportLogin, + AuthenticationController.passportLogin, + ) + }, +} diff --git a/services/web/modules/authentication/ldap/index.mjs b/services/web/modules/authentication/ldap/index.mjs new file mode 100644 index 00000000000..94743a66117 --- /dev/null +++ b/services/web/modules/authentication/ldap/index.mjs @@ -0,0 +1,17 @@ +let ldapModule = {} +if (process.env.EXTERNAL_AUTH?.includes('ldap')) { + const { default: LDAPModuleManager } = await import('./app/src/LDAPModuleManager.mjs') + const { default: router } = await import('./app/src/LDAPRouter.mjs') + LDAPModuleManager.initSettings() + LDAPModuleManager.initPolicy() + ldapModule = { + name: 'ldap-authentication', + hooks: { + passportSetup: LDAPModuleManager.passportSetup, + getContacts: LDAPModuleManager.getContacts, + getGroupPolicyForUser: LDAPModuleManager.getGroupPolicyForUser, + }, + router: router, + } +} +export default ldapModule diff --git a/services/web/modules/authentication/logout.mjs b/services/web/modules/authentication/logout.mjs new file mode 100644 index 00000000000..4163cf536d5 --- /dev/null +++ b/services/web/modules/authentication/logout.mjs @@ -0,0 +1,18 @@ +let SAMLAuthenticationController +if (process.env.EXTERNAL_AUTH.includes('saml')) { + SAMLAuthenticationController = await import('./saml/app/src/SAMLAuthenticationController.mjs') +} +let OIDCAuthenticationController +if (process.env.EXTERNAL_AUTH.includes('oidc')) { + OIDCAuthenticationController = await import('./oidc/app/src/OIDCAuthenticationController.mjs') +} +export default async function logout(req, res, next) { + switch(req.user.externalAuth) { + case 'saml': + return SAMLAuthenticationController.default.passportLogout(req, res, next) + case 'oidc': + return OIDCAuthenticationController.default.passportLogout(req, res, next) + default: + next() + } +} diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs new file mode 100644 index 00000000000..1309184608f --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -0,0 +1,163 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import UserController from '../../../../../app/src/Features/User/UserController.mjs' +import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.mjs' +import OIDCAuthenticationManager from './OIDCAuthenticationManager.mjs' + +const OIDCAuthenticationController = { + passportLogin(req, res, next) { + req.session.intent = req.query.intent + passport.authenticate('openidconnect')(req, res, next) + }, + passportLoginCallback(req, res, next) { + passport.authenticate( + 'openidconnect', + { keepSessionInfo: true }, + async function (err, user, info) { + if (err) { + return next(err) + } + if(req.session.intent === 'link') { + delete req.session.intent +// After linking, log out from the OIDC provider and redirect back to '/user/settings'. +// Keycloak supports this; Authentik does not (yet). + const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL + const redirectUri = `${Settings.siteUrl.replace(/\/+$/, '')}/user/settings` + return res.redirect(`${logoutUrl}?id_token_hint=${info.idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) + } + if (user) { + req.session.idToken = info.idToken + user.externalAuth = 'oidc' + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'OIDC login', + }) + try { + await AuthenticationController.promises.finishLogin(user, req, res) + } catch (err) { + return next(err) + } + } else { + if (info.redir != null) { + await UserController.doLogout(req) + return res.redirect(info.redir) + } else { + res.status(info.status || 401) + delete info.status + const body = { message: info } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportLogin(req, issuer, profile, context, idToken, accessToken, refreshToken, done) { + let user, info + try { + if(req.session.intent === 'link') { + ;({ user, info } = await OIDCAuthenticationController._doLink( + req, + profile + )) + } else { + ;({ user, info } = await OIDCAuthenticationController._doLogin( + req, + profile + )) + } + } catch (error) { + return done(error) + } + if (user) { + info = { + ...(info || {}), + idToken: idToken + } + } + return done(null, user, info) + }, + async _doLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'OIDC login', fromKnownDevice }, + } + + let user + try { + user = await OIDCAuthenticationManager.promises.findOrCreateUser(profile, auditLog) + } catch (error) { + logger.debug({ email : profile.emails[0].value }, `OIDC login failed: ${error}`) + return { + user: false, + info: { + type: 'error', + text: error.message, + status: 500, + }, + } + } + if (user) { + return { user, info: undefined } + } else { // user account is not created + logger.debug({ email : profile.emails[0].value }, 'OIDC JIT account creation is not allowed for this email') + return { + user: false, + info: { + redir: '/register', + status: 401, + }, + } + } + }, + async _doLink(req, profile) { + const { user: { _id: userId }, ip } = req + try { + const auditLog = { + ipAddress: ip, + initiatorId: userId, + } + await OIDCAuthenticationManager.promises.linkAccount(userId, profile, auditLog) + } catch (error) { + logger.error(error.info, error.message) + return { + user: true, + info: { + type: 'error', + text: error.message, + status: 200, + }, + } + } + return { user: true, info: undefined } + }, + async unlinkAccount(req, res, next) { + try { + const { user: { _id: userId }, body: { providerId }, ip } = req + const auditLog = { + ipAddress: ip, + initiatorId: userId, + } + await ThirdPartyIdentityManager.promises.unlink(userId, providerId, auditLog) + return res.status(204).end() + } catch (error) { + logger.error('Unexpected error in uninkAccount') + return next({ stack: error.stack, info: {userId: req.user?._id} }) + } + }, + async passportLogout(req, res, next) { +// TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken? + const idTokenHint = req.session.idToken + await UserController.doLogout(req) + const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL + const redirectUri = Settings.siteUrl + res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) + }, + passportLogoutCallback(req, res, next) { + const redirectUri = Settings.siteUrl + res.redirect(redirectUri) + }, +} +export default OIDCAuthenticationController diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs new file mode 100644 index 00000000000..a6cecd3b542 --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -0,0 +1,108 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.mjs' +import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.mjs' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.mjs' +import { User } from '../../../../../app/src/models/User.mjs' + +const OIDCAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + const { + attUserId, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + providerId, + } = Settings.oidc + const email = profile.emails[0].value + const oidcUserId = (attUserId === 'email') ? email : profile[attUserId] + const firstName = profile.name?.givenName || "" + const lastName = profile.name?.familyName || "" + let isAdmin = false + if (attAdmin && valAdmin) { + if (attAdmin === 'email') { + isAdmin = (email === valAdmin) + } else { + isAdmin = (profile[attAdmin] === valAdmin) + } + } + const oidcUserData = null // Possibly it can be used later + let user + try { + user = await ThirdPartyIdentityManager.promises.login(providerId, oidcUserId, oidcUserData) + } catch { +// A user with the specified OIDC ID and provider ID is not found. Search for a user with the given email. +// If no user exists with this email, create a new user and link the OIDC account to it (provided this is allowed by allowedOIDCEmailDomains). +// If a user exists but no account from the specified OIDC provider is linked to this user, link the OIDC account to this user. +// If an account from the specified provider is already linked to this user, unlink it, and link the OIDC account to this user. +// (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) + user = await User.findOne({ 'email': email }).exec() + if (!user) { + const allowedDomains = Settings.oidc.allowedOIDCEmailDomains + if ( + allowedDomains && + !allowedDomains.some(pattern => { + const domain = email.split('@')[1] + if (pattern.startsWith('*.')) { + const base = pattern.slice(2) + return domain.endsWith(`.${base}`) + } + return domain === pattern + }) + ) { + return null + } + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + } +// const alreadyLinked = user.thirdPartyIdentifiers.some(item => item.providerId === providerId) +// if (!alreadyLinked) { + auditLog.initiatorId = user._id + await ThirdPartyIdentityManager.promises.link(user._id, providerId, oidcUserId, oidcUserData, auditLog) + await User.updateOne( + { _id: user._id }, + { $set : { + 'emails.0.confirmedAt': Date.now(), //email of external user is confirmed + }, + } + ).exec() +// } else { +// throw new Error(`Overleaf user ${user.email} is already linked to another ${providerId} user`) +// } + } + + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if (attAdmin && valAdmin) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, + async linkAccount(userId, profile, auditLog) { + const { + attUserId, + providerId, + } = Settings.oidc + const oidcUserId = (attUserId === 'email') ? profile.emails[0].value : profile[attUserId] + const oidcUserData = null // Possibly it can be used later + await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog) + }, +} + +export default { + promises: OIDCAuthenticationManager, +} diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs new file mode 100644 index 00000000000..861ac17d1c8 --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -0,0 +1,85 @@ +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import { boolFromEnv } from '../../../utils.mjs' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.mjs' +import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' +import { Strategy as OIDCStrategy } from 'passport-openidconnect' + +const OIDCModuleManager = { + initSettings() { + let providerId = process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc' + Settings.oidc = { + enable: true, + providerId: providerId, + identityServiceName: process.env.OVERLEAF_OIDC_IDENTITY_SERVICE_NAME || `Log in with ${Settings.oauthProviders[providerId].name}`, + attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id', + attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), + allowedOIDCEmailDomains: process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS === undefined + ? null + : process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS.split(',').map(s => s.trim()).filter(Boolean), + } + }, + passportSetup(passport, callback) { + const oidcOptions = { + issuer: process.env.OVERLEAF_OIDC_ISSUER, + authorizationURL: process.env.OVERLEAF_OIDC_AUTHORIZATION_URL, + tokenURL: process.env.OVERLEAF_OIDC_TOKEN_URL, + userInfoURL: process.env.OVERLEAF_OIDC_USER_INFO_URL, + clientID: process.env.OVERLEAF_OIDC_CLIENT_ID, + clientSecret: process.env.OVERLEAF_OIDC_CLIENT_SECRET, + callbackURL: `${Settings.siteUrl.replace(/\/+$/, '')}/oidc/login/callback`, + scope: process.env.OVERLEAF_OIDC_SCOPE || 'openid profile email', + passReqToCallback: true, + } + try { + passport.use( + new OIDCStrategy( + oidcOptions, + OIDCAuthenticationController.doPassportLogin + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + PermissionsManager.registerCapability('use-ai', { default : false }) + } catch (error) { + logger.info({}, error.message) + } + const oidcPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'oidc' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'oidcPolicy', + { 'change-password' : false }, + { validator: oidcPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'oidcPolicy' : true }, + subscription : null + }).then(userValidationMap => { + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, { groupPolicy }) + }).catch(error => { + callback(error) + }) + }, +} + +export default OIDCModuleManager diff --git a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs new file mode 100644 index 00000000000..94ce9e3798c --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs @@ -0,0 +1,18 @@ +import logger from '@overleaf/logger' +import UserController from '../../../../../app/src/Features/User/UserController.mjs' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' +import logout from '../../../logout.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init OIDC router') + webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin) + AuthenticationController.addEndpointToLoginWhitelist('/oidc/login') + webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback) + AuthenticationController.addEndpointToLoginWhitelist('/oidc/login/callback') + webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback) + webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount) + webRouter.post('/logout', logout, UserController.logout) + }, +} diff --git a/services/web/modules/authentication/oidc/index.mjs b/services/web/modules/authentication/oidc/index.mjs new file mode 100644 index 00000000000..f10ff64c822 --- /dev/null +++ b/services/web/modules/authentication/oidc/index.mjs @@ -0,0 +1,16 @@ +let oidcModule = {} +if (process.env.EXTERNAL_AUTH?.includes('oidc')) { + const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs') + const { default: router } = await import('./app/src/OIDCRouter.mjs') + OIDCModuleManager.initSettings() + OIDCModuleManager.initPolicy() + oidcModule = { + name: 'oidc-authentication', + hooks: { + passportSetup: OIDCModuleManager.passportSetup, + getGroupPolicyForUser: OIDCModuleManager.getGroupPolicyForUser, + }, + router: router, + } +} +export default oidcModule diff --git a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs new file mode 100644 index 00000000000..c124d94160d --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs @@ -0,0 +1,150 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import passport from 'passport' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import SAMLAuthenticationManager from './SAMLAuthenticationManager.mjs' +import UserController from '../../../../../app/src/Features/User/UserController.mjs' +import UserSessionsManager from '../../../../../app/src/Features/User/UserSessionsManager.mjs' +import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.mjs' +import { xmlResponse } from '../../../../../app/src/infrastructure/Response.mjs' +import { readFilesContentFromEnv } from '../../../utils.mjs' + +const SAMLAuthenticationController = { + passportLogin(req, res, next) { + if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') { + const csp = res.getHeader('Content-Security-Policy') + if (csp) { + res.setHeader( + 'Content-Security-Policy', + csp.replace(/(?:^|\s)(default-src|form-action)[^;]*;?/g, '') + ) + } + } + passport.authenticate('saml')(req, res, next) + }, + passportLoginCallback(req, res, next) { + // This function is middleware which wraps the passport.authenticate middleware, + // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, + // and send a `{redir: ""}` response on success + passport.authenticate( + 'saml', + { keepSessionInfo: true }, + async function (err, user, info) { + if (err) { + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'SAML login', + }) + try { + await AuthenticationController.promises.finishLogin(user, req, res) + } catch (err) { + return next(err) + } + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 401) + delete info.status + const body = { message: info } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportLogin(req, profile, done) { + let user, info + try { + ;({ user, info } = await SAMLAuthenticationController._doPassportLogin( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'SAML login', fromKnownDevice }, + } + + let user + try { + user = await SAMLAuthenticationManager.promises.findOrCreateUser(profile, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user) { + user.externalAuth = 'saml' + req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex} + return { user, info: undefined } + } else { // we cannot be here, something is terribly wrong + logger.debug({ email : profile.mail }, 'failed SAML log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, + async passportLogout(req, res, next) { + passport._strategy('saml').logout(req, async (err, url) => { + await UserController.doLogout(req) + if (err) return next(err) + res.redirect(url) + }) + }, + passportLogoutCallback(req, res, next) { +//TODO: is it possible to close the editor? + passport.authenticate('saml')(req, res, (err) => { + if (err) return next(err) + res.redirect('/login'); + }) + }, + async doPassportLogout(req, profile, done) { + let user, info + try { + ;({ user, info } = await SAMLAuthenticationController._doPassportLogout( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportLogout(req, profile) { + if (req?.session?.saml_extce?.nameID === profile.nameID && + req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) { + profile = req.user + } + await UserSessionsManager.promises.untrackSession(req.user, req.sessionID).catch(err => { + logger.warn({ err, userId: req.user._id }, 'failed to untrack session') + }) + return { user: profile, info: undefined } + }, + getSPMetadata(req, res) { + const samlStratery = passport._strategy('saml') + res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`) + xmlResponse(res, + samlStratery.generateServiceProviderMetadata( + readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), + readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT) + ) + ) + }, +} +export default SAMLAuthenticationController diff --git a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs new file mode 100644 index 00000000000..40947971a82 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs @@ -0,0 +1,85 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.mjs' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.mjs' +import SAMLIdentityManager from '../../../../../app/src/Features/User/SAMLIdentityManager.mjs' +import { User } from '../../../../../app/src/models/User.mjs' + +const SAMLAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + const { + attUserId, + attEmail, + attFirstName, + attLastName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.saml + const externalUserId = profile[attUserId] + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + const firstName = attFirstName ? profile[attFirstName] : "" + const lastName = attLastName ? profile[attLastName] : email + let isAdmin = false + if (attAdmin && valAdmin) { + isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + const providerId = '1' // for now, only one fixed IdP is supported +// We search for a SAML user, and if none is found, we search for a user with the given email. If a user is found, +// we update the user to be a SAML user, otherwise, we create a new SAML user with the given email. In the case of +// multiple SAML IdPs, one would have to do something similar, or possibly report an error like +// 'the email is associated with the wrong IdP' + let user = await SAMLIdentityManager.getUser(providerId, externalUserId, attUserId) + if (!user) { + user = await User.findOne({ 'email': email }).exec() + if (!user) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + samlIdentifiers: [{ providerId: providerId }], + } + ) + } + // cannot use SAMLIdentityManager.linkAccounts because affilations service is not there + await User.updateOne( + { _id: user._id }, + { + $set : { + 'emails.0.confirmedAt': Date.now(), //email of saml user is confirmed + 'emails.0.samlProviderId': providerId, + 'samlIdentifiers.0.providerId': providerId, + 'samlIdentifiers.0.externalUserId': externalUserId, + 'samlIdentifiers.0.userIdAttribute': attUserId, + }, + } + ).exec() + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if (attAdmin && valAdmin) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, + { + $inc: { loginEpoch: 1 }, + $set: userDetails, + $unset: { hashedPassword: "" }, + }, + ).exec() + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + promises: SAMLAuthenticationManager, +} diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs new file mode 100644 index 00000000000..305afccfd8b --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -0,0 +1,101 @@ +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.mjs' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' +import { Strategy as SAMLStrategy } from '@node-saml/passport-saml' + +const SAMLModuleManager = { + initSettings() { + Settings.saml = { + enable: true, + identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Log in with SAML IdP', + attUserId: process.env.OVERLEAF_SAML_USER_ID_FIELD || 'nameID', + attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', + attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', + attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', + attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN), + } +}, + passportSetup(passport, callback) { + const samlOptions = { + entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, + callbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/login/callback`, + issuer: process.env.OVERLEAF_SAML_ISSUER, + audience: process.env.OVERLEAF_SAML_AUDIENCE, + idpCert: readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), + privateKey: readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), + decryptionPvk: readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), + signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, + additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), + additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), + identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, + acceptedClockSkewMs: numFromEnv(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS), + attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, + authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, + forceAuthn: boolFromEnv(process.env.OVERLEAF_SAML_FORCE_AUTHN), + disableRequestedAuthnContext: boolFromEnv(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT), + skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST + authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, + validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, + requestIdExpirationPeriodMs: numFromEnv(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS), + // cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, + logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, + logoutCallbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/logout/callback`, + additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + wantAssertionsSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_ASSERTIONS_SIGNED), + wantAuthnResponseSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_AUTHN_RESPONSE_SIGNED), + passReqToCallback: true, + } + try { + passport.use( + new SAMLStrategy( + samlOptions, + SAMLAuthenticationController.doPassportLogin, + SAMLAuthenticationController.doPassportLogout + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + PermissionsManager.registerCapability('use-ai', { default : false }) + } catch (error) { + logger.info({}, error.message) + } + const samlPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'saml' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'samlPolicy', + { 'change-password' : false }, + { validator: samlPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'samlPolicy' : true }, + subscription : null + }).then(userValidationMap => { + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, { groupPolicy }) + }).catch(error => { + callback(error) + }) + }, +} + +export default SAMLModuleManager diff --git a/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs b/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs new file mode 100644 index 00000000000..c0d617b299b --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs @@ -0,0 +1,11 @@ +import logger from '@overleaf/logger' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML NonCsrfRouter') + webRouter.post('/saml/login/callback', SAMLAuthenticationController.passportLoginCallback) + webRouter.get ('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback) + webRouter.post('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback) + }, +} diff --git a/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs b/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs new file mode 100644 index 00000000000..6ffd9b281ee --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs @@ -0,0 +1,16 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import UserController from '../../../../../app/src/Features/User/UserController.mjs' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' +import logout from '../../../logout.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML router') + webRouter.get('/saml/login', SAMLAuthenticationController.passportLogin) + AuthenticationController.addEndpointToLoginWhitelist('/saml/login') + webRouter.get('/saml/meta', SAMLAuthenticationController.getSPMetadata) + AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') + webRouter.post('/logout', logout, UserController.logout) + }, +} diff --git a/services/web/modules/authentication/saml/index.mjs b/services/web/modules/authentication/saml/index.mjs new file mode 100644 index 00000000000..36f0281637a --- /dev/null +++ b/services/web/modules/authentication/saml/index.mjs @@ -0,0 +1,18 @@ +let samlModule = {} +if (process.env.EXTERNAL_AUTH?.includes('saml')) { + const { default: SAMLModuleManager } = await import('./app/src/SAMLModuleManager.mjs') + const { default: router } = await import('./app/src/SAMLRouter.mjs') + const { default: nonCsrfRouter } = await import('./app/src/SAMLNonCsrfRouter.mjs') + SAMLModuleManager.initSettings() + SAMLModuleManager.initPolicy() + samlModule = { + name: 'saml-authentication', + hooks: { + passportSetup: SAMLModuleManager.passportSetup, + getGroupPolicyForUser: SAMLModuleManager.getGroupPolicyForUser, + }, + router: router, + nonCsrfRouter: nonCsrfRouter, + } +} +export default samlModule diff --git a/services/web/modules/authentication/utils.mjs b/services/web/modules/authentication/utils.mjs new file mode 100644 index 00000000000..468dae32b15 --- /dev/null +++ b/services/web/modules/authentication/utils.mjs @@ -0,0 +1,42 @@ +import fs from 'fs' +function readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} +function numFromEnv(env) { + return env ? Number(env) : undefined +} +function boolFromEnv(env) { + if (env === undefined || env === null) return undefined + if (typeof env === "string") { + const envLower = env.toLowerCase() + if (envLower === 'true') return true + if (envLower === 'false') return false + } + throw new Error("Invalid value for boolean envirionment variable") +} + +function splitFullName(fullName) { + fullName = fullName.trim(); + let lastSpaceIndex = fullName.lastIndexOf(' '); + let firstNames = fullName.substring(0, lastSpaceIndex).trim(); + let lastName = fullName.substring(lastSpaceIndex + 1).trim(); + return [firstNames, lastName]; +} + +export { + readFilesContentFromEnv, + numFromEnv, + boolFromEnv, + splitFullName, +} diff --git a/services/web/modules/git-bridge/app/src/GitBridgeAuthMiddleware.mjs b/services/web/modules/git-bridge/app/src/GitBridgeAuthMiddleware.mjs new file mode 100644 index 00000000000..df609c9c876 --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgeAuthMiddleware.mjs @@ -0,0 +1,41 @@ +import logger from '@overleaf/logger' +import AuthorizationManager from '../../../../app/src/Features/Authorization/AuthorizationManager.mjs' +import GitBridgePATManager from './GitBridgePATManager.mjs' + +const permissionChecks = { + read: AuthorizationManager.promises.canUserReadProject, + write: AuthorizationManager.promises.canUserWriteProjectContent +} + +export default function ensureTokenProjectAccess(permission) { + const checkPermission = permissionChecks[permission] + if (!checkPermission) { + throw new Error(`Invalid permission: ${permission}`) + } + + return async function (req, res, next) { + try { + const projectId = req.params.project_id + if (!projectId) return res.sendStatus(400) + + const header = req.headers.authorization || '' + const [scheme, token] = header.trim().split(/\s+/, 2) + if (scheme?.toLowerCase() !== 'bearer' || !token) { + return res.sendStatus(401) + } + + const userId = await GitBridgePATManager.getUserId(token) + if (!userId) return res.sendStatus(401) + + const allowed = await checkPermission(userId, projectId, null) + + if (!allowed) return res.sendStatus(403) + + req.user_id = userId + return next() + } catch (err) { + logger.error({ err }, 'Failed to check personal access token') + return res.sendStatus(500) + } + } +} diff --git a/services/web/modules/git-bridge/app/src/GitBridgeController.mjs b/services/web/modules/git-bridge/app/src/GitBridgeController.mjs new file mode 100644 index 00000000000..f07d335e364 --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgeController.mjs @@ -0,0 +1,261 @@ +// Controller for API v0 endpoints used by git-bridge +// These endpoints allow git-bridge to access project metadata, versions, and snapshots. + +import settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { expressify } from '@overleaf/promise-utils' +import { fetchJson } from '@overleaf/fetch-utils' + +import JsonWebToken from '../../../../app/src/infrastructure/JsonWebToken.mjs' +import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' +import HistoryController from '../../../../app/src/Features/History/HistoryController.mjs' +import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' +import GitBridgeHandler from './GitBridgeHandler.mjs' + +const PROJECT_HISTORY = settings.apis.project_history.url +const V1_HISTORY = settings.apis.v1_history.url + +/* + Entrypoint (GET): /api/v0/docs/:project_id + Called by git-bridge to retrieve metadata about the latest project version. + + The response must contain: + { + latestVerId: number, + latestVerAt: string, + latestVerBy: { email: string, name: string } + } + + This data is retrieved from the project-history service: + /project/${projectId}/version +*/ + +async function getDoc(req, res, next) { + + const projectId = req.params.project_id + + try { + const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 }) + if (!project) throw new Error('Project not found') + + const latestVerInfo = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version`) + const timestamp = latestVerInfo.timestamp || new Date().toISOString() + const userId = latestVerInfo.v2Authors && latestVerInfo.v2Authors[0] + + let user = null + if (userId) { + user = await UserGetter.promises.getUser(userId, { first_name: 1, last_name: 1, email: 1 }) + } + + const name = HistoryController._displayNameForUser(user) + const email = user?.email || 'anonymous@nowhere' + + res.json({ + latestVerId: latestVerInfo.version, + latestVerAt: timestamp, + latestVerBy: { email, name }, + }) + } catch (err) { + logger.error({ err, projectId }, 'Error retrieving the latest version metadata') + return res.sendStatus(400) + } +} + +/* + Entrypoint (GET): /api/v0/docs/:project_id/saved_vers + Called by git-bridge to retrieve metadata about labeled (saved) versions. + + The response must be an array of objects: + { + versionId: number, + comment: string, + createdAt: timestamp, + user: { email: string, name: string } + } + + This data is retrieved from the project-history service: + /project/${projectId}/labels +*/ + +async function getSavedVers(req, res, next) { + + const projectId = req.params.project_id + + try { + const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 }) + if (!project) throw new Error('Project is not found') + + let labels = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/labels`) + + const userIdSet = new Set(labels.map(label => label.user_id)) + // For backward compatibility: labels created anonymously may not contain user_id + userIdSet.delete(undefined) + const users = await UserGetter.promises.getUsers(Array.from(userIdSet), { first_name: 1, last_name: 1, email: 1 }) + + const savedVers = [] + labels.forEach(label => { + const user = users.find(u => String(u._id) === label.user_id) + const name = HistoryController._displayNameForUser(user) + const email = user?.email || 'anonymous@nowhere' + savedVers.push({ + versionId: label.version, + comment: label.comment, + createdAt: label.created_at, + user: { name, email }, + }) + }) + + res.json(savedVers) + + } catch (err) { + logger.error({ err, projectId }, 'Error retrieving the saved versions metadata') + return res.sendStatus(400) + } +} + +/* + Entrypoint (GET): /api/v0/docs/:project_id/snapshots/:version + Called by git-bridge to retrieve a full project snapshot for a specific version. + + Needs in the response the following object: + { + srcs: [ [ fileContent: string, pathname: string ], ... ], + atts: [ [ downloadUrl: string, pathname: string ], ... ], + } + + This data is retrieved from the project-history service: + entrypoint: /project/${projectId}/version/${version} +*/ + +async function getSnapshot(req, res, next) { + + const projectId = req.params.project_id + const versionString = req.params.version + + try { + if (!versionString) throw new Error('No version specified') + const version = Number(versionString) + + const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 }) + if (!project) throw new Error('Project not found') + + const snapshot = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version/${version}`) + const files = snapshot.files || {} + + const srcs = [] + const atts = [] + + for (const [pathname, file] of Object.entries(files)) { + if (!file?.data) continue + + if (!GitBridgeHandler.normalizeAndValidateFilePath(pathname)) { + throw new Error(`Invalid pathname: ${pathname}`) + } + + const { content, hash } = file.data + + if (content !== undefined) { + // Updated text file: return file content directly + srcs.push([content, pathname]) + + } else if (hash) { + // Binary file ("file") or unchanged text file (thoght it's "doc", add to atts anyway) + // Git-bridge downloads it directly from v1-history using a temporary JWT. + const token = await JsonWebToken.promises.sign( + { project_id: projectId }, + { expiresIn: '10m' } + ) + const downloadUrl = `${V1_HISTORY}/projects/${projectId}/blobs/${hash}?token=${token}` + + atts.push([downloadUrl, pathname]) + + } else { + throw new Error('Snapshot: both content and hash missing: ', pathname) + } + } + + res.json({ srcs, atts }) + + } catch (err) { + + logger.error({ err, projectId, versionString }, 'Error in getSnapshot') + return res.sendStatus(400) + } +} + +/* + Entry point (POST): /api/v0/docs/:project_id/snapshots + Called by git-bridge after a push. + + body: + { + latestVerId: number, + files: [ + { name: string, url: string | null } + ], + postbackUrl: string + } + + latestVerId: + Version that is expected to be updated + files: + Array describing the complete file set of the new project version + files.name: + File path in the project + files.url: + If not null, the file is new or modified and must be downloaded from + git-bridge using this URL, if null, the file is unchanged + postbackUrl: + URL where the update result must be posted + + returns: + { code: 'accepted' } or { code: 'outOfDate' } + Any other response is treated by git-bridge as an error. +*/ + +async function postSnapshot(req, res) { + + const projectId = req.params.project_id + const { latestVerId, files, postbackUrl } = req.body + + if (!postbackUrl) { + logger.error({ projectId }, 'postSnapshot: empty postbackUrl') + return res.sendStatus(400) + } + + const userId = req.user_id || null // from auth middleware + + try { + const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 }) + if (!project) { + logger.error({ projectId }, 'Project not found') + return res.sendStatus(404) + } + + const { version: olLatestVerId } = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version`) + + if (latestVerId !== olLatestVerId) { + // version in Overleaf is changed since last pull, git user needs to pull again + return res.status(409).json({ code: 'outOfDate' }) + } + + // When "accepted" is returned, git-bridge does not finish the push yet. + // Instead it waits for the postback callback (performed in pushUpdate) + // So it should be returned when the server has accepted the request but has not finished processing yet. + res.status(200).json({ code: 'accepted' }) + + GitBridgeHandler.pushUpdate(projectId, files, postbackUrl, userId) + .catch(err => logger.error({ err, projectId }, 'Error pushing update')) + + } catch (err) { + logger.error({ err, projectId }, 'Error posting snapshot') + res.sendStatus(500) + } +} + +export default { + getDoc: expressify(getDoc), + getSavedVers: expressify(getSavedVers), + getSnapshot: expressify(getSnapshot), + postSnapshot: expressify(postSnapshot), +} diff --git a/services/web/modules/git-bridge/app/src/GitBridgeHandler.mjs b/services/web/modules/git-bridge/app/src/GitBridgeHandler.mjs new file mode 100644 index 00000000000..890599d779e --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgeHandler.mjs @@ -0,0 +1,153 @@ +// Helper functions used by GitBridgeController. + +import path from 'path' +import settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { fetchJson } from '@overleaf/fetch-utils' +import { fetchStream } from '@overleaf/fetch-utils' + +import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs' +import UpdateMerger from '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs' + +const PROJECT_HISTORY = settings.apis.project_history.url + +function normalizeAndValidateFilePath(fileName) { + if (!fileName || fileName.includes('\0')) return null + + const normalized = path.posix.normalize(fileName) + + if (normalized.startsWith('/') || normalized.startsWith('..')) return null + if (normalized === '.git' || normalized.startsWith('.git/')) return null + + return normalized +} + +function validateFileName(fileName) { + const normalized = normalizeAndValidateFilePath(fileName) + +// if normalized path is not acceptable: error +// if normalized path differs from original: suggest rename +// otherwise the filename is valid + if (!normalized) { + return { file: fileName, state: 'error' } + } + if (normalized !== fileName) { + return { file: fileName, cleanFile: normalized } + } + return null +} + +// Push update to Overleaf. +// update is an array of objects: { name: string, url: string | null }, provided by git-bridge +// name: file path in the new project version (without leading '/') +// url: download link from git-bridge, null means the file has not changed +// Any existing entities whose paths are not present in update must be deleted. +// Note: entity paths returned by ProjectEntityHandler include a leading '/' + +async function pushUpdate(projectId, update, postbackUrl, userId) { + + const invalidFiles = [] + + update.forEach(file => { + const validationError = validateFileName(file.name) + if (validationError) invalidFiles.push(validationError) + }) + + if (invalidFiles.length > 0) { + await postback(postbackUrl, { code: 'invalidFiles', errors: invalidFiles }) + return + } + + try { + const { docs, files } = await ProjectEntityHandler.promises.getAllEntities(projectId) + + const entityPaths = [...docs.map(d => d.path), ...files.map(f => f.path)] + // prepend '/', paths in the entities have it + const pathsInUpdate = new Set(update.map(f => '/' + f.name)) + + // Entities present in the project but missing in the update must be deleted + const deletedPaths = entityPaths.filter(p => !pathsInUpdate.has(p)) + + for (const path of deletedPaths) { + await UpdateMerger.promises.deleteUpdate(userId, projectId, path, 'git-bridge') + } + + // Apply file updates received from git-bridge + for (const file of update) { + if (!file.url) continue + + const stream = await fetchStream(file.url) + const fileName = '/' + file.name + await UpdateMerger.promises.mergeUpdate(userId, projectId, fileName, stream, 'git-bridge') + } + + } catch (err) { + await postback(postbackUrl, { code: 'error' }) + throw err + } + + const { version } = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version`) + await postback(postbackUrl, { code: 'upToDate', latestVerId: version }) +} + +/* +Postback expected data: + +Success: { code: "upToDate", latestVerId: number } + latestVerId is a new version ID created by the push + +Error: +{ + code: "invalidFiles", + errors: [ + { + file: "path/to/file", + cleanFile: "suggested_name" + }, + { + file: "path/to/file", + state: "disallowed" + } + ] +} + cleanFile is interpreted as a sanitized replacement filename + state "disallowed": git bridge gives a hint "wrong file extension" (not used) + +Error: { code: "error" } + Generic error. + +Error (not used): { code: "outOfDate" } + The pushed snapshot was based on an outdated version. + It can be used when the snapshot was accepted initially, + but later processing discovered the base version was outdated. + +Error (not used): +{ + code: "invalidProject", + errors: [ + "error message 1", + "error message 2" + ] +} + +*/ + +async function postback(postbackUrl, code) { + + try { + await fetchJson(postbackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(code), + }) + + } catch (err) { + logger.error({ err, postbackUrl, code }, 'Failed to post back to git-bridge') + + } +} + +export default { + normalizeAndValidateFilePath, + pushUpdate, +} diff --git a/services/web/modules/git-bridge/app/src/GitBridgePATController.mjs b/services/web/modules/git-bridge/app/src/GitBridgePATController.mjs new file mode 100644 index 00000000000..3db26a4b1f0 --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgePATController.mjs @@ -0,0 +1,98 @@ +import logger from '@overleaf/logger' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.mjs' +import GitBridgePATManager from './GitBridgePATManager.mjs' + +const MAX_PAT_COUNT = 10 + +async function _sendSecurityAlertCreatedPAT(user) { + const emailOptions = { + to: user.email, + actionDescribed: `a new Git authentication token has been generated for your account ${user.email}`, + action: 'new Git authentication token generated', + } + try { + await EmailHandler.promises.sendEmail('securityAlert', emailOptions) + } catch (error) { + // log error when sending security alert email but do not pass back + logger.error( + { error, userId: user._id }, + 'could not send security alert new Git authentication token generated' + ) + } +} + +const GitBridgePATController = { + async getUserPersonalAccessTokens(req, res) { + const user = SessionManager.getSessionUser(req.session) + if (!user) return res.sendStatus(401) + + try { + const userId = user._id + const tokens = + await GitBridgePATManager.getTokens(userId) + return res.json(tokens) + } catch (err) { + logger.error({ err, userId }, 'Failed to get personal access tokens') + return res.sendStatus(500) + } + }, + + async createPersonalAccessToken(req, res){ + const user = SessionManager.getSessionUser(req.session) + if (!user) return res.sendStatus(401) + + try { + const count = await GitBridgePATManager.countTokens(user._id) + if (count >= MAX_PAT_COUNT) return res.sendStatus(403) + + const token = await GitBridgePATManager.createToken(user._id) + + // no need to wait, errors are logged and not passed back + _sendSecurityAlertCreatedPAT(user) + + return res.json(token) + + } catch (err) { + logger.error({ err }, 'Error in PAR create') + return res.sendStatus(500) + } + }, + + async deletePersonalAccessToken(req, res){ + const user = SessionManager.getSessionUser(req.session) + if (!user) return res.sendStatus(401) + const userId = user._id + + const tokenId = req.params?.token_id + if (!tokenId) return res.sendStatus(400) + + try { + const deleted = await GitBridgePATManager.deleteToken(tokenId, userId) + if (!deleted) return res.sendStatus(404) + + return res.sendStatus(200) + } catch (err) { + logger.error({ err, userId, tokenId }, 'Error in PAT delete') + return res.sendStatus(500) + } + }, + + async validatePersonalAccessToken(req, res) { + const header = req.headers.authorization || '' + const [scheme, token] = header.trim().split(/\s+/, 2) + if (scheme?.toLowerCase() !== 'bearer' || !token) return res.sendStatus(401) + + try { + const userId = await GitBridgePATManager.getUserId(token) + if (!userId) return res.sendStatus(401) + + return res.sendStatus(200) + } catch (err) { + logger.error({ err }, 'Error in PAT validate') + return res.sendStatus(500) + } + }, +} + +export default GitBridgePATController diff --git a/services/web/modules/git-bridge/app/src/GitBridgePATManager.mjs b/services/web/modules/git-bridge/app/src/GitBridgePATManager.mjs new file mode 100644 index 00000000000..e078d79aa5f --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgePATManager.mjs @@ -0,0 +1,125 @@ +import logger from '@overleaf/logger' +import crypto from 'crypto' +import { ObjectId } from 'mongodb' +import { db } from '../../../../app/src/infrastructure/mongodb.mjs' +import { OauthApplication } from '../../../../app/src/models/OauthApplication.mjs' + +const PAT_PREFIX = 'olp_' +const PAT_LENGTH = 36 // without 'olp_' prefix +const PAT_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +function _hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex') +} + +function _generateToken(length) { + const patCharsLength = PAT_CHARS.length + let token = '' + for (let i = 0; i < length; i++) { + token += PAT_CHARS[crypto.randomInt(patCharsLength)] + } + return token +} + +const GitBridgePATManager = { + + async getTokens(user_id) { + const query = { + user_id, + scope: /\bgit_bridge\b/, + type: 'personal_access_token' + } + const projection = { + accessTokenPartial: 1, + createdAt: 1, + expiresAt: 1, + lastUsedAt: 1 + } + + return await db.oauthAccessTokens + .find(query, { projection }) + .sort({ createdAt: 1 }) + .toArray() + }, + + async countTokens(user_id) { + const query = { + user_id, + scope: /\bgit_bridge\b/, + type: 'personal_access_token' + } + return db.oauthAccessTokens.countDocuments(query) + }, + + async createToken(user_id) { + const token = PAT_PREFIX + _generateToken(PAT_LENGTH) + const createdAt = new Date() + const expiresAt = new Date(createdAt) + expiresAt.setFullYear(expiresAt.getFullYear() + 1) + const accessTokenPartial = token.substring(0, 8) + + const result = await db.oauthAccessTokens.insertOne({ + accessToken: _hashToken(token), + accessTokenPartial, + user_id, + type: 'personal_access_token', + scope: 'git_bridge', + createdAt, + expiresAt, + }) + + return { + _id: result.insertedId, + accessToken: token, + accessTokenPartial, + createdAt, + expiresAt + } + }, + + async deleteToken(tokenId, user_id) { + const query = { + _id: new ObjectId(tokenId), + user_id, + } + const result = await db.oauthAccessTokens.deleteOne(query) + return result?.deletedCount || 0 + }, + + async getUserId(token) { + if (!token?.startsWith(PAT_PREFIX)) return null + + const now = new Date() + const objToken = await db.oauthAccessTokens.findOne( + { + accessToken: _hashToken(token), + type: 'personal_access_token', + scope: /\bgit_bridge\b/, + expiresAt: { $gt: now } + }, { projection: { user_id: 1 } } + ) + + if (!objToken?.user_id) return null + +// does the user still exists and not deleted? +// tokens of a deleted user are not deleted until user expires + const user = await db.users.findOne( + { _id: new ObjectId(objToken.user_id) }, + { projection: { _id: 1 } } + ) + + if (!user) return null + + // non-blocking + db.oauthAccessTokens.updateOne( + { _id: objToken._id }, + { $set: { lastUsedAt: now } } + ).catch(err => + logger.error({ err }, 'Failed to update lastUsedAt') + ) + + return objToken.user_id + }, +} + +export default GitBridgePATManager diff --git a/services/web/modules/git-bridge/app/src/GitBridgeRouter.mjs b/services/web/modules/git-bridge/app/src/GitBridgeRouter.mjs new file mode 100644 index 00000000000..c4354785276 --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgeRouter.mjs @@ -0,0 +1,57 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs' +import GitBridgeController from './GitBridgeController.mjs' +import ensureTokenProjectAccess from './GitBridgeAuthMiddleware.mjs' +import GitBridgePATController from './GitBridgePATController.mjs' +import RateLimiterMiddleware from '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs' +import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.mjs' + +const oauthTokenInfoRateLimiter = new RateLimiter('oauth-token-info', { + points: 30, + duration: 60, +}) + +export default { + apply(webRouter, privateApiRouter, publicApiRouter) { + logger.debug({}, 'Init git-bridge router') + +// Called by git-bridge served by web-api + privateApiRouter.get('/api/v0/docs/:project_id', + ensureTokenProjectAccess('read'), + GitBridgeController.getDoc + ) + privateApiRouter.get( + '/api/v0/docs/:project_id/saved_vers', + ensureTokenProjectAccess('read'), + GitBridgeController.getSavedVers + ) + privateApiRouter.get( + '/api/v0/docs/:project_id/snapshots/:version', + ensureTokenProjectAccess('read'), + GitBridgeController.getSnapshot + ) + privateApiRouter.post( + '/api/v0/docs/:project_id/snapshots', + ensureTokenProjectAccess('write'), + GitBridgeController.postSnapshot + ) +// Called by git-bridge to validate a PAT, served by web + publicApiRouter.get('/oauth/token/info', + RateLimiterMiddleware.rateLimit(oauthTokenInfoRateLimiter), + GitBridgePATController.validatePersonalAccessToken + ) + webRouter.get('/git-bridge/personal-access-tokens', + AuthenticationController.requireLogin(), + GitBridgePATController.getUserPersonalAccessTokens + ) + webRouter.post('/git-bridge/personal-access-tokens', + AuthenticationController.requireLogin(), + GitBridgePATController.createPersonalAccessToken + ) + webRouter.delete('/git-bridge/personal-access-tokens/:token_id', + AuthenticationController.requireLogin(), + GitBridgePATController.deletePersonalAccessToken + ) + } +} diff --git a/services/web/modules/git-bridge/frontend/js/card/components/git-integration-card.tsx b/services/web/modules/git-bridge/frontend/js/card/components/git-integration-card.tsx new file mode 100644 index 00000000000..e8685b93460 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/card/components/git-integration-card.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useProjectContext } from '@/shared/context/project-context' +import IntegrationCard from '@/features/integrations-panel/integration-card.tsx' +import GitLogoOrange from '@/shared/svgs/git-logo-orange' + +import GitModalWrapper from './git-modal-wrapper' + +function GitSyncCard() { + const { t } = useTranslation() + const { project } = useProjectContext() + const [show, setShow] = useState(false) + + const projectId = project?._id + if (!projectId) return null + + return ( + <> + } + onClick={() => setShow(true)} + /> + + setShow(false)} + projectId={projectId} + /> + + ) +} + +export default GitSyncCard diff --git a/services/web/modules/git-bridge/frontend/js/card/components/git-modal-content.tsx b/services/web/modules/git-bridge/frontend/js/card/components/git-modal-content.tsx new file mode 100644 index 00000000000..5ad515bf167 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/card/components/git-modal-content.tsx @@ -0,0 +1,75 @@ +import { Trans, useTranslation } from 'react-i18next' +import { CopyToClipboard } from '@/shared/components/copy-to-clipboard' + +import { + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' + +type Props = { + handleHide: () => void + projectId: string +} + +export default function GitModalContent({ + handleHide, + projectId, +}: Props) { + const { t } = useTranslation() + + const gitCloneCommand = `git clone ${window.location.protocol}//git@${window.location.host}/git/${projectId}` + + return ( + <> + + {t('clone_with_git')} + + + +

    {t('git_bridge_modal_git_clone_your_project')}

    + +
    + + + {gitCloneCommand} + + + +
    + , + ]} + /> +
    + + + + {t('close')} + + + {t('go_to_settings')} + + + + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/card/components/git-modal-wrapper.tsx b/services/web/modules/git-bridge/frontend/js/card/components/git-modal-wrapper.tsx new file mode 100644 index 00000000000..468e03ade34 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/card/components/git-modal-wrapper.tsx @@ -0,0 +1,29 @@ +import { OLModal } from '@/shared/components/ol/ol-modal' +import GitModalContent from './git-modal-content' + +type Props = { + show: boolean + projectId: string + handleHide: () => void +} + +export default function GitModalWrapper({ + show, + projectId, + handleHide, +}: Props) { + return ( + + + + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/card/components/git-modal.tsx b/services/web/modules/git-bridge/frontend/js/card/components/git-modal.tsx new file mode 100644 index 00000000000..41d14c5b9d3 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/card/components/git-modal.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import getMeta from '@/utils/meta' +import GitFork from '@/shared/svgs/git-fork' +import { useProjectContext } from '@/shared/context/project-context' +import LeftMenuButton from '@/features/editor-left-menu/components/left-menu-button' +import GitModalWrapper from './git-modal-wrapper' + +function GitSyncButton() { + const { t } = useTranslation() + const { project } = useProjectContext() + const [show, setShow] = useState(false) + + const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled') + const projectId = project?._id + + if (!gitBridgeEnabled || !projectId) return null + + return ( + <> + setShow(true)} + icon={} + > + {t('git')} + + + setShow(false)} + projectId={projectId} + /> + + ) +} + +export default GitSyncButton diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/git-integration-widget.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/git-integration-widget.tsx new file mode 100644 index 00000000000..481d7e1d865 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/git-integration-widget.tsx @@ -0,0 +1,144 @@ +import { useState, useCallback, useEffect } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { debugConsole } from '@/utils/debugging' +import { postJSON, getJSON } from '@/infrastructure/fetch-json' +import useAsync from '@/shared/hooks/use-async' +import Notification from '@/shared/components/notification' +import OLButton from '@/shared/components/ol/ol-button' +import GitLogoOrange from '@/shared/svgs/git-logo-orange' +import TokenTable from './token-table' +import ExposeTokenModal from './modals/expose-token-modal' +import { Token } from '../../../../types/api' + +export default function GitIntegrationWidget() { + const { t } = useTranslation() + + const [tokens, setTokens] = useState([]) + const [showExposeTokenModal, setShowExposeTokenModal] = useState(false) + const [secretToken, setSecretToken] = useState(null) + + const { runAsync, isLoading, isError, reset } = useAsync() + + useEffect(() => { + runAsync(getJSON('/git-bridge/personal-access-tokens')) + .then((data: Token[]) => setTokens(data)) + .catch(debugConsole.error) + }, [runAsync]) + + const handleCreateToken = useCallback(() => { + runAsync(postJSON('/git-bridge/personal-access-tokens')) + .then((data: Token & { accessToken: string }) => { + const { accessToken, ...newToken } = data + setTokens(prev => [...prev, newToken]) + setSecretToken(accessToken) + setShowExposeTokenModal(true) + }) + .catch((err) => { + debugConsole.error(err) + setTimeout(() => reset(), 5000) + }) + }, [runAsync]) + + const handleDeleteToken = useCallback((id: string) => { + setTokens(prev => prev.filter(t => t._id !== id)) + }, []) + + const tokenCount = tokens.length + + return ( +
    + +
    + +
    + +
    + +
    +

    {t('git_integration')}

    +
    + +

    + , + ]} + /> +

    + +

    + {t('your_git_access_tokens')} +

    + +

    + {t('your_git_access_info')} +

    + +
      + {tokenCount > 0 ? ( + <> +
    • {t('your_git_access_info_bullet_1')}
    • +
    • {t('your_git_access_info_bullet_2')}
    • + + ) : ( + <> +
    • + ]} + /> +
    • +
    • {t('your_git_access_info_bullet_4')}
    • +
    • {t('your_git_access_info_bullet_5')}
    • + + )} +
    + + + + {isError && ( +
    + +
    + )} + +
    + + {tokenCount === 0 && ( +
    + + {t('generate_token')} + +
    + )} + + {showExposeTokenModal && secretToken && ( + { + setShowExposeTokenModal(false) + setSecretToken(null) + }} + /> + )} +
    + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/modals/delete-token-modal.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/modals/delete-token-modal.tsx new file mode 100644 index 00000000000..b530d1083be --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/modals/delete-token-modal.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from 'react-i18next' +import { deleteJSON } from '@/infrastructure/fetch-json' +import { + OLModal, + OLModalHeader, + OLModalTitle, + OLModalBody, + OLModalFooter, +} from '@/shared/components/ol/ol-modal' +import Notification from '@/shared/components/notification' +import OLButton from '@/shared/components/ol/ol-button' +import useAsync from '@/shared/hooks/use-async' +import { debugConsole } from '@/utils/debugging' + +type Props = { + show: boolean + handleHide: () => void + tokenId: string + onDeleted: (id: string) => void +} + +export default function DeleteTokenModal({ + show, + handleHide, + tokenId, + onDeleted, +}: Props) { + const { t } = useTranslation() + + const { isLoading, isError, runAsync, reset } = useAsync() + + const handleClose = () => { + reset() + handleHide() + } + + const handleDelete = () => { + runAsync( + deleteJSON(`/git-bridge/personal-access-tokens/${tokenId}`, { + body: { _csrf: window.csrfToken }, + }) + ) + .then(() => { + onDeleted(tokenId) + handleClose() + }) + .catch(debugConsole.error) + } + + return ( + + + {t('delete_authentication_token')} + + + +

    {t('delete_authentication_token_info')}

    + + {isError && ( +
    + +
    + )} +
    + + + + {t('cancel')} + + + + {t('delete_token')} + + +
    + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/modals/expose-token-modal.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/modals/expose-token-modal.tsx new file mode 100644 index 00000000000..f59c1a36df9 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/modals/expose-token-modal.tsx @@ -0,0 +1,80 @@ +import { Trans, useTranslation } from 'react-i18next' +import { + OLModal, + OLModalHeader, + OLModalTitle, + OLModalBody, + OLModalFooter, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import { CopyToClipboard } from '@/shared/components/copy-to-clipboard' + +type Props = { + secretToken: string + handleHide: () => void +} + +export default function ExposeTokenModal({ + secretToken, + handleHide, +}: Props) { + const { t } = useTranslation() + + return ( + + + + {t('git_authentication_token')} + + + + +

    + {t('git_authentication_token_create_modal_info_1')} +

    + +
    +
    + + {secretToken} + + +
    +
    + +

    + , + , + ]} + /> +

    +
    + + + + {t('close')} + + +
    + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/token-table-footer.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-footer.tsx new file mode 100644 index 00000000000..ca48ffc9ccf --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-footer.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from 'react-i18next' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLButton from '@/shared/components/ol/ol-button' +import Cell from '@/features/settings/components/emails/cell' + +type Props = { + tokenCount: number + limitReached: boolean + onCreateToken: () => void + isLoading: boolean +} + +export default function TokenTableFooter({ + tokenCount, + limitReached, + onCreateToken, + isLoading, +}: Props) { + const { t } = useTranslation() + + if (tokenCount === 0) return null + + return ( + <> +
    +
    + + + + {limitReached ? ( +

    {t('token_limit_reached')}

    + ) : ( + + {t('add_another_token')} + + )} +
    +
    +
    +
    + + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/token-table-header.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-header.tsx new file mode 100644 index 00000000000..a72aafc6e8c --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-header.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import Cell from '@/features/settings/components/emails/cell' + +export default function TokenTableHeader() { + const { t } = useTranslation() + + return ( + <> + + + {t('token')} + + + + {t('created_at')} + + + + {t('last_used')} + + + + {t('expires')} + + + +
    +
    + + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/token-table-row.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-row.tsx new file mode 100644 index 00000000000..3523002ce4d --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-row.tsx @@ -0,0 +1,66 @@ +import moment from 'moment' +import { useTranslation } from 'react-i18next' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLButton from '@/shared/components/ol/ol-button' +import Cell from '@/features/settings/components/emails/cell' +import { Token } from '../../../../types/api' + +type Props = { + token: Token + handleDeleteClick: (id: string) => void +} + +function TokenTableRow({ token, handleDeleteClick }: Props) { + const { t } = useTranslation() + +console.log("token = ", token) + + const created = moment(token.createdAt).format('Do MMM YYYY') + const lastUsed = token.lastUsedAt + ? moment(token.lastUsedAt).format('Do MMM YYYY') + : t('never') + const expires = moment(token.expiresAt).format('Do MMM YYYY') + + const handleClick = () => handleDeleteClick(token._id) + + return ( + + + {token.accessTokenPartial + '************'} + + + + {created} + + + + {lastUsed} + + + + {expires} + + + + + + + + + + + ) +} + +export default TokenTableRow diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/token-table.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/token-table.tsx new file mode 100644 index 00000000000..69cc29ec8b4 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/token-table.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import DeleteTokenModal from './modals/delete-token-modal' +import TokenTableHeader from './token-table-header' +import TokenTableRow from './token-table-row' +import TokenTableFooter from './token-table-footer' +import { Token } from '../../../../types/api' + +const MAX_TOKENS = 10 + +type Props = { + tokens: Token[] + onCreateToken: () => void + onDeleteToken: (id: string) => void +} + +export default function TokenTable({ + tokens, + onCreateToken, + onDeleteToken, +}: Props) { + const { t } = useTranslation() + + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [selectedTokenId, setSelectedTokenId] = useState('') + + const handleDeleteClick = (id: string) => { + setSelectedTokenId(id) + setShowDeleteModal(true) + } + + const tokenCount = tokens.length + const limitReached = tokenCount >= MAX_TOKENS + + return ( + <> + {tokenCount > 0 && } + + {tokens.map((token) => ( + + ))} + + + + setShowDeleteModal(false)} + onDeleted={(id: string) => { + onDeleteToken(id) + setShowDeleteModal(false) + }} + /> + + ) +} diff --git a/services/web/modules/git-bridge/index.mjs b/services/web/modules/git-bridge/index.mjs new file mode 100644 index 00000000000..46f60b2a605 --- /dev/null +++ b/services/web/modules/git-bridge/index.mjs @@ -0,0 +1,48 @@ +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import { db } from '../../app/src/infrastructure/mongodb.mjs' +import Modules from '../../app/src/infrastructure/Modules.mjs' +import GitBridgeRouter from './app/src/GitBridgeRouter.mjs' + +let GitBridgeModule = {} + +if (process.env.GIT_BRIDGE_ENABLED === 'true') { + logger.debug({}, 'Enabling git-bridge module') + + Settings.enableGitBridge = true + + // Delete all user's git-bridge tokens on user expire (hook 'expireDeletedUser') + Modules.hooks.attach('expireDeletedUser', async userId => { + try { + const query = { + user_id: userId, + scope: /\bgit_bridge\b/, + type: 'personal_access_token' + } + await db.oauthAccessTokens.deleteMany(query) + } catch (err) { + logger.warn({ userId, err }, 'on user expire: failed deleting git-bridge tokens') + } + }) + // Delete project from /data/git-bridge on project expire (hook 'projectExpired') + Modules.hooks.attach('projectExpired', async projectId => { + const gitBridgeApiBaseUrl = process.env.GIT_BRIDGE_API_BASE_URL || + `http://${process.env.GIT_BRIDGE_HOST || 'git-bridge'}:${ + process.env.GIT_BRIDGE_PORT || '8000' + }/api` + try { + const res = await fetch(`${gitBridgeApiBaseUrl}/projects/${projectId}`, { + method: 'DELETE', + }) + if (!res.ok) throw new Error(`error status: ${res.status}`) + + } catch (err) { + logger.warn({ projectId, err }, 'on project expire: failed deleting project in git-bridge') + } + }) + GitBridgeModule = { + router: GitBridgeRouter, + } +} + +export default GitBridgeModule diff --git a/services/web/modules/git-bridge/types/api.d.ts b/services/web/modules/git-bridge/types/api.d.ts new file mode 100644 index 00000000000..e505d01216e --- /dev/null +++ b/services/web/modules/git-bridge/types/api.d.ts @@ -0,0 +1,7 @@ +export type Token = { + _id: string + accessTokenPartial: string + created_at: string + lastUsedAt?: string + expiresAt: string +} diff --git a/services/web/modules/launchpad/app/src/LaunchpadController.mjs b/services/web/modules/launchpad/app/src/LaunchpadController.mjs index 94cc8b35a25..5e2add12677 100644 --- a/services/web/modules/launchpad/app/src/LaunchpadController.mjs +++ b/services/web/modules/launchpad/app/src/LaunchpadController.mjs @@ -153,7 +153,8 @@ function registerExternalAuthAdmin(authMethod) { await User.updateOne( { _id: user._id }, { - $set: { isAdmin: true, emails: [{ email, reversedHostname }] }, + $set: { isAdmin: true, emails: [{ email, reversedHostname, 'confirmedAt' : Date.now() }] }, + $unset: { 'hashedPassword': "" }, // external-auth user must not have a hashedPassword } ).exec() } catch (err) { diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug index 1af19bb4feb..f7b15d90627 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/services/web/modules/launchpad/app/views/launchpad.pug @@ -129,6 +129,41 @@ block content span(data-ol-inflight='idle') #{translate("register")} span(hidden data-ol-inflight='pending') #{translate("registering")}… + h3 #{translate('local_account')} + p + | #{translate('alternatively_create_local_admin_account')} + + form( + data-ol-async-form + data-ol-register-admin + action='/launchpad/register_admin' + method='POST' + ) + input(name='_csrf' type='hidden' value=csrfToken) + +formMessages + .form-group + label.form-label(for='email') #{translate("email")} + input.form-control( + name='email' + type='email' + id='email-local' + autocomplete='username' + required + autofocus='true' + ) + .form-group + label.form-label(for='passwordField') #{translate("password")} + input#passwordField.form-control( + name='password' + type='password' + autocomplete='new-password' + required + ) + .actions + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("register")} + span(hidden data-ol-inflight='pending') #{translate("registering")}… + // Saml Form if authMethod === 'saml' h3 #{translate('saml')} @@ -158,6 +193,41 @@ block content span(data-ol-inflight='idle') #{translate("register")} span(hidden data-ol-inflight='pending') #{translate("registering")}… + h3 #{translate('local_account')} + p + | #{translate('alternatively_create_local_admin_account')} + + form( + data-ol-async-form + data-ol-register-admin + action='/launchpad/register_admin' + method='POST' + ) + input(name='_csrf' type='hidden' value=csrfToken) + +formMessages + .form-group + label.form-label(for='email-local') #{translate("email")} + input.form-control( + name='email' + type='email' + id='email-local' + autocomplete='username' + required + autofocus='true' + ) + .form-group + label.form-label(for='passwordField') #{translate("password")} + input#passwordField.form-control( + name='password' + type='password' + autocomplete='new-password' + required + ) + .actions + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("register")} + span(hidden data-ol-inflight='pending') #{translate("registering")}… + br diff --git a/services/web/modules/sandboxed-compiles/index.mjs b/services/web/modules/sandboxed-compiles/index.mjs new file mode 100644 index 00000000000..456b03aaa09 --- /dev/null +++ b/services/web/modules/sandboxed-compiles/index.mjs @@ -0,0 +1,25 @@ +import Settings from '@overleaf/settings' + +const parseTextExtensions = function (extensions) { + if (extensions) { + return extensions.split(',').map(ext => ext.trim()) + } else { + return [] + } +} + +if (process.env.SANDBOXED_COMPILES === 'true') { + Settings.allowedImageNames = parseTextExtensions(process.env.ALL_TEX_LIVE_DOCKER_IMAGES) + .map((imageName, index) => ({ + imageName, + imageDesc: parseTextExtensions(process.env.ALL_TEX_LIVE_DOCKER_IMAGE_NAMES)[index] + || imageName.split(':')[1], + })) + if(!process.env.TEX_LIVE_DOCKER_IMAGE) { + process.env.TEX_LIVE_DOCKER_IMAGE = Settings.allowedImageNames[0].imageName + } + Settings.currentImageName = process.env.TEX_LIVE_DOCKER_IMAGE +} + +const SandboxedCompilesModule = {} +export default SandboxedCompilesModule diff --git a/services/web/modules/server-ce-scripts/scripts/add-image-repo-prefix.mjs b/services/web/modules/server-ce-scripts/scripts/add-image-repo-prefix.mjs new file mode 100644 index 00000000000..c13a3a67e16 --- /dev/null +++ b/services/web/modules/server-ce-scripts/scripts/add-image-repo-prefix.mjs @@ -0,0 +1,88 @@ +import minimist from 'minimist' +import { db } from '../../../app/src/infrastructure/mongodb.mjs' +import { fileURLToPath } from 'url' + +const filename = fileURLToPath(import.meta.url) + +export default async function main() { + const argv = minimist(process.argv.slice(2), { + boolean: ['dry-run'], + default: { 'dry-run': true }, + string: ['prefix'], + }) + + const dryRun = argv['dry-run'] + const prefix = argv.prefix + + if (!prefix) { + console.error(`Usage: node ${filename} --prefix= [--no-dry-run]`) + process.exit(1) + } + + const normalizedPrefix = prefix.replace(/\/+$/, '') + const collections = ['projects', 'templates'] + + for (const collectionName of collections) { + const collection = db[collectionName] + + console.log(`\nProcessing collection: ${collectionName}`) + + const rows = [] + const cursor = collection.find({}) + + for await (const doc of cursor) { + const oldName = doc.imageName + if (typeof oldName !== 'string') continue + if (oldName.includes('/')) continue + + const newName = `${normalizedPrefix}/${oldName}` + + rows.push({ + id: String(doc._id), + old: oldName, + new: newName, + doc, + }) + } + + const idWidth = Math.max(...rows.map(r => r.id.length), 2) + const oldWidth = Math.max(...rows.map(r => r.old.length), 3) + + let count = 0 + + for (const r of rows) { + count++ + + console.log( + `_id: ${r.id.padEnd(idWidth)} ${r.old.padEnd(oldWidth)} -> ${r.new}` + ) + + if (!dryRun) { + await collection.updateOne( + { _id: r.doc._id }, + { $set: { imageName: r.new } } + ) + } + } + + console.log( + `Total entries ${dryRun ? 'to be changed' : 'changed'} in ${collectionName}: ${count}` + ) + } + + console.log( + dryRun + ? '\nDry-run complete. No changes made. Use --no-dry-run to apply updates.' + : '\nUpdate complete.' + ) +} + +if (filename === process.argv[1]) { + try { + await main() + process.exit(0) + } catch (error) { + console.error({ error }) + process.exit(1) + } +} diff --git a/services/web/modules/server-ce-scripts/scripts/strip-image-repo-prefix.mjs b/services/web/modules/server-ce-scripts/scripts/strip-image-repo-prefix.mjs new file mode 100644 index 00000000000..d4f6808185b --- /dev/null +++ b/services/web/modules/server-ce-scripts/scripts/strip-image-repo-prefix.mjs @@ -0,0 +1,85 @@ +import minimist from 'minimist' +import { db } from '../../../app/src/infrastructure/mongodb.mjs' +import { fileURLToPath } from 'url' + +const filename = fileURLToPath(import.meta.url) + +function stripRepoPrefix(name) { + if (typeof name !== 'string') return '' + const parts = name.split('/') + return parts[parts.length - 1] +} + +export default async function main() { + const argv = minimist(process.argv.slice(2), { + boolean: ['dry-run'], + default: { 'dry-run': true }, + }) + + const dryRun = argv['dry-run'] + const collections = ['projects', 'templates'] + + for (const collectionName of collections) { + const collection = db[collectionName] + + console.log(`\nProcessing collection: ${collectionName}`) + + const rows = [] + const cursor = collection.find({ imageName: { $regex: '/' } }) + + for await (const doc of cursor) { + const oldName = doc.imageName + if (typeof oldName !== 'string') continue + + const newName = stripRepoPrefix(oldName) + if (oldName === newName) continue + + rows.push({ + id: String(doc._id), + old: oldName, + new: newName, + doc, + }) + } + + const idWidth = Math.max(...rows.map(r => r.id.length), 2) + const oldWidth = Math.max(...rows.map(r => r.old.length), 3) + + let count = 0 + + for (const r of rows) { + count++ + + console.log( + `_id: ${r.id.padEnd(idWidth)} ${r.old.padEnd(oldWidth)} -> ${r.new}` + ) + + if (!dryRun) { + await collection.updateOne( + { _id: r.doc._id }, + { $set: { imageName: r.new } } + ) + } + } + + console.log( + `Total entries ${dryRun ? 'to be changed' : 'changed'} in ${collectionName}: ${count}` + ) + } + + console.log( + dryRun + ? '\nDry-run complete. No changes made. Use --no-dry-run to apply updates.' + : '\nUpdate complete.' + ) +} + +if (filename === process.argv[1]) { + try { + await main() + process.exit(0) + } catch (error) { + console.error({ error }) + process.exit(1) + } +} diff --git a/services/web/modules/symbol-palette/frontend/components/symbol-palette-body.js b/services/web/modules/symbol-palette/frontend/components/symbol-palette-body.js new file mode 100644 index 00000000000..3f9eb7fc5f4 --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/components/symbol-palette-body.js @@ -0,0 +1,76 @@ +import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' +import SymbolPaletteItems from './symbol-palette-items' + +export default function SymbolPaletteBody({ + categories, + categorisedSymbols, + filteredSymbols, + handleSelect, + focusInput, + activeCategoryId, +}) { + const { t } = useTranslation() + + // searching with matches: show the matched symbols + // searching with no matches: show a message + // note: include empty tab panels so that aria-controls on tabs can still reference the panel ids + if (filteredSymbols) { + return ( +
    + {filteredSymbols.length ? ( + + ) : ( +
    {t('no_symbols_found')}
    + )} + + {categories.map(category => ( + + ) + } + + // not searching: show the symbols grouped by category + return ( +
    + {categories.map((category) => ( + + ))} +
    + ) + + +} +SymbolPaletteBody.propTypes = { + categories: PropTypes.arrayOf(PropTypes.object).isRequired, + categorisedSymbols: PropTypes.object, + filteredSymbols: PropTypes.arrayOf(PropTypes.object), + handleSelect: PropTypes.func.isRequired, + focusInput: PropTypes.func.isRequired, + activeCategoryId: PropTypes.string.isRequired, +} diff --git a/services/web/modules/symbol-palette/frontend/components/symbol-palette-close-button.js b/services/web/modules/symbol-palette/frontend/components/symbol-palette-close-button.js new file mode 100644 index 00000000000..98b4a447fbf --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/components/symbol-palette-close-button.js @@ -0,0 +1,29 @@ +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' +import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' + +export default function SymbolPaletteCloseButton() { + const { toggleSymbolPalette } = useEditorPropertiesContext() + const { t } = useTranslation() + + const handleClick = () => { + toggleSymbolPalette() + window.dispatchEvent(new CustomEvent('editor:focus')) + } + + return ( +
    + +
    + ) +} + +SymbolPaletteCloseButton.propTypes = { + focusInput: PropTypes.func, +} diff --git a/services/web/modules/symbol-palette/frontend/components/symbol-palette-content.js b/services/web/modules/symbol-palette/frontend/components/symbol-palette-content.js new file mode 100644 index 00000000000..b395cac53d3 --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/components/symbol-palette-content.js @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' +import { matchSorter } from 'match-sorter' +import symbols from '../data/symbols.json' +import { buildCategorisedSymbols, createCategories } from '../utils/categories' +import SymbolPaletteSearch from './symbol-palette-search' +import SymbolPaletteBody from './symbol-palette-body' +import SymbolPaletteTabs from './symbol-palette-tabs' +import SymbolPaletteCloseButton from './symbol-palette-close-button' + +export default function SymbolPaletteContent({ handleSelect }) { + const [input, setInput] = useState('') + + const { t } = useTranslation() + + // build the list of categories with translated labels + const categories = useMemo(() => createCategories(t), [t]) + const [activeCategoryId, setActiveCategoryId] = useState(categories[0]?.id) + + // group the symbols by category + const categorisedSymbols = useMemo( + () => buildCategorisedSymbols(categories), + [categories] + ) + + // select symbols which match the input + const filteredSymbols = useMemo(() => { + if (input === '') { + return null + } + + const words = input.trim().split(/\s+/) + + return words.reduceRight( + (symbols, word) => + matchSorter(symbols, word, { + keys: ['command', 'description', 'character', 'aliases'], + threshold: matchSorter.rankings.CONTAINS, + }), + symbols + ) + }, [input]) + + const inputRef = useRef(null) + + // allow the input to be focused + const focusInput = useCallback(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, []) + + // focus the input when the symbol palette is opened + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, []) + return ( +
    +
    +
    +
    + +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + ) +} +SymbolPaletteContent.propTypes = { + handleSelect: PropTypes.func.isRequired, +} diff --git a/services/web/modules/symbol-palette/frontend/components/symbol-palette-item.js b/services/web/modules/symbol-palette/frontend/components/symbol-palette-item.js new file mode 100644 index 00000000000..bca8377bfa5 --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/components/symbol-palette-item.js @@ -0,0 +1,84 @@ +import { useEffect, useRef, forwardRef } from 'react' +import PropTypes from 'prop-types' +import OLTooltip from '@/shared/components/ol/ol-tooltip' + +const SymbolPaletteItem = forwardRef(function ({ + focused, + handleSelect, + handleKeyDown, + symbol, +}, ref) { + const buttonRef = useRef(null) + + // Forward internal ref to parent + useEffect(() => { + if (ref) { + if (typeof ref === 'function') { + ref(buttonRef.current) + } else { + ref.current = buttonRef.current + } + } + }, [ref]) + + // Focus the item when it becomes focused + useEffect(() => { + if ( + focused && + buttonRef.current && + document.activeElement?.closest('.symbol-palette-items') + ) { + buttonRef.current.focus() + } + }, [focused]) + + return ( + +
    + {symbol.description} +
    +
    + {symbol.command} +
    + {symbol.notes && ( +
    + {symbol.notes} +
    + )} +
    + } + overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} + > + + + ) +}) + +SymbolPaletteItem.propTypes = { + symbol: PropTypes.shape({ + codepoint: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + command: PropTypes.string.isRequired, + character: PropTypes.string.isRequired, + notes: PropTypes.string, + }), + handleKeyDown: PropTypes.func.isRequired, + handleSelect: PropTypes.func.isRequired, + focused: PropTypes.bool, +} +export default SymbolPaletteItem diff --git a/services/web/modules/symbol-palette/frontend/components/symbol-palette-items.js b/services/web/modules/symbol-palette/frontend/components/symbol-palette-items.js new file mode 100644 index 00000000000..ad8d004a092 --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/components/symbol-palette-items.js @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import PropTypes from 'prop-types' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' +import SymbolPaletteItem from './symbol-palette-item' + +export default function SymbolPaletteItems({ + items, + handleSelect, + focusInput, +}) { + const [focusedIndex, setFocusedIndex] = useState(0) + const itemRefs = useRef([]) + + useEffect(() => { + itemRefs.current = items.map((_, i) => itemRefs.current[i] || null) + setFocusedIndex(0) + }, [items]) + + const getItemRects = () => { + return itemRefs.current.map(ref => ref?.getBoundingClientRect?.() ?? null) + } + const { toggleSymbolPalette } = useEditorPropertiesContext() + + const handleKeyDown = useCallback( + event => { + if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return + + const rects = getItemRects() + const currentRect = rects[focusedIndex] + if (!currentRect) return + + let newIndex = focusedIndex + + switch (event.key) { + case 'ArrowLeft': + newIndex = focusedIndex > 0 ? focusedIndex - 1 : items.length - 1 + break + case 'ArrowRight': + newIndex = focusedIndex < items.length - 1 ? focusedIndex + 1 : 0 + break + case 'ArrowUp': + case 'ArrowDown': { + const direction = event.key === 'ArrowUp' ? -1 : 1 + const candidates = rects + .map((rect, i) => ({ rect, i })) + .filter(({ rect }, i) => + i !== focusedIndex && + rect && + Math.abs(rect.x - currentRect.x) < currentRect.width * 0.8 && + (direction === -1 ? rect.y < currentRect.y : rect.y > currentRect.y) + ) + + if (candidates.length > 0) { + const closest = candidates.reduce((a, b) => + Math.abs(b.rect.y - currentRect.y) < Math.abs(a.rect.y - currentRect.y) ? b : a + ) + newIndex = closest.i + } + break + } + case 'Home': + newIndex = 0 + break + case 'End': + newIndex = items.length - 1 + break + case 'Enter': + case ' ': + handleSelect(items[focusedIndex]) + toggleSymbolPalette() + break + case 'Escape': + toggleSymbolPalette() + window.dispatchEvent(new CustomEvent('editor:focus')) + break + + default: + focusInput() + return + } + + event.preventDefault() + setFocusedIndex(newIndex) + }, + [focusedIndex, items, focusInput, handleSelect] + ) + + return ( +
    + {items.map((symbol, index) => ( + { + handleSelect(symbol) + setFocusedIndex(index) + }} + handleKeyDown={handleKeyDown} + focused={index === focusedIndex} + ref={el => { + itemRefs.current[index] = el + }} + /> + ))} +
    + ) +} + +SymbolPaletteItems.propTypes = { + items: PropTypes.arrayOf( + PropTypes.shape({ + codepoint: PropTypes.string.isRequired, + }) + ).isRequired, + handleSelect: PropTypes.func.isRequired, + focusInput: PropTypes.func.isRequired, +} + diff --git a/services/web/modules/symbol-palette/frontend/components/symbol-palette-search.js b/services/web/modules/symbol-palette/frontend/components/symbol-palette-search.js new file mode 100644 index 00000000000..42e30f78861 --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/components/symbol-palette-search.js @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import useDebounce from '@/shared/hooks/use-debounce' + +export default function SymbolPaletteSearch({ setInput, inputRef }) { + const [localInput, setLocalInput] = useState('') + + // debounce the search input until a typing delay + const debouncedLocalInput = useDebounce(localInput, 250) + + useEffect(() => { + setInput(debouncedLocalInput) + }, [debouncedLocalInput, setInput]) + + const { t } = useTranslation() + + const inputRefCallback = useCallback( + element => { + inputRef.current = element + }, + [inputRef] + ) + + return ( + { + setLocalInput(event.target.value) + }} + /> + ) +} + +SymbolPaletteSearch.propTypes = { + setInput: PropTypes.func.isRequired, + inputRef: PropTypes.object.isRequired, +} diff --git a/services/web/modules/symbol-palette/frontend/components/symbol-palette-tabs.js b/services/web/modules/symbol-palette/frontend/components/symbol-palette-tabs.js new file mode 100644 index 00000000000..80f0421f27f --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/components/symbol-palette-tabs.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types' +import { useState, useRef } from 'react' + + +export default function SymbolPaletteTabs({ + categories, + activeCategoryId, + setActiveCategoryId, +}) { + + const buttonRefs = useRef([]) + const focusTab = (index) => { + setActiveCategoryId(categories[index].id) + buttonRefs.current[index]?.focus() + } + + const handleKeyDown = (e, index) => { + switch (e.key) { + case 'ArrowRight': + focusTab((index + 1) % categories.length) + break + case 'ArrowLeft': + focusTab((index - 1 + categories.length) % categories.length) + break + case 'Home': + case 'PageUp': + focusTab(0) + break + case 'End': + case 'PageDown': + focusTab(categories.length - 1) + break + default: + break + } + } + + return ( +
    + {categories.map((category, index) => { + const selected = activeCategoryId === category.id + return ( + + ) + })} +
    + ) +} + +SymbolPaletteTabs.propTypes = { + categories: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + })).isRequired, + activeCategoryId: PropTypes.string.isRequired, + setActiveCategoryId: PropTypes.func.isRequired, +} diff --git a/services/web/modules/symbol-palette/frontend/components/symbol-palette.js b/services/web/modules/symbol-palette/frontend/components/symbol-palette.js new file mode 100644 index 00000000000..2f1cc5e8c88 --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/components/symbol-palette.js @@ -0,0 +1,8 @@ +import SymbolPaletteContent from './symbol-palette-content' + +export default function SymbolPalette() { + const handleSelect = (symbol) => { + window.dispatchEvent(new CustomEvent('editor:insert-symbol', { detail: symbol })) + } + return +} diff --git a/services/web/modules/symbol-palette/frontend/data/symbols.json b/services/web/modules/symbol-palette/frontend/data/symbols.json new file mode 100644 index 00000000000..af160b3eedf --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/data/symbols.json @@ -0,0 +1,872 @@ +[ + { + "category": "Greek", + "command": "\\alpha", + "codepoint": "U+1D6FC", + "description": "Lowercase Greek letter alpha", + "aliases": ["a", "α"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\beta", + "codepoint": "U+1D6FD", + "description": "Lowercase Greek letter beta", + "aliases": ["b", "β"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\gamma", + "codepoint": "U+1D6FE", + "description": "Lowercase Greek letter gamma", + "aliases": ["γ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\delta", + "codepoint": "U+1D6FF", + "description": "Lowercase Greek letter delta", + "aliases": ["Ī“"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\varepsilon", + "codepoint": "U+1D700", + "description": "Lowercase Greek letter epsilon, varepsilon", + "aliases": ["ε"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\epsilon", + "codepoint": "U+1D716", + "description": "Lowercase Greek letter epsilon lunate", + "aliases": ["ε"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\zeta", + "codepoint": "U+1D701", + "description": "Lowercase Greek letter zeta", + "aliases": ["ζ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\eta", + "codepoint": "U+1D702", + "description": "Lowercase Greek letter eta", + "aliases": ["Ī·"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\vartheta", + "codepoint": "U+1D717", + "description": "Lowercase Greek letter curly theta, vartheta", + "aliases": ["Īø"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\theta", + "codepoint": "U+1D703", + "description": "Lowercase Greek letter theta", + "aliases": ["Īø"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\iota", + "codepoint": "U+1D704", + "description": "Lowercase Greek letter iota", + "aliases": ["ι"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\kappa", + "codepoint": "U+1D705", + "description": "Lowercase Greek letter kappa", + "aliases": ["Īŗ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\lambda", + "codepoint": "U+1D706", + "description": "Lowercase Greek letter lambda", + "aliases": ["Ī»"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\mu", + "codepoint": "U+1D707", + "description": "Lowercase Greek letter mu", + "aliases": ["μ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\nu", + "codepoint": "U+1D708", + "description": "Lowercase Greek letter nu", + "aliases": ["ν"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\xi", + "codepoint": "U+1D709", + "description": "Lowercase Greek letter xi", + "aliases": ["ξ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\pi", + "codepoint": "U+1D70B", + "description": "Lowercase Greek letter pi", + "aliases": ["Ļ€"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\varrho", + "codepoint": "U+1D71A", + "description": "Lowercase Greek letter rho, varrho", + "aliases": ["ρ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\rho", + "codepoint": "U+1D70C", + "description": "Lowercase Greek letter rho", + "aliases": ["ρ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\sigma", + "codepoint": "U+1D70E", + "description": "Lowercase Greek letter sigma", + "aliases": ["σ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\varsigma", + "codepoint": "U+1D70D", + "description": "Lowercase Greek letter final sigma, varsigma", + "aliases": ["Ļ‚"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\tau", + "codepoint": "U+1D70F", + "description": "Lowercase Greek letter tau", + "aliases": ["Ļ„"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\upsilon", + "codepoint": "U+1D710", + "description": "Lowercase Greek letter upsilon", + "aliases": ["Ļ…"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\phi", + "codepoint": "U+1D719", + "description": "Lowercase Greek letter phi", + "aliases": ["φ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\varphi", + "codepoint": "U+1D711", + "description": "Lowercase Greek letter phi, varphi", + "aliases": ["φ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\chi", + "codepoint": "U+1D712", + "description": "Lowercase Greek letter chi", + "aliases": ["χ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\psi", + "codepoint": "U+1D713", + "description": "Lowercase Greek letter psi", + "aliases": ["ψ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\omega", + "codepoint": "U+1D714", + "description": "Lowercase Greek letter omega", + "aliases": ["ω"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Gamma", + "codepoint": "U+00393", + "description": "Uppercase Greek letter Gamma", + "aliases": ["Ī“"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Delta", + "codepoint": "U+00394", + "description": "Uppercase Greek letter Delta", + "aliases": ["Ī”"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Theta", + "codepoint": "U+00398", + "description": "Uppercase Greek letter Theta", + "aliases": ["Θ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Lambda", + "codepoint": "U+0039B", + "description": "Uppercase Greek letter Lambda", + "aliases": ["Ī›"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Xi", + "codepoint": "U+0039E", + "description": "Uppercase Greek letter Xi", + "aliases": ["Īž"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Pi", + "codepoint": "U+003A0", + "description": "Uppercase Greek letter Pi", + "aliases": ["Ī "], + "notes": "Use \\prod for the product." + }, + { + "category": "Greek", + "command": "\\Sigma", + "codepoint": "U+003A3", + "description": "Uppercase Greek letter Sigma", + "aliases": ["Ī£"], + "notes": "Use \\sum for the sum." + }, + { + "category": "Greek", + "command": "\\Upsilon", + "codepoint": "U+003A5", + "description": "Uppercase Greek letter Upsilon", + "aliases": ["Ī„"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Phi", + "codepoint": "U+003A6", + "description": "Uppercase Greek letter Phi", + "aliases": ["Φ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Psi", + "codepoint": "U+003A8", + "description": "Uppercase Greek letter Psi", + "aliases": ["ĪØ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Omega", + "codepoint": "U+003A9", + "description": "Uppercase Greek letter Omega", + "aliases": ["Ī©"], + "notes": "" + }, + { + "category": "Relations", + "command": "\\neq", + "codepoint": "U+02260", + "description": "Not equal", + "aliases": ["!="], + "notes": "" + }, + { + "category": "Relations", + "command": "\\leq", + "codepoint": "U+02264", + "description": "Less than or equal", + "aliases": ["<="], + "notes": "" + }, + { + "category": "Relations", + "command": "\\geq", + "codepoint": "U+02265", + "description": "Greater than or equal", + "aliases": [">="], + "notes": "" + }, + { + "category": "Relations", + "command": "\\ll", + "codepoint": "U+0226A", + "description": "Much less than", + "aliases": ["<<"], + "notes": "" + }, + { + "category": "Relations", + "command": "\\gg", + "codepoint": "U+0226B", + "description": "Much greater than", + "aliases": [">>"], + "notes": "" + }, + { + "category": "Relations", + "command": "\\prec", + "codepoint": "U+0227A", + "description": "Precedes", + "notes": "" + }, + { + "category": "Relations", + "command": "\\succ", + "codepoint": "U+0227B", + "description": "Succeeds", + "notes": "" + }, + { + "category": "Relations", + "command": "\\in", + "codepoint": "U+02208", + "description": "Set membership", + "notes": "" + }, + { + "category": "Relations", + "command": "\\notin", + "codepoint": "U+02209", + "description": "Negated set membership", + "notes": "" + }, + { + "category": "Relations", + "command": "\\ni", + "codepoint": "U+0220B", + "description": "Contains", + "notes": "" + }, + { + "category": "Relations", + "command": "\\subset", + "codepoint": "U+02282", + "description": "Subset", + "notes": "" + }, + { + "category": "Relations", + "command": "\\subseteq", + "codepoint": "U+02286", + "description": "Subset or equals", + "notes": "" + }, + { + "category": "Relations", + "command": "\\supset", + "codepoint": "U+02283", + "description": "Superset", + "notes": "" + }, + { + "category": "Relations", + "command": "\\simeq", + "codepoint": "U+02243", + "description": "Similar", + "notes": "" + }, + { + "category": "Relations", + "command": "\\approx", + "codepoint": "U+02248", + "description": "Approximate", + "notes": "" + }, + { + "category": "Relations", + "command": "\\equiv", + "codepoint": "U+02261", + "description": "Identical with", + "notes": "" + }, + { + "category": "Relations", + "command": "\\cong", + "codepoint": "U+02245", + "description": "Congruent with", + "notes": "" + }, + { + "category": "Relations", + "command": "\\mid", + "codepoint": "U+02223", + "description": "Mid, divides, vertical bar, modulus, absolute value", + "notes": "Use \\lvert...\\rvert for the absolute value." + }, + { + "category": "Relations", + "command": "\\nmid", + "codepoint": "U+02224", + "description": "Negated mid, not divides", + "notes": "Requires \\usepackage{amssymb}." + }, + { + "category": "Relations", + "command": "\\parallel", + "codepoint": "U+02225", + "description": "Parallel, double vertical bar, norm", + "notes": "Use \\lVert...\\rVert for the norm." + }, + { + "category": "Relations", + "command": "\\perp", + "codepoint": "U+027C2", + "description": "Perpendicular", + "notes": "" + }, + { + "category": "Operators", + "command": "\\times", + "codepoint": "U+000D7", + "description": "Cross product, multiplication", + "aliases": ["x"], + "notes": "" + }, + { + "category": "Operators", + "command": "\\div", + "codepoint": "U+000F7", + "description": "Division", + "notes": "" + }, + { + "category": "Operators", + "command": "\\cap", + "codepoint": "U+02229", + "description": "Intersection", + "notes": "" + }, + { + "category": "Operators", + "command": "\\cup", + "codepoint": "U+0222A", + "description": "Union", + "notes": "" + }, + { + "category": "Operators", + "command": "\\cdot", + "codepoint": "U+022C5", + "description": "Dot product, multiplication", + "notes": "" + }, + { + "category": "Operators", + "command": "\\cdots", + "codepoint": "U+022EF", + "description": "Centered dots", + "notes": "" + }, + { + "category": "Operators", + "command": "\\bullet", + "codepoint": "U+02219", + "description": "Bullet", + "notes": "" + }, + { + "category": "Operators", + "command": "\\circ", + "codepoint": "U+025E6", + "description": "Circle", + "notes": "" + }, + { + "category": "Operators", + "command": "\\wedge", + "codepoint": "U+02227", + "description": "Wedge, logical and", + "notes": "" + }, + { + "category": "Operators", + "command": "\\vee", + "codepoint": "U+02228", + "description": "Vee, logical or", + "notes": "" + }, + { + "category": "Operators", + "command": "\\setminus", + "codepoint": "U+0005C", + "description": "Set minus, backslash", + "notes": "Use \\backslash for a backslash." + }, + { + "category": "Operators", + "command": "\\oplus", + "codepoint": "U+02295", + "description": "Plus sign in circle", + "notes": "" + }, + { + "category": "Operators", + "command": "\\otimes", + "codepoint": "U+02297", + "description": "Multiply sign in circle", + "notes": "" + }, + { + "category": "Operators", + "command": "\\sum", + "codepoint": "U+02211", + "description": "Summation operator", + "notes": "Use \\Sigma for the letter Sigma." + }, + { + "category": "Operators", + "command": "\\prod", + "codepoint": "U+0220F", + "description": "Product operator", + "notes": "Use \\Pi for the letter Pi." + }, + { + "category": "Operators", + "command": "\\bigcap", + "codepoint": "U+022C2", + "description": "Intersection operator", + "notes": "" + }, + { + "category": "Operators", + "command": "\\bigcup", + "codepoint": "U+022C3", + "description": "Union operator", + "notes": "" + }, + { + "category": "Operators", + "command": "\\int", + "codepoint": "U+0222B", + "description": "Integral operator", + "notes": "" + }, + { + "category": "Operators", + "command": "\\iint", + "codepoint": "U+0222C", + "description": "Double integral operator", + "notes": "Requires \\usepackage{amsmath}." + }, + { + "category": "Operators", + "command": "\\iiint", + "codepoint": "U+0222D", + "description": "Triple integral operator", + "notes": "Requires \\usepackage{amsmath}." + }, + { + "category": "Arrows", + "command": "\\leftarrow", + "codepoint": "U+02190", + "description": "Leftward arrow", + "aliases": ["<-"], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\rightarrow", + "codepoint": "U+02192", + "description": "Rightward arrow", + "aliases": ["->"], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\leftrightarrow", + "codepoint": "U+02194", + "description": "Left and right arrow", + "aliases": ["<->"], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\uparrow", + "codepoint": "U+02191", + "description": "Upward arrow", + "notes": "" + }, + { + "category": "Arrows", + "command": "\\downarrow", + "codepoint": "U+02193", + "description": "Downward arrow", + "notes": "" + }, + { + "category": "Arrows", + "command": "\\Leftarrow", + "codepoint": "U+021D0", + "description": "Is implied by", + "aliases": ["<="], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\Rightarrow", + "codepoint": "U+021D2", + "description": "Implies", + "aliases": ["=>"], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\Leftrightarrow", + "codepoint": "U+021D4", + "description": "Left and right double arrow", + "aliases": ["<=>"], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\mapsto", + "codepoint": "U+021A6", + "description": "Maps to, rightward", + "notes": "" + }, + { + "category": "Arrows", + "command": "\\nearrow", + "codepoint": "U+02197", + "description": "NE pointing arrow", + "notes": "" + }, + { + "category": "Arrows", + "command": "\\searrow", + "codepoint": "U+02198", + "description": "SE pointing arrow", + "notes": "" + }, + { + "category": "Arrows", + "command": "\\rightleftharpoons", + "codepoint": "U+021CC", + "description": "Right harpoon over left", + "notes": "" + }, + { + "category": "Arrows", + "command": "\\leftharpoonup", + "codepoint": "U+021BC", + "description": "Left harpoon up", + "notes": "" + }, + { + "category": "Arrows", + "command": "\\rightharpoonup", + "codepoint": "U+021C0", + "description": "Right harpoon up", + "notes": "" + }, + { + "category": "Arrows", + "command": "\\leftharpoondown", + "codepoint": "U+021BD", + "description": "Left harpoon down", + "notes": "" + }, + { + "category": "Arrows", + "command": "\\rightharpoondown", + "codepoint": "U+021C1", + "description": "Right harpoon down", + "notes": "" + }, + { + "category": "Misc", + "command": "\\infty", + "codepoint": "U+0221E", + "description": "Infinity", + "notes": "" + }, + { + "category": "Misc", + "command": "\\partial", + "codepoint": "U+1D715", + "description": "Partial differential", + "notes": "" + }, + { + "category": "Misc", + "command": "\\nabla", + "codepoint": "U+02207", + "description": "Nabla, del, hamilton operator", + "notes": "" + }, + { + "category": "Misc", + "command": "\\varnothing", + "codepoint": "U+02300", + "description": "Empty set", + "notes": "Requires \\usepackage{amssymb}." + }, + { + "category": "Misc", + "command": "\\forall", + "codepoint": "U+02200", + "description": "For all", + "notes": "" + }, + { + "category": "Misc", + "command": "\\exists", + "codepoint": "U+02203", + "description": "There exists", + "notes": "" + }, + { + "category": "Misc", + "command": "\\neg", + "codepoint": "U+000AC", + "description": "Not sign", + "notes": "" + }, + { + "category": "Misc", + "command": "\\Re", + "codepoint": "U+0211C", + "description": "Real part", + "notes": "" + }, + { + "category": "Misc", + "command": "\\Im", + "codepoint": "U+02111", + "description": "Imaginary part", + "notes": "" + }, + { + "category": "Misc", + "command": "\\Box", + "codepoint": "U+025A1", + "description": "Square", + "notes": "Requires \\usepackage{amssymb}." + }, + { + "category": "Misc", + "command": "\\triangle", + "codepoint": "U+025B3", + "description": "Triangle", + "notes": "" + }, + { + "category": "Misc", + "command": "\\aleph", + "codepoint": "U+02135", + "description": "Hebrew letter aleph", + "notes": "" + }, + { + "category": "Misc", + "command": "\\wp", + "codepoint": "U+02118", + "description": "Weierstrass letter p", + "notes": "" + }, + { + "category": "Misc", + "command": "\\#", + "codepoint": "U+00023", + "description": "Number sign, hashtag", + "notes": "" + }, + { + "category": "Misc", + "command": "\\$", + "codepoint": "U+00024", + "description": "Dollar sign", + "notes": "" + }, + { + "category": "Misc", + "command": "\\%", + "codepoint": "U+00025", + "description": "Percent sign", + "notes": "" + }, + { + "category": "Misc", + "command": "\\&", + "codepoint": "U+00026", + "description": "Et sign, and, ampersand", + "notes": "" + }, + { + "category": "Misc", + "command": "\\{", + "codepoint": "U+0007B", + "description": "Left curly brace", + "notes": "" + }, + { + "category": "Misc", + "command": "\\}", + "codepoint": "U+0007D", + "description": "Right curly brace", + "notes": "" + }, + { + "category": "Misc", + "command": "\\langle", + "codepoint": "U+027E8", + "description": "Left angle bracket, bra", + "notes": "" + }, + { + "category": "Misc", + "command": "\\rangle", + "codepoint": "U+027E9", + "description": "Right angle bracket, ket", + "notes": "" + } +] diff --git a/services/web/modules/symbol-palette/frontend/utils/categories.js b/services/web/modules/symbol-palette/frontend/utils/categories.js new file mode 100644 index 00000000000..872534771fe --- /dev/null +++ b/services/web/modules/symbol-palette/frontend/utils/categories.js @@ -0,0 +1,44 @@ +import symbols from '../data/symbols.json' +export function createCategories(t) { + return [ + { + id: 'Greek', + label: t('category_greek'), + }, + { + id: 'Arrows', + label: t('category_arrows'), + }, + { + id: 'Operators', + label: t('category_operators'), + }, + { + id: 'Relations', + label: t('category_relations'), + }, + { + id: 'Misc', + label: t('category_misc'), + }, + ] +} + +export function buildCategorisedSymbols(categories) { + const output = {} + + for (const category of categories) { + output[category.id] = [] + } + + for (const item of symbols) { + if (item.category in output) { + item.character = String.fromCodePoint( + parseInt(item.codepoint.replace(/^U\+0*/, ''), 16) + ) + output[item.category].push(item) + } + } + + return output +} diff --git a/services/web/modules/symbol-palette/index.mjs b/services/web/modules/symbol-palette/index.mjs new file mode 100644 index 00000000000..0b7e69a8e36 --- /dev/null +++ b/services/web/modules/symbol-palette/index.mjs @@ -0,0 +1,4 @@ +import logger from '@overleaf/logger' +logger.debug({}, 'Enable Symbol Palette') +const SymbolPaletteModule = {} +export default SymbolPaletteModule diff --git a/services/web/modules/template-gallery/app/src/CleanHtml.mjs b/services/web/modules/template-gallery/app/src/CleanHtml.mjs new file mode 100644 index 00000000000..1e38437016a --- /dev/null +++ b/services/web/modules/template-gallery/app/src/CleanHtml.mjs @@ -0,0 +1,22 @@ +import sanitizeHtml from 'sanitize-html' + +const sanitizeOptions = { + linksOnly: { + allowedTags: ["a"], + allowedAttributes: { "a": ["href"] } + }, + reachText: { + allowedTags: ["h1", "h2", "h3", "h4", "h5", "h6", + "p", "blockquote", "ul", "ol", "li", + "em", "strong", "s", "code", "pre", "hr", "a"], + allowedAttributes: { "a": ["href"] } + }, + plainText: { + allowedTags: [], + allowedAttributes: {}, + }, +} + +export function cleanHtml(text, sanitizeType) { + return sanitizeHtml(text, sanitizeOptions[sanitizeType]) +} diff --git a/services/web/modules/template-gallery/app/src/TemplateAuthorizationMiddleware.mjs b/services/web/modules/template-gallery/app/src/TemplateAuthorizationMiddleware.mjs new file mode 100644 index 00000000000..b949e4198b8 --- /dev/null +++ b/services/web/modules/template-gallery/app/src/TemplateAuthorizationMiddleware.mjs @@ -0,0 +1,38 @@ +import Settings from '@overleaf/settings' +import AdminAuthorizationHelper from '../../../../app/src/Features/Helpers/AdminAuthorizationHelper.mjs' +const { hasAdminAccess } = AdminAuthorizationHelper +import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import { Template } from './models/Template.mjs' + +async function ensureTemplateManagementAccess(req, res, next) { + const user = SessionManager.getSessionUser(req.session) + const userId = SessionManager.getLoggedInUserId(req.session) + + const isPrivileged = + hasAdminAccess(user) || + Settings.templates?.user_id === userId + + if (isPrivileged) return next() + + const templateId = req.params?.template_id + + if (!templateId) { + if (Settings.templates?.nonAdminCanManage) return next() + return HttpErrorHandler.forbidden(req, res) + } + + // unprivileged owner is allowed to edit/delete own template + // even non-admin is not allowed to manage templates + const template = await Template.findById(templateId) + .select('owner') + .lean() + + if (template?.owner?.toString() === userId) return next() + + return HttpErrorHandler.forbidden(req, res) +} + +export default { + ensureTemplateManagementAccess, +} diff --git a/services/web/modules/template-gallery/app/src/TemplateErrors.mjs b/services/web/modules/template-gallery/app/src/TemplateErrors.mjs new file mode 100644 index 00000000000..6b7ed014fec --- /dev/null +++ b/services/web/modules/template-gallery/app/src/TemplateErrors.mjs @@ -0,0 +1,13 @@ +import OError from '@overleaf/o-error' + +export class TemplateNameConflictError extends OError { + constructor(ownerId, message = 'template_with_this_title_exists_and_owned_by_x') { + super(message, { ownerId }) + } +} + +export class RecompileRequiredError extends OError { + constructor(message = 'Recompile required') { + super(message, { status: 400 }) + } +} diff --git a/services/web/modules/template-gallery/app/src/TemplateGalleryController.mjs b/services/web/modules/template-gallery/app/src/TemplateGalleryController.mjs new file mode 100644 index 00000000000..b8b294f0639 --- /dev/null +++ b/services/web/modules/template-gallery/app/src/TemplateGalleryController.mjs @@ -0,0 +1,168 @@ +import Path from 'node:path' +import { fileURLToPath } from 'node:url' +import logger from '@overleaf/logger' +import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs' +import Errors from '../../../../app/src/Features/Errors/Errors.js' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import TemplateGalleryManager from'./TemplateGalleryManager.mjs' +import { getUserName } from './TemplateGalleryHelper.mjs' +import { TemplateNameConflictError, RecompileRequiredError } from './TemplateErrors.mjs' +import Settings from '@overleaf/settings' + +const __dirname = Path.dirname(fileURLToPath(import.meta.url)) + +async function createTemplateFromProject(req, res, next) { + const t = req.i18n.translate + try { + const userId = SessionManager.getLoggedInUserId(req.session) + const result = await TemplateGalleryManager.createTemplateFromProject({ + projectId: req.params.Project_id, + userId, + templateSource: req.body, + }) + if (result.conflict) { + const ownerName = (result.templateOwnerName === 'you') ? t('you') : result.templateOwnerName + const message = `${t('template_with_this_title_exists_and_owned_by_x', { x: ownerName })} ` + + t(result.canOverride ? 'do_you_want_to_overwrite_it' : 'you_cant_overwrite_it') + return res.status(409).json({ canOverride: result.canOverride, message }) + } + return res.status(200).json({ template_id: result.templateId }) + } catch (error) { + if (error instanceof Errors.InvalidNameError) { + return res.status(error.info?.status || 400).json({ message: error.message }) + } + + const mainMessage = t('failed_to_publish_as_a_template') + if (error instanceof RecompileRequiredError) { + return res.status(error.info?.status || 400).json({ + message: `${mainMessage} ${t('try_recompile_project')}` + }) + } + return res.status(400).json({ message: mainMessage }) + } +} + +async function editTemplate(req, res, next) { + const t = req.i18n.translate + try { + const result = await TemplateGalleryManager.editTemplate({ + templateId: req.params.template_id, + updates: req.body + }) + res.status(200).json(result) + } catch (error) { + if (error instanceof TemplateNameConflictError) { + const ownerId = error.info?.ownerId + const userId = SessionManager.getLoggedInUserId(req.session) + const ownerName = (ownerId === userId) + ? t('you') + : await getUserName(ownerId) || t('unknown') + const message = t(error.message, { x: ownerName }) + return res.status(409).json({ message }) + } + if (error instanceof Errors.InvalidNameError) { + return res.status(error.info?.status || 400).json({ message: error.message }) + } + logger.error({ error }, 'Failure saving template') + return res.status(500).json({ message: t('something_went_wrong_server') }) + } +} + +async function deleteTemplate(req, res, next) { + const t = req.i18n.translate + try { + await TemplateGalleryManager.deleteTemplate({ + templateId: req.params.template_id, + version: req.body.version + }) + res.sendStatus(200) + } catch (error) { + logger.error({ error }, 'Failure deleting template') + return res.status(500).json({ message: t('something_went_wrong_server') }) + } +} + +async function getTemplatePreview(req, res, next) { + try { + const templateId = req.params.template_id + const { version, style } = req.query + + const { stream, contentType } = await TemplateGalleryManager.fetchTemplatePreview({ templateId, version, style }) + + res.setHeader('Content-Type', contentType) + stream.pipe(res) + } catch (error) { + if (error.info?.status == 404) { + return ErrorController.notFound(req, res, next) + } + return res.status(error.info?.status || 400).json(error.info) + } +} + +async function templatesCategoryPage(req, res, next) { + const t = req.i18n.translate + try { + let { category } = req.params + const result = await TemplateGalleryManager.getTemplatesPageData(category) + + let title + if (result.categoryName) { + title = t('latex_templates') + ' — ' + result.categoryName + } else { + category = null + title = t('templates_page_title') + } + res.render(Path.resolve(__dirname, '../views/template_gallery/template-gallery'), { + title, + category, + }) + } catch (error) { + next(error) + } +} + +async function templateDetailsPage(req, res, next) { + const t = req.i18n.translate + const userId = SessionManager.getLoggedInUserId(req.session) + try { + const template = await TemplateGalleryManager.getTemplate('_id', req.params.template_id) + res.render(Path.resolve(__dirname, '../views/template_gallery/template'), { + title: `${t('template')}: ${template.name}`, + template: JSON.stringify(template), + languages: Settings.languages, + userIsTemplatesManager: Boolean(Settings.templates?.user_id && Settings.templates.user_id === userId) + }) + } catch (error) { + return ErrorController.notFound(req, res, next) + } +} + +async function getTemplateJSON(req, res, next) { + try { + const { key, val } = req.query + const template = await TemplateGalleryManager.getTemplate(key, val) + res.json(template) + } catch (error) { + next(error) + } +} + +async function getCategoryTemplatesJSON(req, res, next) { + try { + const result = await TemplateGalleryManager.getCategoryTemplates(req.query) + res.json(result) + } catch (error) { + next(error) + } +} + +export default { + createTemplateFromProject, + editTemplate, + deleteTemplate, + getTemplatePreview, + templatesCategoryPage, + templateDetailsPage, + getTemplateJSON, + getCategoryTemplatesJSON, +} diff --git a/services/web/modules/template-gallery/app/src/TemplateGalleryHelper.mjs b/services/web/modules/template-gallery/app/src/TemplateGalleryHelper.mjs new file mode 100644 index 00000000000..884a844ec88 --- /dev/null +++ b/services/web/modules/template-gallery/app/src/TemplateGalleryHelper.mjs @@ -0,0 +1,238 @@ +import { marked } from 'marked' +import request from 'request' +import logger from '@overleaf/logger' +import settings from '@overleaf/settings' +import Errors from '../../../../app/src/Features/Errors/Errors.js' +import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' +import ProjectLocator from '../../../../app/src/Features/Project/ProjectLocator.mjs' +import ProjectZipStreamManager from '../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs' +import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs' +import ClsiManager from '../../../../app/src/Features/Compile/ClsiManager.mjs' +import CompileManager from '../../../../app/src/Features/Compile/CompileManager.mjs' +import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' +import { fetchStreamWithResponse } from '@overleaf/fetch-utils' +import { Template } from './models/Template.mjs' +import { RecompileRequiredError } from './TemplateErrors.mjs' +import { cleanHtml } from './CleanHtml.mjs' + +const TIMEOUT = 30000 + +const MAX_PROJECT_NAME_LENGTH = 150 +const MAX_FORM_INPUT_LENGTH = 512 +const MAX_TEMPLATE_DESCRIPTION_LENGTH = 4096 + +// Configure marked for CommonMark-only parsing +marked.setOptions({ + gfm: false, + breaks: false, + headerIds: false, + mangle: false, +}) + +marked.use({ + renderer: { + html: () => '' // strips any HTML tags + } +}) + +function _createZipStreamForProjectAsync(projectId) { + return new Promise((resolve, reject) => { + ProjectZipStreamManager.createZipStreamForProject(projectId, (err, archive) => { + if (err) { + return reject(err) + } + archive.on('error', (err) => reject(err)) + resolve(archive) + }) + }) +} + +export function validateTemplateInput({ name, descriptionMD, authorMD, license }) { + if (name?.length > MAX_PROJECT_NAME_LENGTH) { + throw new Errors.InvalidNameError(`Template title exceeds the maximum length of ${MAX_PROJECT_NAME_LENGTH} characters.`) + } + if (descriptionMD?.length > MAX_TEMPLATE_DESCRIPTION_LENGTH) { + throw new Errors.InvalidNameError(`Template description exceeds the maximum length of ${MAX_TEMPLATE_DESCRIPTION_LENGTH} characters.`) + } + if (authorMD?.length > MAX_FORM_INPUT_LENGTH) { + throw new Errors.InvalidNameError('Author name is too long.') + } + if (license?.length > MAX_FORM_INPUT_LENGTH) { + throw new Errors.InvalidNameError('License name is too long.') + } +} + +export async function canUserOverrideTemplate(template, userId) { + let templateOwnerId = template.owner + let templateOwnerName = 'you' + let userIsOwner = true + let userIsAdmin + if (templateOwnerId != userId) { + userIsOwner = false + try { + userIsAdmin = (await UserGetter.promises.getUser(userId, { isAdmin: 1 })).isAdmin + } catch (error) { + logger.error({ error, userId }, 'Logged in user does not exist, strange...') + userIsAdmin = false + } + templateOwnerName = await getUserName(templateOwnerId) || 'unknown' + } + const canOverride = userIsOwner || userIsAdmin || (settings.templates?.user_id === userId) + return { canOverride, templateOwnerName } +} + +export async function getUserName(userId) { + try { + const user = await UserGetter.promises.getUser(userId, { + first_name: 1, + last_name: 1, + }) + return ((user?.first_name || "") + " " + (user?.last_name || "")).trim() + } catch (error) { + return 'unknown' + } +} + +export async function generateTemplateData(projectId, { + descriptionMD, + authorMD, + category, + license, + name, +}) { + try { + await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId) + + const project = await ProjectGetter.promises.getProject(projectId, { + imageName: true, + compiler: true, + spellCheckLanguage: true, + rootDoc_id: true, + rootFolder: true, + }) + + const { path } = await ProjectLocator.promises.findRootDoc({ project }) + const mainFile = path.fileSystem.replace(/^\/+/, '') + const template = { + name, + category, + authorMD, + descriptionMD, + license, + mainFile, + compiler: project.compiler, + imageName: project.imageName, + language: project.spellCheckLanguage, + lastUpdated: new Date(), + } + + await renderTemplateHtmlFields(template) + return template + } catch (error) { + logger.error({ error, projectId }, 'Failed to retrieve project data') + throw error + } +} + +export async function uploadTemplateAssets(projectId, userId, build, template) { + let zipStream, pdfStream + try { + [zipStream, pdfStream] = await Promise.all([ + _createZipStreamForProjectAsync(projectId), + CompileManager.promises + .getProjectCompileLimits(projectId) + .then((limits) => + ClsiManager.promises.getOutputFileStream(projectId, userId, limits, undefined, build, 'output.pdf') + ) + ]) + } catch (error) { + logger.error({ error, projectId }, 'No output.pdf?') + throw new RecompileRequiredError() + } + try { + const templateUrl = `${settings.apis.filestore.url}/template/${template._id}/v/${template.version}` + const zipUrl = `${templateUrl}/zip` + const pdfUrl = `${templateUrl}/pdf` + const [zipReq, pdfReq] = await Promise.all([ + fetchStreamWithResponse(zipUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + }, + body: zipStream, + signal: AbortSignal.timeout(TIMEOUT), + }), + fetchStreamWithResponse(pdfUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/pdf', + }, + body: pdfStream, + signal: AbortSignal.timeout(TIMEOUT), + }), + ]) + const uploadErrors = [] + if (zipReq?.response.status !== 200) { + uploadErrors.push({ file: 'zip', uri: zipUrl, statusCode: zipReq.response.status }) + } + if (pdfReq?.response.status !== 200) { + uploadErrors.push({ file: 'pdf', uri: pdfUrl, statusCode: pdfReq.response.status }) + } + if (uploadErrors.length > 0) { + logger.error({ uploadErrors }, 'Template upload failed') + throw new RecompileRequiredError() + } + } catch (error) { + if (error instanceof RecompileRequiredError) throw error + throw error + } +} + +export async function deleteTemplateAssets(templateId, version, deleteFromDb) { + if (deleteFromDb) { + try { + await Template.deleteOne({ _id: templateId }).exec() + } catch (error) { + logger.error({ error, templateId }, 'Failed to delete template from MongoDB') + throw error + } + } + + // kick off file deletions, but don't wait + const baseUrl = settings.apis.filestore.url + const urlTemplate = `${baseUrl}/template/${templateId}/v/${version}/zip` + const urlImages = `${baseUrl}/template/${templateId}/v/${version}/pdf` + + const optsTemplate = { + method: 'DELETE', + uri: urlTemplate, + timeout: TIMEOUT, + } + + const optsImages = { + method: 'DELETE', + uri: urlImages, + timeout: TIMEOUT, + } + + request(optsTemplate, (err, response) => { + if (err) + logger.warn({ err, templateId }, 'Failed to delete template zip from filestore') + }) + + request(optsImages, (err, response) => { + if (err) + logger.warn({ err, templateId }, 'Failed to delete images from filestore') + }) +} + +export function renderTemplateHtmlFields(updates) { + if (updates.descriptionMD !== undefined) { + const descriptionRawHTML = marked.parse(updates.descriptionMD) + updates.description = cleanHtml(descriptionRawHTML, "reachText") + } + if (updates.authorMD !== undefined) { + const authorRawHTML = marked.parse(updates.authorMD) + updates.author = cleanHtml(authorRawHTML, "linksOnly") + } +} diff --git a/services/web/modules/template-gallery/app/src/TemplateGalleryManager.mjs b/services/web/modules/template-gallery/app/src/TemplateGalleryManager.mjs new file mode 100644 index 00000000000..6325b841ec5 --- /dev/null +++ b/services/web/modules/template-gallery/app/src/TemplateGalleryManager.mjs @@ -0,0 +1,214 @@ +import _ from 'lodash' +import logger from '@overleaf/logger' +import { Readable } from 'stream' +import settings from '@overleaf/settings' +import { OError } from '../../../../app/src/Features/Errors/Errors.js' +import { Template } from './models/Template.mjs' +import { + validateTemplateInput, + renderTemplateHtmlFields, + uploadTemplateAssets, + deleteTemplateAssets, + canUserOverrideTemplate, + generateTemplateData +} from './TemplateGalleryHelper.mjs' +import { cleanHtml } from './CleanHtml.mjs' +import { TemplateNameConflictError } from './TemplateErrors.mjs' +import { fetchStreamWithResponse } from '@overleaf/fetch-utils' +const TIMEOUT = 30000 + +async function editTemplate({ templateId, updates }) { + + validateTemplateInput(updates) + + const template = await Template.findById(templateId) + if (!template) { + throw new OError('Current template not found, strange...', { status: 500, templateId }) + } + + if (updates.name) { + const conflictingTemplate = await Template.findOne( + { name: updates.name, _id: { $ne: templateId } }, + { owner: true } + ).exec() + if (conflictingTemplate) { + throw new TemplateNameConflictError(String(conflictingTemplate.owner)) + } + } + + await renderTemplateHtmlFields(updates) + updates.lastUpdated = new Date() + Object.assign(template, updates) + + await template.save() + + return updates +} + +async function deleteTemplate({ templateId, version }) { + await deleteTemplateAssets(templateId, version, true) +} + +async function createTemplateFromProject({ projectId, userId, templateSource }) { + validateTemplateInput(templateSource) + let template = await Template.findOne({ name: templateSource.name }).exec() + + if (template && !templateSource.override) { + const { canOverride, templateOwnerName } = await canUserOverrideTemplate(template, userId) + + return { + conflict: true, + canOverride, + templateOwnerName + } + } + + const templateData = await generateTemplateData(projectId, templateSource) + + let previousVersionExists + if (!template) { + template = new Template(templateData) + template.owner = userId + previousVersionExists = false + } else { + Object.assign(template, templateData, { + version: template.version + 1, + }) + previousVersionExists = true + } + + await uploadTemplateAssets(projectId, userId, templateSource.build, template) + await template.save() + + if (previousVersionExists) { + deleteTemplateAssets(template._id, template.version - 1, false) + } + return { + conflict: false, + templateId: template._id, + } +} + +async function fetchTemplatePreview({ templateId, version, style }) { + if (!templateId || !version) { + throw new OError('Template ID and version are required', { status: 404 }) + } + const styleParam = style ? `style=${style}` : '' + const isImage = (style === 'preview' || style === 'thumbnail') + + if (style && !isImage) { + throw new OError('Wrong style', { status: 404, style }) + } + + const pdfUrl = `${settings.apis.filestore.url}/template/${templateId}/v/${version}/pdf?${styleParam}` + const { response } = await fetchStreamWithResponse(pdfUrl, { + method: 'GET', + signal: AbortSignal.timeout(TIMEOUT), + }) + + if (!response.ok) { + throw new OError(`Failed to fetch file: ${response.statusText}`, { status: 400, templateId, version, styleParam }) + } + + return { + stream: Readable.from(response.body), + contentType: isImage ? 'application/octet-stream' : 'application/pdf' + } +} + +async function getTemplatesPageData(category) { + const categoryName = settings.templateLinks.find(item => item.url.endsWith(`/${category}`))?.name + const templateLinks = categoryName ? undefined : settings.templateLinks.filter(link => link.url !== '/templates/all') + return { + categoryName, + templateLinks + } +} + +async function getTemplate(key, val) { + if (!key || !val) { + logger.warn('No key or val provided to getTemplate') + return null + } + + const query = { [key]: val } + const template = await Template.findOne(query).exec() + if (!template) return null + + return _formatTemplateForPage(template) +} + +async function getCategoryTemplates(reqQuery) { + const { + category, + by = 'lastUpdated', + order = 'desc', + } = reqQuery || {} + + const query = (category === 'all') ? {} : { category : '/templates/' + category } + const projection = { _id : 1, version : 1, name : 1, author : 1, description : 1, lastUpdated : 1 } + const allTemplates = await Template.find(query, projection).exec() + const formattedTemplates = allTemplates.map(_formatTemplateForList) + const sortedTemplates = _sortTemplates(formattedTemplates, { by, order }) + + return { + totalSize: sortedTemplates.length, + templates: sortedTemplates, + } +} + +function _sortTemplates(templates, sort) { + if ( + (sort.by && !['lastUpdated', 'name'].includes(sort.by)) || + (sort.order && !['asc', 'desc'].includes(sort.order)) + ) { + throw new OError('Invalid sorting criteria', { status: 400, sort }) + } + const sortedTemplates = _.orderBy( + templates, + [sort.by || 'lastUpdated'], + [sort.order || 'desc'] + ) + return sortedTemplates +} + +function _formatTemplateForList(template) { + return { + id: String(template._id), + version: String(template.version), + name: template.name, + author: cleanHtml(template.author, "plainText"), + description: cleanHtml(template.description, "plainText"), + lastUpdated: template.lastUpdated, + } +} + +function _formatTemplateForPage(template) { + return { + id: template._id.toString(), + version: template.version.toString(), + category: template.category, + name: template.name, + author: cleanHtml(template.author, "linksOnly"), + authorMD: template.authorMD, + description: cleanHtml(template.description, "reachText"), + descriptionMD: template.descriptionMD, + license: template.license, + lastUpdated: template.lastUpdated, + owner: template.owner, + mainFile: template.mainFile, + compiler: template.compiler, + imageName: template.imageName, + language: template.language, + } +} + +export default { + createTemplateFromProject, + editTemplate, + deleteTemplate, + getTemplate, + getCategoryTemplates, + fetchTemplatePreview, + getTemplatesPageData, +} diff --git a/services/web/modules/template-gallery/app/src/TemplateGalleryRouter.mjs b/services/web/modules/template-gallery/app/src/TemplateGalleryRouter.mjs new file mode 100644 index 00000000000..91020672612 --- /dev/null +++ b/services/web/modules/template-gallery/app/src/TemplateGalleryRouter.mjs @@ -0,0 +1,85 @@ +import logger from '@overleaf/logger' + +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import RateLimiterMiddleware from '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs' +import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.mjs' +import TemplateGalleryController from './TemplateGalleryController.mjs' +import TemplateAuthorizationMiddleware from './TemplateAuthorizationMiddleware.mjs' + +const rateLimiterNewTemplate = new RateLimiter('create-template-from-project', { + points: 20, + duration: 60, +}) +const rateLimiter = new RateLimiter('template-gallery', { + points: 60, + duration: 60, +}) +const rateLimiterThumbnails = new RateLimiter('template-gallery-thumbnails', { + points: 240, + duration: 60, +}) + +export default { + rateLimiter, + apply(webRouter) { + logger.debug({}, 'Init templates router') + + webRouter.post( + '/template/new/:Project_id', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiterNewTemplate), + TemplateAuthorizationMiddleware.ensureTemplateManagementAccess, + TemplateGalleryController.createTemplateFromProject + ) + + webRouter.get( + '/template/:template_id', + RateLimiterMiddleware.rateLimit(rateLimiter), + TemplateGalleryController.templateDetailsPage + ) + + webRouter.post( + '/template/:template_id/edit', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiter), + TemplateAuthorizationMiddleware.ensureTemplateManagementAccess, + TemplateGalleryController.editTemplate + ) + + webRouter.delete( + '/template/:template_id/delete', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiter), + TemplateAuthorizationMiddleware.ensureTemplateManagementAccess, + TemplateGalleryController.deleteTemplate + ) + + webRouter.get( + '/templates/:category?', + RateLimiterMiddleware.rateLimit(rateLimiter), + TemplateGalleryController.templatesCategoryPage + ) + + webRouter.get( + '/api/template', + RateLimiterMiddleware.rateLimit(rateLimiter), + TemplateGalleryController.getTemplateJSON + ) + + webRouter.get( + '/api/templates', + RateLimiterMiddleware.rateLimit(rateLimiter), + TemplateGalleryController.getCategoryTemplatesJSON + ) + + webRouter.get( + '/template/:template_id/preview', + (req, res, next) => { + req.query.style === 'thumbnail' + ? RateLimiterMiddleware.rateLimit(rateLimiterThumbnails)(req, res, next) + : RateLimiterMiddleware.rateLimit(rateLimiter)(req, res, next) + }, + TemplateGalleryController.getTemplatePreview + ) + }, +} diff --git a/services/web/modules/template-gallery/app/src/models/Template.mjs b/services/web/modules/template-gallery/app/src/models/Template.mjs new file mode 100644 index 00000000000..8372b1944a1 --- /dev/null +++ b/services/web/modules/template-gallery/app/src/models/Template.mjs @@ -0,0 +1,32 @@ +import mongoose from '../../../../../app/src/infrastructure/Mongoose.mjs' + +const { Schema } = mongoose +const { ObjectId } = Schema + +export const TemplateSchema = new Schema( + { + name: { type: String, required: true }, + category: { type: String, required: true }, + description: { type: String }, + descriptionMD: { type: String }, + author: { type: String }, + authorMD: { type: String }, + license: { type: String, required: true }, + mainFile: { type: String, required: true }, + compiler: { type: String, required: true }, + imageName: { type: String }, + language: { type: String }, + version: { type: Number, default: 1, required: true }, + owner: { type: ObjectId, ref: 'User' }, + lastUpdated: { + type: Date, + default() { + return new Date() + }, + required: true + }, + }, + { minimize: false } +) + +export const Template = mongoose.model('Template', TemplateSchema) diff --git a/services/web/modules/template-gallery/app/views/template_gallery/template-gallery.pug b/services/web/modules/template-gallery/app/views/template_gallery/template-gallery.pug new file mode 100644 index 00000000000..15b2ca0ae3b --- /dev/null +++ b/services/web/modules/template-gallery/app/views/template_gallery/template-gallery.pug @@ -0,0 +1,15 @@ +extends ../../../../../app/views/layout-react + +block entrypointVar + - entrypoint = 'modules/template-gallery/pages/template-gallery' + +block vars + - const suppressFooter = true + - const suppressPugCookieBanner = true + - isWebsiteRedesign = true + +block append meta + meta(name="ol-templateCategory" data-type="string" content=category) + +block content + #template-gallery-root diff --git a/services/web/modules/template-gallery/app/views/template_gallery/template.pug b/services/web/modules/template-gallery/app/views/template_gallery/template.pug new file mode 100644 index 00000000000..5bf1441b26d --- /dev/null +++ b/services/web/modules/template-gallery/app/views/template_gallery/template.pug @@ -0,0 +1,19 @@ +extends ../../../../../app/views/layout-react + +block entrypointVar + - entrypoint = 'modules/template-gallery/pages/template' + +block vars + - const suppressNavbar = true + - const suppressFooter = true + - isWebsiteRedesign = true + +block append meta + meta(name="ol-template" data-type="json" content=template) + meta(name="ol-languages" data-type="json" content=languages) + meta(name="ol-userIsAdmin" data-type="boolean" content=hasAdminAccess()) + meta(name="ol-userIsTemplatesManager" data-type="boolean" content=userIsTemplatesManager) + +block content + #template-root + diff --git a/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-header-all.tsx b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-header-all.tsx new file mode 100644 index 00000000000..ffe20c311bf --- /dev/null +++ b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-header-all.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next' +import OLCol from '@/shared/components/ol/ol-col' +import OLRow from '@/shared/components/ol/ol-row' + +export default function GalleryHeaderAll() { + const { t } = useTranslation() + return ( +
    + + +

    + + + {t('overleaf_template_gallery')} + + + {t('latex_templates')} +

    +
    +
    +
    +
    +

    {t('latex_templates_for_journal_articles')} +

    +
    +
    +
    + ) +} diff --git a/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-header-tagged.tsx b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-header-tagged.tsx new file mode 100644 index 00000000000..aaf761fe5de --- /dev/null +++ b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-header-tagged.tsx @@ -0,0 +1,33 @@ +import getMeta from '@/utils/meta' +import OLCol from '@/shared/components/ol/ol-col' +import OLRow from '@/shared/components/ol/ol-row' +import GallerySearchSortHeader from './gallery-search-sort-header' + +export default function GalleryHeaderTagged({ category }) { + const title = getMeta('og:title') + const { templateLinks } = getMeta('ol-ExposedSettings') || [] + + const description = templateLinks?.find(link => link.url.split("/").pop() === category)?.description + const gotoAllLink = (category !== 'all') + return ( +
    + + { category && ( + <> + + +

    {title}

    +
    +
    + + +

    {description}

    +
    +
    + + )} +
    + ) +} diff --git a/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-popular-tags.tsx b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-popular-tags.tsx new file mode 100644 index 00000000000..2e5d847d23b --- /dev/null +++ b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-popular-tags.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' + +export default function GalleryPopularTags() { + const { t } = useTranslation() + const { templateLinks } = getMeta('ol-ExposedSettings') || [] + + if(!templateLinks || templateLinks.length < 2) return null + + return ( +
    + ) +} diff --git a/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-search-sort-header.tsx b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-search-sort-header.tsx new file mode 100644 index 00000000000..f52f920b761 --- /dev/null +++ b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/gallery-search-sort-header.tsx @@ -0,0 +1,78 @@ +import { useTemplateGalleryContext } from '../context/template-gallery-context' +import { useTranslation } from 'react-i18next' +import SearchForm from './search-form' +import OLCol from '@/shared/components/ol/ol-col' +import OLRow from '@/shared/components/ol/ol-row' +import useSort from '../hooks/use-sort' +import withContent, { SortBtnProps } from './sort/with-content' +import MaterialIcon from '@/shared/components/material-icon' + +function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) { + return ( + + ) +} + +const SortByButton = withContent(SortBtn) + +export default function GallerySearchSortHeader( { gotoAllLink }: { boolean } ) { + const { t } = useTranslation() + const { + searchText, + setSearchText, + sort, + } = useTemplateGalleryContext() + + const { handleSort } = useSort() + return ( + + {gotoAllLink ? ( + + + + {t('all_templates')} + + + ) : ( + + + + {t('template_gallery')} + + + )} + + handleSort('lastUpdated')} + /> + + handleSort('name')} + /> + + + + + + ) +} diff --git a/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/search-form.tsx b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/search-form.tsx new file mode 100644 index 00000000000..ed287cf4c31 --- /dev/null +++ b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/search-form.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from 'react-i18next' +import { MergeAndOverride } from '../../../../../../../types/utils' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import MaterialIcon from '@/shared/components/material-icon' + +type SearchFormOwnProps = { + inputValue: string + setInputValue: (input: string) => void +} + +type SearchFormProps = MergeAndOverride< + React.ComponentProps, + SearchFormOwnProps +> + +export default function SearchForm({ + inputValue, + setInputValue, +}: SearchFormProps) { + const { t } = useTranslation() + let placeholderMessage = t('search') + const placeholder = `${placeholderMessage}…` + + const handleChange: React.ComponentProps['onChange'] = e => { + setInputValue(e.target.value) + } + + const handleClear = () => setInputValue('') + + return ( + e.preventDefault()} + > + } + append={ + inputValue.length > 0 && ( + + ) + } + /> + + ) +} diff --git a/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/sort/with-content.tsx b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/sort/with-content.tsx new file mode 100644 index 00000000000..8c77484fdf2 --- /dev/null +++ b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/sort/with-content.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { Sort } from '../../types/api' + +type SortBtnOwnProps = { + column: string + sort: Sort + text: string + onClick: () => void +} + +type WithContentProps = { + iconType?: string + screenReaderText: string +} + +export type SortBtnProps = SortBtnOwnProps & WithContentProps + +function withContent( + WrappedComponent: React.ComponentType +) { + function WithContent(hocProps: T) { + const { t } = useTranslation() + const { column, text, sort } = hocProps + let iconType + + let screenReaderText = t('sort_by_x', { x: text }) + + if (column === sort.by) { + iconType = + sort.order === 'asc' ? 'arrow_upward_alt' : 'arrow_downward_alt' + screenReaderText = t('reverse_x_sort_order', { x: text }) + } + + return ( + + ) + } + + return WithContent +} + +export default withContent diff --git a/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/template-gallery-entry.tsx b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/template-gallery-entry.tsx new file mode 100644 index 00000000000..2a5964722ac --- /dev/null +++ b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/template-gallery-entry.tsx @@ -0,0 +1,29 @@ +import { memo } from 'react' +import { cleanHtml } from '../../../../../app/src/CleanHtml.mjs' + +function TemplateGalleryEntry({ template }) { + return ( +
    + +
    + {template.name} +
    + + {template.name} + + +
    +
    +

    +

    +
    +
    +
    +
    + ) +} + +export default memo(TemplateGalleryEntry) diff --git a/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/template-gallery-root.tsx b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/template-gallery-root.tsx new file mode 100644 index 00000000000..adf54e8d869 --- /dev/null +++ b/services/web/modules/template-gallery/frontend/js/features/template-gallery/components/template-gallery-root.tsx @@ -0,0 +1,64 @@ +import { TemplateGalleryProvider } from '../context/template-gallery-context' +import { useTranslation } from 'react-i18next' +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 getMeta from '@/utils/meta' +import DefaultNavbar from '@/shared/components/navbar/default-navbar' +import Footer from '@/shared/components/footer/footer' +import GalleryHeaderTagged from './gallery-header-tagged' +import GalleryHeaderAll from './gallery-header-all' +import TemplateGallery from './template-gallery' +import GallerySearchSortHeader from './gallery-search-sort-header' +import GalleryPopularTags from './gallery-popular-tags' + +function TemplateGalleryRoot() { + const { isReady } = useWaitForI18n() + if (!isReady) { + return null + } + return ( + + + + ) +} + +function TemplateGalleryPageContent() { + const { t } = useTranslation() + const navbarProps = getMeta('ol-navbar') + const footerProps = getMeta('ol-footer') + const category = getMeta('ol-templateCategory') + + return ( + <> + +
    +
    + {category ? ( + <> + + + + ) : ( + <> + + +
    +
    + +

    {t('all_templates')}

    + +
    + + )} +
    +
    +