Compare commits

...
Author SHA1 Message Date
yu-i-i f32a52b253 Github Sync: initial commit 2026-06-20 03:49:21 +02:00
yu-i-i 26e7bcbf69 Git integration: use named export for settings page widget
remove stray console.log
2026-06-20 03:42:34 +02:00
Synésio Neto 92ee773c32 Fix a greyed out create file button after the Zotero Integration was merged. 2026-06-20 03:41:49 +02:00
yu-i-i 89883b0efe Reference picker: normal search instead of fuzzy; overleafying modal 2026-04-13 16:55:04 +02:00
David Rotermund 53d437dca2 Reference picker 2026-04-13 16:55:04 +02:00
yu-i-i e1dd90fa8d Authentication: fix ldap searchAttributes for passport-ldapauth 3.0.1 2026-04-01 22:42:23 +02:00
David Rotermund 120def39cc Zotero Integration (#164) 2026-04-01 22:37:43 +02:00
David Rotermund 2b737db3c9 Logo Tools (#158)
* Logo Tools

* Moved to /tools/logo; Checks dependencies first and stops if they are not there; Added information about 3_remove_branding_from_projectpage.sh in README ; reordered the commands of 1_convert.sh to avoid the logo-horizontal.png problem
2026-04-01 22:37:03 +02:00
yu-i-i 526699bc4e Git Bridge: fix incorrect token creation date display 2026-03-26 23:49:34 +01:00
David Rotermund a223f4f290 Admin tools: introduce Activ Projects tab on the Manage Site page
- code moved to modules/admin-tools (yu-i-i)
2026-03-26 23:49:34 +01:00
yu-i-i 1a264f7562 Git Bridge: route PAT validation endpoint to correct router 2026-03-20 21:19:42 +01:00
yu-i-i a2a455517e Admin tools: fix project list action icon in 6.1.0 dark theme 2026-03-20 02:51:39 +01:00
yu-i-i 5692338d4b Admin tools: themed dashboard 2026-03-20 02:51:05 +01:00
Tao Chen b46501f874 Git Bridge: support git-bridge configuration via env in nginx
- generate overleaf.conf dynamically
- support GIT_BRIDGE_HOST and GIT_BRIDGE_PORT
- configure client_max_body_size for /git using GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE
2026-03-20 00:00:05 +01:00
yu-i-i 377732a832 Add hotfixes 6.1.1 and 6.1.2 (adapted) 2026-03-15 03:39:16 +01:00
yu-i-i 362fc56949 Update README.md 2026-03-15 01:42:06 +01:00
yu-i-i 9d00984b97 Sandboxed compiles: add scripts to add/strip repo prefix from texlive images 2026-03-15 01:42:05 +01:00
yu-i-i 14d0300fc5 Add env variable to enable restoring project/file from history snapshot 2026-03-15 01:42:05 +01:00
yu-i-i fdb7578742 Admin tools: minor improvements
- enable Projects button in navbar for admin projects page
- enable copy of user ID in user info modal
- add null check in UserListController (thanks @moschlar)
2026-03-15 01:42:05 +01:00
yu-i-i c250e705b8 Git Bridge: Add git integration 2026-03-15 01:41:49 +01:00
yu-i-i eb53dd3b22 Sandboxed compiles: set imageName to default if undefined in project 2026-03-14 05:13:52 +01:00
yu-i-i 860b999ce2 Authentication: update passport-ldapauth to 3.0.1; fix minor issues 2026-03-14 05:13:52 +01:00
yu-i-i bb0804c6eb Linked URL: prevent SSRF via DNS rebinding; minor fixes 2026-03-14 05:13:51 +01:00
yu-i-i 7b407d2523 Template gallery: add template management authorization middleware
- Middleware to enforce template access rules
- Introduce template manager role
- Fix minor issues
2026-03-14 05:13:38 +01:00
yu-i-i 114911957e Admin tools: optimize user/project list performance
Add frontend pagination
Add search debouncing
Render modals conditionally
Remove unnecessary sorting
2026-03-13 21:04:13 +01:00
191 changed files with 10609 additions and 689 deletions
+2 -6
View File
@@ -9,12 +9,8 @@ assignees: ''
<!--
Note: If you are using 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
This form should only be used to report bugs in the
Community Edition release of Overleaf.
Note: This form should only be used to report bugs in the
Extended CE release of Overleaf.
-->
-5
View File
@@ -4,8 +4,3 @@
## Related issues / Pull Requests
<!-- Fixes #xyz, Contributes to #xyz, Related to #xyz-->
## Contributor Agreement
- [ ] I confirm I have signed the [Contributor License Agreement](https://github.com/overleaf/overleaf/blob/main/CONTRIBUTING.md#contributor-license-agreement)
+7 -17
View File
@@ -1,43 +1,33 @@
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.
+70
View File
@@ -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.
+11 -7
View File
@@ -25,16 +25,17 @@
The present "extended" version of Overleaf CE includes:
- Template Gallery
- Sandboxed Compiles with TeX Live image selection
- 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
- "From External URL" feature
- Symbol palette
- Import file from external URL
- Advanced administrator tools for managing user accounts and projects
- Git integration
> [!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.
@@ -74,11 +75,14 @@ folder are added.
## Authors
[The Overleaf Team](https://www.overleaf.com/about)
[yu-i-i](https://github.com/yu-i-i/overleaf-cep) — Extensions for CE unless otherwise noted
[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).
+1 -1
View File
@@ -88,7 +88,7 @@ services:
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.js:/overleaf/services/linked-url-proxy/app.js
- ../services/linked-url-proxy/app.mjs:/overleaf/services/linked-url-proxy/app.mjs
notifications:
command: ["node", "--watch", "app.ts"]
+16 -1
View File
@@ -153,7 +153,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
@@ -187,3 +187,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
+5
View File
@@ -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',
+1 -1
View File
@@ -31,7 +31,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,url,zotero"
# Enables Thumbnail generation using ImageMagick
ENABLE_CONVERSIONS: 'true'
+1
View File
@@ -2,6 +2,7 @@
"name": "overleaf",
"private": true,
"dependencies": {
"passport-ldapauth": "^3.0.1",
"patch-package": "^8.0.0"
},
"devDependencies": {
@@ -1,41 +1,41 @@
diff --git a/node_modules/ldapauth-fork/lib/ldapauth.js b/node_modules/ldapauth-fork/lib/ldapauth.js
index 85ecf36a8b..a7d07e0f78 100644
index 88398ed..0334177 100644
--- a/node_modules/ldapauth-fork/lib/ldapauth.js
+++ b/node_modules/ldapauth-fork/lib/ldapauth.js
@@ -69,6 +69,7 @@ function LdapAuth(opts) {
@@ -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);
@@ -108,21 +109,7 @@ function LdapAuth(opts) {
@@ -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) {
- 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) {
- 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() {
this.once('_installReconnectListener', function () {
self.log && self.log.trace('install reconnect listener');
self._adminClient.on('connect', function() {
@@ -384,6 +371,28 @@ LdapAuth.prototype._findGroups = function(user, callback) {
self._adminClient.on('connect', function () {
@@ -401,6 +388,37 @@ LdapAuth.prototype._findGroups = function (user, callback) {
*/
LdapAuth.prototype.authenticate = function(username, password, 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
@@ -56,9 +56,18 @@ index 85ecf36a8b..a7d07e0f78 100644
+ }
+};
+
+/**
+ * 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'));
+4 -2
View File
@@ -40,7 +40,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
@@ -112,7 +112,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"]
+12
View File
@@ -0,0 +1,12 @@
FROM sharelatex/sharelatex:6.1.0
# Apply security updates to base image
RUN apt update && apt install -y linux-libc-dev \
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
&& rm -rf /var/lib/apt/lists/*
# Fix rendering logic for UI elements in the redesigned editor
COPY pr_31004.patch .
RUN patch -p1 < pr_31004.patch && rm pr_31004.patch
RUN node genScript compile | bash
+131
View File
@@ -0,0 +1,131 @@
--- a/services/web/frontend/js/features/ide-redesign/components/rail/rail-help-dropdown.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/rail/rail-help-dropdown.tsx
@@ -10,6 +10,7 @@ import {
export default function RailHelpDropdown() {
const showSupport = getMeta('ol-showSupport')
+ const showDocumentation = getMeta('ol-wikiEnabled')
const { t } = useTranslation()
const { setActiveModal } = useRailContext()
const openKeyboardShortcutsModal = useCallback(() => {
@@ -24,20 +25,24 @@ export default function RailHelpDropdown() {
<DropdownItem onClick={openKeyboardShortcutsModal}>
{t('keyboard_shortcuts')}
</DropdownItem>
- <DropdownItem
- href="/learn"
- role="menuitem"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t('documentation')}
- </DropdownItem>
- <DropdownDivider />
- {showSupport && (
- <DropdownItem onClick={openContactUsModal}>
- {t('contact_us')}
+ {showDocumentation && (
+ <DropdownItem
+ href="/learn"
+ role="menuitem"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {t('documentation')}
</DropdownItem>
)}
+ {showSupport && (
+ <>
+ <DropdownDivider />
+ <DropdownItem onClick={openContactUsModal}>
+ {t('contact_us')}
+ </DropdownItem>
+ </>
+ )}
</DropdownMenu>
)
}
diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx
index 1610cdb89675..648d2f47fb53 100644
--- a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx
@@ -43,6 +43,8 @@ export const ToolbarMenuBar = () => {
const openProject = useOpenProject()
const anonymous = getMeta('ol-anonymous')
+ const showSupport = getMeta('ol-showSupport')
+ const showDocumentation = getMeta('ol-wikiEnabled')
useCommandProvider(
() => [
@@ -266,19 +268,25 @@ export const ToolbarMenuBar = () => {
title={t('keyboard_shortcuts')}
onClick={openKeyboardShortcutsModal}
/>
- <MenuBarOption
- title={t('documentation')}
- eventKey="documentation"
- href="/learn"
- target="_blank"
- rel="noopener noreferrer"
- />
- <DropdownDivider />
- <MenuBarOption
- eventKey="contact_us"
- title={t('contact_us')}
- onClick={openContactUsModal}
- />
+ {showDocumentation && (
+ <MenuBarOption
+ title={t('documentation')}
+ eventKey="documentation"
+ href="/learn"
+ target="_blank"
+ rel="noopener noreferrer"
+ />
+ )}
+ {showSupport && (
+ <>
+ <DropdownDivider />
+ <MenuBarOption
+ eventKey="contact_us"
+ title={t('contact_us')}
+ onClick={openContactUsModal}
+ />
+ </>
+ )}
</MenuBarDropdown>
</MenuBar>
<WordCountModal
diff --git a/services/web/frontend/js/features/ide-redesign/contexts/settings-modal-context.tsx b/services/web/frontend/js/features/ide-redesign/contexts/settings-modal-context.tsx
index 56f0d4e30fe4..ab3bbd9d2b7f 100644
--- a/services/web/frontend/js/features/ide-redesign/contexts/settings-modal-context.tsx
+++ b/services/web/frontend/js/features/ide-redesign/contexts/settings-modal-context.tsx
@@ -29,6 +29,7 @@ import DarkModePdfSetting from '../components/settings/appearance-settings/dark-
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import ProjectNotificationsSetting from '../components/settings/editor-settings/project-notifications-setting'
+import getMeta from '@/utils/meta'
const [referenceSearchSettingModule] = importOverleafModules(
'referenceSearchSetting'
@@ -82,6 +83,7 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
children,
}) => {
const { t } = useTranslation()
+ const { isOverleaf } = getMeta('ol-ExposedSettings')
const { overallTheme } = useProjectSettingsContext()
// TODO ide-redesign-cleanup: Rename this field and move it directly into this context
@@ -265,9 +267,10 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
title: t('subscription'),
icon: 'account_balance',
href: '/user/subscription',
+ hidden: !isOverleaf,
},
],
- [t, overallTheme, hasEmailNotifications, noNewEditorOptOut]
+ [t, overallTheme, hasEmailNotifications, noNewEditorOptOut, isOverleaf]
)
const settingsTabs = useMemo(
+13
View File
@@ -0,0 +1,13 @@
FROM sharelatex/sharelatex:6.1.1
# Apply security updates to base image
RUN apt update && apt install -y linux-libc-dev \
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
&& apt purge -y imagemagick \
&& apt autoremove -y \
&& apt install -y poppler-utils \
&& rm -rf /var/lib/apt/lists/*
# Update converter
COPY issue_31527.patch .
RUN patch -p0 < issue_31527.patch && rm issue_31527.patch
+87
View File
@@ -0,0 +1,87 @@
--- services/filestore/app/js/FileConverter.js
+++ services/filestore/app/js/FileConverter.js
@@ -22,45 +22,44 @@ export default {
}
async function convert(sourcePath, requestedFormat) {
- const width = '600x'
+ const width = '1500'
return await _convert(sourcePath, requestedFormat, [
- 'convert',
- '-define',
- `pdf:fit-page=${width}`,
- '-flatten',
- '-density',
- '300',
- `${sourcePath}[0]`,
+ 'pdftocairo',
+ '-png',
+ '-singlefile',
+ '-scale-to-x',
+ width.toString(),
+ '-scale-to-y',
+ '-1',
+ sourcePath,
])
}
async function thumbnail(sourcePath) {
- const width = '548x'
- return await _convert(sourcePath, 'jpg', [
- 'convert',
- '-flatten',
- '-background',
- 'white',
- '-density',
- '300',
- `${sourcePath}[0]`,
- '-resize',
- width,
+ const width = 700
+ return await _convert(sourcePath, 'png', [
+ 'pdftocairo',
+ '-png',
+ '-singlefile',
+ '-scale-to-x',
+ width.toString(),
+ '-scale-to-y',
+ '-1',
+ sourcePath,
])
}
async function preview(sourcePath) {
- const width = '794x'
- return await _convert(sourcePath, 'jpg', [
- 'convert',
- '-flatten',
- '-background',
- 'white',
- '-density',
- '300',
- `${sourcePath}[0]`,
- '-resize',
- width,
+ const width = 1000
+ return await _convert(sourcePath, 'png', [
+ 'pdftocairo',
+ '-png',
+ '-singlefile',
+ '-scale-to-x',
+ width.toString(),
+ '-scale-to-y',
+ '-1',
+ sourcePath,
])
}
@@ -74,7 +73,8 @@ async function _convert(sourcePath, requestedFormat, command) {
const timer = new metrics.Timer('imageConvert')
const destPath = `${sourcePath}.${requestedFormat}`
- command.push(destPath)
+ const outputBaseName = sourcePath
+ command.push(outputBaseName)
command = Settings.commands.convertCommandPrefix.concat(command)
try {
@@ -16,12 +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.
@@ -37,6 +43,15 @@ if [ -f "${nginx_template_file}" ]; then
< "${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
@@ -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;
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -103,7 +103,7 @@ 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,
+1 -1
View File
@@ -33,7 +33,7 @@ const server = app.listen(port, host, function (error) {
process.on('SIGTERM', () => {
server.close(() => {
logger.info({ host, port }, 'linked-url-proxy HTTP server closed')
metrics.close()
Metrics.close()
})
})
@@ -7,6 +7,7 @@ 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
@@ -46,7 +47,7 @@ async function checkUrlAccess(hostname, targetUrl) {
throw err
}
// Permit explicitly allowed resources without checking blocked IPs
if (isAllowedResource(targetUrl)) return
if (isAllowedResource(targetUrl)) return records[0].address
for (const { address } of records) {
if (isBlockedIp(address, targetUrl)) {
const err = new Error(`Blocked IP address: ${address}`)
@@ -54,6 +55,7 @@ async function checkUrlAccess(hostname, targetUrl) {
throw err
}
}
return records[0].address
}
async function validateAndFetch(rawUrl, redirectCount = 0) {
@@ -89,7 +91,16 @@ async function validateAndFetch(rawUrl, redirectCount = 0) {
const normalizedUrl = url.toString()
// check DNS and allowed resources
await checkUrlAccess(url.hostname, normalizedUrl)
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',
@@ -98,7 +109,7 @@ async function validateAndFetch(rawUrl, redirectCount = 0) {
}
try {
const { stream, response } = await fetchStreamWithResponse(normalizedUrl, opts)
const { stream, response } = await fetchStreamWithResponse(normalizedUrl, { ...opts, dispatcher: agent })
const contentLengthHeader = response.headers.get('content-length')
if (contentLengthHeader) {
+2 -5
View File
@@ -16,10 +16,7 @@
"express": "^4.22.1",
"ipaddr.js": "^2.1.0",
"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"
"strict-url-sanitise": "^0.0.1",
"undici": "^7.22.0"
}
}
@@ -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.`
)
}
@@ -531,6 +531,7 @@ const _ProjectController = {
collaberator_refs: 1, // used for link sharing analytics
pendingEditor_refs: 1, // used for link sharing analytics
reviewer_refs: 1,
imageName: 1,
}),
userIsMemberOfGroupSubscription: sessionUser
? (async () =>
@@ -606,6 +607,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,
@@ -731,7 +734,7 @@ const _ProjectController = {
}
const isAdminOrTemplateOwner =
hasAdminAccess(user) || Settings.templates?.nonAdminCanManage
hasAdminAccess(user) || Settings.templates?.nonAdminCanManage || Settings.templates?.user_id === userId
const showTemplatesServerPro =
Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner
@@ -79,6 +79,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'),
+5
View File
@@ -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')
@@ -41,6 +42,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
+109 -16
View File
@@ -985,14 +985,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: [],
@@ -1011,15 +1036,45 @@ module.exports = {
sourceEditorToolbarComponents: [],
sourceEditorToolbarEndButtons: [],
rootContextProviders: [],
mainEditorLayoutModals: [],
mainEditorLayoutModals: [
Path.resolve(
__dirname,
'../modules/reference-picker/frontend/components/reference-picker-controller.tsx'
),
],
mainEditorLayoutPanels: [],
langFeedbackLinkingWidgets: [],
labsExperiments: [],
integrationLinkingWidgets: [],
referenceLinkingWidgets: [],
importProjectFromGithubModalWrapper: [],
importProjectFromGithubMenu: [],
editorLeftMenuSync: [],
integrationLinkingWidgets: [
Path.resolve(
__dirname,
'../modules/github-sync/frontend/js/components/github-sync-widget.tsx'
),
],
referenceLinkingWidgets: [
Path.resolve(
__dirname,
'../modules/zotero/frontend/js/components/zotero-widget'
),
],
importProjectFromGithubModalWrapper: [
Path.resolve(
__dirname,
'../modules/github-sync/frontend/js/components/import-from-github-modal-wrapper.tsx'
),
],
importProjectFromGithubMenu: [
Path.resolve(
__dirname,
'../modules/github-sync/frontend/js/components/import-from-github-menu.tsx'
),
],
editorLeftMenuSync: [
Path.resolve(
__dirname,
'../modules/git-bridge/frontend/js/card/components/git-modal.tsx'
),
],
editorLeftMenuManageTemplate: [
Path.resolve(
__dirname,
@@ -1032,7 +1087,12 @@ module.exports = {
'../modules/template-gallery/frontend/js/features/template/components/menubar-manage-template'
),
],
oauth2Server: [],
oauth2Server: [
Path.resolve(
__dirname,
'../modules/git-bridge/frontend/js/widget/components/git-integration-widget.tsx'
)
],
managedGroupSubscriptionEnrollmentNotification: [],
managedGroupEnrollmentInvite: [],
ssoCertificateInfo: [],
@@ -1043,7 +1103,12 @@ module.exports = {
rollingBuildsUpdatedAlert: [],
offlineModeToolbarButtons: [],
settingsEntries: [],
autoCompleteExtensions: [],
autoCompleteExtensions: [
Path.resolve(
__dirname,
'../modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts'
),
],
sectionTitleGenerators: [],
toastGenerators: [
Path.resolve(
@@ -1069,10 +1134,24 @@ module.exports = {
'../modules/full-project-search/frontend/js/components/full-project-search.tsx'
),
],
integrationPanelComponents: [],
integrationPanelComponents: [
Path.resolve(
__dirname,
'../modules/github-sync/frontend/js/components/github-integration-card.tsx'
),
Path.resolve(
__dirname,
'../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx'
),
],
referenceSearchSetting: [],
errorLogsComponents: [],
referenceIndices: [],
referenceIndices: [
Path.resolve(
__dirname,
'../modules/reference-picker/frontend/reference-index/advanced-reference-index.ts'
),
],
railEntries: [],
railPopovers: [],
},
@@ -1083,12 +1162,16 @@ module.exports = {
'server-ce-scripts',
'sandboxed-compiles',
'symbol-palette',
'reference-picker',
'track-changes',
'authentication/ldap',
'authentication/saml',
'authentication/oidc',
'admin-tools', // import after authentication
'template-gallery',
'git-bridge',
'github-sync',
'zotero',
],
viewIncludes: {},
@@ -1102,7 +1185,6 @@ module.exports = {
'app/views/project/ide-react': [`img-src 'self' data: blob:`],
},
},
unsupportedBrowsers: {
ie: '<=11',
safari: '<=14',
@@ -1129,6 +1211,17 @@ module.exports = {
}),
},
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) {
@@ -176,6 +176,8 @@
"are_you_still_at": "",
"are_you_sure_you_want_to_cancel_add_on": "",
"as_email": "",
"ask_proj_owner_to_add_you_as_github_collaborator": "",
"ask_proj_owner_to_export_to_github": "",
"ask_proj_owner_to_unlink_from_current_github": "",
"ask_proj_owner_to_upgrade_for_full_history": "",
"ask_repo_owner_to_reconnect": "",
@@ -365,6 +367,7 @@
"contacting_the_sales_team": "",
"continue": "",
"continue_github_merge": "",
"continue_github_merge": "",
"continue_to": "",
"continue_using_free_features": "",
"continue_with_free_plan": "",
@@ -748,6 +751,7 @@
"github_is_no_longer_connected": "",
"github_is_premium": "",
"github_large_files_error": "",
"github_merge_manually": "",
"github_merge_failed": "",
"github_no_master_branch_error": "",
"github_private_description": "",
@@ -761,6 +765,9 @@
"github_timeout_error": "",
"github_too_many_files_error": "",
"github_validation_check": "",
"github_validation_check_auth": "",
"github_validation_name_exists": "",
"github_validation_name_invalid": "",
"github_workflow_authorize": "",
"github_workflow_files_delete_github_repo": "",
"github_workflow_files_error": "",
@@ -1262,6 +1269,7 @@
"open_target": "",
"optional": "",
"or": "",
"organization": "",
"organization_name": "",
"organize_tags": "",
"other": "",
@@ -1342,6 +1350,7 @@
"per_license": "",
"per_month": "",
"per_month_x_annually": "",
"per_page": "",
"per_year": "",
"percent_is_the_percentage_of_the_line_width": "",
"permanently_delete_accounts": "",
@@ -1507,10 +1516,13 @@
"reference_managers": "",
"reference_search": "",
"reference_search_setting": "",
"reference_search_results": "",
"reference_search_settings": "",
"reference_search_style": "",
"reference_sync": "",
"references_from_these_libraries_will_be_included_in_your_reference_search_results": "",
"references_picker_empty_hint": "",
"references_picker_title": "",
"refresh": "",
"refresh_page_after_linking_dropbox": "",
"refresh_page_after_starting_free_trial": "",
@@ -2434,9 +2446,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": "",
@@ -2445,4 +2460,4 @@
"zotero_sync_description": "",
"zotero_upgrade_prompt_content": "",
"zotero_upgrade_prompt_title": ""
}
}
@@ -235,7 +235,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
}
if (
update.meta.type === 'external' &&
update.meta.source === 'git-bridge'
(update.meta.source === 'git-bridge' || update.meta.source === 'github')
) {
return
}
@@ -1,10 +1,20 @@
import { useTranslation } from 'react-i18next'
export default function Pagination({ currentPage, totalPages, onPageChange }) {
interface PaginationProps {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
export default function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
const { t } = useTranslation()
if (totalPages <= 1) return null
const pageNumbers = []
const pageNumbers: (number | "...")[] = []
let startPage = Math.max(1, currentPage - 4)
let endPage = Math.min(totalPages, currentPage + 4)
@@ -1,3 +1,108 @@
@mixin light-buttons {
// primary
--btn-primary-color: var(--white);
--btn-primary-background: var(--bg-accent-01);
--btn-primary-hover-background: var(--bg-accent-02);
--btn-primary-hover-border: var(--bg-accent-02);
// secondary
--btn-secondary-color: var(--content-primary);
--btn-secondary-background: var(--bg-light-primary);
--btn-secondary-border: var(--border-primary);
--btn-secondary-hover-background: var(--bg-light-tertiary);
--btn-secondary-hover-border: var(--border-primary);
// ghost
--btn-ghost-color: var(--content-primary);
--btn-ghost-background: var(--bg-light-primary);
--btn-ghost-hover-background: var(--bg-light-tertiary);
// danger
--btn-danger-color: var(--white);
--btn-danger-background: var(--bg-danger-01);
--btn-danger-border: var(--bg-danger-01);
--btn-danger-hover-background: var(--bg-danger-02);
// danger-ghost
--btn-danger-ghost-color: var(--content-danger);
--btn-danger-ghost-background: var(--bg-light-primary);
--btn-danger-ghost-hover-background: var(--bg-danger-03);
--btn-danger-ghost-border: var(--bg-light-primary);
--btn-danger-ghost-hover-border: var(--bg-light-primary);
// premium
--btn-premium-color: var(--white);
--btn-premium-background: var(--blue-70);
--btn-premium-hover-background: var(--blue-70);
// premium-secondary
--btn-premium-secondary-color: var(--blue-60);
--btn-premium-secondary-background: var(--bg-light-primary);
--btn-premium-secondary-border: var(--blue-60);
--btn-premium-secondary-hover-background: var(--bg-info-03);
--btn-premium-secondary-hover-border: var(--blue-60);
}
@mixin dark-buttons {
// primary
--btn-primary-color: var(--white);
--btn-primary-background: var(--bg-accent-01);
--btn-primary-hover-background: var(--bg-accent-02);
--btn-primary-hover-border: var(--bg-accent-02);
// secondary
--btn-secondary-color: var(--white);
--btn-secondary-background: var(--bg-dark-primary);
--btn-secondary-border: var(--bg-light-primary);
--btn-secondary-hover-background: var(--bg-dark-tertiary);
--btn-secondary-hover-border: var(--bg-light-primary);
// ghost
--btn-ghost-color: var(--white);
--btn-ghost-background: var(--bg-dark-secondary);
--btn-ghost-hover-background: var(--bg-dark-tertiary);
// danger
--btn-danger-color: var(--white);
--btn-danger-background: var(--bg-danger-01);
--btn-danger-border: var(--bg-danger-01);
--btn-danger-hover-background: var(--bg-danger-02);
// danger-ghost
--btn-danger-ghost-color: var(--bg-danger-03);
--btn-danger-ghost-background: var(--bg-dark-secondary);
--btn-danger-ghost-hover-background: var(--bg-dark-tertiary);
--btn-danger-ghost-border: var(--bg-danger-01);
--btn-danger-ghost-hover-border: var(--bg-danger-01);
// premium
--btn-premium-color: var(--white);
--btn-premium-background: var(--blue-70);
--btn-premium-hover-background: var(--blue-70);
// premium-secondary, todo once WIP designs for dm are finished
--btn-premium-secondary-color: var(--content-primary);
--btn-premium-secondary-background: var(--blue-30);
--btn-premium-secondary-border: var(--blue-60);
--btn-premium-secondary-hover-background: var(--blue-30);
--btn-premium-secondary-hover-border: var(--blue-60);
}
:root {
.btn {
@include light-buttons;
}
@include theme('default') {
.ide-redesign-main,
.project-ds-nav-page {
.btn {
@include dark-buttons;
}
}
}
}
.btn {
// Focus style for all buttons
--bs-btn-focus-box-shadow: 0 0 0 2px var(--border-active-dark);
@@ -35,70 +140,73 @@
// Variants
&.btn-primary {
@include ol-button-variant(
$color: var(--white),
$background: var(--bg-accent-01),
$hover-background: var(--bg-accent-02),
$hover-border: var(--bg-accent-02)
$color: var(--btn-primary-color),
$background: var(--btn-primary-background),
$hover-background: var(--btn-primary-hover-background),
$hover-border: var(--btn-primary-hover-border)
);
}
&.btn-secondary {
@include ol-button-variant(
$color: var(--content-primary),
$background: var(--bg-light-primary),
$border: var(--border-primary),
$hover-background: var(--bg-light-tertiary),
$hover-border: var(--border-primary),
$color: var(--btn-secondary-color),
$background: var(--btn-secondary-background),
$border: var(--btn-secondary-border),
$hover-background: var(--btn-secondary-hover-background),
$hover-border: var(--btn-secondary-hover-border),
$borderless: false
);
}
&.btn-ghost {
@include ol-button-variant(
$color: var(--content-primary),
$background: var(--bg-light-primary),
$hover-background: var(--bg-light-tertiary)
$color: var(--btn-ghost-color),
$background: var(--btn-ghost-background),
$hover-background: var(--btn-ghost-hover-background)
);
}
&.btn-danger {
@include ol-button-variant(
$color: var(--white),
$background: var(--bg-danger-01),
$border: var(--bg-danger-01),
$hover-background: var(--bg-danger-02)
$color: var(--btn-danger-color),
$background: var(--btn-danger-background),
$border: var(--btn-danger-border),
$hover-background: var(--btn-danger-hover-background)
);
}
&.btn-danger-ghost {
@include ol-button-variant(
$color: var(--content-danger),
$background: var(--bg-light-primary),
$hover-background: var(--bg-danger-03)
$color: var(--btn-danger-ghost-color),
$background: var(--btn-danger-ghost-background),
$border: var(--btn-danger-ghost-border),
$hover-background: var(--btn-danger-ghost-hover-background),
$hover-border: var(--btn-danger-ghost-hover-border),
$borderless: false
);
}
&.btn-premium {
@include ol-button-variant(
$color: var(--white),
$background: var(--blue-70)
$color: var(--btn-premium-color),
$background: var(--btn-premium-background)
);
background: var(--premium-gradient);
transition: none;
&:hover {
background: var(--blue-70);
background: var(--btn-premium-hover-background);
}
}
&.btn-premium-secondary {
@include ol-button-variant(
$color: var(--blue-60),
$background: var(--bg-light-primary),
$border: var(--blue-60),
$hover-background: var(--bg-info-03),
$hover-border: var(--blue-60),
$color: var(--btn-premium-secondary-color),
$background: var(--btn-premium-secondary-background),
$border: var(--btn-premium-secondary-border),
$hover-background: var(--btn-premium-secondary-hover-background),
$hover-border: var(--btn-premium-secondary-hover-border),
$borderless: false
);
}
@@ -261,6 +369,18 @@ a.btn:visited {
text-decoration: none;
}
.copy-button.copy-button-ghost {
@include ol-button-variant(
$color: var(--content-primary),
$background: transparent,
$hover-background: var(--bg-light-tertiary)
);
.button-content {
gap: var(--spacing-02);
}
}
.btn-reset {
@include reset-button;
}
@@ -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);
}
}
}
@@ -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;
}
}
}
@@ -0,0 +1,25 @@
@include theme('default') {
#manage-users-root {
.website-redesign-navbar,
.website-redesign .navbar-default {
@include navbar-dark;
}
}
.project-list-wrapper {
.project-dash-table {
.btn-link {
&:hover,
&:focus {
background-color: var(--bg-dark-tertiary) !important;
}
}
}
}
.user-ds-nav-page {
@include dark-dropdown-menu;
.table,
.table-container {
@include dark-table;
}
}
}
@@ -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);
}
}
}
}
}
@@ -1,9 +1,42 @@
: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 {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
color: var(--content-secondary);
@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);
@@ -15,9 +48,6 @@
.navbar-default {
position: relative;
--navbar-toggler-expanded-bg: none;
--navbar-toggler-expanded-color: var(--content-secondary);
.navbar-header .navbar-logo {
@include media-breakpoint-up(md) {
position: relative;
@@ -48,7 +78,7 @@
.nav-item-help::before {
content: '';
display: block;
border-top: 1px solid var(--border-divider);
border-top: 1px solid var(--border-divider-themed);
margin: var(--spacing-07) var(--spacing-06);
}
@@ -63,8 +93,8 @@
border-radius: var(--border-radius-medium);
&.show {
background-color: var(--bg-accent-03);
color: var(--green-60);
background-color: var(--ds-nav-active-bg);
color: var(--ds-nav-active-color);
}
}
}
@@ -78,6 +108,7 @@
}
.user-list-wrapper {
background-color: var(--ds-nav-bg-color);
flex-grow: 1;
display: flex;
overflow-y: hidden;
@@ -95,6 +126,10 @@
.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 {
@@ -117,39 +152,13 @@
border-top: solid 1px transparent;
&.show-shadow {
border-top-color: var(--border-divider);
border-top-color: var(--border-divider-themed);
}
}
}
.user-list-sidebar-survey-link {
color: var(--content-secondary) !important;
}
.survey-notification {
background-color: var(--bg-light-secondary);
color: var(--content-secondary);
box-shadow: none;
border-radius: var(--border-radius-large);
position: relative;
.user-notification-close {
border: none;
padding: 0;
background: none;
position: absolute;
top: var(--spacing-07);
right: var(--spacing-07);
color: inherit;
}
p {
margin-bottom: var(--spacing-03);
}
}
.user-list-sidebar-survey-wrapper .user-notifications {
margin-bottom: var(--spacing-05);
}
hr {
border-top: 1px solid var(--border-divider-themed);
}
ul.user-list-filters {
@@ -163,7 +172,7 @@
> button {
width: 100%;
text-align: left;
color: var(--content-secondary);
color: var(--ds-nav-color);
background: none;
border-radius: var(--border-radius-medium);
border: none;
@@ -171,18 +180,19 @@
}
&:hover button {
background-color: var(--bg-light-secondary);
background-color: var(--ds-nav-hover-bg);
}
&.active button {
background-color: var(--bg-accent-03);
color: var(--green-60);
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 {
@@ -195,7 +205,7 @@
button.dropdown-toggle {
border-radius: var(--border-radius-full);
border: none;
color: var(--content-secondary);
color: var(--ds-nav-color);
height: 20px;
width: 20px;
padding: 0;
@@ -224,7 +234,7 @@
.user-dash-table {
.btn-link {
color: var(--content-secondary);
color: var(--content-secondary-themed);
height: var(--spacing-08);
width: var(--spacing-08);
border-radius: 100%;
@@ -233,12 +243,12 @@
&:hover,
&:focus {
background-color: #d9d9d9 !important;
background-color: rgb(var(--table-icon-bg-hover) / 8%) !important;
}
}
.dash-cell-name a {
color: var(--content-secondary) !important;
color: var(--content-primary-themed) !important;
}
}
@@ -249,7 +259,7 @@
> * {
@include media-breakpoint-up(md) {
border-left: 1px solid var(--border-divider);
border-left: 1px solid var(--border-divider-themed);
}
}
@@ -257,17 +267,16 @@
flex-grow: 1;
overflow-y: auto;
position: relative;
background-color: var(--bg-light-secondary);
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);
border-top: 1px solid var(--border-divider-themed);
}
}
.cookie-banner {
position: static;
background-color: var(--bg-light-primary);
// Remove the parts of the shadow that stick out of the sides
clip-path: inset(-13px 0 0 0);
@@ -276,11 +285,16 @@
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(--content-secondary);
color: var(--ds-nav-color);
background: none;
height: 44px;
width: 44px;
@@ -288,12 +302,12 @@
overflow: hidden;
&:hover {
background-color: var(--bg-light-secondary);
background-color: var(--ds-nav-hover-bg);
}
&.show {
background-color: var(--bg-accent-03);
color: var(--green-60);
background-color: var(--ds-nav-active-bg);
color: var(--ds-nav-active-color);
}
&::after {
@@ -314,6 +328,7 @@
}
.ds-nav-ds-name {
color: var(--ds-nav-color);
margin-bottom: var(--spacing-05);
span {
@@ -323,4 +338,59 @@
@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);
}
}
@@ -17,7 +17,7 @@
padding: 0 var(--spacing-02);
}
#user-list-root .user-notifications ul {
#manage-users-root .user-notifications ul {
margin-bottom: 0;
}
@@ -50,7 +50,7 @@
.user-list-title {
@include heading-sm;
color: $content-secondary;
color: var(--content-secondary-themed);
font-weight: bold;
min-width: 0;
}
@@ -241,7 +241,7 @@ ul.user-list-filters {
@include media-breakpoint-down(md) {
tr:not(:last-child) {
border-bottom: 1px solid $table-border-color;
border-bottom: 1px solid var(--border-divider-themed);
}
td {
@@ -260,7 +260,7 @@ ul.user-list-filters {
.table-header-sort-btn {
border: 0;
text-align: left;
color: var(--content-secondary);
color: var(--content-secondary-themed);
background-color: transparent;
padding: 0;
font-weight: bold;
@@ -271,7 +271,7 @@ ul.user-list-filters {
&:hover,
&:focus {
color: var(--content-secondary);
color: var(--content-secondary-themed);
text-decoration: none;
}
@@ -283,11 +283,8 @@ ul.user-list-filters {
.dash-row-admin {
font-weight: bold;
td {
a {
color: darkred !important;
--bs-link-color: darkred !important;
}
a {
color: var(--content-danger) !important;
}
}
@@ -474,7 +471,7 @@ ul.user-list-filters {
}
.dash-cell-actions {
width: 12;
width: 12%;
}
}
@@ -532,57 +529,18 @@ ul.user-list-filters {
}
}
.survey-notification {
display: flex;
flex-wrap: wrap;
padding: var(--spacing-06);
background-color: var(--bg-dark-tertiary);
border-color: transparent;
color: var(--neutral-20);
box-shadow: 2px 4px 6px rgb(0 0 0 / 25%);
border-radius: var(--border-radius-base);
@include media-breakpoint-up(md) {
flex-wrap: nowrap;
}
button.close {
@extend .text-white;
padding: 0;
}
}
.user-list-sidebar-survey-wrapper {
.survey-notification {
font-size: var(--font-size-02);
a {
text-decoration: none;
}
}
@include media-breakpoint-down(md) {
.survey-notification {
font-size: unset;
.user-list-sidebar-survey-link {
display: block;
align-items: center;
min-width: 48px;
min-height: 48px;
padding-top: var(--spacing-07);
}
}
}
}
.user-list-load-more-button {
margin-bottom: var(--spacing-05);
}
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;
}
}
@@ -9,5 +9,11 @@
@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';
@import 'github-sync';
@import '../../../modules/reference-picker/frontend/styles/reference-picker';
@@ -0,0 +1,8 @@
.github-sync-commits-heading {
align-items: center;
display: flex;
font-size: var(--font-size-03);
font-weight: 600;
justify-content: space-between;
line-height: var(--line-height-03);
}
+24 -2
View File
@@ -118,6 +118,9 @@
"articles": "Artikel",
"as_a_member_of_sso_required": "Als Mitglied von <b>__institutionName__</b> musst du dich über dein institutionelles Portal bei <b>__appName__</b> anmelden.",
"ascending": "Aufsteigend",
"ask_proj_owner_to_add_you_as_github_collaborator": "Du hast keine Berechtigung, in das verknüpfte Repository (<0>__repoFullName__</0>) zu pushen. Bitte den Projektinhaber (<1>__projectOwnerEmail__</1>), dich als Kollaborator zu diesem Repository hinzuzufügen.",
"ask_proj_owner_to_export_to_github": "Bitte den Projektinhaber (<0>__projectOwnerEmail__</0>), das Projekt in ein Git-Repository zu exportieren und dich als Kollaborator hinzuzufügen.",
"ask_proj_owner_to_unlink_from_current_github": "Bitte den Projektinhaber (<0>__projectOwnerEmail__</0>), das Projekt vom aktuellen Repository zu entkoppeln und eine Verbindung zu einem anderen Repository herzustellen.",
"ask_proj_owner_to_upgrade_for_full_history": "Bitte den Projektinhaber um ein Abonnement-Upgrade, um auf den vollständigen Verlauf dieses Projekts zugreifen zu können.",
"ask_proj_owner_to_upgrade_for_references_search": "Bitte den Projekteigentümer um ein Abonnement-Upgrade, damit du die Referenz-Suchfunktion verwenden kannst.",
"august": "August",
@@ -512,8 +515,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.</0> Für weitere Anweisungen zur Verwendung von Anmelde-Tokens, besuche unsere <1>Hilfe-Seite</1>.",
"git_bridge_modal_click_generate": "Klicke jetzt auf <strong>Token generieren</strong> 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</0>.",
"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</0>.",
"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</0>.",
"github_commit_message_placeholder": "Commit-Meldung für Änderungen die in __appName__ gemacht wurden",
@@ -525,11 +531,12 @@
"github_integration_lowercase": "Git- und GitHub-Integration",
"github_is_premium": "GitHub-Sync ist eine Premiumfunktion",
"github_large_files_error": "Zusammenführung fehlgeschlagen: Dein GitHub-Repository enthält Dateien mit einer Dateigröße von mehr als 50 MB",
"github_merge_failed": "Deine Änderungen in __appName__ und GitHub konnten nicht automatisch zusammengeführt werden. Bitte führe den <0>__sharelatex_branch__</0> mit dem Standard-Branch in Git zusammen. Klicke unten um fortzufahren, nachdem du manuell zusammengeführt hast.",
"github_merge_manually": "Bitte führe den <0>__sharelatex_branch__</0> mit dem Standard-Branch in Git zusammen. Klicke unten um fortzufahren, nachdem du manuell zusammengeführt hast.",
"github_merge_failed": "Deine Änderungen in __appName__ und GitHub konnten nicht automatisch zusammengeführt werden.",
"github_no_master_branch_error": "Dieses Repository kann nicht importiert werden, da ihm ein Standard-Branch fehlt. Stell sicher, dass das Projekt einen Standard-Branch hat",
"github_private_description": "Du wählst, wer dieses Repository sehen und etwas übergeben kann.",
"github_public_description": "Jeder kann dieses Repository sehen. Du entscheidest wer committen darf.",
"github_repository_diverged": "Der Standard-Branch des verknüpften Repositorys wurde forciert gepusht. Das Pullen von GitHub-Änderungen nach einem forciertem Push kann dazu führen, dass Overleaf und GitHub nicht mehr synchron sind. Möglicherweise musst du Änderungen nach dem Pullen erneut Pushen um wieder synchron zu sein",
"github_repository_diverged": "Der Standard-Branch des verknüpften Repositorys wurde forciert gepusht. Die Liste der letzten Commits unten kann aufgrund einer umgeschriebenen Historie seit der letzten Synchronisierung unvollständig sein.",
"github_successfully_linked_description": "Danke, wir haben dein GitHub-Nutzerkonto erfolgreich mit __appName__ verknüpft. Du kannst die __appName__-Projekte jetzt in GitHub exportieren oder Projekte aus deinen GitHub-Repositories importieren.",
"github_symlink_error": "Dein GitHub-Repository enthält Dateien mit symbolischen Links, was derzeit von Overleaf nicht unterstützt wird. Entferne diese und versuche es erneut.",
"github_sync": "GitHub Synchronisierung",
@@ -960,6 +967,7 @@
"pending": "Ausstehend",
"pending_additional_licenses": "Dein Abonnement wird geändert, um <0>__pendingAdditionalLicenses__</0> zusätzliche Lizenz(en) für insgesamt <1>__pendingTotalLicenses__</1> 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",
@@ -1017,6 +1025,7 @@
"project_has_too_many_files_limit": "Dieses Projekt hat das Limit von __limit__ Dateien erreicht",
"project_last_published_at": "Dein Projekt wurde zuletzt veröffentlicht am",
"project_layout_sharing_submission": "Projektlayout, Freigabe und Einreichung",
"project_linked_to": "Dieses Projekt ist verlinkt mit",
"project_name": "Projektname",
"project_not_linked_to_github": "Dieses Projekt ist nicht mit einem GitHub Repository verlinkt. Du kannst ein neues Repository in GitHub erstellen:",
"project_ownership_transfer_confirmation_1": "Möchtest du <0>__user__</0> wirklich zum Eigentümer von <1>__project__</1> machen?",
@@ -1302,7 +1311,9 @@
"to_many_login_requests_2_mins": "In dieses Konto wurde sich zu häufig eingeloggt. Bitte warte 2 Minuten, bevor du es noch einmal versuchst.",
"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_requests": "Es gingen in kurzer Zeit zu viele Anfragen ein. Bitte warte einen Moment und versuche es erneut.",
@@ -1438,10 +1449,18 @@
"you_can_now_log_in_sso": "Du kannst dich jetzt über deine Institution anmelden und möglicherweise <0>kostenlose __appName__ „Professionell“-Funktionen</0> 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_dont_have_any_repositories": "Du hast keine Repositories",
"you_have_added_x_of_group_size_y": "Du hast <0>__addedUsersSize__</0> von <1>__groupSize__</1> 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__</0> 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</0>-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__</0>.",
@@ -1452,8 +1471,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",
+18 -5
View File
@@ -217,7 +217,9 @@
"as_a_member_of_sso_required": "As a member of <b>__institutionName__</b>, you must log in to <b>__appName__</b> through your institution.",
"as_email": "as __email__",
"ascending": "Ascending",
"ask_proj_owner_to_unlink_from_current_github": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to unlink the project from the current GitHub repository and create a connection to a different repository.",
"ask_proj_owner_to_add_you_as_github_collaborator": "You don’t have permission to push to the linked repository <0>__repoFullName__</0>. Ask the owner of the project (<1>__projectOwnerEmail__</1>) to add you as a collaborator to this repository.",
"ask_proj_owner_to_export_to_github": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to export the project to a Git repository and add you as a collaborator.",
"ask_proj_owner_to_unlink_from_current_github": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to unlink the project from the current git repository and create a connection to a different repository.",
"ask_proj_owner_to_upgrade_for_full_history": "Please ask the project owner to upgrade to access this project’s full history.",
"ask_repo_owner_to_reconnect": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
"ask_repo_owner_to_renew_overleaf_subscription": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
@@ -951,11 +953,12 @@
"github_is_no_longer_connected": "GitHub is no longer connected to this project.",
"github_is_premium": "GitHub Sync is a premium feature",
"github_large_files_error": "Merge failed: your GitHub repository contains files over the 50mb file size limit ",
"github_merge_failed": "Your changes in __appName__ and GitHub could not be automatically merged. Please manually merge the <0>__sharelatex_branch__</0> branch into the default branch in git. Click below to continue, after you have manually merged.",
"github_merge_manually": "Please manually merge the <0>__sharelatex_branch__</0> branch into the default branch in git. Click below to continue, after you have manually merged.",
"github_merge_failed": "Your changes in __appName__ and GitHub could not be automatically merged.",
"github_no_master_branch_error": "This repository cannot be imported as it is missing a default branch. Please make sure the project has a default branch",
"github_private_description": "You choose who can see and commit to this repository.",
"github_public_description": "Anyone can see this repository. You choose who can commit.",
"github_repository_diverged": "The default branch of the linked repository has been force-pushed. Pulling GitHub changes after a force push can cause Overleaf and GitHub to get out of sync. You might need to push changes after pulling to get back in sync.",
"github_repository_diverged": "The default branch of the linked repository has been force-pushed. The list of recent commits below may be incomplete due to rewritten history since the last sync.",
"github_successfully_linked_description": "Thanks, we’ve successfully linked your GitHub account to __appName__. You can now export your __appName__ projects to GitHub, or import projects from your GitHub repositories.",
"github_symlink_error": "Your GitHub repository contains symbolic link files, which are not currently supported by Overleaf. Please remove these and try again.",
"github_sync": "GitHub Sync",
@@ -965,6 +968,9 @@
"github_timeout_error": "Syncing your Overleaf project with GitHub has timed out. This may be due to the overall size of your project, or the number of files/changes to sync, being too large.",
"github_too_many_files_error": "This repository cannot be imported as it exceeds the maximum number of files allowed",
"github_validation_check": "Please check that the repository name is valid, and that you have permission to create the repository.",
"github_validation_check_auth": "Please check that you have permission to create the repository.",
"github_validation_name_exists": "Repository with this name already exists.",
"github_validation_name_invalid": "Repository name is invalid.",
"github_workflow_authorize": "Authorize GitHub Workflow files",
"github_workflow_files_delete_github_repo": "The repository has been created on GitHub but linking was unsuccessful. You will have to delete GitHub repository or choose a new name.",
"github_workflow_files_error": "The __appName__ GitHub sync service couldn’t sync GitHub Workflow files (in .github/workflows/). Please authorize __appName__ to edit your GitHub workflow files and try again.",
@@ -1730,6 +1736,7 @@
"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",
@@ -1931,7 +1938,10 @@
"reference_search_settings": "Reference search settings",
"reference_search_style": "Reference search style",
"reference_sync": "Reference manager sync",
"reference_search_results": "Reference search results",
"references_from_these_libraries_will_be_included_in_your_reference_search_results": "References from these libraries will be included in your reference search results.",
"references_picker_empty_hint": "Start typing to search references",
"references_picker_title": "Select references",
"refresh": "Refresh",
"refresh_page_after_linking_dropbox": "Please refresh this page after linking your account to Dropbox.",
"refresh_page_after_starting_free_trial": "Please refresh this page after starting your free trial.",
@@ -2690,7 +2700,7 @@
"unlink_provider_account_title": "Unlink __provider__ Account",
"unlink_provider_account_warning": "Warning: When you unlink your account from __provider__ you will not be able to sign in using __provider__ anymore.",
"unlink_reference": "Unlink References Provider",
"unlink_the_project_from_the_current_github_repo": "Unlink the project from the current GitHub repository and create a connection to a repository you own. (You need an active __appName__ subscription to set up a GitHub Sync).",
"unlink_the_project_from_the_current_github_repo": "Unlink the project from the current GitHub repository and create a connection to a repository you own.",
"unlink_user_explanation": "You’re about to remove the SSO login option for <0>__email__</0>. This will force them to reauthenticate their Overleaf account with your IdP. They’ll receive an email asking them to do this.",
"unlink_users": "Unlink users",
"unlink_warning_reference": "Warning: When you unlink your account from this provider you will not be able to import references into your projects.",
@@ -3006,9 +3016,12 @@
"zoom_to": "Zoom to",
"zotero": "Zotero",
"zotero_and_mendeley_integrations": "<0>Zotero</0> and <0>Mendeley</0> 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",
@@ -3017,4 +3030,4 @@
"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"
}
}
+46 -3
View File
@@ -55,6 +55,7 @@
"activation_link": "Ссылка для активации",
"activation_token_expired": "Срок действия Вашего ключа истёк. Вам необходимо запросить новый ключ активации.",
"add": "Добавить",
"add_another_token": "Добавить ещё один токен",
"add_your_first_group_member_now": "Добавьте первых участников группы сейчас",
"added": "добавлены",
"adding": "Добавление",
@@ -68,6 +69,9 @@
"annual": "Цена за год",
"anonymous": "Аноним",
"april": "Апрель",
"ask_proj_owner_to_add_you_as_github_collaborator": "У вас нет прав на запись в подключенный репозиторий (<0>__repoFullName__</0>). Попросите владельца проекта (<1>__projectOwnerEmail__</1>) добавить вас в качестве коллаборатора в этот Git-репозиторий.",
"ask_proj_owner_to_export_to_github": "Попросите владельца проекта (<0>__projectOwnerEmail__</0>) экспортировать проект в Git-репозиторий и добавить вас в качестве коллаборатора.",
"ask_proj_owner_to_unlink_from_current_github": "Попросите владельца проекта (<0>projectOwnerEmail</0>) отключить проект от текущего Git-репозитория и создать подключение к другому репозиторию.",
"august": "Август",
"auto_complete": "Автодополнение",
"autocomplete": "Автозавершение",
@@ -87,6 +91,7 @@
"cancel_your_subscription": "Остановить подписку",
"cant_find_email": "Извините, данный адрес не зарегистрирован.",
"cant_find_page": "К сожалению, страница не найдена",
"cant_see_what_youre_looking_for_question": "Не можете найти то, что ищете?",
"category": "Категория",
"change": "Изменить",
"change_password": "Изменение пароля",
@@ -103,6 +108,7 @@
"clear_sessions_success": "Сессии завершены",
"clearing": "Очистка",
"click_here_to_view_sl_in_lng": "Кликните здесь, для использования __appName__ на <0>__lngName__</0>",
"clone_with_git": "Клонировать при помощи git",
"close": "Закрыть",
"clsi_maintenance": "На сервере компиляции проводятся ремонтные работы, и он будет вскоре доступен снова.",
"cn": "Китайский (упрощённый)",
@@ -134,6 +140,7 @@
"create_account": "Создать аккаунт",
"create_new_subscription": "Создать новую подписку",
"create_project_in_github": "Создать проект на GitHub",
"created_at": "Создано",
"creating": "Создание",
"credit_card": "банковская карта",
"cs": "Чешский",
@@ -174,6 +181,7 @@
"es": "Испанский",
"every": "каждый",
"example_project": "Использовать пример",
"expires": "Истекает",
"expiry": "Срок действия",
"export_project_to_github": "Экспорт проекта на GitHub",
"failed_to_publish_as_a_template": "Не удалось создать шаблон.",
@@ -193,12 +201,20 @@
"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. Нажмите ниже, чтобы продолжить после ручного объединения.",
"github_merge_manually": "Пожалуйста, вручную объедините ветку <0>__sharelatex_branch__</0> с веткой по умолчанию в git. Нажмите ниже, чтобы продолжить после ручного объединения.",
"github_merge_failed": "Ваши изменения в __appName__ и GitHub не были автоматически объединены.",
"github_public_description": "Этот репозиторий может просмотреть каждый. Вы выбираете, кто может править.",
"github_repository_diverged": "Ветка по умолчанию подключённого репозитория была принудительно обновлена. Список последних коммитов ниже может быть неполным из-за переписанной истории с момента последней синхронизации.",
"github_successfully_linked_description": "Спасибо, мы успешно связали ваш аккаунт на GitHub с __appName__. Теперь вы можете экспортировать проекты с __appName__ в GitHub или импортировать в обратном направлении.",
"github_sync": "Синхронизация с GitHub",
"github_sync_description": "Вы можете связать ваши проекты __appName__ с репозиториями на GitHub. Создавайте коммиты в __appName__ и объединяйте их с коммитами, сделанными оффлайн или на GitHub.",
@@ -219,6 +235,7 @@
"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": "Неактивные проекты",
@@ -242,6 +259,7 @@
"language": "Язык",
"last_modified": "Последнее изменение",
"last_name": "Фамилия",
"last_used": "Последнее использование",
"latex_templates": "Шаблоны",
"learn_more": "Узнать больше",
"leave_group": "Покинуть группу",
@@ -320,6 +338,7 @@
"open_project": "Открыть проект",
"optional": "Необязательный",
"or": "или",
"organization": "Организация",
"other_logs_and_files": "Другие логи и файлы",
"over": "свыше",
"owned_projects": "Собственные проекты",
@@ -332,6 +351,7 @@
"password_reset_token_expired": "Ваш код восстановления пароля истёк. Пожалуйста, запросите восстановление пароля по почте ещё раз и перейдите по ссылке в письме.",
"pdf_viewer": "Просмотрщик PDF",
"pending": "В ожидании",
"per_page": "на страницу",
"permanently_delete_accounts": "Окончательно удалить аккаунты",
"permanently_delete_projects": "Окончательно удалить проекты",
"personal": "Личный",
@@ -358,6 +378,7 @@
"professional": "Профессионал",
"project_categories_tags": "Категории и теги проектов",
"project_last_published_at": "В последний раз проект был опубликован",
"project_linked_to": "Этот проект связан с репозиторием",
"project_name": "Название проекта",
"project_not_linked_to_github": "Этот проект не связан ни с одним проектом на GitHub. Вы можете создать для него проект на GitHub:",
"project_too_large": "Проект слишком большой",
@@ -418,6 +439,7 @@
"search_bib_files": "Поиск по автору, названию, году",
"search_projects": "Поиск по проектам",
"search_references": "Поиск .bib файлов в проекте",
"search_replace": "Заменить",
"security": "Безопасность",
"select_a_new_owner_for_projects": "Выберите нового владельца для проектов этого пользователя",
"select_all_projects": "Выбрать все проекты",
@@ -452,7 +474,8 @@
"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": "Попробовать бесплатно!",
@@ -500,6 +523,8 @@
"title": "Название",
"to_many_login_requests_2_mins": "Было предпринято слишком много попыток входа. Пожалуйста, подождите 2 минуты, прежде чем пробовать снова",
"to_modify_your_subscription_go_to": "Для изменения подписки перейдите по ссылке:",
"token": "Токен",
"token_limit_reached": "Вы достигли лимита в 10 токенов. Чтобы создать новый токен аутентификации, удалите один из имеющихся.",
"too_many_files_uploaded_throttled_short_period": "Слишком много файлов загружено за раз - на короткое время загрузка была приостановлена.",
"too_recently_compiled": "Этот проект был скомпилирован совсем недавно, поэтому компиляция была пропущена.",
"total_words": "Количество слов",
@@ -517,6 +542,8 @@
"unlimited_collabs": "Неограниченно число соавторов",
"unlimited_projects": "Неограниченное число проектов",
"unlink": "Отсоединить",
"unlink": "Отсоединить",
"unlink_github_repository": "Отсоединить Git-репозиторий",
"unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?",
"unpublish": "Отменить публикацию",
"unpublishing": "Отмена публикации",
@@ -557,11 +584,27 @@
"year": "год",
"you": "Вы",
"you_cant_overwrite_it": "Перезаписать нельзя.",
"you_dont_have_any_repositories": "У вас нет ни одного репозитория",
"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__."
}
@@ -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),
}
@@ -1,6 +1,7 @@
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'
@@ -77,5 +78,10 @@ export default {
AuthorizationMiddleware.ensureUserIsSiteAdmin,
ProjectListController.undeleteProject
)
webRouter.get('/admin/active-projects',
AuthorizationMiddleware.ensureUserIsSiteAdmin,
AdminToolsController.activeProjects,
)
},
}
@@ -4,21 +4,32 @@ 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 PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.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 { OError } from '../../../../app/src/Features/Errors/Errors.js'
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 ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.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
@@ -30,8 +41,24 @@ async function manageProjectsPage(req, res, next) {
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,
})
}
@@ -16,12 +16,14 @@ import OneTimeTokenHandler from '../../../../app/src/Features/Security/OneTimeTo
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))
@@ -71,8 +73,6 @@ function cleanupSession(req) {
async function manageUsersPage(req, res, next) {
cleanupSession(req)
const userId = SessionManager.getLoggedInUserId(req.session)
const usersBlobPending = _getUsers().catch(err => {
logger.err({ err }, 'users listing in background failed')
return undefined
@@ -84,8 +84,24 @@ async function manageUsersPage(req, res, next) {
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,
@@ -290,8 +306,8 @@ function _sortAndPaginate(users, sort, page) {
function _formatUserInfo(user, maxDate) {
let authMethods = []
if (availableAuthMethods.includes('local') && user.hashedPassword) authMethods.push('local')
if (availableAuthMethods.includes('saml') && user.samlIdentifiers.length > 0) authMethods.push('saml')
if (availableAuthMethods.includes('oidc') && user.thirdPartyIdentifiers.length > 0) authMethods.push('oidc')
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')
@@ -323,8 +339,8 @@ function _matchesFilters(user, filters) {
if (
filters.search?.length &&
user.email.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 &&
user.first_name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 &&
user.last_name.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)
@@ -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)
@@ -15,6 +15,8 @@ block append meta
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'
@@ -16,6 +16,8 @@ block append meta
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'
@@ -13,15 +13,15 @@ function ManageProjectsRoot() {
if (!isReady) return null
return (
<SplitTestProvider>
<UserSettingsProvider>
<UserListProvider>
<ProjectListProvider projectsOwnerId={null}>
<UserListProvider>
<ProjectListProvider projectsOwnerId={null}>
<SplitTestProvider>
<UserSettingsProvider>
<ProjectListRoot />
</ProjectListProvider>
</UserListProvider>
</UserSettingsProvider>
</SplitTestProvider>
</UserSettingsProvider>
</SplitTestProvider>
</ProjectListProvider>
</UserListProvider>
)
}
@@ -28,15 +28,15 @@ function ManageUsersRoot() {
if (!isReady) return null
return (
<SplitTestProvider>
<UserSettingsProvider>
<UsersPageProvider>
<UserListProvider>
<UsersPageProvider>
<UserListProvider>
<SplitTestProvider>
<UserSettingsProvider>
<UsersPageSelector />
</UserListProvider>
</UsersPageProvider>
</UserSettingsProvider>
</SplitTestProvider>
</UserSettingsProvider>
</SplitTestProvider>
</UserListProvider>
</UsersPageProvider>
)
}
@@ -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 = '<p class="text-muted">Loading...</p>'
}
fetch('/admin/active-projects')
.then(res => res.json())
.then(data => {
container.innerHTML = renderTable(data)
isLoaded = true
})
.catch(() => {
container.innerHTML =
'<p class="text-danger">Failed to load active projects</p>'
})
.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 `
<div class="alert alert-success">
<span style="color: green;">✔</span>
<strong> Great news!</strong>
No projects are currently being edited.
</div>
`
}
const rows = data.map(project => {
const owner = project.owner || {}
const users = project.activeUsers || []
const usersHtml = users.length
? `<ul class="list-unstyled mb-0">${users.map(u =>
`<li><strong>${u.name}</strong>${u.email ? ` (${u.email})` : ''}</li>`
).join('')}</ul>`
: '<em>None detected</em>'
return `
<tr>
<td><a href="/project/${project.id}" target="_blank">${project.name}</a></td>
<td>${owner.name || 'Unknown'}</td>
<td>${
owner.email
? `<a href="mailto:${owner.email}">${owner.email}</a>`
: 'N/A'
}</td>
<td>${usersHtml}</td>
<td>${project.connectionCount}</td>
</tr>
`
}).join('')
return `
<p class="small">
<strong>${data.length}</strong> project(s) currently being edited
</p>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Project Name</th>
<th>Owner</th>
<th>Owner Email</th>
<th>Active Users</th>
<th>Connections</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`
}
@@ -1,56 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../context/project-list-context'
import OLButton from '@/shared/components/ol/ol-button'
export default function LoadMore() {
const {
visibleProjects,
hiddenProjectsCount,
loadMoreCount,
showAllProjects,
loadMoreProjects,
} = useProjectListContext()
const { t } = useTranslation()
return (
<div className="text-center">
{hiddenProjectsCount > 0 ? (
<>
<OLButton
variant="secondary"
className="project-list-load-more-button"
onClick={() => loadMoreProjects()}
>
{t('show_x_more_projects', { x: loadMoreCount })}
</OLButton>
</>
) : null}
<p>
{hiddenProjectsCount > 0 ? (
<>
<span aria-live="polite">
{t('showing_x_out_of_n_projects', {
x: visibleProjects.length,
n: visibleProjects.length + hiddenProjectsCount,
})}
</span>{' '}
<OLButton
variant="link"
onClick={() => showAllProjects()}
className="btn-inline-link"
>
{t('show_all_projects')}
</OLButton>
</>
) : (
<span aria-live="polite">
{t('showing_x_out_of_n_projects', {
x: visibleProjects.length,
n: visibleProjects.length,
})}
</span>
)}
</p>
</div>
)
}
@@ -13,7 +13,6 @@ 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'
import sortUsers from '../../../user-list/util/sort-users'
type TransferProjectModalProps = Pick<
React.ComponentProps<typeof ProjectsActionModal>,
@@ -35,9 +34,8 @@ function TransferProjectModal({
const potentialOwners = useMemo(() => {
if (!loadedUsers) return null;
const sortedUsers = sortUsers(loadedUsers, { by: 'name', order: 'asc' })
const result: UserRef[] = []
for (const user of sortedUsers) {
for (const user of loadedUsers) {
if (!user.deleted && user.id !== projectsOwnerId) {
result.push({
id: user.id,
@@ -15,9 +15,13 @@ 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() {
@@ -32,8 +36,12 @@ export function ProjectListDsNav() {
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 = (
@@ -51,11 +59,15 @@ export function ProjectListDsNav() {
return (
<div className="project-ds-nav-page website-redesign">
<DefaultNavbar
{...navbarProps}
overleafLogo={overleafLogo}
showCloseIcon
/>
<div className="manage-projects-page">
<DefaultNavbar
{...navbarProps}
overleafLogo={
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
}
showCloseIcon
/>
</div>
<div className="project-list-wrapper">
<SidebarDsNav />
<div className="project-ds-nav-content-and-messages">
@@ -100,7 +112,10 @@ export function ProjectListDsNav() {
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
<ProjectListSummary />
</div>
<div className="mt-3">
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />
</div>
</div>
</main>
@@ -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 (
<div className="text-center">
<span aria-live="polite">
{t('showing_x_out_of_n_projects', {
x: visibleProjects.length,
n: visibleProjects.length + hiddenProjectsCount,
})}
</span>
<span className="mx-2">·</span>
<span className="d-inline-flex gap-1">
<Dropdown>
<DropdownToggle
as="span"
className="entries-per-page-toggle"
>
{projectsPerPage}
</DropdownToggle>
<DropdownMenu>
{OPTIONS.map((value) => (
<DropdownItem
key={value}
active={value === projectsPerPage}
onClick={() => setProjectsPerPage(value)}
>
{value}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<span>{t('per_page')}</span>
</span>
</div>
)
}
@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '@/infrastructure/event-tracking'
import classnames from 'classnames'
import { MergeAndOverride } from '../../../../../../types/utils'
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 OLCol from '@/shared/components/ol/ol-col'
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
@@ -26,20 +27,39 @@ function SearchForm({
...props
}: SearchFormProps) {
const { t } = useTranslation()
const placeholderMessage = t('search_projects')
const placeholder = `${placeholderMessage}…`
const placeholder = t('search_projects')+'…'
const [localValue, setLocalValue] = useState(inputValue)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setLocalValue(inputValue)
}, [inputValue])
const handleChange: React.ComponentProps<
typeof OLFormControl
>['onChange'] = e => {
eventTracking.sendMB('admin-user-project-list-page-interaction', {
eventTracking.sendMB('admin-project-list-page-interaction', {
action: 'search',
isSmallDevice,
})
setInputValue(e.target.value)
const value = e.target.value
setLocalValue(value)
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
setInputValue(value)
}, 300)
}
const handleClear = () => setInputValue('')
const handleClear = () => {
setLocalValue('')
setInputValue('')
}
return (
<OLForm
@@ -51,14 +71,15 @@ function SearchForm({
<OLFormGroup>
<OLCol>
<OLFormControl
name="search"
type="text"
value={inputValue}
value={localValue}
onChange={handleChange}
placeholder={placeholder}
aria-label={placeholder}
prepend={<MaterialIcon type="search" />}
append={
inputValue.length > 0 && (
localValue.length > 0 && (
<button
type="button"
className="form-control-search-clear-btn"
@@ -27,7 +27,7 @@ function SidebarDsNav() {
const { sessionUser } = getMeta('ol-navbar')
const { containerRef, scrolledUp } = useScrolled()
const themedDsNav = useFeatureFlag('themed-project-dashboard')
const { getUserNameById } = useUserIdentityContext()
const { projectsOwnerId } = useProjectListContext()
@@ -47,12 +47,14 @@ function DeleteProjectButton({ project, children }: DeleteProjectButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<DeleteProjectModal
projects={[project]}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteProjectModal
projects={[project]}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -42,12 +42,14 @@ function PurgeProjectButton({ project, children }: PurgeProjectButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<PurgeProjectModal
projects={[project]}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeProjectModal
projects={[project]}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -51,12 +51,14 @@ function RestoreProjectButton({
return (
<>
{children(text, handleOpenModal)}
<RestoreProjectModal
projects={[project]}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreProjectModal
projects={[project]}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -54,12 +54,14 @@ function TransferProjectButton({ project, children }: TransferProjectButtonProps
return (
<>
{children(text, handleOpenModal)}
<TransferProjectModal
projects={[project]}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TransferProjectModal
projects={[project]}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -47,12 +47,14 @@ function TrashProjectButton({ project, children }: TrashProjectButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<TrashProjectModal
projects={[project]}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TrashProjectModal
projects={[project]}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -18,6 +18,7 @@ export const ProjectCheckbox = memo<{ projectId: string; projectName: string }>(
return (
<OLFormCheckbox
id={`select_project_${projectId}`}
autoComplete="off"
onChange={handleCheckboxChange}
checked={selectedProjectIds.has(projectId)}
@@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect } from 'react'
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'
@@ -28,9 +28,12 @@ function ProjectListTable() {
const {
visibleProjects,
sort,
searchText,
selectedProjects,
selectOrUnselectAllProjects,
filter,
currentPage,
setCurrentPage,
} = useProjectListContext()
const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>(null)
@@ -49,6 +52,10 @@ function ProjectListTable() {
selectedProjects.length !== visibleProjects.length
}
}, [selectedProjects, visibleProjects])
const [lastNonSearchPage, setLastNonSearchPage] = useState(1)
const [isSearching, setIsSearching] = useState(false)
return (
<OLTable className="project-dash-table" container={false} hover>
<caption className="visually-hidden">{t('projects_list')}</caption>
@@ -59,6 +66,7 @@ function ProjectListTable() {
aria-label={t('select_projects')}
>
<OLFormCheckbox
name="select_all_projects"
autoComplete="off"
onChange={handleAllProjectsCheckboxChange}
checked={
@@ -43,12 +43,14 @@ function DeleteProjectsButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</OLButton>
<DeleteProjectModal
projects={selectedProjects}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteProjectModal
projects={selectedProjects}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -36,12 +36,14 @@ function PurgeProjectsButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('purge')}
</OLButton>
<PurgeProjectModal
projects={selectedProjects}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeProjectModal
projects={selectedProjects}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -47,12 +47,14 @@ function RestoreProjectsButton() {
<OLButton variant="primary" onClick={handleOpenModal}>
{t('restore')}
</OLButton>
<RestoreProjectModal
projects={selectedProjects}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreProjectModal
projects={selectedProjects}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -60,12 +60,14 @@ function TransferProjectsButton() {
icon="swap_horiz"
/>
</OLTooltip>
<TransferProjectModal
projects={selectedProjects}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TransferProjectModal
projects={selectedProjects}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -52,12 +52,14 @@ function TrashProjectsButton() {
icon="delete"
/>
</OLTooltip>
<TrashProjectModal
projects={selectedProjects}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TrashProjectModal
projects={selectedProjects}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -21,8 +21,6 @@ import { getProjects } from '../util/api'
import { useUserIdentityContext } from '../../user-list/context/user-identity-context'
import sortProjects from '../util/sort-projects'
const MAX_PROJECT_PER_PAGE = 20
export type Filter = 'owned' | 'trashed' | 'deleted' | 'inactive'
type FilterMap = {
@@ -54,8 +52,6 @@ export type ProjectListContextValue = {
filter: Filter
hiddenProjectsCount: number
isLoading: ReturnType<typeof useAsync>['isLoading']
loadMoreCount: number
loadMoreProjects: () => void
loadProgress: number
removeProjectFromView: (project: Project) => void
selectFilter: (filter: Filter) => void
@@ -66,13 +62,17 @@ export type ProjectListContextValue = {
setSearchText: React.Dispatch<React.SetStateAction<string>>
setSelectedProjectIds: React.Dispatch<React.SetStateAction<Set<string>>>
setSort: React.Dispatch<React.SetStateAction<Sort>>
showAllProjects: () => void
sort: Sort
toggleSelectedProject: (projectId: string, selected?: boolean) => void
totalProjectsCount: number
projectsOwnerId: string | null
updateProjectViewData: (newProjectData: Project) => void
visibleProjects: Project[]
currentPage: number
setCurrentPage: React.Dispatch<React.SetStateAction<number>>
totalPages: number
projectsPerPage: number,
setProjectsPerPage: React.Dispatch<React.SetStateAction<number>>,
}
export const ProjectListContext = createContext<
@@ -92,9 +92,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
prefetchedProjectsBlob?.projects ?? []
)
const [maxVisibleProjects, setMaxVisibleProjects] =
useState(MAX_PROJECT_PER_PAGE)
const [loadProgress, setLoadProgress] = useState(
prefetchedProjectsBlob ? 100 : 20
)
@@ -113,7 +110,32 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
})
const prevSortRef = useRef<Sort>(sort)
const [searchText, setSearchText] = useState('')
const [searchText, setSearchTextState] = useState('')
const lastNonSearchPageRef = useRef(1)
const isSearchingRef = useRef(false)
const [currentPage, setCurrentPage] = useState(1)
const setSearchText: React.Dispatch<React.SetStateAction<string>> = 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,
@@ -131,7 +153,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
if (prefetchedProjectsBlob) return
setLoadProgress(40)
runAsync(getProjects({ userId: projectsOwnerId, by: 'lastUpdated', order: 'desc' }))
.then(data => {
setLoadedProjects(data.projects)
@@ -144,7 +165,12 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
}, [projectsOwnerId, runAsync, prefetchedProjectsBlob])
const sortedProjects = useMemo(() => {
if (prevSortRef.current === sort) return loadedProjects
if (
prevSortRef.current.by === sort.by &&
prevSortRef.current.order === sort.order
) {
return loadedProjects
}
const sorted = sortProjects(loadedProjects, sort, getUserById)
prevSortRef.current = sort
@@ -152,40 +178,49 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
}, [loadedProjects, sort, getUserById])
const filteredProjects = useMemo(() => {
let result = sortedProjects
const predicate = filters[filter]
const hasSearch = searchText.length > 0
const lower = hasSearch ? searchText.toLowerCase() : null
if (searchText.length) {
const lower = searchText.toLowerCase()
result = result.filter(project =>
project.name.toLowerCase().includes(lower)
)
}
return sortedProjects.filter(project => {
if (hasSearch) {
if (!project.name.toLowerCase().includes(lower!)) {
return false
}
}
return result.filter(filters[filter])
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(0, maxVisibleProjects)
}, [filteredProjects, maxVisibleProjects])
return filteredProjects.slice(startIndex, startIndex + projectsPerPage)
}, [filteredProjects, startIndex, projectsPerPage])
const hiddenProjectsCount = Math.max(
filteredProjects.length - visibleProjects.length,
0
)
const loadMoreCount = Math.min(
hiddenProjectsCount,
MAX_PROJECT_PER_PAGE
)
const showAllProjects = useCallback(() => {
setMaxVisibleProjects(v => v + hiddenProjectsCount)
}, [hiddenProjectsCount])
const loadMoreProjects = useCallback(() => {
setMaxVisibleProjects(v => v + loadMoreCount)
}, [loadMoreCount])
const [selectedProjectIds, setSelectedProjectIds] = useState(
() => new Set<string>()
)
@@ -233,6 +268,8 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
const selectFilter = useCallback(
(filter: Filter) => {
setFilter(filter)
selectOrUnselectAllProjects(false)
setCurrentPage(1)
setSort(prev => {
if (filter === 'deleted' && prev.by === 'lastUpdated') {
@@ -243,8 +280,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
}
return prev
})
selectOrUnselectAllProjects(false)
},
[selectOrUnselectAllProjects]
)
@@ -269,8 +304,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
filter,
hiddenProjectsCount,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromView,
selectFilter,
@@ -281,21 +314,23 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
setSearchText,
setSelectedProjectIds,
setSort,
showAllProjects,
sort,
toggleSelectedProject,
totalProjectsCount,
updateProjectViewData,
projectsOwnerId,
visibleProjects,
currentPage,
setCurrentPage,
totalPages,
projectsPerPage,
setProjectsPerPage
}),
[
error,
filter,
hiddenProjectsCount,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromView,
selectFilter,
@@ -306,13 +341,17 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
setSearchText,
setSelectedProjectIds,
setSort,
showAllProjects,
sort,
toggleSelectedProject,
totalProjectsCount,
projectsOwnerId,
updateProjectViewData,
visibleProjects,
currentPage,
setCurrentPage,
totalPages,
projectsPerPage,
setProjectsPerPage
]
)
@@ -7,9 +7,10 @@ const toggleSort = (order: SortingOrder): SortingOrder =>
order === 'asc' ? 'desc' : 'asc'
function useSort() {
const { filter, sort, setSort } = useProjectListContext()
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,
@@ -18,13 +19,15 @@ function useSort() {
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])
}, [filter, sort.by, setSort, setCurrentPage])
return { handleSort }
}
@@ -1,56 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useUserListContext } from '../context/user-list-context'
import OLButton from '@/shared/components/ol/ol-button'
export default function LoadMore() {
const {
visibleUsers,
hiddenUsersCount,
loadMoreCount,
showAllUsers,
loadMoreUsers,
} = useUserListContext()
const { t } = useTranslation()
return (
<div className="text-center">
{hiddenUsersCount > 0 ? (
<>
<OLButton
variant="secondary"
className="user-list-load-more-button"
onClick={() => loadMoreUsers()}
>
{t('show_x_more_users', { x: loadMoreCount })}
</OLButton>
</>
) : null}
<p>
{hiddenUsersCount > 0 ? (
<>
<span aria-live="polite">
{t('showing_x_out_of_n_users', {
x: visibleUsers.length,
n: visibleUsers.length + hiddenUsersCount,
})}
</span>{' '}
<OLButton
variant="link"
onClick={() => showAllUsers()}
className="btn-inline-link"
>
{t('show_all_users')}
</OLButton>
</>
) : (
<span aria-live="polite">
{t('showing_x_out_of_n_users', {
x: visibleUsers.length,
n: visibleUsers.length,
})}
</span>
)}
</p>
</div>
)
}
@@ -9,7 +9,6 @@ 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'
import sortUsers from '../../../user-list/util/sort-users'
type DeleteUserModalProps = Pick<
React.ComponentProps<typeof UsersActionModal>,
@@ -34,12 +33,10 @@ function DeleteUserModal({
const potentialOwners = useMemo(() => {
if (!loadedUsers) return []
const excludeIds = new Set(users.map(u => u.id))
const possibleUsers = loadedUsers.filter(
return loadedUsers.filter(
user => !user.deleted && !excludeIds.has(user.id)
)
return sortUsers(possibleUsers, { by: 'name', order: 'asc' })
}, [loadedUsers, users])
useEffect(() => {
@@ -42,7 +42,8 @@ function ShowUserInfoModal({
const user = users[0]
const [activationLink, setActivationLink] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [copiedId, setCopiedId] = useState(false)
const [copiedActivationLink, setCopiedActivationLink] = useState(false)
useEffect(() => {
if (!showModal) return
@@ -56,22 +57,22 @@ function ShowUserInfoModal({
})
}, [showModal, user.id])
const handleCopy = () => {
if (!activationLink) return
const markCopied = (setter: React.Dispatch<React.SetStateAction<boolean>>) => {
setter(true)
setTimeout(() => setter(false), 1500)
}
const markCopied = () => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
const handleCopy = (text: string, setter: React.Dispatch<React.SetStateAction<boolean>>) => {
if (!text) return
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(activationLink).then(markCopied)
navigator.clipboard.writeText(text).then(() => markCopied(setter))
return
}
// fallback for older browsers
const tempInput = document.createElement('input')
tempInput.value = activationLink
tempInput.value = text
tempInput.style.position = 'fixed'
tempInput.style.opacity = '0'
document.body.appendChild(tempInput)
@@ -79,7 +80,7 @@ function ShowUserInfoModal({
document.execCommand('copy')
document.body.removeChild(tempInput)
markCopied()
markCopied(setter)
}
return (
@@ -95,6 +96,22 @@ function ShowUserInfoModal({
<>
<Card.Header>{t('Account')}</Card.Header>
<Body>
<InfoRow
label={'ID'}
value={
<span
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleCopy(user.id, setCopiedId)}
>
{user.id}
{copiedId && (
<span className="ms-2 text-success">
({t('copied')})
</span>
)}
</span>
}
/>
<InfoRow label={t('email_address')} value={user.email} />
<InfoRow label={t('first_name')} value={user.firstName || '—'} />
<InfoRow label={t('last_name')} value={user.lastName || '—'} />
@@ -110,15 +127,15 @@ function ShowUserInfoModal({
/>
)}
{activationLink && (
<InfoRow
<InfoRow
label={t('activation_link')}
value={
<span
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={handleCopy}
onClick={() => handleCopy(activationLink, setCopiedActivationLink)}
>
{activationLink}
{copied && (
{copiedActivationLink && (
<span className="ms-2 text-success">
({t('copied')})
</span>
@@ -170,4 +187,3 @@ function ShowUserInfoModal({
}
export default ShowUserInfoModal
@@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import * as eventTracking from '@/infrastructure/event-tracking'
@@ -8,12 +9,10 @@ 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'
import { Filter } from '../context/user-list-context'
type SearchFormOwnProps = {
inputValue: string
setInputValue: (input: string) => void
filter: Filter
}
type SearchFormProps = MergeAndOverride<
@@ -24,13 +23,19 @@ type SearchFormProps = MergeAndOverride<
function SearchForm({
inputValue,
setInputValue,
filter,
className,
...props
}: SearchFormProps) {
const { t } = useTranslation()
const placeholder = t('search')+'…'
const [localValue, setLocalValue] = useState(inputValue)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setLocalValue(inputValue)
}, [inputValue])
const handleChange: React.ComponentProps<
typeof OLFormControl
>['onChange'] = e => {
@@ -38,10 +43,23 @@ function SearchForm({
action: 'search',
isSmallDevice,
})
setInputValue(e.target.value)
const value = e.target.value
setLocalValue(value)
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
setInputValue(value)
}, 300)
}
const handleClear = () => setInputValue('')
const handleClear = () => {
setLocalValue('')
setInputValue('')
}
return (
<OLForm
@@ -55,13 +73,13 @@ function SearchForm({
<OLFormControl
name="search"
type="text"
value={inputValue}
value={localValue}
onChange={handleChange}
placeholder={placeholder}
aria-label={placeholder}
prepend={<MaterialIcon type="search" />}
append={
inputValue.length > 0 && (
localValue.length > 0 && (
<button
type="button"
className="form-control-search-clear-btn"
@@ -40,12 +40,14 @@ function DeleteUserButton({ user, children }: DeleteUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<DeleteUserModal
users={[user]}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteUserModal
users={[user]}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -43,13 +43,15 @@ function FlagUserButton({ user, action, children }: FlagUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<FlagUserModal
users={[user]}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<FlagUserModal
users={[user]}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -40,12 +40,14 @@ function PurgeUserButton({ user, children }: PurgeUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<PurgeUserModal
users={[user]}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeUserModal
users={[user]}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -40,12 +40,14 @@ function RestoreUserButton({ user, children }: RestoreUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<RestoreUserModal
users={[user]}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreUserModal
users={[user]}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -42,12 +42,14 @@ function SendRegEmailButton({ user, children }: SendRegEmailButtonProps) {
return (
<span style={isHidden ? { visibility: 'hidden' } : undefined }>
{children(text, handleOpenModal)}
<SendRegEmailModal
users={[user]}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<SendRegEmailModal
users={[user]}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</span>
)
}
@@ -38,11 +38,13 @@ function ShowUserInfoButton({ user, children }: ShowUserInfoButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<ShowUserInfoModal
users={[user]}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<ShowUserInfoModal
users={[user]}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -40,12 +40,14 @@ function UpdateUserButton({ user, children }: UpdateUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<UpdateUserModal
users={[user]}
actionHandler={handleUpdateUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<UpdateUserModal
users={[user]}
actionHandler={handleUpdateUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -28,10 +28,13 @@ function UserListTable() {
const {
visibleUsers,
sort,
searchText,
selectedUsers,
selectOrUnselectAllUsers,
selfVisibleCount,
filter,
currentPage,
setCurrentPage,
} = useUserListContext()
const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>(null)
@@ -39,12 +39,14 @@ function DeleteUsersButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</OLButton>
<DeleteUserModal
users={selectedUsers}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteUserModal
users={selectedUsers}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -70,13 +70,15 @@ function FlagUsersButton({ action }: { action: string }) {
unfilled={unfilled}
/>
</OLTooltip>
<FlagUserModal
users={selectedUsers}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<FlagUserModal
users={selectedUsers}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -33,13 +33,14 @@ function PurgeUsersButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('purge')}
</OLButton>
<PurgeUserModal
users={selectedUsers}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeUserModal
users={selectedUsers}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -33,13 +33,14 @@ function RestoreUsersButton() {
<OLButton variant="primary" onClick={handleOpenModal}>
{t('restore')}
</OLButton>
<RestoreUserModal
users={selectedUsers}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreUserModal
users={selectedUsers}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -47,12 +47,14 @@ function SendRegEmailsButton({ action }: { action: string }) {
unfilled={true}
/>
</OLTooltip>
<SendRegEmailModal
users={selectedUsers}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<SendRegEmailModal
users={selectedUsers}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -7,7 +7,7 @@ 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 LoadMore from './load-more'
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'
@@ -17,7 +17,11 @@ 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')
@@ -30,7 +34,11 @@ export function UserListDsNav() {
setSearchText,
selectedUsers,
filter,
currentPage,
setCurrentPage,
totalPages,
} = useUserListContext()
const activeOverallTheme = useActiveOverallTheme('themed-project-dashboard')
const tableTopArea = (
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
@@ -40,7 +48,6 @@ export function UserListDsNav() {
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
className="overflow-hidden flex-grow-1"
/>
</div>
@@ -50,7 +57,9 @@ export function UserListDsNav() {
<div className="user-ds-nav-page website-redesign">
<DefaultNavbar
{...navbarProps}
overleafLogo={overleafLogo}
overleafLogo={
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
}
showCloseIcon
/>
<div className="user-list-wrapper">
@@ -77,7 +86,6 @@ export function UserListDsNav() {
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
/>
</OLCol>
</OLRow>
@@ -98,7 +106,10 @@ export function UserListDsNav() {
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
<UserListSummary />
</div>
<div className="mt-3">
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />
</div>
</div>
</main>
@@ -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 (
<div className="text-center">
<span aria-live="polite">
{t('showing_x_out_of_n_users', {
x: visibleUsers.length,
n: visibleUsers.length + hiddenUsersCount,
})}
</span>
<span className="mx-2">·</span>
<span className="d-inline-flex gap-1">
<Dropdown>
<DropdownToggle
as="span"
className="entries-per-page-toggle"
>
{usersPerPage}
</DropdownToggle>
<DropdownMenu>
{OPTIONS.map((value) => (
<DropdownItem
key={value}
active={value === usersPerPage}
onClick={() => setUsersPerPage(value)}
>
{value}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<span>{t('per_page')}</span>
</span>
</div>
)
}
@@ -6,6 +6,7 @@ import {
useEffect,
useMemo,
useState,
useRef,
} from 'react'
import {
filter as arrayFilter,
@@ -25,8 +26,6 @@ import sortUsers from '../util/sort-users'
import { UserIdentityProvider } from './user-identity-context'
const MAX_USER_PER_PAGE = 10
type AuthMethods = 'local' | 'ldap' | 'saml' | 'oidc'
export type Filter = 'all' | 'admin' | 'suspended' | 'inactive' | AuthMethods | 'deleted'
@@ -68,8 +67,6 @@ export type UserListContextValue = {
filterTranslations: Map<Filter, string>
hiddenUsersCount: number
isLoading: ReturnType<typeof useAsync>['isLoading']
loadMoreCount: number
loadMoreUsers: () => void
loadProgress: number
removeUserFromView: (user: User) => void
searchText: string
@@ -81,13 +78,17 @@ export type UserListContextValue = {
setSearchText: React.Dispatch<React.SetStateAction<string>>
setSelectedUserIds: React.Dispatch<React.SetStateAction<Set<string>>>
setSort: React.Dispatch<React.SetStateAction<Sort>>
showAllUsers: () => void
sort: Sort
toggleSelectedUser: (userId: string, selected?: boolean) => void
totalUsersCount: number
updateUserViewData: (newUserData: User) => void
loadedUsers: User[]
visibleUsers: User[]
currentPage: number
setCurrentPage: React.Dispatch<React.SetStateAction<number>>
totalPages: number
usersPerPage: number,
setUsersPerPage: React.Dispatch<React.SetStateAction<number>>,
}
export const UserListContext = createContext<
@@ -104,9 +105,6 @@ export function UserListProvider({ children }: UserListProviderProps) {
prefetchedUsersBlob?.users ?? []
)
const [maxVisibleUsers, setMaxVisibleUsers] =
useState(MAX_USER_PER_PAGE)
const [loadProgress, setLoadProgress] = useState(
prefetchedUsersBlob ? 100 : 20
)
@@ -130,7 +128,31 @@ export function UserListProvider({ children }: UserListProviderProps) {
'all'
)
const [searchText, setSearchText] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [searchText, setSearchTextState] = useState('')
const lastNonSearchPageRef = useRef(1)
const isSearchingRef = useRef(false)
const setSearchText: React.Dispatch<React.SetStateAction<string>> = 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,
@@ -143,10 +165,11 @@ export function UserListProvider({ children }: UserListProviderProps) {
})
const isLoading = isIdle ? true : loading
useEffect(() => {
if (prefetchedUsersBlob) return
setLoadProgress(40)
runAsync(getUsers({ by: 'signUpDate', order: 'desc' }))
runAsync(getUsers({ by: 'name', order: 'asc' }))
.then(data => {
setLoadedUsers(data.users)
setTotalUsersCount(data.totalSize)
@@ -161,7 +184,10 @@ export function UserListProvider({ children }: UserListProviderProps) {
setLoadedUsers(prev => [newUser as User, ...prev])
}, [])
const processedUsers = useMemo(() => {
const isDefaultSort =
sort.by === 'name' && sort.order === 'asc'
const filteredUsers = useMemo(() => {
let users = loadedUsers
if (searchText.length) {
@@ -175,35 +201,50 @@ export function UserListProvider({ children }: UserListProviderProps) {
users = arrayFilter(users, filters[filter])
return sortUsers(users, sort)
}, [loadedUsers, searchText, filter, sort])
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(0, maxVisibleUsers)
}, [processedUsers, maxVisibleUsers])
return processedUsers.slice(startIndex, startIndex + usersPerPage)
}, [processedUsers, startIndex, usersPerPage])
const hiddenUsersCount = Math.max(
processedUsers.length - visibleUsers.length,
0
)
const loadMoreCount = Math.min(
hiddenUsersCount,
MAX_USER_PER_PAGE
)
const selfVisibleCount = useMemo(() => {
return visibleUsers.some(u => u.id === selfId) ? 1 : 0
}, [visibleUsers])
const showAllUsers = useCallback(() => {
setMaxVisibleUsers(maxVisibleUsers + hiddenUsersCount)
}, [hiddenUsersCount, maxVisibleUsers])
const loadMoreUsers = useCallback(() => {
setMaxVisibleUsers(maxVisibleUsers + loadMoreCount)
}, [maxVisibleUsers, loadMoreCount])
const [selectedUserIds, setSelectedUserIds] = useState(
() => new Set<string>()
)
@@ -266,10 +307,10 @@ export function UserListProvider({ children }: UserListProviderProps) {
return prev
})
const selected = false
selectOrUnselectAllUsers(selected)
selectOrUnselectAllUsers(false)
setCurrentPage(1)
},
[selectOrUnselectAllUsers, setFilter]
[selectOrUnselectAllUsers]
)
const updateUserViewData = useCallback((newUserData: User) => {
@@ -294,8 +335,6 @@ export function UserListProvider({ children }: UserListProviderProps) {
filterTranslations,
hiddenUsersCount,
isLoading,
loadMoreCount,
loadMoreUsers,
loadProgress,
removeUserFromView,
searchText,
@@ -307,13 +346,17 @@ export function UserListProvider({ children }: UserListProviderProps) {
setSearchText,
setSelectedUserIds,
setSort,
showAllUsers,
sort,
toggleSelectedUser,
totalUsersCount,
updateUserViewData,
loadedUsers,
visibleUsers,
currentPage,
setCurrentPage,
totalPages,
usersPerPage,
setUsersPerPage,
}),
[
addUserToView,
@@ -322,8 +365,6 @@ export function UserListProvider({ children }: UserListProviderProps) {
filterTranslations,
hiddenUsersCount,
isLoading,
loadMoreCount,
loadMoreUsers,
loadProgress,
removeUserFromView,
searchText,
@@ -335,19 +376,21 @@ export function UserListProvider({ children }: UserListProviderProps) {
setSearchText,
setSelectedUserIds,
setSort,
showAllUsers,
sort,
toggleSelectedUser,
totalUsersCount,
updateUserViewData,
loadedUsers,
visibleUsers,
currentPage,
setCurrentPage,
totalPages,
usersPerPage,
setUsersPerPage,
]
)
if (!loadedUsers || loadedUsers.length === 0) {
return null
}
if (isLoading) return null
return (
<UserIdentityProvider users={loadedUsers}>
@@ -8,9 +8,10 @@ const toggleSort = (order: SortingOrder): SortingOrder => {
}
function useSort() {
const { filter, sort, setSort } = useUserListContext()
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,
@@ -19,13 +20,15 @@ function useSort() {
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])
}, [filter, sort.by, setSort, setCurrentPage])
return { handleSort }
}
@@ -1,5 +1,4 @@
import Settings from '@overleaf/settings'
import { callbackify } from '@overleaf/promise-utils'
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'
@@ -2,7 +2,7 @@ import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import { promisify } from 'util'
import passport from 'passport'
import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js'
import ldapjs from 'ldapjs'
import UserGetter from '../../../../../app/src/Features/User/UserGetter.mjs'
import { splitFullName } from '../../../utils.mjs'
@@ -30,7 +30,7 @@ const LDAPModuleManager = {
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 || '[]'),
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),
@@ -5,7 +5,6 @@ import AuthenticationController from '../../../../../app/src/Features/Authentica
import UserController from '../../../../../app/src/Features/User/UserController.mjs'
import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.mjs'
import OIDCAuthenticationManager from './OIDCAuthenticationManager.mjs'
import { acceptsJson } from '../../../../../app/src/infrastructure/RequestContentTypeDetection.mjs'
const OIDCAuthenticationController = {
passportLogin(req, res, next) {
@@ -142,17 +141,10 @@ const OIDCAuthenticationController = {
initiatorId: userId,
}
await ThirdPartyIdentityManager.promises.unlink(userId, providerId, auditLog)
return res.status(200).end()
return res.status(204).end()
} catch (error) {
logger.error(error.info, error.message)
return {
user: false,
info: {
type: 'error',
text: 'Can not unlink account',
status: 200,
}
}
logger.error('Unexpected error in uninkAccount')
return next({ stack: error.stack, info: {userId: req.user?._id} })
}
},
async passportLogout(req, res, next) {
@@ -1,7 +1,6 @@
import logger from '@overleaf/logger'
import passport from 'passport'
import Settings from '@overleaf/settings'
import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs'
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'
@@ -1,5 +1,4 @@
import logger from '@overleaf/logger'
import passport from 'passport'
import Settings from '@overleaf/settings'
import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs'
import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.mjs'

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