mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-13 05:33:43 +01:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -1019,7 +1019,12 @@ module.exports = {
|
||||
referenceLinkingWidgets: [],
|
||||
importProjectFromGithubModalWrapper: [],
|
||||
importProjectFromGithubMenu: [],
|
||||
editorLeftMenuSync: [],
|
||||
editorLeftMenuSync: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-bridge/frontend/js/card/components/git-modal.tsx'
|
||||
),
|
||||
],
|
||||
editorLeftMenuManageTemplate: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
@@ -1032,7 +1037,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: [],
|
||||
@@ -1069,7 +1079,12 @@ module.exports = {
|
||||
'../modules/full-project-search/frontend/js/components/full-project-search.tsx'
|
||||
),
|
||||
],
|
||||
integrationPanelComponents: [],
|
||||
integrationPanelComponents: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx'
|
||||
),
|
||||
],
|
||||
referenceSearchSetting: [],
|
||||
errorLogsComponents: [],
|
||||
referenceIndices: [],
|
||||
@@ -1089,6 +1104,7 @@ module.exports = {
|
||||
'authentication/oidc',
|
||||
'admin-tools', // import after authentication
|
||||
'template-gallery',
|
||||
'git-bridge',
|
||||
],
|
||||
viewIncludes: {},
|
||||
|
||||
@@ -1102,7 +1118,6 @@ module.exports = {
|
||||
'app/views/project/ide-react': [`img-src 'self' data: blob:`],
|
||||
},
|
||||
},
|
||||
|
||||
unsupportedBrowsers: {
|
||||
ie: '<=11',
|
||||
safari: '<=14',
|
||||
@@ -1129,6 +1144,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) {
|
||||
|
||||
@@ -1342,6 +1342,7 @@
|
||||
"per_license": "",
|
||||
"per_month": "",
|
||||
"per_month_x_annually": "",
|
||||
"per_page": "",
|
||||
"per_year": "",
|
||||
"percent_is_the_percentage_of_the_line_width": "",
|
||||
"permanently_delete_accounts": "",
|
||||
|
||||
+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,9 @@
|
||||
@import 'symbol-palette';
|
||||
@import 'writefull';
|
||||
@import 'labs';
|
||||
@import 'admin-tools/entries-per-page';
|
||||
@import 'admin-tools/user-list';
|
||||
@import 'admin-tools/user-list-ds-nav';
|
||||
@import 'admin-tools/manage-projects-page';
|
||||
@import 'admin-tools/manage-users-page';
|
||||
@import 'admin-tools/pagination-dark';
|
||||
|
||||
@@ -512,8 +512,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",
|
||||
@@ -960,6 +963,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",
|
||||
@@ -1302,7 +1306,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.",
|
||||
@@ -1442,6 +1448,13 @@
|
||||
"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>.",
|
||||
|
||||
@@ -1730,6 +1730,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",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"activation_link": "Ссылка для активации",
|
||||
"activation_token_expired": "Срок действия Вашего ключа истёк. Вам необходимо запросить новый ключ активации.",
|
||||
"add": "Добавить",
|
||||
"add_another_token": "Добавить ещё один токен",
|
||||
"add_your_first_group_member_now": "Добавьте первых участников группы сейчас",
|
||||
"added": "добавлены",
|
||||
"adding": "Добавление",
|
||||
@@ -103,6 +104,7 @@
|
||||
"clear_sessions_success": "Сессии завершены",
|
||||
"clearing": "Очистка",
|
||||
"click_here_to_view_sl_in_lng": "Кликните здесь, для использования __appName__ на <0>__lngName__</0>",
|
||||
"clone_with_git": "Клонировать при помощи git",
|
||||
"close": "Закрыть",
|
||||
"clsi_maintenance": "На сервере компиляции проводятся ремонтные работы, и он будет вскоре доступен снова.",
|
||||
"cn": "Китайский (упрощённый)",
|
||||
@@ -134,6 +136,7 @@
|
||||
"create_account": "Создать аккаунт",
|
||||
"create_new_subscription": "Создать новую подписку",
|
||||
"create_project_in_github": "Создать проект на GitHub",
|
||||
"created_at": "Создано",
|
||||
"creating": "Создание",
|
||||
"credit_card": "банковская карта",
|
||||
"cs": "Чешский",
|
||||
@@ -174,6 +177,7 @@
|
||||
"es": "Испанский",
|
||||
"every": "каждый",
|
||||
"example_project": "Использовать пример",
|
||||
"expires": "Истекает",
|
||||
"expiry": "Срок действия",
|
||||
"export_project_to_github": "Экспорт проекта на GitHub",
|
||||
"failed_to_publish_as_a_template": "Не удалось создать шаблон.",
|
||||
@@ -193,8 +197,14 @@
|
||||
"free_dropbox_and_history": "Бесплатные Dropbox и История",
|
||||
"full_doc_history": "Полная история изменений",
|
||||
"general": "Общие",
|
||||
"generate_token": "Создать токен",
|
||||
"generic_something_went_wrong": "Извините, что-то пошло не так",
|
||||
"get_in_touch": "Связаться с нами",
|
||||
"git": "Git",
|
||||
"git_bridge_modal_git_clone_your_project": "Клонируйте Ваш проект при помощи ссылки и токена аутентификации git",
|
||||
"git_clone_project_command": "Команда git clone для проекта",
|
||||
"git_clone_this_project": "Клонируйте проект при помощи git.",
|
||||
"git_integration": "Интеграция с Git",
|
||||
"github_commit_message_placeholder": "Сообщение о фиксации изменений в __appName__...",
|
||||
"github_is_premium": "Синхронизация с GitHub доступна только в премиум аккаунте",
|
||||
"github_merge_failed": "Ваши изменения в __appName__ и GitHub не были автоматически объединены. Пожалуйста, вручную объедините ветку <0>__sharelatex_branch__</0> с веткой по умолчанию в git. Нажмите ниже, чтобы продолжить после ручного объединения.",
|
||||
@@ -242,6 +252,7 @@
|
||||
"language": "Язык",
|
||||
"last_modified": "Последнее изменение",
|
||||
"last_name": "Фамилия",
|
||||
"last_used": "Последнее использование",
|
||||
"latex_templates": "Шаблоны",
|
||||
"learn_more": "Узнать больше",
|
||||
"leave_group": "Покинуть группу",
|
||||
@@ -332,6 +343,7 @@
|
||||
"password_reset_token_expired": "Ваш код восстановления пароля истёк. Пожалуйста, запросите восстановление пароля по почте ещё раз и перейдите по ссылке в письме.",
|
||||
"pdf_viewer": "Просмотрщик PDF",
|
||||
"pending": "В ожидании",
|
||||
"per_page": "на страницу",
|
||||
"permanently_delete_accounts": "Окончательно удалить аккаунты",
|
||||
"permanently_delete_projects": "Окончательно удалить проекты",
|
||||
"personal": "Личный",
|
||||
@@ -452,7 +464,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 +513,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": "Количество слов",
|
||||
@@ -558,6 +573,13 @@
|
||||
"you": "Вы",
|
||||
"you_cant_overwrite_it": "Перезаписать нельзя.",
|
||||
"you_have_added_x_of_group_size_y": "Вы добавили <0>__addedUsersSize__</0> из <1>__groupSize__</1> доступных участников",
|
||||
"your_git_access_info": "При запросе пароля используйте ваш токен аутентификации Git.",
|
||||
"your_git_access_info_bullet_1": "Вы можете иметь до 10 токенов.",
|
||||
"your_git_access_info_bullet_2": "Если вы достигли максимума, необходимо удалить имеющийся токен, прежде чем создавать новый.",
|
||||
"your_git_access_info_bullet_3": "Вы можете создать токен с помощью кнопки <0>Создать токен</0>.",
|
||||
"your_git_access_info_bullet_4": "После того как вы создадите токен, у вас больше не будет возможности увидеть его снова. Пожалуйста, скопируйте его и сохраните в безопасности.",
|
||||
"your_git_access_info_bullet_5": "Ранее созданные токены будут отображаться здесь.",
|
||||
"your_git_access_tokens": "Ваши токены аутентификации Git",
|
||||
"your_plan": "Ваш тариф",
|
||||
"your_projects": "Созданные мной",
|
||||
"your_sessions": "Ваши сессии",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import AuthorizationManager from '../../../../app/src/Features/Authorization/AuthorizationManager.mjs'
|
||||
import GitBridgePATManager from './GitBridgePATManager.mjs'
|
||||
|
||||
const permissionChecks = {
|
||||
read: AuthorizationManager.promises.canUserReadProject,
|
||||
write: AuthorizationManager.promises.canUserWriteProjectContent
|
||||
}
|
||||
|
||||
export default function ensureTokenProjectAccess(permission) {
|
||||
const checkPermission = permissionChecks[permission]
|
||||
if (!checkPermission) {
|
||||
throw new Error(`Invalid permission: ${permission}`)
|
||||
}
|
||||
|
||||
return async function (req, res, next) {
|
||||
try {
|
||||
const projectId = req.params.project_id
|
||||
if (!projectId) return res.sendStatus(400)
|
||||
|
||||
const header = req.headers.authorization || ''
|
||||
const [scheme, token] = header.trim().split(/\s+/, 2)
|
||||
if (scheme?.toLowerCase() !== 'bearer' || !token) {
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
const userId = await GitBridgePATManager.getUserId(token)
|
||||
if (!userId) return res.sendStatus(401)
|
||||
|
||||
const allowed = await checkPermission(userId, projectId, null)
|
||||
|
||||
if (!allowed) return res.sendStatus(403)
|
||||
|
||||
req.user_id = userId
|
||||
return next()
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to check personal access token')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Controller for API v0 endpoints used by git-bridge
|
||||
// These endpoints allow git-bridge to access project metadata, versions, and snapshots.
|
||||
|
||||
import settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import { fetchJson } from '@overleaf/fetch-utils'
|
||||
|
||||
import JsonWebToken from '../../../../app/src/infrastructure/JsonWebToken.mjs'
|
||||
import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs'
|
||||
import HistoryController from '../../../../app/src/Features/History/HistoryController.mjs'
|
||||
import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs'
|
||||
import GitBridgeHandler from './GitBridgeHandler.mjs'
|
||||
|
||||
const PROJECT_HISTORY = settings.apis.project_history.url
|
||||
const V1_HISTORY = settings.apis.v1_history.url
|
||||
|
||||
/*
|
||||
Entrypoint (GET): /api/v0/docs/:project_id
|
||||
Called by git-bridge to retrieve metadata about the latest project version.
|
||||
|
||||
The response must contain:
|
||||
{
|
||||
latestVerId: number,
|
||||
latestVerAt: string,
|
||||
latestVerBy: { email: string, name: string }
|
||||
}
|
||||
|
||||
This data is retrieved from the project-history service:
|
||||
/project/${projectId}/version
|
||||
*/
|
||||
|
||||
async function getDoc(req, res, next) {
|
||||
|
||||
const projectId = req.params.project_id
|
||||
|
||||
try {
|
||||
const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 })
|
||||
if (!project) throw new Error('Project not found')
|
||||
|
||||
const latestVerInfo = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version`)
|
||||
const timestamp = latestVerInfo.timestamp || new Date().toISOString()
|
||||
const userId = latestVerInfo.v2Authors && latestVerInfo.v2Authors[0]
|
||||
|
||||
let user = null
|
||||
if (userId) {
|
||||
user = await UserGetter.promises.getUser(userId, { first_name: 1, last_name: 1, email: 1 })
|
||||
}
|
||||
|
||||
const name = HistoryController._displayNameForUser(user)
|
||||
const email = user?.email || 'anonymous@nowhere'
|
||||
|
||||
res.json({
|
||||
latestVerId: latestVerInfo.version,
|
||||
latestVerAt: timestamp,
|
||||
latestVerBy: { email, name },
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error({ err, projectId }, 'Error retrieving the latest version metadata')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Entrypoint (GET): /api/v0/docs/:project_id/saved_vers
|
||||
Called by git-bridge to retrieve metadata about labeled (saved) versions.
|
||||
|
||||
The response must be an array of objects:
|
||||
{
|
||||
versionId: number,
|
||||
comment: string,
|
||||
createdAt: timestamp,
|
||||
user: { email: string, name: string }
|
||||
}
|
||||
|
||||
This data is retrieved from the project-history service:
|
||||
/project/${projectId}/labels
|
||||
*/
|
||||
|
||||
async function getSavedVers(req, res, next) {
|
||||
|
||||
const projectId = req.params.project_id
|
||||
|
||||
try {
|
||||
const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 })
|
||||
if (!project) throw new Error('Project is not found')
|
||||
|
||||
let labels = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/labels`)
|
||||
|
||||
const userIdSet = new Set(labels.map(label => label.user_id))
|
||||
// For backward compatibility: labels created anonymously may not contain user_id
|
||||
userIdSet.delete(undefined)
|
||||
const users = await UserGetter.promises.getUsers(Array.from(userIdSet), { first_name: 1, last_name: 1, email: 1 })
|
||||
|
||||
const savedVers = []
|
||||
labels.forEach(label => {
|
||||
const user = users.find(u => String(u._id) === label.user_id)
|
||||
const name = HistoryController._displayNameForUser(user)
|
||||
const email = user?.email || 'anonymous@nowhere'
|
||||
savedVers.push({
|
||||
versionId: label.version,
|
||||
comment: label.comment,
|
||||
createdAt: label.created_at,
|
||||
user: { name, email },
|
||||
})
|
||||
})
|
||||
|
||||
res.json(savedVers)
|
||||
|
||||
} catch (err) {
|
||||
logger.error({ err, projectId }, 'Error retrieving the saved versions metadata')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Entrypoint (GET): /api/v0/docs/:project_id/snapshots/:version
|
||||
Called by git-bridge to retrieve a full project snapshot for a specific version.
|
||||
|
||||
Needs in the response the following object:
|
||||
{
|
||||
srcs: [ [ fileContent: string, pathname: string ], ... ],
|
||||
atts: [ [ downloadUrl: string, pathname: string ], ... ],
|
||||
}
|
||||
|
||||
This data is retrieved from the project-history service:
|
||||
entrypoint: /project/${projectId}/version/${version}
|
||||
*/
|
||||
|
||||
async function getSnapshot(req, res, next) {
|
||||
|
||||
const projectId = req.params.project_id
|
||||
const versionString = req.params.version
|
||||
|
||||
try {
|
||||
if (!versionString) throw new Error('No version specified')
|
||||
const version = Number(versionString)
|
||||
|
||||
const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 })
|
||||
if (!project) throw new Error('Project not found')
|
||||
|
||||
const snapshot = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version/${version}`)
|
||||
const files = snapshot.files || {}
|
||||
|
||||
const srcs = []
|
||||
const atts = []
|
||||
|
||||
for (const [pathname, file] of Object.entries(files)) {
|
||||
if (!file?.data) continue
|
||||
|
||||
if (!GitBridgeHandler.normalizeAndValidateFilePath(pathname)) {
|
||||
throw new Error(`Invalid pathname: ${pathname}`)
|
||||
}
|
||||
|
||||
const { content, hash } = file.data
|
||||
|
||||
if (content !== undefined) {
|
||||
// Updated text file: return file content directly
|
||||
srcs.push([content, pathname])
|
||||
|
||||
} else if (hash) {
|
||||
// Binary file ("file") or unchanged text file (thoght it's "doc", add to atts anyway)
|
||||
// Git-bridge downloads it directly from v1-history using a temporary JWT.
|
||||
const token = await JsonWebToken.promises.sign(
|
||||
{ project_id: projectId },
|
||||
{ expiresIn: '10m' }
|
||||
)
|
||||
const downloadUrl = `${V1_HISTORY}/projects/${projectId}/blobs/${hash}?token=${token}`
|
||||
|
||||
atts.push([downloadUrl, pathname])
|
||||
|
||||
} else {
|
||||
throw new Error('Snapshot: both content and hash missing: ', pathname)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ srcs, atts })
|
||||
|
||||
} catch (err) {
|
||||
|
||||
logger.error({ err, projectId, versionString }, 'Error in getSnapshot')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Entry point (POST): /api/v0/docs/:project_id/snapshots
|
||||
Called by git-bridge after a push.
|
||||
|
||||
body:
|
||||
{
|
||||
latestVerId: number,
|
||||
files: [
|
||||
{ name: string, url: string | null }
|
||||
],
|
||||
postbackUrl: string
|
||||
}
|
||||
|
||||
latestVerId:
|
||||
Version that is expected to be updated
|
||||
files:
|
||||
Array describing the complete file set of the new project version
|
||||
files.name:
|
||||
File path in the project
|
||||
files.url:
|
||||
If not null, the file is new or modified and must be downloaded from
|
||||
git-bridge using this URL, if null, the file is unchanged
|
||||
postbackUrl:
|
||||
URL where the update result must be posted
|
||||
|
||||
returns:
|
||||
{ code: 'accepted' } or { code: 'outOfDate' }
|
||||
Any other response is treated by git-bridge as an error.
|
||||
*/
|
||||
|
||||
async function postSnapshot(req, res) {
|
||||
|
||||
const projectId = req.params.project_id
|
||||
const { latestVerId, files, postbackUrl } = req.body
|
||||
|
||||
if (!postbackUrl) {
|
||||
logger.error({ projectId }, 'postSnapshot: empty postbackUrl')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const userId = req.user_id || null // from auth middleware
|
||||
|
||||
try {
|
||||
const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 })
|
||||
if (!project) {
|
||||
logger.error({ projectId }, 'Project not found')
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const { version: olLatestVerId } = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version`)
|
||||
|
||||
if (latestVerId !== olLatestVerId) {
|
||||
// version in Overleaf is changed since last pull, git user needs to pull again
|
||||
return res.status(409).json({ code: 'outOfDate' })
|
||||
}
|
||||
|
||||
// When "accepted" is returned, git-bridge does not finish the push yet.
|
||||
// Instead it waits for the postback callback (performed in pushUpdate)
|
||||
// So it should be returned when the server has accepted the request but has not finished processing yet.
|
||||
res.status(200).json({ code: 'accepted' })
|
||||
|
||||
GitBridgeHandler.pushUpdate(projectId, files, postbackUrl, userId)
|
||||
.catch(err => logger.error({ err, projectId }, 'Error pushing update'))
|
||||
|
||||
} catch (err) {
|
||||
logger.error({ err, projectId }, 'Error posting snapshot')
|
||||
res.sendStatus(500)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getDoc: expressify(getDoc),
|
||||
getSavedVers: expressify(getSavedVers),
|
||||
getSnapshot: expressify(getSnapshot),
|
||||
postSnapshot: expressify(postSnapshot),
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Helper functions used by GitBridgeController.
|
||||
|
||||
import path from 'path'
|
||||
import settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import { fetchJson } from '@overleaf/fetch-utils'
|
||||
import { fetchStream } from '@overleaf/fetch-utils'
|
||||
|
||||
import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs'
|
||||
import UpdateMerger from '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs'
|
||||
|
||||
const PROJECT_HISTORY = settings.apis.project_history.url
|
||||
|
||||
function normalizeAndValidateFilePath(fileName) {
|
||||
if (!fileName || fileName.includes('\0')) return null
|
||||
|
||||
const normalized = path.posix.normalize(fileName)
|
||||
|
||||
if (normalized.startsWith('/') || normalized.startsWith('..')) return null
|
||||
if (normalized === '.git' || normalized.startsWith('.git/')) return null
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function validateFileName(fileName) {
|
||||
const normalized = normalizeAndValidateFilePath(fileName)
|
||||
|
||||
// if normalized path is not acceptable: error
|
||||
// if normalized path differs from original: suggest rename
|
||||
// otherwise the filename is valid
|
||||
if (!normalized) {
|
||||
return { file: fileName, state: 'error' }
|
||||
}
|
||||
if (normalized !== fileName) {
|
||||
return { file: fileName, cleanFile: normalized }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Push update to Overleaf.
|
||||
// update is an array of objects: { name: string, url: string | null }, provided by git-bridge
|
||||
// name: file path in the new project version (without leading '/')
|
||||
// url: download link from git-bridge, null means the file has not changed
|
||||
// Any existing entities whose paths are not present in update must be deleted.
|
||||
// Note: entity paths returned by ProjectEntityHandler include a leading '/'
|
||||
|
||||
async function pushUpdate(projectId, update, postbackUrl, userId) {
|
||||
|
||||
const invalidFiles = []
|
||||
|
||||
update.forEach(file => {
|
||||
const validationError = validateFileName(file.name)
|
||||
if (validationError) invalidFiles.push(validationError)
|
||||
})
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
await postback(postbackUrl, { code: 'invalidFiles', errors: invalidFiles })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { docs, files } = await ProjectEntityHandler.promises.getAllEntities(projectId)
|
||||
|
||||
const entityPaths = [...docs.map(d => d.path), ...files.map(f => f.path)]
|
||||
// prepend '/', paths in the entities have it
|
||||
const pathsInUpdate = new Set(update.map(f => '/' + f.name))
|
||||
|
||||
// Entities present in the project but missing in the update must be deleted
|
||||
const deletedPaths = entityPaths.filter(p => !pathsInUpdate.has(p))
|
||||
|
||||
for (const path of deletedPaths) {
|
||||
await UpdateMerger.promises.deleteUpdate(userId, projectId, path, 'git-bridge')
|
||||
}
|
||||
|
||||
// Apply file updates received from git-bridge
|
||||
for (const file of update) {
|
||||
if (!file.url) continue
|
||||
|
||||
const stream = await fetchStream(file.url)
|
||||
const fileName = '/' + file.name
|
||||
await UpdateMerger.promises.mergeUpdate(userId, projectId, fileName, stream, 'git-bridge')
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
await postback(postbackUrl, { code: 'error' })
|
||||
throw err
|
||||
}
|
||||
|
||||
const { version } = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version`)
|
||||
await postback(postbackUrl, { code: 'upToDate', latestVerId: version })
|
||||
}
|
||||
|
||||
/*
|
||||
Postback expected data:
|
||||
|
||||
Success: { code: "upToDate", latestVerId: number }
|
||||
latestVerId is a new version ID created by the push
|
||||
|
||||
Error:
|
||||
{
|
||||
code: "invalidFiles",
|
||||
errors: [
|
||||
{
|
||||
file: "path/to/file",
|
||||
cleanFile: "suggested_name"
|
||||
},
|
||||
{
|
||||
file: "path/to/file",
|
||||
state: "disallowed"
|
||||
}
|
||||
]
|
||||
}
|
||||
cleanFile is interpreted as a sanitized replacement filename
|
||||
state "disallowed": git bridge gives a hint "wrong file extension" (not used)
|
||||
|
||||
Error: { code: "error" }
|
||||
Generic error.
|
||||
|
||||
Error (not used): { code: "outOfDate" }
|
||||
The pushed snapshot was based on an outdated version.
|
||||
It can be used when the snapshot was accepted initially,
|
||||
but later processing discovered the base version was outdated.
|
||||
|
||||
Error (not used):
|
||||
{
|
||||
code: "invalidProject",
|
||||
errors: [
|
||||
"error message 1",
|
||||
"error message 2"
|
||||
]
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
async function postback(postbackUrl, code) {
|
||||
|
||||
try {
|
||||
await fetchJson(postbackUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(code),
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
logger.error({ err, postbackUrl, code }, 'Failed to post back to git-bridge')
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
normalizeAndValidateFilePath,
|
||||
pushUpdate,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||
import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.mjs'
|
||||
import GitBridgePATManager from './GitBridgePATManager.mjs'
|
||||
|
||||
const MAX_PAT_COUNT = 10
|
||||
|
||||
async function _sendSecurityAlertCreatedPAT(user) {
|
||||
const emailOptions = {
|
||||
to: user.email,
|
||||
actionDescribed: `a new Git authentication token has been generated for your account ${user.email}`,
|
||||
action: 'new Git authentication token generated',
|
||||
}
|
||||
try {
|
||||
await EmailHandler.promises.sendEmail('securityAlert', emailOptions)
|
||||
} catch (error) {
|
||||
// log error when sending security alert email but do not pass back
|
||||
logger.error(
|
||||
{ error, userId: user._id },
|
||||
'could not send security alert new Git authentication token generated'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const GitBridgePATController = {
|
||||
async getUserPersonalAccessTokens(req, res) {
|
||||
const user = SessionManager.getSessionUser(req.session)
|
||||
if (!user) return res.sendStatus(401)
|
||||
|
||||
try {
|
||||
const userId = user._id
|
||||
const tokens =
|
||||
await GitBridgePATManager.getTokens(userId)
|
||||
return res.json(tokens)
|
||||
} catch (err) {
|
||||
logger.error({ err, userId }, 'Failed to get personal access tokens')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
},
|
||||
|
||||
async createPersonalAccessToken(req, res){
|
||||
const user = SessionManager.getSessionUser(req.session)
|
||||
if (!user) return res.sendStatus(401)
|
||||
|
||||
try {
|
||||
const count = await GitBridgePATManager.countTokens(user._id)
|
||||
if (count >= MAX_PAT_COUNT) return res.sendStatus(403)
|
||||
|
||||
const token = await GitBridgePATManager.createToken(user._id)
|
||||
|
||||
// no need to wait, errors are logged and not passed back
|
||||
_sendSecurityAlertCreatedPAT(user)
|
||||
|
||||
return res.json(token)
|
||||
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error in PAR create')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
},
|
||||
|
||||
async deletePersonalAccessToken(req, res){
|
||||
const user = SessionManager.getSessionUser(req.session)
|
||||
if (!user) return res.sendStatus(401)
|
||||
const userId = user._id
|
||||
|
||||
const tokenId = req.params?.token_id
|
||||
if (!tokenId) return res.sendStatus(400)
|
||||
|
||||
try {
|
||||
const deleted = await GitBridgePATManager.deleteToken(tokenId, userId)
|
||||
if (!deleted) return res.sendStatus(404)
|
||||
|
||||
return res.sendStatus(200)
|
||||
} catch (err) {
|
||||
logger.error({ err, userId, tokenId }, 'Error in PAT delete')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
},
|
||||
|
||||
async validatePersonalAccessToken(req, res) {
|
||||
const header = req.headers.authorization || ''
|
||||
const [scheme, token] = header.trim().split(/\s+/, 2)
|
||||
if (scheme?.toLowerCase() !== 'bearer' || !token) return res.sendStatus(401)
|
||||
|
||||
try {
|
||||
const userId = await GitBridgePATManager.getUserId(token)
|
||||
if (!userId) return res.sendStatus(401)
|
||||
|
||||
return res.sendStatus(200)
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error in PAT validate')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default GitBridgePATController
|
||||
@@ -0,0 +1,125 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import crypto from 'crypto'
|
||||
import { ObjectId } from 'mongodb'
|
||||
import { db } from '../../../../app/src/infrastructure/mongodb.mjs'
|
||||
import { OauthApplication } from '../../../../app/src/models/OauthApplication.mjs'
|
||||
|
||||
const PAT_PREFIX = 'olp_'
|
||||
const PAT_LENGTH = 36 // without 'olp_' prefix
|
||||
const PAT_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
|
||||
function _hashToken(token) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
function _generateToken(length) {
|
||||
const patCharsLength = PAT_CHARS.length
|
||||
let token = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
token += PAT_CHARS[crypto.randomInt(patCharsLength)]
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
const GitBridgePATManager = {
|
||||
|
||||
async getTokens(user_id) {
|
||||
const query = {
|
||||
user_id,
|
||||
scope: /\bgit_bridge\b/,
|
||||
type: 'personal_access_token'
|
||||
}
|
||||
const projection = {
|
||||
accessTokenPartial: 1,
|
||||
createdAt: 1,
|
||||
expiresAt: 1,
|
||||
lastUsedAt: 1
|
||||
}
|
||||
|
||||
return await db.oauthAccessTokens
|
||||
.find(query, { projection })
|
||||
.sort({ createdAt: 1 })
|
||||
.toArray()
|
||||
},
|
||||
|
||||
async countTokens(user_id) {
|
||||
const query = {
|
||||
user_id,
|
||||
scope: /\bgit_bridge\b/,
|
||||
type: 'personal_access_token'
|
||||
}
|
||||
return db.oauthAccessTokens.countDocuments(query)
|
||||
},
|
||||
|
||||
async createToken(user_id) {
|
||||
const token = PAT_PREFIX + _generateToken(PAT_LENGTH)
|
||||
const createdAt = new Date()
|
||||
const expiresAt = new Date(createdAt)
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
|
||||
const accessTokenPartial = token.substring(0, 8)
|
||||
|
||||
const result = await db.oauthAccessTokens.insertOne({
|
||||
accessToken: _hashToken(token),
|
||||
accessTokenPartial,
|
||||
user_id,
|
||||
type: 'personal_access_token',
|
||||
scope: 'git_bridge',
|
||||
createdAt,
|
||||
expiresAt,
|
||||
})
|
||||
|
||||
return {
|
||||
_id: result.insertedId,
|
||||
accessToken: token,
|
||||
accessTokenPartial,
|
||||
createdAt,
|
||||
expiresAt
|
||||
}
|
||||
},
|
||||
|
||||
async deleteToken(tokenId, user_id) {
|
||||
const query = {
|
||||
_id: new ObjectId(tokenId),
|
||||
user_id,
|
||||
}
|
||||
const result = await db.oauthAccessTokens.deleteOne(query)
|
||||
return result?.deletedCount || 0
|
||||
},
|
||||
|
||||
async getUserId(token) {
|
||||
if (!token?.startsWith(PAT_PREFIX)) return null
|
||||
|
||||
const now = new Date()
|
||||
const objToken = await db.oauthAccessTokens.findOne(
|
||||
{
|
||||
accessToken: _hashToken(token),
|
||||
type: 'personal_access_token',
|
||||
scope: /\bgit_bridge\b/,
|
||||
expiresAt: { $gt: now }
|
||||
}, { projection: { user_id: 1 } }
|
||||
)
|
||||
|
||||
if (!objToken?.user_id) return null
|
||||
|
||||
// does the user still exists and not deleted?
|
||||
// tokens of a deleted user are not deleted until user expires
|
||||
const user = await db.users.findOne(
|
||||
{ _id: new ObjectId(objToken.user_id) },
|
||||
{ projection: { _id: 1 } }
|
||||
)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
// non-blocking
|
||||
db.oauthAccessTokens.updateOne(
|
||||
{ _id: objToken._id },
|
||||
{ $set: { lastUsedAt: now } }
|
||||
).catch(err =>
|
||||
logger.error({ err }, 'Failed to update lastUsedAt')
|
||||
)
|
||||
|
||||
return objToken.user_id
|
||||
},
|
||||
}
|
||||
|
||||
export default GitBridgePATManager
|
||||
@@ -0,0 +1,57 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
|
||||
import GitBridgeController from './GitBridgeController.mjs'
|
||||
import ensureTokenProjectAccess from './GitBridgeAuthMiddleware.mjs'
|
||||
import GitBridgePATController from './GitBridgePATController.mjs'
|
||||
import RateLimiterMiddleware from '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs'
|
||||
import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.mjs'
|
||||
|
||||
const oauthTokenInfoRateLimiter = new RateLimiter('oauth-token-info', {
|
||||
points: 30,
|
||||
duration: 60,
|
||||
})
|
||||
|
||||
export default {
|
||||
apply(webRouter, privateApiRouter, publicApiRouter) {
|
||||
logger.debug({}, 'Init git-bridge router')
|
||||
|
||||
// Called by git-bridge served by web-api
|
||||
privateApiRouter.get('/api/v0/docs/:project_id',
|
||||
ensureTokenProjectAccess('read'),
|
||||
GitBridgeController.getDoc
|
||||
)
|
||||
privateApiRouter.get(
|
||||
'/api/v0/docs/:project_id/saved_vers',
|
||||
ensureTokenProjectAccess('read'),
|
||||
GitBridgeController.getSavedVers
|
||||
)
|
||||
privateApiRouter.get(
|
||||
'/api/v0/docs/:project_id/snapshots/:version',
|
||||
ensureTokenProjectAccess('read'),
|
||||
GitBridgeController.getSnapshot
|
||||
)
|
||||
privateApiRouter.post(
|
||||
'/api/v0/docs/:project_id/snapshots',
|
||||
ensureTokenProjectAccess('write'),
|
||||
GitBridgeController.postSnapshot
|
||||
)
|
||||
// Called by git-bridge to validate a PAT, served by web
|
||||
publicApiRouter.get('/oauth/token/info',
|
||||
RateLimiterMiddleware.rateLimit(oauthTokenInfoRateLimiter),
|
||||
GitBridgePATController.validatePersonalAccessToken
|
||||
)
|
||||
webRouter.get('/git-bridge/personal-access-tokens',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitBridgePATController.getUserPersonalAccessTokens
|
||||
)
|
||||
webRouter.post('/git-bridge/personal-access-tokens',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitBridgePATController.createPersonalAccessToken
|
||||
)
|
||||
webRouter.delete('/git-bridge/personal-access-tokens/:token_id',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitBridgePATController.deletePersonalAccessToken
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import IntegrationCard from '@/features/ide-redesign/components/integrations-panel/integration-card'
|
||||
import GitLogoOrange from '@/shared/svgs/git-logo-orange'
|
||||
|
||||
import GitModalWrapper from './git-modal-wrapper'
|
||||
|
||||
function GitSyncCard() {
|
||||
const { t } = useTranslation()
|
||||
const { project } = useProjectContext()
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const projectId = project?._id
|
||||
if (!projectId) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegrationCard
|
||||
title={t('git')}
|
||||
description={t('git_clone_this_project')}
|
||||
icon={<GitLogoOrange size={32} />}
|
||||
onClick={() => setShow(true)}
|
||||
/>
|
||||
|
||||
<GitModalWrapper
|
||||
show={show}
|
||||
handleHide={() => setShow(false)}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncCard
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
|
||||
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
|
||||
type Props = {
|
||||
handleHide: () => void
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export default function GitModalContent({
|
||||
handleHide,
|
||||
projectId,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const gitCloneCommand = `git clone ${window.location.protocol}//git@${window.location.host}/git/${projectId}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('clone_with_git')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p>{t('git_bridge_modal_git_clone_your_project')}</p>
|
||||
|
||||
<div className="git-bridge-copy">
|
||||
<span aria-label={t('git_clone_project_command')}>
|
||||
<code>
|
||||
{gitCloneCommand}
|
||||
</code>
|
||||
</span>
|
||||
<CopyToClipboard
|
||||
content={gitCloneCommand}
|
||||
tooltipId="git-copy-clone-project-command-tooltip"
|
||||
/>
|
||||
</div>
|
||||
<Trans
|
||||
i18nKey="git_bridge_modal_use_previous_token"
|
||||
components={[
|
||||
<a
|
||||
href="/learn/how-to/Git_integration_authentication_tokens"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
href="/user/settings"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t('go_to_settings')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { OLModal } from '@/shared/components/ol/ol-modal'
|
||||
import GitModalContent from './git-modal-content'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
projectId: string
|
||||
handleHide: () => void
|
||||
}
|
||||
|
||||
export default function GitModalWrapper({
|
||||
show,
|
||||
projectId,
|
||||
handleHide,
|
||||
}: Props) {
|
||||
return (
|
||||
<OLModal
|
||||
show={show}
|
||||
onHide={handleHide}
|
||||
id="git-sync-modal"
|
||||
backdrop="static"
|
||||
size="lg"
|
||||
>
|
||||
<GitModalContent
|
||||
projectId={projectId}
|
||||
handleHide={handleHide}
|
||||
/>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
import GitFork from '@/shared/svgs/git-fork'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import LeftMenuButton from '@/features/editor-left-menu/components/left-menu-button'
|
||||
import GitModalWrapper from './git-modal-wrapper'
|
||||
|
||||
function GitSyncButton() {
|
||||
const { t } = useTranslation()
|
||||
const { project } = useProjectContext()
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||
const projectId = project?._id
|
||||
|
||||
if (!gitBridgeEnabled || !projectId) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeftMenuButton
|
||||
variant="link"
|
||||
className="left-menu-button"
|
||||
onClick={() => setShow(true)}
|
||||
icon={<GitFork />}
|
||||
>
|
||||
{t('git')}
|
||||
</LeftMenuButton>
|
||||
|
||||
<GitModalWrapper
|
||||
show={show}
|
||||
handleHide={() => setShow(false)}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncButton
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { postJSON, getJSON } from '@/infrastructure/fetch-json'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import GitLogoOrange from '@/shared/svgs/git-logo-orange'
|
||||
import TokenTable from './token-table'
|
||||
import ExposeTokenModal from './modals/expose-token-modal'
|
||||
import { Token } from '../../../../types/api'
|
||||
|
||||
export default function GitIntegrationWidget() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [tokens, setTokens] = useState<Token[]>([])
|
||||
const [showExposeTokenModal, setShowExposeTokenModal] = useState(false)
|
||||
const [secretToken, setSecretToken] = useState<string | null>(null)
|
||||
|
||||
const { runAsync, isLoading, isError, reset } = useAsync()
|
||||
|
||||
useEffect(() => {
|
||||
runAsync(getJSON('/git-bridge/personal-access-tokens'))
|
||||
.then((data: Token[]) => setTokens(data))
|
||||
.catch(debugConsole.error)
|
||||
}, [runAsync])
|
||||
|
||||
const handleCreateToken = useCallback(() => {
|
||||
runAsync(postJSON('/git-bridge/personal-access-tokens'))
|
||||
.then((data: Token & { accessToken: string }) => {
|
||||
const { accessToken, ...newToken } = data
|
||||
setTokens(prev => [...prev, newToken])
|
||||
setSecretToken(accessToken)
|
||||
setShowExposeTokenModal(true)
|
||||
})
|
||||
.catch((err) => {
|
||||
debugConsole.error(err)
|
||||
setTimeout(() => reset(), 5000)
|
||||
})
|
||||
}, [runAsync])
|
||||
|
||||
const handleDeleteToken = useCallback((id: string) => {
|
||||
setTokens(prev => prev.filter(t => t._id !== id))
|
||||
}, [])
|
||||
|
||||
const tokenCount = tokens.length
|
||||
|
||||
return (
|
||||
<div className="settings-widget-container">
|
||||
|
||||
<div className="linking-icon-fixed-position">
|
||||
<GitLogoOrange />
|
||||
</div>
|
||||
|
||||
<div className="description-container">
|
||||
|
||||
<div className="title-row">
|
||||
<h4>{t('git_integration')}</h4>
|
||||
</div>
|
||||
|
||||
<p className="small">
|
||||
<Trans
|
||||
i18nKey="git_integration_info"
|
||||
components={[
|
||||
<a
|
||||
key="git-link"
|
||||
href="/learn/how-to/Git_Integration"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<h4 className="ui-heading">
|
||||
{t('your_git_access_tokens')}
|
||||
</h4>
|
||||
|
||||
<p className="small">
|
||||
{t('your_git_access_info')}
|
||||
</p>
|
||||
|
||||
<ul className="small">
|
||||
{tokenCount > 0 ? (
|
||||
<>
|
||||
<li>{t('your_git_access_info_bullet_1')}</li>
|
||||
<li>{t('your_git_access_info_bullet_2')}</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
<Trans
|
||||
i18nKey="your_git_access_info_bullet_3"
|
||||
components={[<strong key="strong" />]}
|
||||
/>
|
||||
</li>
|
||||
<li>{t('your_git_access_info_bullet_4')}</li>
|
||||
<li>{t('your_git_access_info_bullet_5')}</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<TokenTable
|
||||
tokens={tokens}
|
||||
onCreateToken={handleCreateToken}
|
||||
onDeleteToken={handleDeleteToken}
|
||||
/>
|
||||
|
||||
{isError && (
|
||||
<div className="notification-list">
|
||||
<Notification
|
||||
type="error"
|
||||
content={t('something_went_wrong_server')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{tokenCount === 0 && (
|
||||
<div>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
id="generate-token-button"
|
||||
onClick={handleCreateToken}
|
||||
disabled={isLoading || isError}
|
||||
>
|
||||
{t('generate_token')}
|
||||
</OLButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showExposeTokenModal && secretToken && (
|
||||
<ExposeTokenModal
|
||||
secretToken={secretToken}
|
||||
handleHide={() => {
|
||||
setShowExposeTokenModal(false)
|
||||
setSecretToken(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user