mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f32a52b253 | ||
|
|
26e7bcbf69 | ||
|
|
92ee773c32 | ||
|
|
89883b0efe | ||
|
|
53d437dca2 | ||
|
|
e1dd90fa8d | ||
|
|
120def39cc | ||
|
|
2b737db3c9 | ||
|
|
526699bc4e | ||
|
|
a223f4f290 | ||
|
|
1a264f7562 | ||
|
|
a2a455517e | ||
|
|
5692338d4b | ||
|
|
b46501f874 | ||
|
|
377732a832 | ||
|
|
362fc56949 | ||
|
|
9d00984b97 | ||
|
|
14d0300fc5 | ||
|
|
fdb7578742 | ||
|
|
c250e705b8 | ||
|
|
eb53dd3b22 | ||
|
|
860b999ce2 | ||
|
|
bb0804c6eb | ||
|
|
7b407d2523 | ||
|
|
114911957e |
@@ -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.
|
||||
|
||||
-->
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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'));
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+12
-2
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
-3
@@ -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,
|
||||
|
||||
+21
-6
@@ -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>
|
||||
|
||||
+60
@@ -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>
|
||||
)
|
||||
}
|
||||
+31
-10
@@ -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"
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -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)}
|
||||
|
||||
+9
-1
@@ -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={
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+81
-42
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
-4
@@ -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(() => {
|
||||
|
||||
+30
-14
@@ -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"
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+9
-7
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+7
-5
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+9
-7
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-7
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-7
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+16
-5
@@ -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>
|
||||
|
||||
+60
@@ -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>
|
||||
)
|
||||
}
|
||||
+83
-40
@@ -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
Reference in New Issue
Block a user