+
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 @@
- Wiki ⢠+ Wiki ⢠Server Pro ⢠- Contributing ⢠- Mailing List ⢠Authors ⢠License
-
+
- 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
\\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__0>",
+ "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__0> Ń Š²ŠµŃŠŗŠ¾Š¹ по ŃŠ¼Š¾Š»ŃŠ°Š½ŠøŃ Š² 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__0> Š±ŃŠ“ŠµŃ ŃŠæŠøŃŠ°Š½ <1>__collectionDate__1>",
@@ -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__0>?",
"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__0> ŠøŠ· <1>__groupSize__1> ГоŃŃŃŠæŠ½ŃŃ
ŃŃŠ°ŃŃŠ½ŠøŠŗŠ¾Š²",
+ "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>ДозГаŃŃ ŃŠ¾ŠŗŠµŠ½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 (
+ 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 ` ++ ${data.length} project(s) currently being edited +
+ +| Project Name | +Owner | +Owner Email | +Active Users | +Connections | +
|---|
{t('about_to_delete_projects')}
+{t('about_to_permanently_delete_projects')}
+{t('about_to_restore_projects')}
+{t('ownership_of_projects_will_be_transferred')}
+{t('about_to_trash_projects')}
++ {t('trashing_projects_wont_affect_user_collaborators')} +
+{t('about_to_delete_accounts')}
+{t(`about_to_${action}_accounts`)}
+{t('about_to_permanently_delete_accounts')}
+{t('about_to_restore_accounts')}
+{t('about_to_resend_activation_email')}
+{t('git_bridge_modal_git_clone_your_project')}
+ +
+ {gitCloneCommand}
+
+
+
+
+ {t('your_git_access_info')} +
+ +{t('delete_authentication_token_info')}
+ + {isError && ( ++ {t('git_authentication_token_create_modal_info_1')} +
+ +{secretToken}
+
+ {t('token_limit_reached')}
+ ) : ( +{t('latex_templates_for_journal_articles')} +
+{description}
+{link.description}
+{t('no_templates_found')}
+{t('about_to_delete_template')}
+