Compare commits

...
Author SHA1 Message Date
yu-i-i 1a264f7562 Git Bridge: route PAT validation endpoint to correct router 2026-03-20 21:19:42 +01:00
yu-i-i a2a455517e Admin tools: fix project list action icon in 6.1.0 dark theme 2026-03-20 02:51:39 +01:00
yu-i-i 5692338d4b Admin tools: themed dashboard 2026-03-20 02:51:05 +01:00
Tao Chen b46501f874 Git Bridge: support git-bridge configuration via env in nginx
- generate overleaf.conf dynamically
- support GIT_BRIDGE_HOST and GIT_BRIDGE_PORT
- configure client_max_body_size for /git using GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE
2026-03-20 00:00:05 +01:00
yu-i-i 377732a832 Add hotfixes 6.1.1 and 6.1.2 (adapted) 2026-03-15 03:39:16 +01:00
yu-i-i 362fc56949 Update README.md 2026-03-15 01:42:06 +01:00
yu-i-i 9d00984b97 Sandboxed compiles: add scripts to add/strip repo prefix from texlive images 2026-03-15 01:42:05 +01:00
yu-i-i 14d0300fc5 Add env variable to enable restoring project/file from history snapshot 2026-03-15 01:42:05 +01:00
yu-i-i fdb7578742 Admin tools: minor improvements
- enable Projects button in navbar for admin projects page
- enable copy of user ID in user info modal
- add null check in UserListController (thanks @moschlar)
2026-03-15 01:42:05 +01:00
yu-i-i c250e705b8 Git Bridge: Add git integration 2026-03-15 01:41:49 +01:00
yu-i-i eb53dd3b22 Sandboxed compiles: set imageName to default if undefined in project 2026-03-14 05:13:52 +01:00
yu-i-i 860b999ce2 Authentication: update passport-ldapauth to 3.0.1; fix minor issues 2026-03-14 05:13:52 +01:00
yu-i-i bb0804c6eb Linked URL: prevent SSRF via DNS rebinding; minor fixes 2026-03-14 05:13:51 +01:00
yu-i-i 7b407d2523 Template gallery: add template management authorization middleware
- Middleware to enforce template access rules
- Introduce template manager role
- Fix minor issues
2026-03-14 05:13:38 +01:00
yu-i-i 114911957e Admin tools: optimize user/project list performance
Add frontend pagination
Add search debouncing
Render modals conditionally
Remove unnecessary sorting
2026-03-13 21:04:13 +01:00
120 changed files with 3317 additions and 659 deletions
+2 -6
View File
@@ -9,12 +9,8 @@ assignees: ''
<!--
Note: If you are using www.overleaf.com and have a problem,
or if you would like to request a new feature please contact
the support team at support@overleaf.com
This form should only be used to report bugs in the
Community Edition release of Overleaf.
Note: This form should only be used to report bugs in the
Extended CE release of Overleaf.
-->
-5
View File
@@ -4,8 +4,3 @@
## Related issues / Pull Requests
<!-- Fixes #xyz, Contributes to #xyz, Related to #xyz-->
## Contributor Agreement
- [ ] I confirm I have signed the [Contributor License Agreement](https://github.com/overleaf/overleaf/blob/main/CONTRIBUTING.md#contributor-license-agreement)
+7 -17
View File
@@ -1,43 +1,33 @@
Contributing to Overleaf
==========================
Contributing to Overleaf CE+
============================
Thank you for reading this! If you'd like to report a bug or join in the development
of Overleaf, then here are some notes on how to do that.
of Overleaf CE+, then here are some notes on how to do that.
Reporting bugs and opening issues
---------------------------------
If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/overleaf/overleaf/issues).**
If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/yu-i-i/overleaf-cep).**
If there is then please add any more information that you have, or give it a 👍.
When submitting an issue please describe the issue as clearly as possible, including how to
reproduce the bug, which situations it appears in, what you expected to happen, and what actually happens.
If you can include a screenshot for front end issues that is very helpful.
**Note**: If you are using [www.overleaf.com](www.overleaf.com) and have a problem, or if you would like to request a new feature, please contact the Support team at support@overleaf.com. Raise an issue here only to report bugs in the Community Edition release of Overleaf.
Pull Requests
-------------
See [our wiki](https://github.com/overleaf/overleaf/wiki)
See [Overleaf wiki](https://github.com/overleaf/overleaf/wiki)
for how to manage the Overleaf development environment and for our developer guidelines.
We love pull requests, so be bold with them! Don't be afraid of going ahead
and changing something, or adding a new feature. We're very happy to work with you
to get your changes merged into Overleaf.
If you're looking for something to work on, have a look at the [open issues](https://github.com/overleaf/overleaf/issues).
If you're looking for something to work on, have a look at the [open issues](https://github.com/yu-i-i/overleaf-cep/issues).
Security
--------
Please see [our security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability.
Contributor License Agreement
-----------------------------
Before we can accept any contributions of code, we need you to agree to our
[Contributor License Agreement](https://docs.google.com/forms/d/e/1FAIpQLSef79XH3mb7yIiMzZw-yALEegS-wyFetvjTiNBfZvf_IHD2KA/viewform?usp=sf_link).
This is to ensure that you own the copyright of your contribution, and that you
agree to give us a license to use it in both the open source version, and the version
of Overleaf running at www.overleaf.com, which may have additional changes.
Please see [Overleaf security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability.
+70
View File
@@ -0,0 +1,70 @@
# CREDITS
This project includes ideas and adapted code from several open-source projects.\
In most cases the original code has been modified, optimized, or extended.
## Symbol palette
The symbol palette feature is based on the original Overleaf implementation:
https://github.com/overleaf/web/tree/master/frontend/js/features/symbol-palette
The original code was slightly improved, particularly in the parts related to keyboard input.
## LDAP authentication
LDAP authentication was inspired by:
https://github.com/smhaller/ldap-overleaf-sl
The project provided the idea and part of the implementation for adding LDAP users to the user's contacts.
## Autocomplete of reference keys
Autocomplete of reference keys is based on:
https://github.com/lcpu-club/overleaf
The implementation largely follows the referenced code, except that a different `.bib` file parser is used.
## Real-time track changes and comments
The Track Changes and Comments feature exists largely in the original Overleaf codebase.\
The missing parts were implemented based on:
https://github.com/ertuil/overleaf
The referenced code was fixed, optimized, and extended.
## Sandboxed compiles
The Sandboxed Compiles feature was until recently largely present in the original Overleaf codebase.\
The missing parts were implemented in this project.
## Import file from external URL
The "From External URL" feature exists in the original Overleaf code.\
The missing proxy component was implemented in this project.
## Git integration
Git integration is based on:
https://github.com/ayaka-notes/overleaf-pro/tree/feat-git-bridge
The backend was completely rewritten.\
The frontend uses the referenced code, which was optimized and improved.
## Original Features
The following features are original work implemented in this project:
- Template Gallery
- Advanced administrator tools for managing user accounts and projects
- SAML authentication
- OpenID Connect authentication
## Acknowledgments
Thanks to the users of the project for valuable feedback, suggestions,
and help in identifying and fixing bugs.
+11 -7
View File
@@ -25,16 +25,17 @@
The present "extended" version of Overleaf CE includes:
- Template Gallery
- Sandboxed Compiles with TeX Live image selection
- Template gallery
- Sandboxed compiles with TeX Live image selection
- LDAP authentication
- SAML authentication
- OpenID Connect authentication
- Real-time track changes and comments
- Autocomplete of reference keys
- Symbol Palette
- "From External URL" feature
- Symbol palette
- Import file from external URL
- Advanced administrator tools for managing user accounts and projects
- Git integration
> [!CAUTION]
> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles.
@@ -74,11 +75,14 @@ folder are added.
## Authors
[The Overleaf Team](https://www.overleaf.com/about)
[yu-i-i](https://github.com/yu-i-i/overleaf-cep) — Extensions for CE unless otherwise noted
[The Overleaf Team](https://www.overleaf.com/about)\
[yu-i-i](https://github.com/yu-i-i/overleaf-cep) — CE extensions; references to adapted code are listed in [`CREDITS`](CREDITS.md)
## License
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
Copyright (c) Overleaf, 2014-2025.
Copyright (c) Overleaf, 2014-2026.\
Copyright (c) yu-i-i, 2024-2026, for CE extensions.
Portions of the code are derived from other open-source projects; see [`CREDITS`](CREDITS.md).
+1 -1
View File
@@ -88,7 +88,7 @@ services:
volumes:
- ../services/linked-url-proxy/app:/overleaf/services/linked-url-proxy/app
- ../services/linked-url-proxy/config:/overleaf/services/linked-url-proxy/config
- ../services/linked-url-proxy/app.js:/overleaf/services/linked-url-proxy/app.js
- ../services/linked-url-proxy/app.mjs:/overleaf/services/linked-url-proxy/app.mjs
notifications:
command: ["node", "--watch", "app.ts"]
+15
View File
@@ -187,3 +187,18 @@ services:
- "127.0.0.1:80:3808"
volumes:
- ./webpack.config.dev-env.js:/overleaf/services/web/webpack.config.dev-env.js
git-bridge:
image: quay.io/sharelatex/git-bridge:latest
expose:
- "8000"
ports:
- "8000:8000"
environment:
GIT_BRIDGE_API_BASE_URL: "http://web:3000/api/v0"
GIT_BRIDGE_OAUTH2_SERVER: "http://web:3000"
GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000"
GIT_BRIDGE_ROOT_DIR: "/data/git-bridge"
LOG_LEVEL: "DEBUG"
volumes:
- ./data/git-bridge:/data/git-bridge
+5
View File
@@ -14,6 +14,11 @@ module.exports = merge(base, {
target: 'http://real-time:3026',
ws: true,
},
{
context: '/git/**',
target: 'http://git-bridge:8000',
pathRewrite: { '^/git': '' }
},
{
context: ['!**/*.js', '!**/*.css', '!**/*.json'],
target: 'http://web:3000',
+1
View File
@@ -2,6 +2,7 @@
"name": "overleaf",
"private": true,
"dependencies": {
"passport-ldapauth": "^3.0.1",
"patch-package": "^8.0.0"
},
"devDependencies": {
@@ -1,41 +1,41 @@
diff --git a/node_modules/ldapauth-fork/lib/ldapauth.js b/node_modules/ldapauth-fork/lib/ldapauth.js
index 85ecf36a8b..a7d07e0f78 100644
index 88398ed..0334177 100644
--- a/node_modules/ldapauth-fork/lib/ldapauth.js
+++ b/node_modules/ldapauth-fork/lib/ldapauth.js
@@ -69,6 +69,7 @@ function LdapAuth(opts) {
@@ -87,6 +87,7 @@ function LdapAuth(opts) {
this.opts.bindProperty || (this.opts.bindProperty = 'dn');
this.opts.groupSearchScope || (this.opts.groupSearchScope = 'sub');
this.opts.groupDnProperty || (this.opts.groupDnProperty = 'dn');
+ this.opts.tlsStarted = false;
EventEmitter.call(this);
@@ -108,21 +109,7 @@ function LdapAuth(opts) {
@@ -126,21 +127,7 @@ function LdapAuth(opts) {
this._userClient.on('error', this._handleError.bind(this));
var self = this;
- if (this.opts.starttls) {
- // When starttls is enabled, this callback supplants the 'connect' callback
- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function(err) {
- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) {
- if (err) {
- self._handleError(err);
- } else {
- self._onConnectAdmin();
- }
- });
- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function(err) {
- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) {
- if (err) {
- self._handleError(err);
- }
- });
- } else if (opts.reconnect) {
+ if (opts.reconnect && !this.opts.starttls) {
this.once('_installReconnectListener', function() {
this.once('_installReconnectListener', function () {
self.log && self.log.trace('install reconnect listener');
self._adminClient.on('connect', function() {
@@ -384,6 +371,28 @@ LdapAuth.prototype._findGroups = function(user, callback) {
self._adminClient.on('connect', function () {
@@ -401,6 +388,37 @@ LdapAuth.prototype._findGroups = function (user, callback) {
*/
LdapAuth.prototype.authenticate = function(username, password, callback) {
LdapAuth.prototype.authenticate = function (username, password, callback) {
var self = this;
+ if (this.opts.starttls && !this.opts.tlsStarted) {
+ // When starttls is enabled, this callback supplants the 'connect' callback
@@ -56,9 +56,18 @@ index 85ecf36a8b..a7d07e0f78 100644
+ }
+};
+
+/**
+ * Authenticate given credentials against LDAP server
+ *
+ * @private
+ * @param {string} username - The username to authenticate
+ * @param {string} password - The password to verify
+ * @param {resultCallback} callback - Result handling callback
+ * @returns {undefined}
+ */
+LdapAuth.prototype._handleAuthenticate = function (username, password, callback) {
+ this.opts.tlsStarted = true;
+ var self = this;
if (typeof password === 'undefined' || password === null || password === '') {
return callback(new Error('no password given'));
+4 -2
View File
@@ -40,7 +40,7 @@ ADD server-ce/config/env.sh /etc/overleaf/env.sh
# Configure nginx
# ---------------
ADD server-ce/nginx/nginx.conf.template /etc/nginx/templates/nginx.conf.template
ADD server-ce/nginx/overleaf.conf /etc/nginx/sites-enabled/overleaf.conf
ADD server-ce/nginx/overleaf.conf.template /etc/nginx/templates/overleaf.conf.template
ADD server-ce/nginx/clsi-nginx.conf /etc/nginx/sites-enabled/clsi-nginx.conf
@@ -112,7 +112,9 @@ ENV GRACEFUL_SHUTDOWN_DELAY_SECONDS=1
ENV NODE_ENV="production"
ENV LOG_LEVEL="info"
ENV GIT_BRIDGE_HOST=git-bridge
ENV GIT_BRIDGE_PORT=8000
ENV GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE=52428800
EXPOSE 80
ENTRYPOINT ["/sbin/my_init"]
+12
View File
@@ -0,0 +1,12 @@
FROM sharelatex/sharelatex:6.1.0
# Apply security updates to base image
RUN apt update && apt install -y linux-libc-dev \
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
&& rm -rf /var/lib/apt/lists/*
# Fix rendering logic for UI elements in the redesigned editor
COPY pr_31004.patch .
RUN patch -p1 < pr_31004.patch && rm pr_31004.patch
RUN node genScript compile | bash
+131
View File
@@ -0,0 +1,131 @@
--- a/services/web/frontend/js/features/ide-redesign/components/rail/rail-help-dropdown.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/rail/rail-help-dropdown.tsx
@@ -10,6 +10,7 @@ import {
export default function RailHelpDropdown() {
const showSupport = getMeta('ol-showSupport')
+ const showDocumentation = getMeta('ol-wikiEnabled')
const { t } = useTranslation()
const { setActiveModal } = useRailContext()
const openKeyboardShortcutsModal = useCallback(() => {
@@ -24,20 +25,24 @@ export default function RailHelpDropdown() {
<DropdownItem onClick={openKeyboardShortcutsModal}>
{t('keyboard_shortcuts')}
</DropdownItem>
- <DropdownItem
- href="/learn"
- role="menuitem"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t('documentation')}
- </DropdownItem>
- <DropdownDivider />
- {showSupport && (
- <DropdownItem onClick={openContactUsModal}>
- {t('contact_us')}
+ {showDocumentation && (
+ <DropdownItem
+ href="/learn"
+ role="menuitem"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {t('documentation')}
</DropdownItem>
)}
+ {showSupport && (
+ <>
+ <DropdownDivider />
+ <DropdownItem onClick={openContactUsModal}>
+ {t('contact_us')}
+ </DropdownItem>
+ </>
+ )}
</DropdownMenu>
)
}
diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx
index 1610cdb89675..648d2f47fb53 100644
--- a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx
@@ -43,6 +43,8 @@ export const ToolbarMenuBar = () => {
const openProject = useOpenProject()
const anonymous = getMeta('ol-anonymous')
+ const showSupport = getMeta('ol-showSupport')
+ const showDocumentation = getMeta('ol-wikiEnabled')
useCommandProvider(
() => [
@@ -266,19 +268,25 @@ export const ToolbarMenuBar = () => {
title={t('keyboard_shortcuts')}
onClick={openKeyboardShortcutsModal}
/>
- <MenuBarOption
- title={t('documentation')}
- eventKey="documentation"
- href="/learn"
- target="_blank"
- rel="noopener noreferrer"
- />
- <DropdownDivider />
- <MenuBarOption
- eventKey="contact_us"
- title={t('contact_us')}
- onClick={openContactUsModal}
- />
+ {showDocumentation && (
+ <MenuBarOption
+ title={t('documentation')}
+ eventKey="documentation"
+ href="/learn"
+ target="_blank"
+ rel="noopener noreferrer"
+ />
+ )}
+ {showSupport && (
+ <>
+ <DropdownDivider />
+ <MenuBarOption
+ eventKey="contact_us"
+ title={t('contact_us')}
+ onClick={openContactUsModal}
+ />
+ </>
+ )}
</MenuBarDropdown>
</MenuBar>
<WordCountModal
diff --git a/services/web/frontend/js/features/ide-redesign/contexts/settings-modal-context.tsx b/services/web/frontend/js/features/ide-redesign/contexts/settings-modal-context.tsx
index 56f0d4e30fe4..ab3bbd9d2b7f 100644
--- a/services/web/frontend/js/features/ide-redesign/contexts/settings-modal-context.tsx
+++ b/services/web/frontend/js/features/ide-redesign/contexts/settings-modal-context.tsx
@@ -29,6 +29,7 @@ import DarkModePdfSetting from '../components/settings/appearance-settings/dark-
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import ProjectNotificationsSetting from '../components/settings/editor-settings/project-notifications-setting'
+import getMeta from '@/utils/meta'
const [referenceSearchSettingModule] = importOverleafModules(
'referenceSearchSetting'
@@ -82,6 +83,7 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
children,
}) => {
const { t } = useTranslation()
+ const { isOverleaf } = getMeta('ol-ExposedSettings')
const { overallTheme } = useProjectSettingsContext()
// TODO ide-redesign-cleanup: Rename this field and move it directly into this context
@@ -265,9 +267,10 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
title: t('subscription'),
icon: 'account_balance',
href: '/user/subscription',
+ hidden: !isOverleaf,
},
],
- [t, overallTheme, hasEmailNotifications, noNewEditorOptOut]
+ [t, overallTheme, hasEmailNotifications, noNewEditorOptOut, isOverleaf]
)
const settingsTabs = useMemo(
+13
View File
@@ -0,0 +1,13 @@
FROM sharelatex/sharelatex:6.1.1
# Apply security updates to base image
RUN apt update && apt install -y linux-libc-dev \
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
&& apt purge -y imagemagick \
&& apt autoremove -y \
&& apt install -y poppler-utils \
&& rm -rf /var/lib/apt/lists/*
# Update converter
COPY issue_31527.patch .
RUN patch -p0 < issue_31527.patch && rm issue_31527.patch
+87
View File
@@ -0,0 +1,87 @@
--- services/filestore/app/js/FileConverter.js
+++ services/filestore/app/js/FileConverter.js
@@ -22,45 +22,44 @@ export default {
}
async function convert(sourcePath, requestedFormat) {
- const width = '600x'
+ const width = '1500'
return await _convert(sourcePath, requestedFormat, [
- 'convert',
- '-define',
- `pdf:fit-page=${width}`,
- '-flatten',
- '-density',
- '300',
- `${sourcePath}[0]`,
+ 'pdftocairo',
+ '-png',
+ '-singlefile',
+ '-scale-to-x',
+ width.toString(),
+ '-scale-to-y',
+ '-1',
+ sourcePath,
])
}
async function thumbnail(sourcePath) {
- const width = '548x'
- return await _convert(sourcePath, 'jpg', [
- 'convert',
- '-flatten',
- '-background',
- 'white',
- '-density',
- '300',
- `${sourcePath}[0]`,
- '-resize',
- width,
+ const width = 700
+ return await _convert(sourcePath, 'png', [
+ 'pdftocairo',
+ '-png',
+ '-singlefile',
+ '-scale-to-x',
+ width.toString(),
+ '-scale-to-y',
+ '-1',
+ sourcePath,
])
}
async function preview(sourcePath) {
- const width = '794x'
- return await _convert(sourcePath, 'jpg', [
- 'convert',
- '-flatten',
- '-background',
- 'white',
- '-density',
- '300',
- `${sourcePath}[0]`,
- '-resize',
- width,
+ const width = 1000
+ return await _convert(sourcePath, 'png', [
+ 'pdftocairo',
+ '-png',
+ '-singlefile',
+ '-scale-to-x',
+ width.toString(),
+ '-scale-to-y',
+ '-1',
+ sourcePath,
])
}
@@ -74,7 +73,8 @@ async function _convert(sourcePath, requestedFormat, command) {
const timer = new metrics.Timer('imageConvert')
const destPath = `${sourcePath}.${requestedFormat}`
- command.push(destPath)
+ const outputBaseName = sourcePath
+ command.push(outputBaseName)
command = Settings.commands.convertCommandPrefix.concat(command)
try {
@@ -16,12 +16,18 @@ fi
nginx_template_file="${nginx_templates_dir}/nginx.conf.template"
nginx_config_file="${nginx_dir}/nginx.conf"
overleaf_template_file="${nginx_templates_dir}/overleaf.conf.template"
overleaf_config_file="${nginx_dir}/sites-enabled/overleaf.conf"
if [ -f "${nginx_template_file}" ]; then
export NGINX_KEEPALIVE_TIMEOUT="${NGINX_KEEPALIVE_TIMEOUT:-65}"
export NGINX_WORKER_CONNECTIONS="${NGINX_WORKER_CONNECTIONS:-768}"
export NGINX_WORKER_PROCESSES="${NGINX_WORKER_PROCESSES:-4}"
export MAX_UPLOAD_SIZE="${MAX_UPLOAD_SIZE:-50}"
export GIT_BRIDGE_HOST="${GIT_BRIDGE_HOST:-git-bridge}"
export GIT_BRIDGE_PORT="${GIT_BRIDGE_PORT:-8000}"
export GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE="${GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE:-52428800}"
echo "Nginx: generating config file from template"
# Note the single-quotes, they are important.
@@ -37,6 +43,15 @@ if [ -f "${nginx_template_file}" ]; then
< "${nginx_template_file}" \
> "${nginx_config_file}"
# Config git-bridge in nginx
envsubst '
${GIT_BRIDGE_HOST}
${GIT_BRIDGE_PORT}
${GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE}
' \
< "${overleaf_template_file}" \
> "${overleaf_config_file}"
echo "Checking Nginx config"
nginx -t
@@ -21,6 +21,23 @@ server {
proxy_send_timeout 10m;
}
location ^~ /git/ {
resolver_timeout 2s;
resolver 127.0.0.11 valid=10s;
set $git_upstream http://${GIT_BRIDGE_HOST}:${GIT_BRIDGE_PORT};
rewrite ^/git/(.*)$ /$1 break;
client_max_body_size ${GIT_BRIDGE_REPOSTORE_MAX_FILE_SIZE};
proxy_pass $git_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 10m;
proxy_send_timeout 10m;
}
location /socket.io {
proxy_pass http://127.0.0.1:3026;
proxy_http_version 1.1;
+1 -1
View File
@@ -103,7 +103,7 @@ if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') {
image:
process.env.TEXLIVE_IMAGE ||
process.env.TEX_LIVE_DOCKER_IMAGE ||
'quay.io/sharelatex/texlive-full:2017.1',
process.env.ALL_TEX_LIVE_DOCKER_IMAGES.split(',')[0].trim(),
env: {
HOME: '/tmp',
CLSI: 1,
+1 -1
View File
@@ -33,7 +33,7 @@ const server = app.listen(port, host, function (error) {
process.on('SIGTERM', () => {
server.close(() => {
logger.info({ host, port }, 'linked-url-proxy HTTP server closed')
metrics.close()
Metrics.close()
})
})
@@ -7,6 +7,7 @@ import { Transform } from 'node:stream'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import { fetchStreamWithResponse, RequestFailedError } from '@overleaf/fetch-utils'
import { Agent } from 'undici'
function isAllowedResource(targetUrl) {
if (!Settings.allowedResources) return false
@@ -46,7 +47,7 @@ async function checkUrlAccess(hostname, targetUrl) {
throw err
}
// Permit explicitly allowed resources without checking blocked IPs
if (isAllowedResource(targetUrl)) return
if (isAllowedResource(targetUrl)) return records[0].address
for (const { address } of records) {
if (isBlockedIp(address, targetUrl)) {
const err = new Error(`Blocked IP address: ${address}`)
@@ -54,6 +55,7 @@ async function checkUrlAccess(hostname, targetUrl) {
throw err
}
}
return records[0].address
}
async function validateAndFetch(rawUrl, redirectCount = 0) {
@@ -89,7 +91,16 @@ async function validateAndFetch(rawUrl, redirectCount = 0) {
const normalizedUrl = url.toString()
// check DNS and allowed resources
await checkUrlAccess(url.hostname, normalizedUrl)
const validatedIp = await checkUrlAccess(url.hostname, normalizedUrl)
// pin fetch to validated IP to prevent DNS rebinding
const agent = new Agent({
connect: {
lookup(hostname, opts, cb) {
cb(null, validatedIp, 4)
}
}
})
const opts = {
redirect: 'manual',
@@ -98,7 +109,7 @@ async function validateAndFetch(rawUrl, redirectCount = 0) {
}
try {
const { stream, response } = await fetchStreamWithResponse(normalizedUrl, opts)
const { stream, response } = await fetchStreamWithResponse(normalizedUrl, { ...opts, dispatcher: agent })
const contentLengthHeader = response.headers.get('content-length')
if (contentLengthHeader) {
+2 -5
View File
@@ -16,10 +16,7 @@
"express": "^4.22.1",
"ipaddr.js": "^2.1.0",
"als-normalize-urlpath": "^2.3.0",
"strict-url-sanitise": "^0.0.1"
},
"devDependencies": {
"als-normalize-urlpath": "^2.3.0",
"strict-url-sanitise": "^0.0.1"
"strict-url-sanitise": "^0.0.1",
"undici": "^7.22.0"
}
}
@@ -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'),
+30 -4
View File
@@ -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": "",
@@ -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';
+13
View File
@@ -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>.",
+1
View File
@@ -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",
+23 -1
View File
@@ -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>
)
}
@@ -13,7 +13,6 @@ import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import sortUsers from '../../../user-list/util/sort-users'
type TransferProjectModalProps = Pick<
React.ComponentProps<typeof ProjectsActionModal>,
@@ -35,9 +34,8 @@ function TransferProjectModal({
const potentialOwners = useMemo(() => {
if (!loadedUsers) return null;
const sortedUsers = sortUsers(loadedUsers, { by: 'name', order: 'asc' })
const result: UserRef[] = []
for (const user of sortedUsers) {
for (const user of loadedUsers) {
if (!user.deleted && user.id !== projectsOwnerId) {
result.push({
id: user.id,
@@ -15,9 +15,13 @@ import DefaultNavbar from '@/shared/components/navbar/default-navbar'
import Footer from '@/shared/components/footer/footer'
import SidebarDsNav from './sidebar/sidebar-ds-nav'
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg'
import { getUserName } from '../util/user'
import { useProjectListContext } from '../context/project-list-context'
import { useUserIdentityContext } from '../../user-list/context/user-identity-context'
import Pagination from '@/shared/components/pagination-cep'
import ProjectListSummary from './project-list-summary'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
export function ProjectListDsNav() {
@@ -32,8 +36,12 @@ export function ProjectListDsNav() {
selectedProjects,
filter,
projectsOwnerId,
currentPage,
setCurrentPage,
totalPages,
} = useProjectListContext()
const { getUserNameById } = useUserIdentityContext()
const activeOverallTheme = useActiveOverallTheme('themed-project-dashboard')
const userName = projectsOwnerId ? getUserNameById(projectsOwnerId) : t('all_users')
const tableTopArea = (
@@ -51,11 +59,15 @@ export function ProjectListDsNav() {
return (
<div className="project-ds-nav-page website-redesign">
<DefaultNavbar
{...navbarProps}
overleafLogo={overleafLogo}
showCloseIcon
/>
<div className="manage-projects-page">
<DefaultNavbar
{...navbarProps}
overleafLogo={
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
}
showCloseIcon
/>
</div>
<div className="project-list-wrapper">
<SidebarDsNav />
<div className="project-ds-nav-content-and-messages">
@@ -100,7 +112,10 @@ export function ProjectListDsNav() {
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
<ProjectListSummary />
</div>
<div className="mt-3">
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />
</div>
</div>
</main>
@@ -0,0 +1,60 @@
import { useTranslation } from 'react-i18next'
import {
Dropdown,
DropdownMenu,
DropdownItem,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import { useProjectListContext } from '../context/project-list-context'
const OPTIONS = [20, 40, 80]
export default function ProjectListSummary() {
const {
visibleProjects,
hiddenProjectsCount,
projectsPerPage,
setProjectsPerPage,
} = useProjectListContext()
const { t } = useTranslation()
return (
<div className="text-center">
<span aria-live="polite">
{t('showing_x_out_of_n_projects', {
x: visibleProjects.length,
n: visibleProjects.length + hiddenProjectsCount,
})}
</span>
<span className="mx-2">·</span>
<span className="d-inline-flex gap-1">
<Dropdown>
<DropdownToggle
as="span"
className="entries-per-page-toggle"
>
{projectsPerPage}
</DropdownToggle>
<DropdownMenu>
{OPTIONS.map((value) => (
<DropdownItem
key={value}
active={value === projectsPerPage}
onClick={() => setProjectsPerPage(value)}
>
{value}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<span>{t('per_page')}</span>
</span>
</div>
)
}
@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '@/infrastructure/event-tracking'
import classnames from 'classnames'
import { MergeAndOverride } from '../../../../../../types/utils'
import * as eventTracking from '@/infrastructure/event-tracking'
import { isSmallDevice } from '@/infrastructure/event-tracking'
import OLCol from '@/shared/components/ol/ol-col'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLCol from '@/shared/components/ol/ol-col'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import MaterialIcon from '@/shared/components/material-icon'
import { MergeAndOverride } from '../../../../../../types/utils'
type SearchFormOwnProps = {
inputValue: string
@@ -26,20 +27,39 @@ function SearchForm({
...props
}: SearchFormProps) {
const { t } = useTranslation()
const placeholderMessage = t('search_projects')
const placeholder = `${placeholderMessage}…`
const placeholder = t('search_projects')+'…'
const [localValue, setLocalValue] = useState(inputValue)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setLocalValue(inputValue)
}, [inputValue])
const handleChange: React.ComponentProps<
typeof OLFormControl
>['onChange'] = e => {
eventTracking.sendMB('admin-user-project-list-page-interaction', {
eventTracking.sendMB('admin-project-list-page-interaction', {
action: 'search',
isSmallDevice,
})
setInputValue(e.target.value)
const value = e.target.value
setLocalValue(value)
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
setInputValue(value)
}, 300)
}
const handleClear = () => setInputValue('')
const handleClear = () => {
setLocalValue('')
setInputValue('')
}
return (
<OLForm
@@ -51,14 +71,15 @@ function SearchForm({
<OLFormGroup>
<OLCol>
<OLFormControl
name="search"
type="text"
value={inputValue}
value={localValue}
onChange={handleChange}
placeholder={placeholder}
aria-label={placeholder}
prepend={<MaterialIcon type="search" />}
append={
inputValue.length > 0 && (
localValue.length > 0 && (
<button
type="button"
className="form-control-search-clear-btn"
@@ -27,7 +27,7 @@ function SidebarDsNav() {
const { sessionUser } = getMeta('ol-navbar')
const { containerRef, scrolledUp } = useScrolled()
const themedDsNav = useFeatureFlag('themed-project-dashboard')
const { getUserNameById } = useUserIdentityContext()
const { projectsOwnerId } = useProjectListContext()
@@ -47,12 +47,14 @@ function DeleteProjectButton({ project, children }: DeleteProjectButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<DeleteProjectModal
projects={[project]}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteProjectModal
projects={[project]}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -42,12 +42,14 @@ function PurgeProjectButton({ project, children }: PurgeProjectButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<PurgeProjectModal
projects={[project]}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeProjectModal
projects={[project]}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -51,12 +51,14 @@ function RestoreProjectButton({
return (
<>
{children(text, handleOpenModal)}
<RestoreProjectModal
projects={[project]}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreProjectModal
projects={[project]}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -54,12 +54,14 @@ function TransferProjectButton({ project, children }: TransferProjectButtonProps
return (
<>
{children(text, handleOpenModal)}
<TransferProjectModal
projects={[project]}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TransferProjectModal
projects={[project]}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -47,12 +47,14 @@ function TrashProjectButton({ project, children }: TrashProjectButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<TrashProjectModal
projects={[project]}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TrashProjectModal
projects={[project]}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -18,6 +18,7 @@ export const ProjectCheckbox = memo<{ projectId: string; projectName: string }>(
return (
<OLFormCheckbox
id={`select_project_${projectId}`}
autoComplete="off"
onChange={handleCheckboxChange}
checked={selectedProjectIds.has(projectId)}
@@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect } from 'react'
import { useCallback, useRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectListTableRow from './project-list-table-row'
import { useProjectListContext } from '../../context/project-list-context'
@@ -28,9 +28,12 @@ function ProjectListTable() {
const {
visibleProjects,
sort,
searchText,
selectedProjects,
selectOrUnselectAllProjects,
filter,
currentPage,
setCurrentPage,
} = useProjectListContext()
const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>(null)
@@ -49,6 +52,10 @@ function ProjectListTable() {
selectedProjects.length !== visibleProjects.length
}
}, [selectedProjects, visibleProjects])
const [lastNonSearchPage, setLastNonSearchPage] = useState(1)
const [isSearching, setIsSearching] = useState(false)
return (
<OLTable className="project-dash-table" container={false} hover>
<caption className="visually-hidden">{t('projects_list')}</caption>
@@ -59,6 +66,7 @@ function ProjectListTable() {
aria-label={t('select_projects')}
>
<OLFormCheckbox
name="select_all_projects"
autoComplete="off"
onChange={handleAllProjectsCheckboxChange}
checked={
@@ -43,12 +43,14 @@ function DeleteProjectsButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</OLButton>
<DeleteProjectModal
projects={selectedProjects}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteProjectModal
projects={selectedProjects}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -36,12 +36,14 @@ function PurgeProjectsButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('purge')}
</OLButton>
<PurgeProjectModal
projects={selectedProjects}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeProjectModal
projects={selectedProjects}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -47,12 +47,14 @@ function RestoreProjectsButton() {
<OLButton variant="primary" onClick={handleOpenModal}>
{t('restore')}
</OLButton>
<RestoreProjectModal
projects={selectedProjects}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreProjectModal
projects={selectedProjects}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -60,12 +60,14 @@ function TransferProjectsButton() {
icon="swap_horiz"
/>
</OLTooltip>
<TransferProjectModal
projects={selectedProjects}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TransferProjectModal
projects={selectedProjects}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -52,12 +52,14 @@ function TrashProjectsButton() {
icon="delete"
/>
</OLTooltip>
<TrashProjectModal
projects={selectedProjects}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TrashProjectModal
projects={selectedProjects}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -21,8 +21,6 @@ import { getProjects } from '../util/api'
import { useUserIdentityContext } from '../../user-list/context/user-identity-context'
import sortProjects from '../util/sort-projects'
const MAX_PROJECT_PER_PAGE = 20
export type Filter = 'owned' | 'trashed' | 'deleted' | 'inactive'
type FilterMap = {
@@ -54,8 +52,6 @@ export type ProjectListContextValue = {
filter: Filter
hiddenProjectsCount: number
isLoading: ReturnType<typeof useAsync>['isLoading']
loadMoreCount: number
loadMoreProjects: () => void
loadProgress: number
removeProjectFromView: (project: Project) => void
selectFilter: (filter: Filter) => void
@@ -66,13 +62,17 @@ export type ProjectListContextValue = {
setSearchText: React.Dispatch<React.SetStateAction<string>>
setSelectedProjectIds: React.Dispatch<React.SetStateAction<Set<string>>>
setSort: React.Dispatch<React.SetStateAction<Sort>>
showAllProjects: () => void
sort: Sort
toggleSelectedProject: (projectId: string, selected?: boolean) => void
totalProjectsCount: number
projectsOwnerId: string | null
updateProjectViewData: (newProjectData: Project) => void
visibleProjects: Project[]
currentPage: number
setCurrentPage: React.Dispatch<React.SetStateAction<number>>
totalPages: number
projectsPerPage: number,
setProjectsPerPage: React.Dispatch<React.SetStateAction<number>>,
}
export const ProjectListContext = createContext<
@@ -92,9 +92,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
prefetchedProjectsBlob?.projects ?? []
)
const [maxVisibleProjects, setMaxVisibleProjects] =
useState(MAX_PROJECT_PER_PAGE)
const [loadProgress, setLoadProgress] = useState(
prefetchedProjectsBlob ? 100 : 20
)
@@ -113,7 +110,32 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
})
const prevSortRef = useRef<Sort>(sort)
const [searchText, setSearchText] = useState('')
const [searchText, setSearchTextState] = useState('')
const lastNonSearchPageRef = useRef(1)
const isSearchingRef = useRef(false)
const [currentPage, setCurrentPage] = useState(1)
const setSearchText: React.Dispatch<React.SetStateAction<string>> = value => {
setSearchTextState(prev => {
const nextValue =
typeof value === 'function' ? value(prev) : value
const wasSearching = isSearchingRef.current
const willSearch = nextValue.length > 0
if (!wasSearching && willSearch) {
lastNonSearchPageRef.current = currentPage
isSearchingRef.current = true
setCurrentPage(1)
} else if (wasSearching && !willSearch) {
isSearchingRef.current = false
setCurrentPage(lastNonSearchPageRef.current)
}
return nextValue
})
}
const {
isLoading: loading,
@@ -131,7 +153,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
if (prefetchedProjectsBlob) return
setLoadProgress(40)
runAsync(getProjects({ userId: projectsOwnerId, by: 'lastUpdated', order: 'desc' }))
.then(data => {
setLoadedProjects(data.projects)
@@ -144,7 +165,12 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
}, [projectsOwnerId, runAsync, prefetchedProjectsBlob])
const sortedProjects = useMemo(() => {
if (prevSortRef.current === sort) return loadedProjects
if (
prevSortRef.current.by === sort.by &&
prevSortRef.current.order === sort.order
) {
return loadedProjects
}
const sorted = sortProjects(loadedProjects, sort, getUserById)
prevSortRef.current = sort
@@ -152,40 +178,49 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
}, [loadedProjects, sort, getUserById])
const filteredProjects = useMemo(() => {
let result = sortedProjects
const predicate = filters[filter]
const hasSearch = searchText.length > 0
const lower = hasSearch ? searchText.toLowerCase() : null
if (searchText.length) {
const lower = searchText.toLowerCase()
result = result.filter(project =>
project.name.toLowerCase().includes(lower)
)
}
return sortedProjects.filter(project => {
if (hasSearch) {
if (!project.name.toLowerCase().includes(lower!)) {
return false
}
}
return result.filter(filters[filter])
return typeof predicate === 'function'
? predicate(project)
: true
})
}, [sortedProjects, searchText, filter])
const [projectsPerPage, setProjectsPerPage] = useState(20)
const previousProjectsPerPageRef = useRef(projectsPerPage)
useEffect(() => {
const previousProjectsPerPage = previousProjectsPerPageRef.current
if (previousProjectsPerPage !== projectsPerPage) {
const oldStartIndex = (currentPage - 1) * previousProjectsPerPage
const newPage = Math.floor(oldStartIndex / projectsPerPage) + 1
setCurrentPage(newPage)
previousProjectsPerPageRef.current = projectsPerPage
}
}, [projectsPerPage])
const totalPages = Math.ceil(filteredProjects.length / projectsPerPage)
const startIndex = (currentPage - 1) * projectsPerPage
const visibleProjects = useMemo(() => {
return filteredProjects.slice(0, maxVisibleProjects)
}, [filteredProjects, maxVisibleProjects])
return filteredProjects.slice(startIndex, startIndex + projectsPerPage)
}, [filteredProjects, startIndex, projectsPerPage])
const hiddenProjectsCount = Math.max(
filteredProjects.length - visibleProjects.length,
0
)
const loadMoreCount = Math.min(
hiddenProjectsCount,
MAX_PROJECT_PER_PAGE
)
const showAllProjects = useCallback(() => {
setMaxVisibleProjects(v => v + hiddenProjectsCount)
}, [hiddenProjectsCount])
const loadMoreProjects = useCallback(() => {
setMaxVisibleProjects(v => v + loadMoreCount)
}, [loadMoreCount])
const [selectedProjectIds, setSelectedProjectIds] = useState(
() => new Set<string>()
)
@@ -233,6 +268,8 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
const selectFilter = useCallback(
(filter: Filter) => {
setFilter(filter)
selectOrUnselectAllProjects(false)
setCurrentPage(1)
setSort(prev => {
if (filter === 'deleted' && prev.by === 'lastUpdated') {
@@ -243,8 +280,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
}
return prev
})
selectOrUnselectAllProjects(false)
},
[selectOrUnselectAllProjects]
)
@@ -269,8 +304,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
filter,
hiddenProjectsCount,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromView,
selectFilter,
@@ -281,21 +314,23 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
setSearchText,
setSelectedProjectIds,
setSort,
showAllProjects,
sort,
toggleSelectedProject,
totalProjectsCount,
updateProjectViewData,
projectsOwnerId,
visibleProjects,
currentPage,
setCurrentPage,
totalPages,
projectsPerPage,
setProjectsPerPage
}),
[
error,
filter,
hiddenProjectsCount,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromView,
selectFilter,
@@ -306,13 +341,17 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
setSearchText,
setSelectedProjectIds,
setSort,
showAllProjects,
sort,
toggleSelectedProject,
totalProjectsCount,
projectsOwnerId,
updateProjectViewData,
visibleProjects,
currentPage,
setCurrentPage,
totalPages,
projectsPerPage,
setProjectsPerPage
]
)
@@ -7,9 +7,10 @@ const toggleSort = (order: SortingOrder): SortingOrder =>
order === 'asc' ? 'desc' : 'asc'
function useSort() {
const { filter, sort, setSort } = useProjectListContext()
const { filter, sort, setSort, setCurrentPage } = useProjectListContext()
const handleSort = (by: Sort['by']) => {
setCurrentPage(1)
setSort(prev => ({
by,
order: prev.by === by ? toggleSort(prev.order) : prev.order,
@@ -18,13 +19,15 @@ function useSort() {
useEffect(() => {
if (filter === 'deleted' && sort.by === 'lastUpdated') {
setCurrentPage(1)
setSort(prev => ({ ...prev, by: 'deletedAt' }))
}
if (filter !== 'deleted' && sort.by === 'deletedAt') {
setCurrentPage(1)
setSort(prev => ({ ...prev, by: 'lastUpdated' }))
}
}, [filter, sort.by, setSort])
}, [filter, sort.by, setSort, setCurrentPage])
return { handleSort }
}
@@ -1,56 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useUserListContext } from '../context/user-list-context'
import OLButton from '@/shared/components/ol/ol-button'
export default function LoadMore() {
const {
visibleUsers,
hiddenUsersCount,
loadMoreCount,
showAllUsers,
loadMoreUsers,
} = useUserListContext()
const { t } = useTranslation()
return (
<div className="text-center">
{hiddenUsersCount > 0 ? (
<>
<OLButton
variant="secondary"
className="user-list-load-more-button"
onClick={() => loadMoreUsers()}
>
{t('show_x_more_users', { x: loadMoreCount })}
</OLButton>
</>
) : null}
<p>
{hiddenUsersCount > 0 ? (
<>
<span aria-live="polite">
{t('showing_x_out_of_n_users', {
x: visibleUsers.length,
n: visibleUsers.length + hiddenUsersCount,
})}
</span>{' '}
<OLButton
variant="link"
onClick={() => showAllUsers()}
className="btn-inline-link"
>
{t('show_all_users')}
</OLButton>
</>
) : (
<span aria-live="polite">
{t('showing_x_out_of_n_users', {
x: visibleUsers.length,
n: visibleUsers.length,
})}
</span>
)}
</p>
</div>
)
}
@@ -9,7 +9,6 @@ import OLForm from '@/shared/components/ol/ol-form'
import SelectOwnerForm from '../../../project-list/components/select-owner-form'
import { useUserListContext } from '../../../user-list/context/user-list-context'
import { UserRef } from '../../../../../types/project/api'
import sortUsers from '../../../user-list/util/sort-users'
type DeleteUserModalProps = Pick<
React.ComponentProps<typeof UsersActionModal>,
@@ -34,12 +33,10 @@ function DeleteUserModal({
const potentialOwners = useMemo(() => {
if (!loadedUsers) return []
const excludeIds = new Set(users.map(u => u.id))
const possibleUsers = loadedUsers.filter(
return loadedUsers.filter(
user => !user.deleted && !excludeIds.has(user.id)
)
return sortUsers(possibleUsers, { by: 'name', order: 'asc' })
}, [loadedUsers, users])
useEffect(() => {
@@ -42,7 +42,8 @@ function ShowUserInfoModal({
const user = users[0]
const [activationLink, setActivationLink] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [copiedId, setCopiedId] = useState(false)
const [copiedActivationLink, setCopiedActivationLink] = useState(false)
useEffect(() => {
if (!showModal) return
@@ -56,22 +57,22 @@ function ShowUserInfoModal({
})
}, [showModal, user.id])
const handleCopy = () => {
if (!activationLink) return
const markCopied = (setter: React.Dispatch<React.SetStateAction<boolean>>) => {
setter(true)
setTimeout(() => setter(false), 1500)
}
const markCopied = () => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
const handleCopy = (text: string, setter: React.Dispatch<React.SetStateAction<boolean>>) => {
if (!text) return
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(activationLink).then(markCopied)
navigator.clipboard.writeText(text).then(() => markCopied(setter))
return
}
// fallback for older browsers
const tempInput = document.createElement('input')
tempInput.value = activationLink
tempInput.value = text
tempInput.style.position = 'fixed'
tempInput.style.opacity = '0'
document.body.appendChild(tempInput)
@@ -79,7 +80,7 @@ function ShowUserInfoModal({
document.execCommand('copy')
document.body.removeChild(tempInput)
markCopied()
markCopied(setter)
}
return (
@@ -95,6 +96,22 @@ function ShowUserInfoModal({
<>
<Card.Header>{t('Account')}</Card.Header>
<Body>
<InfoRow
label={'ID'}
value={
<span
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleCopy(user.id, setCopiedId)}
>
{user.id}
{copiedId && (
<span className="ms-2 text-success">
({t('copied')})
</span>
)}
</span>
}
/>
<InfoRow label={t('email_address')} value={user.email} />
<InfoRow label={t('first_name')} value={user.firstName || '—'} />
<InfoRow label={t('last_name')} value={user.lastName || '—'} />
@@ -110,15 +127,15 @@ function ShowUserInfoModal({
/>
)}
{activationLink && (
<InfoRow
<InfoRow
label={t('activation_link')}
value={
<span
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={handleCopy}
onClick={() => handleCopy(activationLink, setCopiedActivationLink)}
>
{activationLink}
{copied && (
{copiedActivationLink && (
<span className="ms-2 text-success">
({t('copied')})
</span>
@@ -170,4 +187,3 @@ function ShowUserInfoModal({
}
export default ShowUserInfoModal
@@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import * as eventTracking from '@/infrastructure/event-tracking'
@@ -8,12 +9,10 @@ import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import MaterialIcon from '@/shared/components/material-icon'
import { MergeAndOverride } from '../../../../../../types/utils'
import { Filter } from '../context/user-list-context'
type SearchFormOwnProps = {
inputValue: string
setInputValue: (input: string) => void
filter: Filter
}
type SearchFormProps = MergeAndOverride<
@@ -24,13 +23,19 @@ type SearchFormProps = MergeAndOverride<
function SearchForm({
inputValue,
setInputValue,
filter,
className,
...props
}: SearchFormProps) {
const { t } = useTranslation()
const placeholder = t('search')+'…'
const [localValue, setLocalValue] = useState(inputValue)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setLocalValue(inputValue)
}, [inputValue])
const handleChange: React.ComponentProps<
typeof OLFormControl
>['onChange'] = e => {
@@ -38,10 +43,23 @@ function SearchForm({
action: 'search',
isSmallDevice,
})
setInputValue(e.target.value)
const value = e.target.value
setLocalValue(value)
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
setInputValue(value)
}, 300)
}
const handleClear = () => setInputValue('')
const handleClear = () => {
setLocalValue('')
setInputValue('')
}
return (
<OLForm
@@ -55,13 +73,13 @@ function SearchForm({
<OLFormControl
name="search"
type="text"
value={inputValue}
value={localValue}
onChange={handleChange}
placeholder={placeholder}
aria-label={placeholder}
prepend={<MaterialIcon type="search" />}
append={
inputValue.length > 0 && (
localValue.length > 0 && (
<button
type="button"
className="form-control-search-clear-btn"
@@ -40,12 +40,14 @@ function DeleteUserButton({ user, children }: DeleteUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<DeleteUserModal
users={[user]}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteUserModal
users={[user]}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -43,13 +43,15 @@ function FlagUserButton({ user, action, children }: FlagUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<FlagUserModal
users={[user]}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<FlagUserModal
users={[user]}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -40,12 +40,14 @@ function PurgeUserButton({ user, children }: PurgeUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<PurgeUserModal
users={[user]}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeUserModal
users={[user]}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -40,12 +40,14 @@ function RestoreUserButton({ user, children }: RestoreUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<RestoreUserModal
users={[user]}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreUserModal
users={[user]}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -42,12 +42,14 @@ function SendRegEmailButton({ user, children }: SendRegEmailButtonProps) {
return (
<span style={isHidden ? { visibility: 'hidden' } : undefined }>
{children(text, handleOpenModal)}
<SendRegEmailModal
users={[user]}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<SendRegEmailModal
users={[user]}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</span>
)
}
@@ -38,11 +38,13 @@ function ShowUserInfoButton({ user, children }: ShowUserInfoButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<ShowUserInfoModal
users={[user]}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<ShowUserInfoModal
users={[user]}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -40,12 +40,14 @@ function UpdateUserButton({ user, children }: UpdateUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<UpdateUserModal
users={[user]}
actionHandler={handleUpdateUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<UpdateUserModal
users={[user]}
actionHandler={handleUpdateUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -28,10 +28,13 @@ function UserListTable() {
const {
visibleUsers,
sort,
searchText,
selectedUsers,
selectOrUnselectAllUsers,
selfVisibleCount,
filter,
currentPage,
setCurrentPage,
} = useUserListContext()
const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>(null)
@@ -39,12 +39,14 @@ function DeleteUsersButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</OLButton>
<DeleteUserModal
users={selectedUsers}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteUserModal
users={selectedUsers}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -70,13 +70,15 @@ function FlagUsersButton({ action }: { action: string }) {
unfilled={unfilled}
/>
</OLTooltip>
<FlagUserModal
users={selectedUsers}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<FlagUserModal
users={selectedUsers}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -33,13 +33,14 @@ function PurgeUsersButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('purge')}
</OLButton>
<PurgeUserModal
users={selectedUsers}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeUserModal
users={selectedUsers}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -33,13 +33,14 @@ function RestoreUsersButton() {
<OLButton variant="primary" onClick={handleOpenModal}>
{t('restore')}
</OLButton>
<RestoreUserModal
users={selectedUsers}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreUserModal
users={selectedUsers}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -47,12 +47,14 @@ function SendRegEmailsButton({ action }: { action: string }) {
unfilled={true}
/>
</OLTooltip>
<SendRegEmailModal
users={selectedUsers}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<SendRegEmailModal
users={selectedUsers}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}
@@ -7,7 +7,7 @@ import UsersDropdown from './dropdown/users-dropdown'
import SortByDropdown from './dropdown/sort-by-dropdown'
import UserTools from './table/user-tools/user-tools'
import UserListTitle from './title/user-list-title'
import LoadMore from './load-more'
import CountUsers from './count-users'
import OLCol from '@/shared/components/ol/ol-col'
import OLRow from '@/shared/components/ol/ol-row'
import { TableContainer } from '@/shared/components/table'
@@ -17,7 +17,11 @@ import DefaultNavbar from '@/shared/components/navbar/default-navbar'
import Footer from '@/shared/components/footer/footer'
import SidebarDsNav from './sidebar/sidebar-ds-nav'
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg'
import CookieBanner from '@/shared/components/cookie-banner'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
import Pagination from '@/shared/components/pagination-cep'
import UserListSummary from './user-list-summary'
export function UserListDsNav() {
const navbarProps = getMeta('ol-navbar')
@@ -30,7 +34,11 @@ export function UserListDsNav() {
setSearchText,
selectedUsers,
filter,
currentPage,
setCurrentPage,
totalPages,
} = useUserListContext()
const activeOverallTheme = useActiveOverallTheme('themed-project-dashboard')
const tableTopArea = (
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
@@ -40,7 +48,6 @@ export function UserListDsNav() {
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
className="overflow-hidden flex-grow-1"
/>
</div>
@@ -50,7 +57,9 @@ export function UserListDsNav() {
<div className="user-ds-nav-page website-redesign">
<DefaultNavbar
{...navbarProps}
overleafLogo={overleafLogo}
overleafLogo={
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
}
showCloseIcon
/>
<div className="user-list-wrapper">
@@ -77,7 +86,6 @@ export function UserListDsNav() {
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
/>
</OLCol>
</OLRow>
@@ -98,7 +106,10 @@ export function UserListDsNav() {
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
<UserListSummary />
</div>
<div className="mt-3">
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />
</div>
</div>
</main>
@@ -0,0 +1,60 @@
import { useTranslation } from 'react-i18next'
import {
Dropdown,
DropdownMenu,
DropdownItem,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import { useUserListContext } from '../context/user-list-context'
const OPTIONS = [20, 40, 80]
export default function UserListSummary() {
const {
visibleUsers,
hiddenUsersCount,
usersPerPage,
setUsersPerPage,
} = useUserListContext()
const { t } = useTranslation()
return (
<div className="text-center">
<span aria-live="polite">
{t('showing_x_out_of_n_users', {
x: visibleUsers.length,
n: visibleUsers.length + hiddenUsersCount,
})}
</span>
<span className="mx-2">·</span>
<span className="d-inline-flex gap-1">
<Dropdown>
<DropdownToggle
as="span"
className="entries-per-page-toggle"
>
{usersPerPage}
</DropdownToggle>
<DropdownMenu>
{OPTIONS.map((value) => (
<DropdownItem
key={value}
active={value === usersPerPage}
onClick={() => setUsersPerPage(value)}
>
{value}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<span>{t('per_page')}</span>
</span>
</div>
)
}
@@ -6,6 +6,7 @@ import {
useEffect,
useMemo,
useState,
useRef,
} from 'react'
import {
filter as arrayFilter,
@@ -25,8 +26,6 @@ import sortUsers from '../util/sort-users'
import { UserIdentityProvider } from './user-identity-context'
const MAX_USER_PER_PAGE = 10
type AuthMethods = 'local' | 'ldap' | 'saml' | 'oidc'
export type Filter = 'all' | 'admin' | 'suspended' | 'inactive' | AuthMethods | 'deleted'
@@ -68,8 +67,6 @@ export type UserListContextValue = {
filterTranslations: Map<Filter, string>
hiddenUsersCount: number
isLoading: ReturnType<typeof useAsync>['isLoading']
loadMoreCount: number
loadMoreUsers: () => void
loadProgress: number
removeUserFromView: (user: User) => void
searchText: string
@@ -81,13 +78,17 @@ export type UserListContextValue = {
setSearchText: React.Dispatch<React.SetStateAction<string>>
setSelectedUserIds: React.Dispatch<React.SetStateAction<Set<string>>>
setSort: React.Dispatch<React.SetStateAction<Sort>>
showAllUsers: () => void
sort: Sort
toggleSelectedUser: (userId: string, selected?: boolean) => void
totalUsersCount: number
updateUserViewData: (newUserData: User) => void
loadedUsers: User[]
visibleUsers: User[]
currentPage: number
setCurrentPage: React.Dispatch<React.SetStateAction<number>>
totalPages: number
usersPerPage: number,
setUsersPerPage: React.Dispatch<React.SetStateAction<number>>,
}
export const UserListContext = createContext<
@@ -104,9 +105,6 @@ export function UserListProvider({ children }: UserListProviderProps) {
prefetchedUsersBlob?.users ?? []
)
const [maxVisibleUsers, setMaxVisibleUsers] =
useState(MAX_USER_PER_PAGE)
const [loadProgress, setLoadProgress] = useState(
prefetchedUsersBlob ? 100 : 20
)
@@ -130,7 +128,31 @@ export function UserListProvider({ children }: UserListProviderProps) {
'all'
)
const [searchText, setSearchText] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [searchText, setSearchTextState] = useState('')
const lastNonSearchPageRef = useRef(1)
const isSearchingRef = useRef(false)
const setSearchText: React.Dispatch<React.SetStateAction<string>> = value => {
setSearchTextState(prev => {
const nextValue =
typeof value === 'function' ? value(prev) : value
const wasSearching = isSearchingRef.current
const willSearch = nextValue.length > 0
if (!wasSearching && willSearch) {
lastNonSearchPageRef.current = currentPage
isSearchingRef.current = true
setCurrentPage(1)
} else if (wasSearching && !willSearch) {
isSearchingRef.current = false
setCurrentPage(lastNonSearchPageRef.current)
}
return nextValue
})
}
const {
isLoading: loading,
@@ -143,10 +165,11 @@ export function UserListProvider({ children }: UserListProviderProps) {
})
const isLoading = isIdle ? true : loading
useEffect(() => {
if (prefetchedUsersBlob) return
setLoadProgress(40)
runAsync(getUsers({ by: 'signUpDate', order: 'desc' }))
runAsync(getUsers({ by: 'name', order: 'asc' }))
.then(data => {
setLoadedUsers(data.users)
setTotalUsersCount(data.totalSize)
@@ -161,7 +184,10 @@ export function UserListProvider({ children }: UserListProviderProps) {
setLoadedUsers(prev => [newUser as User, ...prev])
}, [])
const processedUsers = useMemo(() => {
const isDefaultSort =
sort.by === 'name' && sort.order === 'asc'
const filteredUsers = useMemo(() => {
let users = loadedUsers
if (searchText.length) {
@@ -175,35 +201,50 @@ export function UserListProvider({ children }: UserListProviderProps) {
users = arrayFilter(users, filters[filter])
return sortUsers(users, sort)
}, [loadedUsers, searchText, filter, sort])
return users
}, [loadedUsers, searchText, filter])
const processedUsers = useMemo(() => {
if (isDefaultSort) {
return filteredUsers
}
return sortUsers(filteredUsers, sort)
}, [filteredUsers, sort, isDefaultSort])
const [usersPerPage, setUsersPerPage] = useState(20)
const previousUsersPerPageRef = useRef(usersPerPage)
useEffect(() => {
const previousUsersPerPage = previousUsersPerPageRef.current
if (previousUsersPerPage !== usersPerPage) {
const oldStartIndex = (currentPage - 1) * previousUsersPerPage
const newPage = Math.floor(oldStartIndex / usersPerPage) + 1
setCurrentPage(newPage)
previousUsersPerPageRef.current = usersPerPage
}
}, [usersPerPage])
const totalPages = useMemo(
() => Math.ceil(processedUsers.length / usersPerPage),
[processedUsers.length, usersPerPage]
)
const startIndex = (currentPage - 1) * usersPerPage
const visibleUsers = useMemo(() => {
return processedUsers.slice(0, maxVisibleUsers)
}, [processedUsers, maxVisibleUsers])
return processedUsers.slice(startIndex, startIndex + usersPerPage)
}, [processedUsers, startIndex, usersPerPage])
const hiddenUsersCount = Math.max(
processedUsers.length - visibleUsers.length,
0
)
const loadMoreCount = Math.min(
hiddenUsersCount,
MAX_USER_PER_PAGE
)
const selfVisibleCount = useMemo(() => {
return visibleUsers.some(u => u.id === selfId) ? 1 : 0
}, [visibleUsers])
const showAllUsers = useCallback(() => {
setMaxVisibleUsers(maxVisibleUsers + hiddenUsersCount)
}, [hiddenUsersCount, maxVisibleUsers])
const loadMoreUsers = useCallback(() => {
setMaxVisibleUsers(maxVisibleUsers + loadMoreCount)
}, [maxVisibleUsers, loadMoreCount])
const [selectedUserIds, setSelectedUserIds] = useState(
() => new Set<string>()
)
@@ -266,10 +307,10 @@ export function UserListProvider({ children }: UserListProviderProps) {
return prev
})
const selected = false
selectOrUnselectAllUsers(selected)
selectOrUnselectAllUsers(false)
setCurrentPage(1)
},
[selectOrUnselectAllUsers, setFilter]
[selectOrUnselectAllUsers]
)
const updateUserViewData = useCallback((newUserData: User) => {
@@ -294,8 +335,6 @@ export function UserListProvider({ children }: UserListProviderProps) {
filterTranslations,
hiddenUsersCount,
isLoading,
loadMoreCount,
loadMoreUsers,
loadProgress,
removeUserFromView,
searchText,
@@ -307,13 +346,17 @@ export function UserListProvider({ children }: UserListProviderProps) {
setSearchText,
setSelectedUserIds,
setSort,
showAllUsers,
sort,
toggleSelectedUser,
totalUsersCount,
updateUserViewData,
loadedUsers,
visibleUsers,
currentPage,
setCurrentPage,
totalPages,
usersPerPage,
setUsersPerPage,
}),
[
addUserToView,
@@ -322,8 +365,6 @@ export function UserListProvider({ children }: UserListProviderProps) {
filterTranslations,
hiddenUsersCount,
isLoading,
loadMoreCount,
loadMoreUsers,
loadProgress,
removeUserFromView,
searchText,
@@ -335,19 +376,21 @@ export function UserListProvider({ children }: UserListProviderProps) {
setSearchText,
setSelectedUserIds,
setSort,
showAllUsers,
sort,
toggleSelectedUser,
totalUsersCount,
updateUserViewData,
loadedUsers,
visibleUsers,
currentPage,
setCurrentPage,
totalPages,
usersPerPage,
setUsersPerPage,
]
)
if (!loadedUsers || loadedUsers.length === 0) {
return null
}
if (isLoading) return null
return (
<UserIdentityProvider users={loadedUsers}>
@@ -8,9 +8,10 @@ const toggleSort = (order: SortingOrder): SortingOrder => {
}
function useSort() {
const { filter, sort, setSort } = useUserListContext()
const { filter, sort, setSort, setCurrentPage } = useUserListContext()
const handleSort = (by: Sort['by']) => {
setCurrentPage(1)
setSort(prev => ({
by,
order: prev.by === by ? toggleSort(sort.order) : sort.order,
@@ -19,13 +20,15 @@ function useSort() {
useEffect(() => {
if (filter === 'deleted' && sort.by === 'signUpDate') {
setCurrentPage(1)
setSort(prev => ({ ...prev, by: 'deletedAt' }))
}
if (filter !== 'deleted' && sort.by === 'deletedAt') {
setCurrentPage(1)
setSort(prev => ({ ...prev, by: 'signUpDate' }))
}
}, [filter, sort.by, setSort])
}, [filter, sort.by, setSort, setCurrentPage])
return { handleSort }
}
@@ -1,5 +1,4 @@
import Settings from '@overleaf/settings'
import { callbackify } from '@overleaf/promise-utils'
import UserCreator from '../../../../../app/src/Features/User/UserCreator.mjs'
import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.mjs'
import { User } from '../../../../../app/src/models/User.mjs'
@@ -2,7 +2,7 @@ import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import { promisify } from 'util'
import passport from 'passport'
import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js'
import ldapjs from 'ldapjs'
import UserGetter from '../../../../../app/src/Features/User/UserGetter.mjs'
import { splitFullName } from '../../../utils.mjs'
@@ -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
@@ -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