mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0720a65de3 | |||
| 95d1f52c2e | |||
| a52d9232a9 | |||
| fe1672356a | |||
| 2a8d845cbc | |||
| b593930338 | |||
| 050200af36 | |||
| 5274b99277 | |||
| 7cabefaf34 | |||
| 81729ae72b | |||
| cd2d9fc218 | |||
| 4d7340d70b | |||
| e596b57cc2 | |||
| 58b6901b7b | |||
| b8fd239f2e | |||
| 201fbb56eb | |||
| 823b0442a2 | |||
| 4ff663e112 | |||
| e5ded0a6a5 | |||
| 784d075233 | |||
| 1c9b300e25 | |||
| 870bf1157b | |||
| d2aba1de96 | |||
| 052924d816 | |||
| 2fe887ef57 | |||
| e288a5d38e | |||
| dc5c3f5280 | |||
| 610721eed6 | |||
| d50f0986bb | |||
| 90af35d4bd | |||
| 3810373195 | |||
| 3fd893e728 | |||
| 13e3aaf861 | |||
| b43ebabbaf | |||
| 42550a003a | |||
| 08b7c11461 | |||
| 8a9a8cf751 | |||
| 2d6167e933 | |||
| 6b05b101d0 | |||
| 79fe73fbe1 | |||
| 63376ed9c8 | |||
| 0cec66b3bb | |||
| fcc73e772b | |||
| b5cb6128c4 | |||
| 261226c110 | |||
| 6fff437196 | |||
| 4f34e70d32 | |||
| 4c04bd9359 | |||
| 16c8ae6132 | |||
| 30bba3a69b | |||
| 32c1712fdc | |||
| d392149860 | |||
| 30108ebded | |||
| d0bd2baa9f | |||
| fd984774d0 | |||
| e216c68f6d | |||
| 2e2a8bcc94 | |||
| 245d14f905 | |||
| 8717d160ce | |||
| 587ba85cc9 | |||
| 80bb1ce977 | |||
| c598c61afe | |||
| 68b07eb466 | |||
| 06a149407a | |||
| b9387734c7 | |||
| 810b7908e4 | |||
| 6b76a898fa | |||
| 8ba83e2173 | |||
| 5a4b8c5faa | |||
| 3f8bdf7ac2 | |||
| 9c4b4f4989 | |||
| c5d534b2ad | |||
| bed3d1078e | |||
| 83e87254c6 | |||
| f576ddccfe | |||
| 0a674eacfa | |||
| ceac57bd64 | |||
| 97f31e3f2a | |||
| a06671e8ce | |||
| fd3c21d28b | |||
| c0c36bacbb | |||
| 7bd1ea7c40 | |||
| 5ebb1e8a61 | |||
| 96d6987858 | |||
| 3602198cd8 | |||
| 00bab31cff | |||
| 3ef2b7cf42 | |||
| 18743da2fc | |||
| fe1307d7e7 | |||
| a226889143 | |||
| 347f033802 | |||
| f5c659f902 | |||
| 722d10e7de | |||
| ce001547b5 | |||
| 8d05e2b095 | |||
| 19e40cf814 | |||
| 2bb9b50637 |
+165
-140
@@ -1,48 +1,80 @@
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
NODE_ENV=production
|
||||
|
||||
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
|
||||
# in your terminal to generate a random value.
|
||||
SECRET_KEY=generate_a_new_key
|
||||
|
||||
# Generate a unique random key. The format is not important but you could still use
|
||||
# `openssl rand -hex 32` in your terminal to produce this.
|
||||
UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# For production point these at your databases, in development the default
|
||||
# should work out of the box.
|
||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
||||
DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# For redis you can either specify an ioredis compatible url like this
|
||||
REDIS_URL=redis://redis:6379
|
||||
# or alternatively, if you would like to provide additional connection options,
|
||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||
# for a list of available options.
|
||||
# Example: Use Redis Sentinel for high availability
|
||||
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
|
||||
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
# This URL should point to the fully qualified, publicly accessible, URL. If using a
|
||||
# proxy this will be the proxy's URL.
|
||||
URL=
|
||||
|
||||
# The port to expose the Outline server on, this should match what is configured
|
||||
# in your docker-compose.yml
|
||||
PORT=3000
|
||||
|
||||
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
||||
# server, for normal operation this does not need to be set.
|
||||
COLLABORATION_URL=
|
||||
|
||||
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
|
||||
# This will cause paths to javascript, stylesheets, and images to be updated to
|
||||
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
|
||||
# How many processes should be spawned. As a reasonable rule divide your servers
|
||||
# available memory by 512 for a rough estimate
|
||||
WEB_CONCURRENCY=1
|
||||
|
||||
# Generate a hex-encoded 32-byte random key. Use `openssl rand -hex 32` in your
|
||||
# terminal to generate a random value.
|
||||
SECRET_KEY=generate_a_new_key
|
||||
|
||||
# Generate a unique random key. The format is not important but you could still use
|
||||
# `openssl rand -hex 32` in your terminal to generate a random value.
|
||||
UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––––– DATABASE –––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# The database URL for your production database, including username, password, and database name.
|
||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
||||
|
||||
# The in-memory database pool per-process settings. Ensure that the pool size that will not exceed
|
||||
# the maximum number of connections allowed by your database. Defaults to 0 and 5.
|
||||
DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
|
||||
# Uncomment this line if you will not use SSL for connecting to Postgres. This is acceptable
|
||||
# if the database and the application are on the same machine.
|
||||
# PGSSLMODE=disable
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––––––– REDIS –––––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# The Redis URL for your environment you can either specify an ioredis compatible url or a Base64
|
||||
# encoded configuration object.
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/redis-LGM4BFXYp4
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––– FILE STORAGE –––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# Specify what storage system to use. Possible value is one of "s3" or "local".
|
||||
# For "local", the avatar images and document attachments will be saved on local disk.
|
||||
# For "local" images and document attachments will be saved on local disk, for "s3" they
|
||||
# will be stored in an S3-compatible network store.
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/file-storage-N4M0T6Ypu7
|
||||
FILE_STORAGE=local
|
||||
|
||||
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
|
||||
# which all attachments/images go. Make sure that the process has permissions to create
|
||||
# this path and also to write files to it.
|
||||
# which all attachments/images are stored. Make sure that the process has permissions to
|
||||
# create this path and also to write files to it.
|
||||
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
|
||||
# Maximum allowed size for the uploaded attachment.
|
||||
@@ -56,8 +88,8 @@ FILE_STORAGE_IMPORT_MAX_SIZE=
|
||||
# and the files are temporary being automatically deleted after a period of time.
|
||||
FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE=
|
||||
|
||||
# To support uploading of images for avatars and document attachments in a distributed
|
||||
# architecture an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
|
||||
# To support uploading of images for avatars and document attachments in a distributed
|
||||
# architecture, an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
|
||||
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||
AWS_REGION=xx-xxxx-x
|
||||
@@ -67,38 +99,55 @@ AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
AWS_S3_ACL=private
|
||||
|
||||
# –––––––––––––– AUTHENTICATION ––––––––––––––
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––––––––– SSL –––––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is one
|
||||
# of three ways to configure SSL and can be left empty.
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/ssl-pzk7WO8d1n
|
||||
SSL_KEY=
|
||||
SSL_CERT=
|
||||
|
||||
# Auto-redirect to https in production. The default is true but you may set to
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
|
||||
# or Microsoft is required for a working installation or you'll have no sign-in
|
||||
# options.
|
||||
# Discord, or Microsoft is required for a working installation or you'll
|
||||
# have no sign-in options.
|
||||
|
||||
# To configure Slack auth, you'll need to create an Application at
|
||||
# => https://api.slack.com/apps
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
|
||||
# https://<URL>/auth/slack.callback
|
||||
# Slack sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
|
||||
SLACK_CLIENT_ID=get_a_key_from_slack
|
||||
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
#
|
||||
# When configuring the Client ID, add an Authorized redirect URI:
|
||||
# https://<URL>/auth/google.callback
|
||||
# Google sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/google-hOuvtCmTqQ
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
|
||||
# the guide for details on setting up your Azure App:
|
||||
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
|
||||
# Microsoft Entra / Azure AD sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/microsoft-entra-UVz6jsIOcv
|
||||
AZURE_CLIENT_ID=
|
||||
AZURE_CLIENT_SECRET=
|
||||
AZURE_RESOURCE_APP_ID=
|
||||
|
||||
# To configure generic OIDC auth, you'll need some kind of identity provider.
|
||||
# See documentation for whichever IdP you use to acquire the following info:
|
||||
# Redirect URI is https://<URL>/auth/oidc.callback
|
||||
# Discord sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/discord-g4JdWFFub6
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
DISCORD_SERVER_ID=
|
||||
DISCORD_SERVER_ROLES=
|
||||
|
||||
# Generic OIDC provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/oidc-8CPBm6uC0I
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_AUTH_URI=
|
||||
@@ -116,83 +165,54 @@ OIDC_DISPLAY_NAME=OpenID Connect
|
||||
# Space separated auth scopes.
|
||||
OIDC_SCOPES=openid profile email
|
||||
|
||||
# To configure the GitHub integration, you'll need to create a GitHub App at
|
||||
# => https://github.com/settings/apps
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "Permissions & events":
|
||||
# https://<URL>/api/github.callback
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––––––– EMAIL –––––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# email sign-in you'll need to connect an SMTP server. Service can be configured
|
||||
# with any service from this list: https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/smtp-cqCJyZGMIB
|
||||
SMTP_SERVICE=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– RATE LIMITER ––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# Whether the rate limiter is enabled or not
|
||||
RATE_LIMITER_ENABLED=true
|
||||
|
||||
# Individual endpoints have hardcoded rate limits that are enabled
|
||||
# with the above setting, however this is a global rate limiter
|
||||
# across all requests
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––– INTEGRATIONS –––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# The GitHub integration allows previewing issue and pull request links
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# Linear
|
||||
# The Linear integration allows previewing issue links as rich mentions
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
# To configure Discord auth, you'll need to create a Discord Application at
|
||||
# => https://discord.com/developers/applications/
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth2":
|
||||
# https://<URL>/auth/discord.callback
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
|
||||
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
|
||||
# integrated with.
|
||||
# Used to verify that the user is a member of the server as well as server
|
||||
# metadata such as nicknames, server icon and name.
|
||||
DISCORD_SERVER_ID=
|
||||
|
||||
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
|
||||
# allowed to access Outline. If this is not set, all members of the server
|
||||
# will be allowed to access Outline.
|
||||
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
|
||||
DISCORD_SERVER_ROLES=
|
||||
|
||||
# –––––––––––––– IMPORTS ––––––––––––––
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is only
|
||||
# required if you do not use an external reverse proxy. See documentation:
|
||||
# https://wiki.generaloutline.com/share/1c922644-40d8-41fe-98f9-df2b67239d45
|
||||
SSL_KEY=
|
||||
SSL_CERT=
|
||||
|
||||
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
|
||||
# This will cause paths to javascript, stylesheets, and images to be updated to
|
||||
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
|
||||
# Auto-redirect to https in production. The default is true but you may set to
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# Have the installation check for updates by sending anonymized statistics to
|
||||
# the maintainers
|
||||
ENABLE_UPDATES=true
|
||||
|
||||
# How many processes should be spawned. As a reasonable rule divide your servers
|
||||
# available memory by 512 for a rough estimate
|
||||
WEB_CONCURRENCY=1
|
||||
|
||||
# You can remove this line if your reverse proxy already logs incoming http
|
||||
# requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Configure lowest severity level for server logs. Should be one of
|
||||
# error, warn, info, http, verbose, debug and silly
|
||||
LOG_LEVEL=info
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
#
|
||||
# following configs are also needed in addition to Slack authentication:
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
|
||||
SLACK_VERIFICATION_TOKEN=your_token
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
@@ -202,29 +222,34 @@ SLACK_MESSAGE_ACTIONS=true
|
||||
DROPBOX_APP_KEY=
|
||||
|
||||
# Optionally enable Sentry (sentry.io) to track errors and performance,
|
||||
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
|
||||
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/sentry-jxcFttcDl5
|
||||
SENTRY_DSN=
|
||||
SENTRY_TUNNEL=
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||
SMTP_SERVICE=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
# Enable importing pages from a Notion workspace
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/notion-2v6g7WY3l3
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
# Optionally enable rate limiter at application web server
|
||||
RATE_LIMITER_ENABLED=true
|
||||
|
||||
# Configure default throttling parameters for rate limiter
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Iframely API config
|
||||
# The Iframely integration allows previews of third-party content within Outline.
|
||||
# For example, hovering over an external link will show a preview.
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/iframely-HwLF1EZ9mo
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––––– DEBUGGING ––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# Have the installation check for updates by sending anonymized statistics to
|
||||
# the maintainers
|
||||
ENABLE_UPDATES=true
|
||||
|
||||
# Debugging categories to enable – you can remove the default "http" value if
|
||||
# your proxy already logs incoming http requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Configure lowest severity level for server logs. Should be one of
|
||||
# error, warn, info, http, verbose, debug, or silly
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Auto Close Unsigned PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Run daily at midnight UTC
|
||||
|
||||
jobs:
|
||||
close-unsigned-prs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Close unsigned PRs
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const now = new Date();
|
||||
const TWO_WEEKS = 14 * 24 * 60 * 60 * 1000; // 14 days in milliseconds
|
||||
|
||||
const prs = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open'
|
||||
});
|
||||
|
||||
for (const pr of prs.data) {
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
const prAge = now - prCreatedAt;
|
||||
|
||||
if (prAge < TWO_WEEKS) continue;
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number
|
||||
});
|
||||
|
||||
const hasNotSignedComment = comments.data.some(comment =>
|
||||
comment.body.toLowerCase().includes('https://cla-assistant.io/pull/badge/not_signed')
|
||||
);
|
||||
|
||||
if (hasNotSignedComment) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: 'This PR has been automatically closed because it has been open for more than 14 days and has not accepted the CLA.'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.83.0
|
||||
Licensed Work: Outline 0.84.0
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-04-11
|
||||
Change Date: 2029-05-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
@@ -14,13 +15,15 @@ import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
@@ -30,6 +33,26 @@ export interface FormData {
|
||||
permission: CollectionPermission | undefined;
|
||||
}
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
const { collections } = useStores();
|
||||
const hasMultipleCollections = collections.orderedData.length > 1;
|
||||
const collectionColors = uniq(
|
||||
collections.orderedData.map((c) => c.color).filter(Boolean)
|
||||
) as string[];
|
||||
|
||||
const iconColor = React.useMemo(
|
||||
() =>
|
||||
collection?.color ??
|
||||
// If all the existing collections have the same color, use that color,
|
||||
// otherwise pick a random color from the palette
|
||||
(hasMultipleCollections && collectionColors.length === 1
|
||||
? collectionColors[0]
|
||||
: randomElement(colorPalette)),
|
||||
[collection?.color]
|
||||
);
|
||||
return iconColor;
|
||||
};
|
||||
|
||||
export const CollectionForm = observer(function CollectionForm_({
|
||||
handleSubmit,
|
||||
collection,
|
||||
@@ -42,11 +65,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const iconColor = React.useMemo(
|
||||
() => collection?.color ?? randomElement(colorPalette),
|
||||
[collection?.color]
|
||||
);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const fallbackIcon = <Icon value="collection" color={iconColor} />;
|
||||
|
||||
const {
|
||||
@@ -70,6 +89,11 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const values = watch();
|
||||
|
||||
// Preload the IconPicker component on mount
|
||||
React.useEffect(() => {
|
||||
void IconPicker.preload();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the user hasn't picked an icon yet, go ahead and suggest one based on
|
||||
// the name of the collection. It's the little things sometimes.
|
||||
@@ -184,7 +208,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
);
|
||||
});
|
||||
|
||||
const StyledIconPicker = styled(IconPicker)`
|
||||
const StyledIconPicker = styled(IconPicker.Component)`
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
@@ -138,7 +138,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={index}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
@@ -154,7 +154,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={item.href}
|
||||
key={index}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
@@ -176,7 +176,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={index}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
@@ -185,18 +185,25 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
);
|
||||
|
||||
return item.tooltip ? (
|
||||
<Tooltip content={item.tooltip} placement={"bottom"}>
|
||||
<Tooltip
|
||||
content={item.tooltip}
|
||||
placement={"bottom"}
|
||||
key={`tooltip-${item.title}-${index}`}
|
||||
>
|
||||
<div>{menuItem}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{menuItem}</>
|
||||
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
|
||||
{menuItem}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
return (
|
||||
// Skip rendering empty submenus
|
||||
return item.items.length > 0 ? (
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
@@ -209,15 +216,17 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <Separator key={index} />;
|
||||
return <Separator key={`separator-${index}`} />;
|
||||
}
|
||||
|
||||
if (item.type === "heading") {
|
||||
return <Header key={index}>{item.title}</Header>;
|
||||
return (
|
||||
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
|
||||
);
|
||||
}
|
||||
|
||||
const _exhaustiveCheck: never = item;
|
||||
|
||||
@@ -64,11 +64,12 @@ function EditableTitle(
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setIsEditing(false);
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue === originalValue || trimmedValue.length === 0) {
|
||||
setValue(originalValue);
|
||||
setIsEditing(false);
|
||||
onCancel?.();
|
||||
return;
|
||||
}
|
||||
@@ -80,6 +81,8 @@ function EditableTitle(
|
||||
setValue(originalValue);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
|
||||
@@ -45,6 +45,7 @@ type Props = {
|
||||
onChange: (icon: string | null, color: string | null) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const IconPicker = ({
|
||||
@@ -59,6 +60,7 @@ const IconPicker = ({
|
||||
onOpen,
|
||||
onClose,
|
||||
borderOnHover,
|
||||
children,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -174,7 +176,9 @@ const IconPicker = ({
|
||||
onClick={handlePopoverButtonClick}
|
||||
$borderOnHover={borderOnHover}
|
||||
>
|
||||
{iconType && icon ? (
|
||||
{children ? (
|
||||
children
|
||||
) : iconType && icon ? (
|
||||
<Icon value={icon} color={color} size={size} initial={initial} />
|
||||
) : (
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
Component: React.LazyExoticComponent<T>;
|
||||
preload: () => Promise<{ default: T }>;
|
||||
}
|
||||
|
||||
interface LazyLoadOptions {
|
||||
retries?: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
|
||||
*
|
||||
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
|
||||
* @param options Optional configuration for retry behavior
|
||||
* @returns An object containing the lazy Component and a preload function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <Suspense fallback={<div>Loading...</div>}>
|
||||
* <MyComponent.Component />
|
||||
* </Suspense>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Preload when needed:
|
||||
* MyComponent.preload();
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
const { retries, interval } = options;
|
||||
|
||||
return {
|
||||
Component: lazyWithRetry(factory, retries, interval),
|
||||
preload: factory,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Popover from "~/components/Popover";
|
||||
@@ -12,7 +13,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
const EmojiPanel = createLazyComponent(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
);
|
||||
|
||||
@@ -104,6 +105,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
onMouseEnter={() => EmojiPanel.preload()}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
@@ -123,7 +125,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
{popover.visible && (
|
||||
<React.Suspense fallback={<Placeholder />}>
|
||||
<EventBoundary>
|
||||
<EmojiPanel
|
||||
<EmojiPanel.Component
|
||||
height={300}
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
|
||||
@@ -93,11 +93,13 @@ export const Suggestions = observer(
|
||||
const suggestions = React.useMemo(() => {
|
||||
const filtered: Suggestion[] = (
|
||||
document
|
||||
? users.notInDocument(document.id, query)
|
||||
? users
|
||||
.notInDocument(document.id, query)
|
||||
.filter((u) => u.id !== user.id)
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.activeOrInvited
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
).filter((u) => !u.isSuspended);
|
||||
|
||||
if (isEmail(query)) {
|
||||
filtered.push(getSuggestionForEmail(query));
|
||||
|
||||
@@ -23,12 +23,20 @@ import ToggleButton from "./components/ToggleButton";
|
||||
import Version from "./components/Version";
|
||||
|
||||
function SettingsSidebar() {
|
||||
const { ui } = useStores();
|
||||
const { ui, integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const configs = useSettingsConfig();
|
||||
const groupedConfig = groupBy(configs, "group");
|
||||
|
||||
const groupedConfig = groupBy(
|
||||
configs.filter((item) =>
|
||||
item.group === "Integrations" && item.pluginId
|
||||
? integrations.findByService(item.pluginId)
|
||||
: true
|
||||
),
|
||||
"group"
|
||||
);
|
||||
|
||||
const returnToApp = React.useCallback(() => {
|
||||
history.push("/home");
|
||||
@@ -63,8 +71,9 @@ function SettingsSidebar() {
|
||||
<SidebarLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClickIntent={item.preload}
|
||||
active={
|
||||
item.path !== settingsPath()
|
||||
item.path.startsWith(settingsPath("templates"))
|
||||
? location.pathname.startsWith(item.path)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -321,6 +321,7 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
z-index: ${depths.mobileSidebar};
|
||||
max-width: 80%;
|
||||
min-width: 280px;
|
||||
padding-left: var(--sal);
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -22,7 +22,7 @@ import SidebarContext from "./SidebarContext";
|
||||
function Collections() {
|
||||
const { documents, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const orderedCollections = collections.orderedData;
|
||||
const orderedCollections = collections.allActive;
|
||||
|
||||
const params = React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -38,10 +38,10 @@ function StarredLink({ star }: Props) {
|
||||
const { ui, collections, documents } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId, collectionId } = star;
|
||||
const collection = collections.get(collectionId);
|
||||
const collection = collectionId ? collections.get(collectionId) : undefined;
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = starredSidebarContext(
|
||||
star.documentId ?? star.collectionId
|
||||
star.documentId ?? star.collectionId ?? ""
|
||||
);
|
||||
const [expanded, setExpanded] = useState(
|
||||
(star.documentId
|
||||
@@ -78,9 +78,9 @@ function StarredLink({ star }: Props) {
|
||||
}, [documentId, documents]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
(ev?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import { action, observable } from "mobx";
|
||||
@@ -134,6 +135,15 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
throw err;
|
||||
});
|
||||
|
||||
// add a listener for all events that logs a sentry breadcrumb
|
||||
this.socket.onAny((event: string, data: Record<string, unknown>) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "websocket",
|
||||
message: `Received event: ${event}`,
|
||||
data,
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"entities",
|
||||
action(async (event: WebsocketEntitiesEvent) => {
|
||||
@@ -251,8 +261,10 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
policies.remove(document.id);
|
||||
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.removeDocument(document.id);
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.removeDocument(document.id);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ function useKeyboardShortcuts({
|
||||
useKeyDown(
|
||||
(ev) =>
|
||||
isModKey(ev) &&
|
||||
!popover.visible &&
|
||||
ev.code === "KeyF" &&
|
||||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isCode } from "@shared/editor/lib/isCode";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { getSafeAreaInsets } from "@shared/utils/browser";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -241,12 +242,16 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
|
||||
if (props.active) {
|
||||
const rect = document.body.getBoundingClientRect();
|
||||
const safeAreaInsets = getSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ReactPortal>
|
||||
<MobileWrapper
|
||||
ref={menuRef}
|
||||
style={{
|
||||
bottom: `calc(100% - ${height - rect.y}px)`,
|
||||
bottom: `calc(100% - ${
|
||||
height - rect.y - safeAreaInsets.bottom
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { v4 } from "uuid";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { isUrl } from "@shared/utils/urls";
|
||||
import Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -29,9 +30,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
|
||||
let mentionType: MentionType | undefined;
|
||||
const url = pastedText ? new URL(pastedText) : undefined;
|
||||
|
||||
if (url) {
|
||||
if (pastedText && isUrl(pastedText)) {
|
||||
const url = new URL(pastedText);
|
||||
const integration = integrations.find((intg: Integration) =>
|
||||
isURLMentionable({ url, integration: intg })
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { action } from "mobx";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { Node, ResolvedPos } from "prosemirror-model";
|
||||
import { EditorState, Plugin } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { PlaceholderPlugin } from "@shared/editor/plugins/PlaceholderPlugin";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import Suggestion from "~/editor/extensions/Suggestion";
|
||||
import BlockMenu from "../components/BlockMenu";
|
||||
@@ -27,7 +29,10 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
const button = document.createElement("button");
|
||||
button.className = "block-menu-trigger";
|
||||
button.type = "button";
|
||||
ReactDOM.render(<PlusIcon />, button);
|
||||
button.addEventListener("click", this.handleClick);
|
||||
const root = createRoot(button);
|
||||
root.render(<PlusIcon />);
|
||||
return button;
|
||||
|
||||
return [
|
||||
...super.plugins,
|
||||
@@ -49,7 +54,6 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
const isEmptyNode = parent && parent.node.content.size === 0;
|
||||
const isSlash = parent && parent.node.textContent === "/";
|
||||
|
||||
if (isEmptyNode) {
|
||||
decorations.push(
|
||||
@@ -69,33 +73,39 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const isEmptyDoc = state.doc.textContent === "";
|
||||
if (!isEmptyDoc) {
|
||||
decorations.push(
|
||||
Decoration.node(
|
||||
parent.pos,
|
||||
parent.pos + parent.node.nodeSize,
|
||||
{
|
||||
class: "placeholder",
|
||||
"data-empty-text": this.options.dictionary.newLineEmpty,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (isSlash) {
|
||||
decorations.push(
|
||||
Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, {
|
||||
class: "placeholder",
|
||||
"data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return DecorationSet.create(state.doc, decorations);
|
||||
},
|
||||
},
|
||||
}),
|
||||
new PlaceholderPlugin([
|
||||
{
|
||||
condition: (
|
||||
node: Node,
|
||||
$start: ResolvedPos,
|
||||
_parent: Node | null,
|
||||
state: EditorState
|
||||
) =>
|
||||
$start.depth === 1 &&
|
||||
node.textContent === "" &&
|
||||
!!state.doc.textContent &&
|
||||
state.selection.$from.pos === $start.pos + node.content.size,
|
||||
text: this.options.dictionary.newLineEmpty,
|
||||
},
|
||||
{
|
||||
condition: (
|
||||
node: Node,
|
||||
$start: ResolvedPos,
|
||||
_parent: Node,
|
||||
state: EditorState
|
||||
) =>
|
||||
$start.depth === 1 &&
|
||||
node.textContent === "/" &&
|
||||
state.selection.$from.pos === $start.pos + node.content.size,
|
||||
text: ` ${this.options.dictionary.newLineWithSlash}`,
|
||||
},
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import Extension from "@shared/editor/lib/Extension";
|
||||
import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
const emdash = new InputRule(/--$/, "—");
|
||||
// Note that the suppression of pipe here prevents conflict with table creation rule.
|
||||
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
|
||||
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+B`,
|
||||
icon: <BoldIcon />,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "em",
|
||||
@@ -75,7 +75,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+I`,
|
||||
icon: <ItalicIcon />,
|
||||
active: isMarkActive(schema.marks.em),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
@@ -83,7 +83,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+D`,
|
||||
icon: <StrikethroughIcon />,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
|
||||
@@ -18,8 +18,7 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
React.useEffect(() => {
|
||||
async function fetchEmbedIntegrations() {
|
||||
try {
|
||||
await integrations.fetchPage({
|
||||
limit: 100,
|
||||
await integrations.fetchAll({
|
||||
type: IntegrationType.Embed,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -13,20 +13,21 @@ import {
|
||||
ImportIcon,
|
||||
ShapesIcon,
|
||||
Icon,
|
||||
PlusIcon,
|
||||
InternetIcon,
|
||||
} from "outline-icons";
|
||||
import React, { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
import ZapierIcon from "~/components/Icons/ZapierIcon";
|
||||
import { Integrations } from "~/scenes/Settings/Integrations";
|
||||
import { createLazyComponent as lazy } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { useComputed } from "./useComputed";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import usePolicy from "./usePolicy";
|
||||
import useStores from "./useStores";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
|
||||
@@ -43,30 +44,38 @@ const Profile = lazy(() => import("~/scenes/Settings/Profile"));
|
||||
const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
|
||||
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
|
||||
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
|
||||
|
||||
export type ConfigItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: React.FC<ComponentProps<typeof Icon>>;
|
||||
component: React.ComponentType;
|
||||
description?: string;
|
||||
preload?: () => void;
|
||||
enabled: boolean;
|
||||
group: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
const useSettingsConfig = () => {
|
||||
const { integrations } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchAll();
|
||||
}, [integrations]);
|
||||
|
||||
const config = useComputed(() => {
|
||||
const items: ConfigItem[] = [
|
||||
// Account
|
||||
{
|
||||
name: t("Profile"),
|
||||
path: settingsPath(),
|
||||
component: Profile,
|
||||
component: Profile.Component,
|
||||
preload: Profile.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: ProfileIcon,
|
||||
@@ -74,7 +83,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Preferences"),
|
||||
path: settingsPath("preferences"),
|
||||
component: Preferences,
|
||||
component: Preferences.Component,
|
||||
preload: Preferences.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: SettingsIcon,
|
||||
@@ -82,7 +92,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Notifications"),
|
||||
path: settingsPath("notifications"),
|
||||
component: Notifications,
|
||||
component: Notifications.Component,
|
||||
preload: Notifications.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: EmailIcon,
|
||||
@@ -90,7 +101,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("API & Apps"),
|
||||
path: settingsPath("api-and-apps"),
|
||||
component: APIAndApps,
|
||||
component: APIAndApps.Component,
|
||||
preload: APIAndApps.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: PadlockIcon,
|
||||
@@ -99,7 +111,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Details"),
|
||||
path: settingsPath("details"),
|
||||
component: Details,
|
||||
component: Details.Component,
|
||||
preload: Details.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: TeamIcon,
|
||||
@@ -107,7 +120,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Security"),
|
||||
path: settingsPath("security"),
|
||||
component: Security,
|
||||
component: Security.Component,
|
||||
preload: Security.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: PadlockIcon,
|
||||
@@ -115,7 +129,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Features"),
|
||||
path: settingsPath("features"),
|
||||
component: Features,
|
||||
component: Features.Component,
|
||||
preload: Features.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: BeakerIcon,
|
||||
@@ -123,7 +138,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Members"),
|
||||
path: settingsPath("members"),
|
||||
component: Members,
|
||||
component: Members.Component,
|
||||
preload: Members.preload,
|
||||
enabled: can.listUsers,
|
||||
group: t("Workspace"),
|
||||
icon: UserIcon,
|
||||
@@ -131,7 +147,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Groups"),
|
||||
path: settingsPath("groups"),
|
||||
component: Groups,
|
||||
component: Groups.Component,
|
||||
preload: Groups.preload,
|
||||
enabled: can.listGroups,
|
||||
group: t("Workspace"),
|
||||
icon: GroupIcon,
|
||||
@@ -139,7 +156,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Templates"),
|
||||
path: settingsPath("templates"),
|
||||
component: Templates,
|
||||
component: Templates.Component,
|
||||
preload: Templates.preload,
|
||||
enabled: can.readTemplate,
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
@@ -147,7 +165,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("api-keys"),
|
||||
component: ApiKeys,
|
||||
component: ApiKeys.Component,
|
||||
preload: ApiKeys.preload,
|
||||
enabled: can.listApiKeys,
|
||||
group: t("Workspace"),
|
||||
icon: CodeIcon,
|
||||
@@ -155,7 +174,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Applications"),
|
||||
path: settingsPath("applications"),
|
||||
component: Applications,
|
||||
component: Applications.Component,
|
||||
preload: Applications.preload,
|
||||
enabled: can.listOAuthClients,
|
||||
group: t("Workspace"),
|
||||
icon: InternetIcon,
|
||||
@@ -163,7 +183,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Shared Links"),
|
||||
path: settingsPath("shares"),
|
||||
component: Shares,
|
||||
component: Shares.Component,
|
||||
preload: Shares.preload,
|
||||
enabled: can.listShares,
|
||||
group: t("Workspace"),
|
||||
icon: GlobeIcon,
|
||||
@@ -171,7 +192,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Import"),
|
||||
path: settingsPath("import"),
|
||||
component: Import,
|
||||
component: Import.Component,
|
||||
preload: Import.preload,
|
||||
enabled: can.createImport,
|
||||
group: t("Workspace"),
|
||||
icon: ImportIcon,
|
||||
@@ -179,19 +201,20 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Export"),
|
||||
path: settingsPath("export"),
|
||||
component: Export,
|
||||
component: Export.Component,
|
||||
preload: Export.preload,
|
||||
enabled: can.createExport,
|
||||
group: t("Workspace"),
|
||||
icon: ExportIcon,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
name: "Zapier",
|
||||
path: integrationSettingsPath("zapier"),
|
||||
component: Zapier,
|
||||
enabled: can.update && isCloudHosted,
|
||||
name: `${t("Install")}…`,
|
||||
path: settingsPath("integrations"),
|
||||
component: Integrations,
|
||||
enabled: true,
|
||||
group: t("Integrations"),
|
||||
icon: ZapierIcon,
|
||||
icon: PlusIcon,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -208,7 +231,10 @@ const useSettingsConfig = () => {
|
||||
? integrationSettingsPath(plugin.id)
|
||||
: settingsPath(plugin.id),
|
||||
group: t(group),
|
||||
component: plugin.value.component,
|
||||
pluginId: plugin.id,
|
||||
description: plugin.value.description,
|
||||
component: plugin.value.component.Component,
|
||||
preload: plugin.value.component.preload,
|
||||
enabled: plugin.value.enabled
|
||||
? plugin.value.enabled(team, user)
|
||||
: can.update,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import Document from "~/models/Document";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
document: Document;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook provides a memoized list of menu items for both collection-specific
|
||||
* templates and workspace-wide templates. It filters templates based on whether
|
||||
* they are published and organizes them into appropriate sections.
|
||||
*
|
||||
* Collection-specific templates are displayed first, followed by workspace templates
|
||||
* with a separator in between (if both types exist).
|
||||
*
|
||||
* @returns An array of MenuItem objects representing templates that can be applied
|
||||
* to the current document. Returns an empty array if no callback is provided.
|
||||
*/
|
||||
export function useTemplateMenuItems({ document, onSelectTemplate }: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const templateToMenuItem = React.useCallback(
|
||||
(template: Document): MenuItem => ({
|
||||
type: "button",
|
||||
title: TextHelper.replaceTemplateVariables(
|
||||
template.titleWithDefault,
|
||||
user
|
||||
),
|
||||
icon: template.icon ? (
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
onClick: () => onSelectTemplate?.(template),
|
||||
}),
|
||||
[user, onSelectTemplate]
|
||||
);
|
||||
|
||||
const templates = documents.templates.filter(
|
||||
(template) => template.publishedAt
|
||||
);
|
||||
|
||||
const collectionItems = templates
|
||||
.filter(
|
||||
(template) =>
|
||||
!template.isWorkspaceTemplate &&
|
||||
template.collectionId === document.collectionId
|
||||
)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceTemplates = templates
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceItems: MenuItem[] = React.useMemo(
|
||||
() =>
|
||||
workspaceTemplates.length
|
||||
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
|
||||
: [],
|
||||
[t, workspaceTemplates]
|
||||
);
|
||||
|
||||
if (!onSelectTemplate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectionItems
|
||||
? workspaceItems.length
|
||||
? [
|
||||
...collectionItems,
|
||||
{ type: "separator" } as MenuItem,
|
||||
...workspaceItems,
|
||||
]
|
||||
: collectionItems
|
||||
: workspaceItems;
|
||||
}
|
||||
+3
-2
@@ -4,7 +4,7 @@ import { LazyMotion } from "framer-motion";
|
||||
import { KBarProvider } from "kbar";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { render } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import { Router } from "react-router-dom";
|
||||
import stores from "~/stores";
|
||||
@@ -79,7 +79,8 @@ if (element) {
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
render(<App />, element);
|
||||
const root = createRoot(element);
|
||||
root.render(<App />);
|
||||
}
|
||||
|
||||
window.addEventListener("load", async () => {
|
||||
|
||||
@@ -2,7 +2,13 @@ import capitalize from "lodash/capitalize";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
|
||||
import {
|
||||
EditIcon,
|
||||
InputIcon,
|
||||
RestoreIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
@@ -57,6 +63,7 @@ import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
|
||||
import { MenuItem, MenuItemButton } from "~/types";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { MenuContext, useMenuContext } from "./MenuContext";
|
||||
@@ -76,6 +83,7 @@ type Props = {
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Invoked when menu is opened */
|
||||
@@ -147,6 +155,7 @@ type MenuContentProps = {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onFindAndReplace?: () => void;
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
onRename?: () => void;
|
||||
showDisplayOptions?: boolean;
|
||||
showToggleEmbeds?: boolean;
|
||||
@@ -156,6 +165,7 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
onOpen,
|
||||
onClose,
|
||||
onFindAndReplace,
|
||||
onSelectTemplate,
|
||||
onRename,
|
||||
showDisplayOptions,
|
||||
showToggleEmbeds,
|
||||
@@ -218,6 +228,11 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
[collections.orderedData, handleRestore, policies]
|
||||
);
|
||||
|
||||
const templateMenuItems = useTemplateMenuItems({
|
||||
document,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
return !isEmpty(can) ? (
|
||||
<ContextMenu
|
||||
{...menuState}
|
||||
@@ -310,6 +325,12 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
actionToMenuItem(archiveDocument, context),
|
||||
actionToMenuItem(moveDocument, context),
|
||||
actionToMenuItem(moveTemplate, context),
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Apply template"),
|
||||
icon: <ShapesIcon />,
|
||||
items: templateMenuItems,
|
||||
},
|
||||
actionToMenuItem(pinDocument, context),
|
||||
actionToMenuItem(createDocumentFromTemplate, context),
|
||||
{
|
||||
@@ -383,6 +404,7 @@ function DocumentMenu({
|
||||
modal = true,
|
||||
showToggleEmbeds,
|
||||
showDisplayOptions,
|
||||
onSelectTemplate,
|
||||
label,
|
||||
onRename,
|
||||
onOpen,
|
||||
@@ -466,6 +488,7 @@ function DocumentMenu({
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
onRename={onRename}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
showDisplayOptions={showDisplayOptions}
|
||||
showToggleEmbeds={showToggleEmbeds}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import { ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
@@ -23,57 +19,12 @@ type Props = {
|
||||
};
|
||||
|
||||
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const user = useCurrentUser();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const templateToMenuItem = React.useCallback(
|
||||
(tmpl: Document): MenuItem => ({
|
||||
type: "button",
|
||||
title: TextHelper.replaceTemplateVariables(tmpl.titleWithDefault, user),
|
||||
icon: tmpl.icon ? (
|
||||
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
onClick: () => onSelectTemplate(tmpl),
|
||||
}),
|
||||
[user, onSelectTemplate]
|
||||
);
|
||||
|
||||
const templates = documents.templates.filter((tmpl) => tmpl.publishedAt);
|
||||
|
||||
const collectionItems = templates
|
||||
.filter(
|
||||
(tmpl) =>
|
||||
!tmpl.isWorkspaceTemplate && tmpl.collectionId === document.collectionId
|
||||
)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceTemplates = templates
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceItems: MenuItem[] = React.useMemo(
|
||||
() =>
|
||||
workspaceTemplates.length
|
||||
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
|
||||
: [],
|
||||
[t, workspaceTemplates]
|
||||
);
|
||||
|
||||
const items = collectionItems
|
||||
? workspaceItems.length
|
||||
? [
|
||||
...collectionItems,
|
||||
{ type: "separator" } as MenuItem,
|
||||
...workspaceItems,
|
||||
]
|
||||
: collectionItems
|
||||
: workspaceItems;
|
||||
const items = useTemplateMenuItems({ onSelectTemplate, document });
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
|
||||
@@ -331,6 +331,16 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the documents that link to this document.
|
||||
*
|
||||
* @returns documents that link to this document
|
||||
*/
|
||||
@computed
|
||||
get backlinks(): Document[] {
|
||||
return this.store.getBacklinkedDocuments(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns users that have been individually given access to the document.
|
||||
*
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class Star extends Model {
|
||||
document?: Document;
|
||||
|
||||
/** The collection ID that is starred. */
|
||||
collectionId: string;
|
||||
collectionId?: string;
|
||||
|
||||
/** The collection that is starred. */
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
|
||||
+26
-4
@@ -6,15 +6,18 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { s } from "@shared/styles";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Text from "~/components/Text";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Text from "./Text";
|
||||
import { Properties } from "~/types";
|
||||
|
||||
const extensions = withUIExtensions(richExtensions);
|
||||
|
||||
@@ -22,8 +25,8 @@ type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
function Overview({ collection }: Props) {
|
||||
const { documents, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: true });
|
||||
const can = usePolicy(collection);
|
||||
@@ -54,6 +57,24 @@ function CollectionDescription({ collection }: Props) {
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
const onCreateLink = React.useCallback(
|
||||
async (params: Properties<Document>) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
...params,
|
||||
},
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
|
||||
return newDocument.url;
|
||||
},
|
||||
[collection, documents]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
@@ -65,6 +86,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
placeholder={`${t("Add a description")}…`}
|
||||
extensions={extensions}
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
onCreateLink={onCreateLink}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update}
|
||||
userId={user.id}
|
||||
@@ -83,4 +105,4 @@ const Placeholder = styled(Text)`
|
||||
min-height: 27px;
|
||||
`;
|
||||
|
||||
export default observer(CollectionDescription);
|
||||
export default observer(Overview);
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
|
||||
import { IconTitleWrapper } from "@shared/components/Icon";
|
||||
import { s } from "@shared/styles";
|
||||
import { StatusFilter } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -20,7 +20,6 @@ import Collection from "~/models/Collection";
|
||||
import { Action } from "~/components/Actions";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import Heading from "~/components/Heading";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSearchPage from "~/components/InputSearchPage";
|
||||
@@ -46,6 +45,7 @@ import DropToImport from "./components/DropToImport";
|
||||
import Empty from "./components/Empty";
|
||||
import MembershipPreview from "./components/MembershipPreview";
|
||||
import Notices from "./components/Notices";
|
||||
import Overview from "./components/Overview";
|
||||
import ShareButton from "./components/ShareButton";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
@@ -66,7 +66,6 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, ui } = useStores();
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const currentPath = location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
@@ -120,21 +119,16 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if ((!can || !collection) && !error && !isFetching) {
|
||||
try {
|
||||
setError(undefined);
|
||||
setFetching(true);
|
||||
await collections.fetch(id);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
try {
|
||||
setError(undefined);
|
||||
await collections.fetch(id);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
|
||||
void fetchData();
|
||||
}, [collections, isFetching, collection, error, id, can]);
|
||||
}, []);
|
||||
|
||||
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
|
||||
|
||||
@@ -145,11 +139,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const hasOverview = can.update || collection?.hasDescription;
|
||||
|
||||
const fallbackIcon = collection ? (
|
||||
<Icon
|
||||
value={collection.icon ?? "collection"}
|
||||
color={collection.color || undefined}
|
||||
size={40}
|
||||
/>
|
||||
<CollectionIcon collection={collection} size={40} expanded />
|
||||
) : null;
|
||||
|
||||
const tabProps = (path: CollectionPath) => ({
|
||||
@@ -168,7 +158,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
left={
|
||||
collection.isArchived ? (
|
||||
<CollectionBreadcrumb collection={collection} />
|
||||
) : collection.isEmpty ? undefined : (
|
||||
) : (
|
||||
<InputSearchPage
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
@@ -212,7 +202,9 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
popoverPosition="bottom-start"
|
||||
onChange={handleIconChange}
|
||||
borderOnHover
|
||||
/>
|
||||
>
|
||||
{fallbackIcon}
|
||||
</IconPicker>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
fallbackIcon
|
||||
@@ -265,7 +257,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
path={collectionPath(collection.path, CollectionPath.Overview)}
|
||||
>
|
||||
{hasOverview ? (
|
||||
<CollectionDescription collection={collection} />
|
||||
<Overview collection={collection} />
|
||||
) : (
|
||||
<Redirect
|
||||
to={{
|
||||
|
||||
@@ -4,7 +4,7 @@ import isEqual from "lodash/isEqual";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { AllSelection } from "prosemirror-state";
|
||||
import { AllSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -146,7 +146,17 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
replaceDocument = (template: Document | Revision) => {
|
||||
/**
|
||||
* Replaces the given selection with a template, if no selection is provided
|
||||
* then the template is inserted at the beginning of the document.
|
||||
*
|
||||
* @param template The template to use
|
||||
* @param selection The selection to replace, if any
|
||||
*/
|
||||
replaceSelection = (
|
||||
template: Document | Revision,
|
||||
selection?: TextSelection | AllSelection
|
||||
) => {
|
||||
const editorRef = this.editor.current;
|
||||
|
||||
if (!editorRef) {
|
||||
@@ -154,6 +164,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const { view, schema } = editorRef;
|
||||
const sel = selection ?? TextSelection.near(view.state.doc.resolve(0));
|
||||
const doc = Node.fromJSON(
|
||||
schema,
|
||||
ProsemirrorHelper.replaceTemplateVariables(
|
||||
@@ -163,11 +174,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
);
|
||||
|
||||
if (doc) {
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setSelection(new AllSelection(view.state.doc))
|
||||
.replaceSelectionWith(doc)
|
||||
);
|
||||
view.dispatch(view.state.tr.setSelection(sel).replaceSelectionWith(doc));
|
||||
}
|
||||
|
||||
this.isEditorDirty = true;
|
||||
@@ -217,7 +224,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
});
|
||||
|
||||
if (response) {
|
||||
await this.replaceDocument(response.data);
|
||||
await this.replaceSelection(
|
||||
response.data,
|
||||
new AllSelection(editorRef.view.state.doc)
|
||||
);
|
||||
toast.success(t("Document restored"));
|
||||
history.replace(this.props.document.url, history.location.state);
|
||||
}
|
||||
@@ -518,7 +528,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||
sharedTree={this.props.sharedTree}
|
||||
onSelectTemplate={this.replaceDocument}
|
||||
onSelectTemplate={this.replaceSelection}
|
||||
onSave={this.onSave}
|
||||
/>
|
||||
<Main fullWidth={document.fullWidth} tocPosition={tocPos}>
|
||||
|
||||
@@ -387,6 +387,7 @@ function DocumentHeader({
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
onFindAndReplace={editor?.commands.openFindAndReplace}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showDisplayOptions
|
||||
|
||||
@@ -18,7 +18,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function References({ document }: Props) {
|
||||
const { collections, documents } = useStores();
|
||||
const { documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
@@ -27,10 +27,8 @@ function References({ document }: Props) {
|
||||
void documents.fetchBacklinks(document.id);
|
||||
}, [documents, document.id]);
|
||||
|
||||
const backlinks = documents.getBacklinkedDocuments(document.id);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const backlinks = document.backlinks;
|
||||
const collection = document.collection;
|
||||
const children = collection
|
||||
? collection.getChildrenForDocument(document.id)
|
||||
: [];
|
||||
|
||||
@@ -415,6 +415,10 @@ function KeyboardShortcuts() {
|
||||
shortcut: <Key>---</Key>,
|
||||
label: t("Horizontal divider"),
|
||||
},
|
||||
{
|
||||
shortcut: <Key>{"|--"}</Key>,
|
||||
label: t("Table"),
|
||||
},
|
||||
{
|
||||
shortcut: <Key>{"```"}</Key>,
|
||||
label: t("Code block"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import { s } from "@shared/styles";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import type OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import ChangeLanguage from "~/components/ChangeLanguage";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -16,6 +17,7 @@ import { useLoggedInSessions } from "~/hooks/useLoggedInSessions";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { BadRequestError, NotFoundError } from "~/utils/errors";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { detectLanguage } from "~/utils/language";
|
||||
import Login from "./Login";
|
||||
@@ -66,12 +68,17 @@ function Authorize() {
|
||||
scope,
|
||||
} = Object.fromEntries(params);
|
||||
const [scopes] = React.useState(() => scope?.split(" ") ?? []);
|
||||
const { error: clientError, data: response } = useRequest(
|
||||
() => client.post("/oauthClients.info", { clientId }),
|
||||
true
|
||||
);
|
||||
const { error: clientError, data: response } = useRequest<{
|
||||
data: OAuthClient;
|
||||
}>(() => client.post("/oauthClients.info", { clientId, redirectUri }), true);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (redirectUri && !clientError) {
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set("error", "access_denied");
|
||||
window.location.href = url.toString();
|
||||
return;
|
||||
}
|
||||
if (window.history.length) {
|
||||
window.history.back();
|
||||
} else {
|
||||
@@ -96,6 +103,7 @@ function Authorize() {
|
||||
!redirectUri && "redirect_uri",
|
||||
!responseType && "response_type",
|
||||
!scope && "scope",
|
||||
!state && "state",
|
||||
].filter(Boolean);
|
||||
|
||||
if (missingParams.length || clientError) {
|
||||
@@ -103,13 +111,20 @@ function Authorize() {
|
||||
<Background>
|
||||
<Centered>
|
||||
<StyledHeading>{t("An error occurred")}</StyledHeading>
|
||||
{clientError ? (
|
||||
{clientError instanceof NotFoundError ? (
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"The OAuth client could not be found, please check the provided client ID"
|
||||
)}
|
||||
<Pre>{clientId}</Pre>
|
||||
</Text>
|
||||
) : clientError instanceof BadRequestError ? (
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"The OAuth client could not be loaded, please check the redirect URI is valid"
|
||||
)}
|
||||
<Pre>{redirectUri}</Pre>
|
||||
</Text>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
{t("Required OAuth parameters are missing")}
|
||||
|
||||
@@ -67,9 +67,7 @@ function APIAndApps() {
|
||||
/>
|
||||
</Text>
|
||||
) : (
|
||||
<Trans>
|
||||
{t("API keys have been disabled by an admin for your account")}
|
||||
</Trans>
|
||||
<Trans>API keys have been disabled by an admin for your account</Trans>
|
||||
)}
|
||||
<PaginatedList<ApiKey>
|
||||
fetch={apiKeys.fetchPage}
|
||||
|
||||
@@ -80,11 +80,16 @@ const Application = observer(function Application({ oauthClient }: Props) {
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
await oauthClient.save(data);
|
||||
toast.success(
|
||||
oauthClient.published
|
||||
? t("Application published")
|
||||
: t("Application updated")
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
[oauthClient]
|
||||
[oauthClient, t]
|
||||
);
|
||||
|
||||
const handleRotateSecret = React.useCallback(async () => {
|
||||
@@ -173,7 +178,6 @@ const Application = observer(function Application({ oauthClient }: Props) {
|
||||
<Input
|
||||
type="text"
|
||||
{...register("description", {
|
||||
required: true,
|
||||
maxLength: OAuthClientValidation.maxDescriptionLength,
|
||||
})}
|
||||
flex
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import groupBy from "lodash/groupBy";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import IntegrationCard from "./components/IntegrationCard";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
|
||||
export function Integrations() {
|
||||
const { t } = useTranslation();
|
||||
const { integrations } = useStores();
|
||||
const items = useSettingsConfig();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const handleQuery = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
};
|
||||
|
||||
const groupedItems = groupBy(
|
||||
items.filter(
|
||||
(item) =>
|
||||
item.group === "Integrations" &&
|
||||
item.enabled &&
|
||||
item.path !== settingsPath("integrations") &&
|
||||
item.name.toLowerCase().includes(query.toLowerCase())
|
||||
),
|
||||
(item) =>
|
||||
item.pluginId && integrations.findByService(item.pluginId)
|
||||
? "connected"
|
||||
: "available"
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Integrations")}>
|
||||
<Heading>{t("Integrations")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Configure a variety of integrations with third-party services.
|
||||
</Trans>
|
||||
</Text>
|
||||
<StickyFilters gap={8}>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleQuery}
|
||||
/>
|
||||
</StickyFilters>
|
||||
|
||||
<Cards gap={30} wrap>
|
||||
{groupedItems.connected?.map((item) => (
|
||||
<IntegrationCard key={item.path} integration={item} isConnected />
|
||||
))}
|
||||
{groupedItems.available?.map((item) => (
|
||||
<IntegrationCard key={item.path} integration={item} />
|
||||
))}
|
||||
</Cards>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const Cards = styled(Flex)`
|
||||
margin-top: 20px;
|
||||
width: "100%";
|
||||
`;
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { ConfigItem } from "~/hooks/useSettingsConfig";
|
||||
import Button from "../../../components/Button";
|
||||
import Flex from "../../../components/Flex";
|
||||
import Text from "../../../components/Text";
|
||||
|
||||
type Props = {
|
||||
integration: ConfigItem;
|
||||
isConnected?: boolean;
|
||||
};
|
||||
|
||||
function IntegrationCard({ integration, isConnected }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card as={Link} to={integration.path}>
|
||||
<Flex align="center" gap={8}>
|
||||
<integration.icon size={48} />
|
||||
<Flex auto column>
|
||||
<Name>{integration.name}</Name>
|
||||
{isConnected && <Status>{t("Connected")}</Status>}
|
||||
</Flex>
|
||||
<Button as="span" neutral>
|
||||
{isConnected ? t("Configure") : t("Connect")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Description>{integration.description}</Description>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default IntegrationCard;
|
||||
|
||||
const Card = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
padding: 20px;
|
||||
width: 300px;
|
||||
background: ${s("background")};
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
color: ${s("text")};
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 200ms ease;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover {
|
||||
box-shadow: rgba(0, 0, 0, 0.08) 0px 2px 4px, rgba(0, 0, 0, 0.06) 0px 4px 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Name = styled(Text)`
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${s("text")};
|
||||
${ellipsis()}
|
||||
`;
|
||||
|
||||
const Description = styled(Text)`
|
||||
margin: 8px 0 0;
|
||||
font-size: 15px;
|
||||
max-width: 100%;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
const Status = styled(Text).attrs({
|
||||
type: "secondary",
|
||||
size: "small",
|
||||
as: "span",
|
||||
})`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
${s("accent")} 0 33%,
|
||||
transparent 33%
|
||||
);
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Scene from "~/components/Scene";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
export function IntegrationScene({
|
||||
children,
|
||||
...rest
|
||||
}: React.ComponentProps<typeof Scene>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
left={
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
type: "route",
|
||||
title: t("Integrations"),
|
||||
icon: <SettingsIcon />,
|
||||
to: settingsPath("integrations"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
@@ -186,6 +186,13 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
statusFilter: [CollectionStatusFilter.Archived],
|
||||
});
|
||||
|
||||
get(id: string): Collection | undefined {
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((collection) => id.endsWith(collection.urlId))
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get archived(): Collection[] {
|
||||
return orderBy(this.orderedData, "archivedAt", "desc").filter(
|
||||
|
||||
@@ -279,19 +279,14 @@ export default class DocumentsStore extends Store<Document> {
|
||||
|
||||
@action
|
||||
fetchBacklinks = async (documentId: string): Promise<void> => {
|
||||
const res = await client.post(`/documents.list`, {
|
||||
const documents = await this.fetchAll({
|
||||
backlinkDocumentId: documentId,
|
||||
});
|
||||
invariant(res?.data, "Document list not available");
|
||||
const { data } = res;
|
||||
|
||||
runInAction("DocumentsStore#fetchBacklinks", () => {
|
||||
data.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
|
||||
this.backlinks.set(
|
||||
documentId,
|
||||
data.map((doc: Partial<Document>) => doc.id)
|
||||
documents.map((doc) => doc.id)
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -300,8 +295,8 @@ export default class DocumentsStore extends Store<Document> {
|
||||
const documentIds = this.backlinks.get(documentId) || [];
|
||||
return orderBy(
|
||||
compact(documentIds.map((id) => this.data.get(id))),
|
||||
"updatedAt",
|
||||
"desc"
|
||||
"title",
|
||||
"asc"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ class IntegrationsStore extends Store<Integration> {
|
||||
super(rootStore, Integration);
|
||||
}
|
||||
|
||||
findByService(service: string) {
|
||||
return this.orderedData.find(
|
||||
(integration) => integration.service === service
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): Integration[] {
|
||||
return naturalSort(Array.from(this.data.values()), "name");
|
||||
|
||||
@@ -104,6 +104,9 @@ export default abstract class Store<T extends Model> {
|
||||
if ("deletedAt" in item && item.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
if ("archivedAt" in item && item.archivedAt) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, options?.maxResults);
|
||||
@@ -114,6 +117,9 @@ export default abstract class Store<T extends Model> {
|
||||
if ("deletedAt" in item && item.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
if ("archivedAt" in item && item.archivedAt) {
|
||||
return false;
|
||||
}
|
||||
if ("searchContent" in item) {
|
||||
const seachables =
|
||||
typeof item.searchContent === "string"
|
||||
|
||||
@@ -3,6 +3,7 @@ import sortBy from "lodash/sortBy";
|
||||
import { action, observable } from "mobx";
|
||||
import Team from "~/models/Team";
|
||||
import User from "~/models/User";
|
||||
import { LazyComponent } from "~/components/LazyLoad";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import Logger from "./Logger";
|
||||
import isCloudHosted from "./isCloudHosted";
|
||||
@@ -27,8 +28,10 @@ type PluginValueMap = {
|
||||
after?: string;
|
||||
/** The displayed icon of the plugin. */
|
||||
icon: React.ElementType;
|
||||
/** The settings screen somponent, should be lazy loaded. */
|
||||
component: React.LazyExoticComponent<React.ComponentType>;
|
||||
/** The lazy loaded settings screen component. */
|
||||
component: LazyComponent<React.ComponentType>;
|
||||
/** The description that will show on the plugins card. */
|
||||
description?: string;
|
||||
/** Whether the plugin is enabled in the current context. */
|
||||
enabled?: (team: Team, user: User) => boolean;
|
||||
};
|
||||
|
||||
+36
-33
@@ -14,6 +14,7 @@
|
||||
"dev:backend": "NODE_ENV=development nodemon --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --ignore *.test.ts --ignore data/ --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
|
||||
"dev:watch": "NODE_ENV=development yarn concurrently -n backend,frontend \"yarn dev:backend\" \"yarn vite:dev\"",
|
||||
"lint": "eslint app server shared plugins",
|
||||
"lint:changed": "git diff --name-only --diff-filter=ACMRTUXB | grep -E '\\.(js|jsx|ts|tsx)$' | xargs -r yarn eslint --fix",
|
||||
"prepare": "husky install",
|
||||
"postinstall": "yarn patch-package",
|
||||
"install-local-ssl": "node ./server/scripts/install-local-ssl.js",
|
||||
@@ -48,18 +49,18 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.787.0",
|
||||
"@aws-sdk/lib-storage": "3.787.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.787.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.787.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.787.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
"@babel/plugin-transform-destructuring": "^7.25.9",
|
||||
"@babel/plugin-transform-regenerator": "^7.27.0",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@aws-sdk/client-s3": "3.812.0",
|
||||
"@aws-sdk/lib-storage": "3.812.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.812.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.812.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.812.0",
|
||||
"@babel/core": "^7.27.1",
|
||||
"@babel/plugin-proposal-decorators": "^7.27.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-destructuring": "^7.27.1",
|
||||
"@babel/plugin-transform-regenerator": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.7.10",
|
||||
@@ -86,15 +87,15 @@
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.0",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.2",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.120.3",
|
||||
"@sentry/react": "^7.120.3",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.6",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.2",
|
||||
"@types/mailparser": "^3.4.5",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/sanitize-filename": "^1.6.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"addressparser": "^1.0.1",
|
||||
@@ -141,7 +142,7 @@
|
||||
"jsdom": "^22.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.21",
|
||||
"katex": "^0.16.22",
|
||||
"kbar": "0.1.0-beta.41",
|
||||
"koa": "^2.16.1",
|
||||
"koa-body": "^6.0.1",
|
||||
@@ -175,7 +176,7 @@
|
||||
"passport-oauth2": "^1.8.0",
|
||||
"passport-slack-oauth2": "^1.2.0",
|
||||
"patch-package": "^7.0.2",
|
||||
"pg": "^8.14.1",
|
||||
"pg": "^8.15.6",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pluralize": "^8.0.0",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
@@ -197,20 +198,20 @@
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.1",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
"react": "^17.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-avatar-editor": "^13.0.2",
|
||||
"react-color": "^2.17.3",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^11.7.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-medium-image-zoom": "5.2.13",
|
||||
"react-medium-image-zoom": "5.2.14",
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.2.2",
|
||||
"react-portal": "^4.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-waypoint": "^10.3.0",
|
||||
@@ -228,6 +229,7 @@
|
||||
"sequelize": "^6.37.3",
|
||||
"sequelize-cli": "^6.6.2",
|
||||
"sequelize-encrypted": "^1.0.0",
|
||||
"sequelize-strict-attributes": "^1.0.2",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"slug": "^5.3.0",
|
||||
"slugify": "^1.6.6",
|
||||
@@ -248,9 +250,9 @@
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"validator": "13.15.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^6.3.3",
|
||||
"vite": "^6.3.4",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -262,11 +264,12 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.27.0",
|
||||
"@babel/preset-typescript": "^7.27.0",
|
||||
"@babel/cli": "^7.27.2",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.3.0",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
@@ -306,10 +309,10 @@
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/quoted-printable": "^1.0.2",
|
||||
"@types/randomstring": "^1.3.0",
|
||||
"@types/react": "^17.0.34",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/react-color": "^3.0.13",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@types/react-portal": "^4.0.7",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -328,7 +331,7 @@
|
||||
"@types/tmp": "^0.2.6",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/utf8": "^3.0.3",
|
||||
"@types/validator": "^13.12.1",
|
||||
"@types/validator": "^13.15.0",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
@@ -342,7 +345,7 @@
|
||||
"discord-api-types": "^0.37.119",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-import-resolver-typescript": "^3.8.0",
|
||||
"eslint-import-resolver-typescript": "^3.10.1",
|
||||
"eslint-plugin-es": "^4.1.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
@@ -357,10 +360,10 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"lint-staged": "^13.3.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"nodemon": "^3.1.10",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.2",
|
||||
"react-refresh": "^0.17.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.5",
|
||||
"terser": "^5.39.0",
|
||||
@@ -378,6 +381,6 @@
|
||||
"qs": "6.9.7",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.83.0",
|
||||
"version": "0.84.0",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Router from "koa-router";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { Client, NotificationEventType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
|
||||
import SigninEmail from "@server/emails/templates/SigninEmail";
|
||||
@@ -86,66 +86,64 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"email.callback",
|
||||
validate(T.EmailCallbackSchema),
|
||||
async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
const { token, client, follow } = ctx.input.query;
|
||||
const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
const token = ctx.input.query?.token || ctx.input.body?.token;
|
||||
const client = ctx.input.query?.client || ctx.input.body?.client;
|
||||
const follow = ctx.input.query?.follow || ctx.input.body?.follow;
|
||||
|
||||
// The link in the email does not include the follow query param, this
|
||||
// is to help prevent anti-virus, and email clients from pre-fetching the link
|
||||
// and spending the token before the user clicks on it. Instead we redirect
|
||||
// to the same URL with the follow query param added from the client side.
|
||||
if (!follow) {
|
||||
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
|
||||
}
|
||||
// The link in the email does not include the follow query param, this
|
||||
// is to help prevent anti-virus, and email clients from pre-fetching the link
|
||||
// and spending the token before the user clicks on it. Instead we redirect
|
||||
// to the same URL with the follow query param added from the client side.
|
||||
if (!follow) {
|
||||
return ctx.redirectOnClient(ctx.request.href + "&follow=true", "POST");
|
||||
}
|
||||
|
||||
let user!: User;
|
||||
let user!: User;
|
||||
|
||||
try {
|
||||
user = await getUserForEmailSigninToken(token as string);
|
||||
} catch (err) {
|
||||
ctx.redirect(`/?notice=expired-token`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
user = await getUserForEmailSigninToken(token as string);
|
||||
} catch (err) {
|
||||
ctx.redirect(`/?notice=expired-token`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.team.emailSigninEnabled) {
|
||||
return ctx.redirect("/?notice=auth-error");
|
||||
}
|
||||
if (!user.team.emailSigninEnabled) {
|
||||
return ctx.redirect("/?notice=auth-error");
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return ctx.redirect("/?notice=user-suspended");
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return ctx.redirect("/?notice=user-suspended");
|
||||
}
|
||||
|
||||
if (user.isInvited) {
|
||||
await new WelcomeEmail({
|
||||
to: user.email,
|
||||
role: user.role,
|
||||
if (user.isInvited) {
|
||||
await new WelcomeEmail({
|
||||
to: user.email,
|
||||
role: user.role,
|
||||
teamUrl: user.team.url,
|
||||
}).schedule();
|
||||
|
||||
const inviter = await user.$get("invitedBy");
|
||||
if (inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)) {
|
||||
await new InviteAcceptedEmail({
|
||||
to: inviter.email,
|
||||
inviterId: inviter.id,
|
||||
invitedName: user.name,
|
||||
teamUrl: user.team.url,
|
||||
}).schedule();
|
||||
|
||||
const inviter = await user.$get("invitedBy");
|
||||
if (
|
||||
inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)
|
||||
) {
|
||||
await new InviteAcceptedEmail({
|
||||
to: inviter.email,
|
||||
inviterId: inviter.id,
|
||||
invitedName: user.name,
|
||||
teamUrl: user.team.url,
|
||||
}).schedule();
|
||||
}
|
||||
}
|
||||
|
||||
// set cookies on response and redirect to team subdomain
|
||||
await signIn(ctx, "email", {
|
||||
user,
|
||||
team: user.team,
|
||||
isNewTeam: false,
|
||||
isNewUser: false,
|
||||
client,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// set cookies on response and redirect to team subdomain
|
||||
await signIn(ctx, "email", {
|
||||
user,
|
||||
team: user.team,
|
||||
isNewTeam: false,
|
||||
isNewUser: false,
|
||||
client: client ?? Client.Web,
|
||||
});
|
||||
};
|
||||
router.get("email.callback", validate(T.EmailCallbackSchema), emailCallback);
|
||||
router.post("email.callback", validate(T.EmailCallbackSchema), emailCallback);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -13,7 +13,12 @@ export type EmailReq = z.infer<typeof EmailSchema>;
|
||||
|
||||
export const EmailCallbackSchema = BaseSchema.extend({
|
||||
query: z.object({
|
||||
token: z.string(),
|
||||
token: z.string().optional(),
|
||||
client: z.nativeEnum(Client).default(Client.Web),
|
||||
follow: z.string().default(""),
|
||||
}),
|
||||
body: z.object({
|
||||
token: z.string().optional(),
|
||||
client: z.nativeEnum(Client).default(Client.Web),
|
||||
follow: z.string().default(""),
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -11,7 +12,6 @@ import List from "~/components/List";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Notice from "~/components/Notice";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
@@ -38,7 +38,7 @@ function GitHub() {
|
||||
}, [integrations]);
|
||||
|
||||
return (
|
||||
<Scene title="GitHub" icon={<GitHubIcon />}>
|
||||
<IntegrationScene title="GitHub" icon={<GitHubIcon />}>
|
||||
<Heading>GitHub</Heading>
|
||||
|
||||
{error === "access_denied" && (
|
||||
@@ -146,7 +146,7 @@ function GitHub() {
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Connect your GitHub account to Outline to enable rich, realtime, issue and pull request previews inside documents.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -6,12 +6,12 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import GoogleIcon from "~/components/Icons/GoogleIcon";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -40,12 +40,6 @@ function GoogleAnalytics() {
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchPage({
|
||||
type: IntegrationType.Analytics,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
React.useEffect(() => {
|
||||
reset({ measurementId: integration?.settings.measurementId });
|
||||
}, [integration, reset]);
|
||||
@@ -75,7 +69,7 @@ function GoogleAnalytics() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Google Analytics")} icon={<GoogleIcon />}>
|
||||
<IntegrationScene title={t("Google Analytics")} icon={<GoogleIcon />}>
|
||||
<Heading>{t("Google Analytics")}</Heading>
|
||||
|
||||
<Text as="p" type="secondary">
|
||||
@@ -100,7 +94,7 @@ function GoogleAnalytics() {
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Measure adoption and engagement by sending view and event analytics directly to your GA4 dashboard.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "googleanalytics",
|
||||
"id": "google-analytics",
|
||||
"name": "Google Analytics",
|
||||
"priority": 30,
|
||||
"description": "Adds support for reporting analytics to a Google."
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -77,14 +77,14 @@ router.get(
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
if (workspace.logoUrl) {
|
||||
transaction.afterCommit(async () => {
|
||||
await UploadLinearWorkspaceLogoTask.schedule({
|
||||
transaction.afterCommit(async () => {
|
||||
if (workspace.logoUrl) {
|
||||
await new UploadLinearWorkspaceLogoTask().schedule({
|
||||
integrationId: integration.id,
|
||||
logoUrl: workspace.logoUrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ctx.redirect(LinearUtils.successUrl());
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Icon from "./Icon";
|
||||
@@ -42,12 +42,6 @@ function Matomo() {
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchPage({
|
||||
type: IntegrationType.Analytics,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
React.useEffect(() => {
|
||||
reset({
|
||||
measurementId: integration?.settings.measurementId,
|
||||
@@ -82,7 +76,7 @@ function Matomo() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title="Matomo" icon={<Icon />}>
|
||||
<IntegrationScene title="Matomo" icon={<Icon />}>
|
||||
<Heading>Matomo</Heading>
|
||||
|
||||
<Text as="p" type="secondary">
|
||||
@@ -121,7 +115,7 @@ function Matomo() {
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -11,7 +11,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Track your docs with a self-hosted, open-source analytics platform, link Outline to Matomo for 100% data ownership, GDPR compliance, and deep usage insights on your own servers.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: (_, user) => user.role === UserRole.Admin,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -66,6 +66,6 @@ export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.
|
||||
protected async scheduleTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
): Promise<void> {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
protected async scheduleNextTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
) {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ export class NotionConverter {
|
||||
if (item.mention.type === "link_mention") {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
text: item.plain_text || item.mention.link_mention.href,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
@@ -302,7 +302,7 @@ export class NotionConverter {
|
||||
if (item.mention.type === "link_preview") {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
text: item.plain_text || item.mention.link_preview.url,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
@@ -314,14 +314,14 @@ export class NotionConverter {
|
||||
};
|
||||
}
|
||||
|
||||
if (!item.plain_text) {
|
||||
return undefined;
|
||||
if (item.plain_text) {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (item.type === "equation") {
|
||||
@@ -336,20 +336,20 @@ export class NotionConverter {
|
||||
};
|
||||
}
|
||||
|
||||
if (!item.text.content) {
|
||||
return undefined;
|
||||
if (item.text.content) {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.text.content,
|
||||
marks: [
|
||||
...mapAttrs(),
|
||||
...(item.text.link
|
||||
? [{ type: "link", attrs: { href: item.text.link.url } }]
|
||||
: []),
|
||||
].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: item.text.content,
|
||||
marks: [
|
||||
...mapAttrs(),
|
||||
...(item.text.link
|
||||
? [{ type: "link", attrs: { href: item.text.link.url } }]
|
||||
: []),
|
||||
].filter(Boolean),
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static rich_text_to_plaintext(item: RichTextItemResponse) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import JWT from "jsonwebtoken";
|
||||
import type { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
import get from "lodash/get";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
OIDCMalformedUserInfoError,
|
||||
AuthenticationError,
|
||||
} from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import { AuthenticationProvider, User } from "@server/models";
|
||||
import { AuthenticationResult } from "@server/types";
|
||||
@@ -58,7 +60,7 @@ if (
|
||||
ctx: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
params: { expires_in: number; id_token: string },
|
||||
_profile: unknown,
|
||||
done: (
|
||||
err: Error | null,
|
||||
@@ -78,14 +80,39 @@ if (
|
||||
accessToken
|
||||
);
|
||||
|
||||
if (!profile.email) {
|
||||
// Some providers, namely ADFS, don't provide anything more than the `sub` claim in the userinfo endpoint
|
||||
// So, we'll decode the params.id_token and see if that contains what we need.
|
||||
const token = (() => {
|
||||
try {
|
||||
const decoded = JWT.decode(params.id_token);
|
||||
|
||||
if (!decoded || typeof decoded !== "object") {
|
||||
Logger.warn("Decoded id_token is not a valid object");
|
||||
return {};
|
||||
}
|
||||
|
||||
return decoded as {
|
||||
email?: string;
|
||||
preferred_username?: string;
|
||||
sub?: string;
|
||||
};
|
||||
} catch (err) {
|
||||
Logger.error("id_token decode threw error: ", err);
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
const email = profile.email ?? token.email ?? null;
|
||||
|
||||
if (!email) {
|
||||
throw AuthenticationError(
|
||||
`An email field was not returned in the profile parameter, but is required.`
|
||||
`An email field was not returned in the profile or id_token parameter, but is required.`
|
||||
);
|
||||
}
|
||||
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
const { domain } = parseEmail(profile.email);
|
||||
const { domain } = parseEmail(email);
|
||||
|
||||
// Only a single OIDC provider is supported – find the existing, if any.
|
||||
const authenticationProvider = team
|
||||
@@ -118,13 +145,16 @@ if (
|
||||
|
||||
// Claim name can be overriden using an env variable.
|
||||
// Default is 'preferred_username' as per OIDC spec.
|
||||
const username = get(profile, env.OIDC_USERNAME_CLAIM);
|
||||
// This will default to the profile.preferred_username, but will fall back to preferred_username from the id_token
|
||||
const username =
|
||||
get(profile, env.OIDC_USERNAME_CLAIM) ??
|
||||
get(token, env.OIDC_USERNAME_CLAIM);
|
||||
const name = profile.name || username || profile.username;
|
||||
const profileId = profile.sub ? profile.sub : profile.id;
|
||||
|
||||
if (!name) {
|
||||
throw AuthenticationError(
|
||||
`Neither a name or username was returned in the profile parameter, but at least one is required.`
|
||||
`Neither a ${env.OIDC_USERNAME_CLAIM}, name or username was returned in the profile parameter, but at least one is required.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +168,7 @@ if (
|
||||
},
|
||||
user: {
|
||||
name,
|
||||
email: profile.email,
|
||||
email,
|
||||
avatarUrl: profile.picture,
|
||||
},
|
||||
authenticationProvider: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Integration from "~/models/Integration";
|
||||
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -13,7 +14,6 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import List from "~/components/List";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
@@ -34,13 +34,7 @@ function Slack() {
|
||||
const error = query.get("error");
|
||||
|
||||
React.useEffect(() => {
|
||||
void collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
void integrations.fetchPage({
|
||||
service: IntegrationService.Slack,
|
||||
limit: 100,
|
||||
});
|
||||
void collections.fetchAll();
|
||||
}, [collections, integrations]);
|
||||
|
||||
const commandIntegration = integrations.find({
|
||||
@@ -67,7 +61,7 @@ function Slack() {
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
<Scene title="Slack" icon={<SlackIcon />}>
|
||||
<IntegrationScene title="Slack" icon={<SlackIcon />}>
|
||||
<Heading>Slack</Heading>
|
||||
|
||||
{error === "access_denied" && (
|
||||
@@ -205,7 +199,7 @@ function Slack() {
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -11,7 +11,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Search your knowledge base directly in Slack, get /outline search, rich link previews, and notifications on new or updated docs.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: (_, user) =>
|
||||
[UserRole.Member, UserRole.Admin].includes(user.role),
|
||||
},
|
||||
|
||||
@@ -156,9 +156,9 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
|
||||
switch (type) {
|
||||
case IntegrationType.Post: {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", collection);
|
||||
authorize(user, "update", user.team);
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Icon from "./Icon";
|
||||
@@ -44,12 +44,6 @@ function Umami() {
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchPage({
|
||||
type: IntegrationType.Analytics,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
React.useEffect(() => {
|
||||
reset({
|
||||
umamiWebsiteId: integration?.settings.measurementId,
|
||||
@@ -85,7 +79,7 @@ function Umami() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title="Umami" icon={<Icon />}>
|
||||
<IntegrationScene title="Umami" icon={<Icon />}>
|
||||
<Heading>Umami</Heading>
|
||||
|
||||
<Text as="p" type="secondary">
|
||||
@@ -145,7 +139,7 @@ function Umami() {
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -11,7 +11,9 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Gain privacy-first insights into how your team consumes docs, inject your self-hosted Umami script across Outline pages to track views and engagement while retaining full control of your data.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: (_, user) => user.role === UserRole.Admin,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -8,9 +8,12 @@ PluginManager.add([
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
group: "Workspace",
|
||||
after: "Shared Links",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
description:
|
||||
"Automate downstream workflows with real-time JSON POSTs, subscribe to events in Outline so external systems can react instantly.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -29,8 +29,12 @@ describe("WebhookProcessor", () => {
|
||||
|
||||
await processor.perform(event);
|
||||
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalled();
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledWith({
|
||||
event,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
@@ -53,7 +57,9 @@ describe("WebhookProcessor", () => {
|
||||
|
||||
await processor.perform(event);
|
||||
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(0);
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("it schedules a delivery for the event for each subscription", async () => {
|
||||
@@ -79,13 +85,21 @@ describe("WebhookProcessor", () => {
|
||||
|
||||
await processor.perform(event);
|
||||
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(2);
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalled();
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledWith({
|
||||
event,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledWith({
|
||||
event,
|
||||
subscriptionId: subscriptionTwo.id,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,10 @@ export default class WebhookProcessor extends BaseProcessor {
|
||||
|
||||
await Promise.all(
|
||||
applicableSubscriptions.map((subscription) =>
|
||||
DeliverWebhookTask.schedule({ event, subscriptionId: subscription.id })
|
||||
new DeliverWebhookTask().schedule({
|
||||
event,
|
||||
subscriptionId: subscription.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { Trans } from "react-i18next";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import Heading from "~/components/Heading";
|
||||
import ZapierIcon from "~/components/Icons/ZapierIcon";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ZapierIcon from "./Icon";
|
||||
|
||||
function Zapier() {
|
||||
const { ui } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const { resolvedTheme } = ui;
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
<Scene title="Zapier" icon={<ZapierIcon />}>
|
||||
<IntegrationScene title="Zapier" icon={<ZapierIcon />}>
|
||||
<Heading>Zapier</Heading>
|
||||
<Helmet>
|
||||
<script
|
||||
@@ -39,14 +41,15 @@ function Zapier() {
|
||||
<zapier-app-directory
|
||||
app="outline"
|
||||
link-target="new-tab"
|
||||
theme={resolvedTheme}
|
||||
sign-up-email={user.email}
|
||||
theme={resolvedTheme === "system" ? undefined : resolvedTheme}
|
||||
hide="notion,confluence-cloud,confluence,google-docs,slack"
|
||||
applimit={6}
|
||||
introcopy="hide"
|
||||
create-without-template="show"
|
||||
use-this-zap="show"
|
||||
/>
|
||||
</Scene>
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
description:
|
||||
"Connect your Outline workspace to Zapier to automate workflows and integrate with thousands of other tools.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "zapier",
|
||||
"name": "Zapier",
|
||||
"description": "Adds a settings screen for connecting to Zapier.",
|
||||
"deployments": ["cloud"]
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import invariant from "invariant";
|
||||
import { Op, WhereOptions } from "sequelize";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -22,8 +21,8 @@ type Props = {
|
||||
|
||||
type Result = {
|
||||
document: Document;
|
||||
share?: Share;
|
||||
collection?: Collection | null;
|
||||
share: Share | null;
|
||||
collection: Collection | null;
|
||||
};
|
||||
|
||||
export default async function loadDocument({
|
||||
@@ -33,9 +32,9 @@ export default async function loadDocument({
|
||||
user,
|
||||
includeState,
|
||||
}: Props): Promise<Result> {
|
||||
let document;
|
||||
let collection;
|
||||
let share;
|
||||
let document: Document | null = null;
|
||||
let collection: Collection | null = null;
|
||||
let share: Share | null = null;
|
||||
|
||||
if (!shareId && !(id && user)) {
|
||||
throw AuthenticationError(`Authentication or shareId required`);
|
||||
@@ -72,20 +71,7 @@ export default async function loadDocument({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
// unscoping here allows us to return unpublished documents
|
||||
model: Document.unscoped(),
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "updatedBy",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
model: Document.scope("withDrafts"),
|
||||
required: true,
|
||||
as: "document",
|
||||
},
|
||||
@@ -129,14 +115,11 @@ export default async function loadDocument({
|
||||
const canReadDocument = user && can(user, "read", document);
|
||||
|
||||
if (canReadDocument) {
|
||||
// Cannot use document.collection here as it does not include the
|
||||
// documentStructure by default through the relationship.
|
||||
if (document.collectionId) {
|
||||
collection = await Collection.findByPk(document.collectionId);
|
||||
|
||||
if (!collection) {
|
||||
throw NotFoundError("Collection could not be found for document");
|
||||
}
|
||||
collection = await Collection.findByPk(document.collectionId, {
|
||||
includeDocumentStructure: true,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -155,11 +138,13 @@ export default async function loadDocument({
|
||||
|
||||
// It is possible to disable sharing at the collection so we must check
|
||||
if (document.collectionId) {
|
||||
collection = await Collection.findByPk(document.collectionId);
|
||||
collection = await Collection.findByPk(document.collectionId, {
|
||||
includeDocumentStructure: true,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
}
|
||||
invariant(collection, "collection not found");
|
||||
|
||||
if (!collection.sharing) {
|
||||
if (!collection?.sharing) {
|
||||
throw AuthorizationError();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import invariant from "invariant";
|
||||
import { Transaction } from "sequelize";
|
||||
import { createContext } from "@server/context";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
@@ -24,7 +23,7 @@ type Props = {
|
||||
/** Position of moved document within document structure */
|
||||
index?: number;
|
||||
/** The IP address of the user moving the document */
|
||||
ip: string;
|
||||
ip: string | null;
|
||||
/** The database transaction to run within */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
@@ -67,6 +66,7 @@ async function documentMover({
|
||||
} else {
|
||||
// Load the current and the next collection upfront and lock them
|
||||
const collection = await Collection.findByPk(document.collectionId!, {
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
paranoid: false,
|
||||
@@ -76,6 +76,7 @@ async function documentMover({
|
||||
if (collectionChanged) {
|
||||
if (collectionId) {
|
||||
newCollection = await Collection.findByPk(collectionId, {
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
@@ -144,12 +145,12 @@ async function documentMover({
|
||||
|
||||
if (collectionId) {
|
||||
// Reload the collection to get relationship data
|
||||
newCollection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId, {
|
||||
newCollection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
includeDocumentStructure: true,
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
invariant(newCollection, "Collection not found");
|
||||
|
||||
result.collections.push(newCollection);
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
||||
import { buildAttachment, buildDocument } from "@server/test/factories";
|
||||
import documentPermanentDeleter from "./documentPermanentDeleter";
|
||||
|
||||
jest.mock("@server/queues/tasks/DeleteAttachmentTask", () => ({
|
||||
schedule: jest.fn(),
|
||||
}));
|
||||
jest.mock("@server/queues/tasks/DeleteAttachmentTask");
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("documentPermanentDeleter", () => {
|
||||
it("should destroy documents", async () => {
|
||||
@@ -60,7 +62,9 @@ describe("documentPermanentDeleter", () => {
|
||||
await document.save();
|
||||
const countDeletedDoc = await documentPermanentDeleter([document]);
|
||||
expect(countDeletedDoc).toEqual(1);
|
||||
expect(DeleteAttachmentTask.schedule).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(DeleteAttachmentTask.prototype.schedule)
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
await Document.unscoped().count({
|
||||
where: {
|
||||
|
||||
@@ -67,7 +67,7 @@ export default async function documentPermanentDeleter(documents: Document[]) {
|
||||
"commands",
|
||||
`Attachment ${attachmentId} scheduled for deletion`
|
||||
);
|
||||
await DeleteAttachmentTask.schedule({
|
||||
await new DeleteAttachmentTask().schedule({
|
||||
attachmentId,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
|
||||
@@ -56,5 +56,5 @@ export default async function userSuspender({
|
||||
}
|
||||
);
|
||||
|
||||
await CleanupDemotedUserTask.schedule({ userId: user.id });
|
||||
await new CleanupDemotedUserTask().schedule({ userId: user.id });
|
||||
}
|
||||
|
||||
@@ -296,7 +296,12 @@ export default abstract class BaseEmail<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let content = ProsemirrorHelper.toHTML(node, {
|
||||
// Process user mentions to ensure they are uptodate with database
|
||||
const processedNode = ProsemirrorHelper.toProsemirror(
|
||||
await ProsemirrorHelper.processMentions(node)
|
||||
);
|
||||
|
||||
let content = ProsemirrorHelper.toHTML(processedNode, {
|
||||
centered: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -37,9 +37,10 @@ export default class CollectionCreatedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected async beforeSend(props: InputProps) {
|
||||
const collection = await Collection.scope("withUser").findByPk(
|
||||
props.collectionId
|
||||
);
|
||||
const collection = await Collection.findByPk(props.collectionId, {
|
||||
includeOwner: true,
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
+46
-5
@@ -13,6 +13,7 @@ import Router from "koa-router";
|
||||
import { AddressInfo } from "net";
|
||||
import stoppable from "stoppable";
|
||||
import throng from "throng";
|
||||
import escape from "lodash/escape";
|
||||
import Logger from "./logging/Logger";
|
||||
import services from "./services";
|
||||
import { getArg } from "./utils/args";
|
||||
@@ -88,13 +89,53 @@ async function start(_id: number, disconnect: () => void) {
|
||||
app.use(defaultRateLimiter());
|
||||
|
||||
/** Perform a redirect on the browser so that the user's auth cookies are included in the request. */
|
||||
app.context.redirectOnClient = function (url: string) {
|
||||
app.context.redirectOnClient = function (
|
||||
/** The URL to redirect to */
|
||||
url: string,
|
||||
/**
|
||||
* The HTTP method to use for the redirect. Use POST when preventing links in emails from being
|
||||
* clicked by bots. Otherwise, use GET.
|
||||
*/
|
||||
method: "GET" | "POST" = "GET"
|
||||
) {
|
||||
this.type = "text/html";
|
||||
this.body = `
|
||||
|
||||
if (method === "POST") {
|
||||
// For POST method, create a form that auto-submits
|
||||
const urlObj = new URL(url);
|
||||
const formAction = `${urlObj.origin}${urlObj.pathname}`;
|
||||
const searchParams = urlObj.searchParams;
|
||||
|
||||
let formFields = "";
|
||||
searchParams.forEach((value, key) => {
|
||||
formFields += `<input type="hidden" name="${escape(
|
||||
key
|
||||
)}" value="${escape(value)}" />`;
|
||||
});
|
||||
|
||||
this.body = `
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;URL='${url}'"/>
|
||||
</head>`;
|
||||
<title>Redirecting…</title>
|
||||
</head>
|
||||
<body>
|
||||
<form id="redirect-form" method="POST" action="${formAction}">
|
||||
${formFields}
|
||||
</form>
|
||||
<script nonce="${this.state.cspNonce}">
|
||||
document.getElementById('redirect-form').submit();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
} else {
|
||||
// Default GET method using meta refresh
|
||||
this.body = `
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;URL='${escape(url)}'" />
|
||||
</head>
|
||||
</html>`;
|
||||
}
|
||||
};
|
||||
|
||||
// Add a health check endpoint to all services
|
||||
@@ -133,7 +174,7 @@ async function start(_id: number, disconnect: () => void) {
|
||||
|
||||
server.on("error", (err) => {
|
||||
if ("code" in err && err.code === "EADDRINUSE") {
|
||||
Logger.error(`Port ${normalizedPort} is already in use. Exiting…`, err);
|
||||
Logger.error(`Port ${normalizedPort} is already in use. Exiting…`, err);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Next } from "koa";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
@@ -37,7 +38,7 @@ export default function apexAuthRedirect<T>({
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
attributes: ["id", "subdomain"],
|
||||
attributes: ["id", "domain", "subdomain"],
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function apexAuthRedirect<T>({
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(getRedirectPath(ctx, team));
|
||||
} catch (err) {
|
||||
Logger.error("Error fetching team", err);
|
||||
return ctx.redirect(getErrorPath(ctx));
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import crypto from "crypto";
|
||||
import { Context, Next } from "koa";
|
||||
import { contentSecurityPolicy } from "koa-helmet";
|
||||
import uniq from "lodash/uniq";
|
||||
import env from "@server/env";
|
||||
|
||||
/**
|
||||
* Create a Content Security Policy middleware for the application.
|
||||
*/
|
||||
export default function createCSPMiddleware() {
|
||||
// Construct scripts CSP based on options in use
|
||||
const defaultSrc = ["'self'"];
|
||||
const scriptSrc = ["'self'"];
|
||||
const styleSrc = ["'self'", "'unsafe-inline'"];
|
||||
|
||||
if (env.isCloudHosted) {
|
||||
scriptSrc.push("www.googletagmanager.com");
|
||||
scriptSrc.push("cdn.zapier.com");
|
||||
styleSrc.push("cdn.zapier.com");
|
||||
}
|
||||
|
||||
// Allow to load assets from Vite
|
||||
if (!env.isProduction) {
|
||||
scriptSrc.push(env.URL.replace(`:${env.PORT}`, ":3001"));
|
||||
scriptSrc.push("localhost:3001");
|
||||
}
|
||||
|
||||
if (env.GOOGLE_ANALYTICS_ID) {
|
||||
scriptSrc.push("www.googletagmanager.com");
|
||||
scriptSrc.push("www.google-analytics.com");
|
||||
}
|
||||
|
||||
if (env.CDN_URL) {
|
||||
scriptSrc.push(env.CDN_URL);
|
||||
styleSrc.push(env.CDN_URL);
|
||||
defaultSrc.push(env.CDN_URL);
|
||||
}
|
||||
|
||||
return function cspMiddleware(ctx: Context, next: Next) {
|
||||
ctx.state.cspNonce = crypto.randomBytes(16).toString("hex");
|
||||
|
||||
return contentSecurityPolicy({
|
||||
directives: {
|
||||
defaultSrc,
|
||||
styleSrc,
|
||||
scriptSrc: [
|
||||
...uniq(scriptSrc),
|
||||
env.DEVELOPMENT_UNSAFE_INLINE_CSP
|
||||
? "'unsafe-inline'"
|
||||
: `'nonce-${ctx.state.cspNonce}'`,
|
||||
],
|
||||
mediaSrc: ["*", "data:", "blob:"],
|
||||
imgSrc: ["*", "data:", "blob:"],
|
||||
frameSrc: ["*", "data:"],
|
||||
// Do not use connect-src: because self + websockets does not work in
|
||||
// Safari, ref: https://bugs.webkit.org/show_bug.cgi?id=201591
|
||||
connectSrc: ["*"],
|
||||
},
|
||||
})(ctx, next);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up() {
|
||||
if (
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.DEPLOYMENT === "hosted"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptName = path.basename(__filename);
|
||||
const scriptPath = path.join(
|
||||
process.cwd(),
|
||||
"build",
|
||||
`server/scripts/${scriptName}`
|
||||
);
|
||||
|
||||
execFileSync("node", [scriptPath], { stdio: "inherit" });
|
||||
},
|
||||
|
||||
async down() {
|
||||
// noop
|
||||
},
|
||||
};
|
||||
@@ -16,7 +16,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("#url", () => {
|
||||
test("should return correct url for the collection", () => {
|
||||
it("should return correct url for the collection", () => {
|
||||
const collection = new Collection({
|
||||
id: "1234",
|
||||
});
|
||||
@@ -25,7 +25,7 @@ describe("#url", () => {
|
||||
});
|
||||
|
||||
describe("getDocumentParents", () => {
|
||||
test("should return array of parent document ids", async () => {
|
||||
it("should return array of parent document ids", async () => {
|
||||
const parent = await buildDocument();
|
||||
const document = await buildDocument();
|
||||
const collection = await buildCollection({
|
||||
@@ -41,7 +41,7 @@ describe("getDocumentParents", () => {
|
||||
expect(result ? result[0] : undefined).toBe(parent.id);
|
||||
});
|
||||
|
||||
test("should return array of parent document ids", async () => {
|
||||
it("should return array of parent document ids", async () => {
|
||||
const parent = await buildDocument();
|
||||
const document = await buildDocument();
|
||||
const collection = await buildCollection({
|
||||
@@ -56,7 +56,7 @@ describe("getDocumentParents", () => {
|
||||
expect(result?.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should not error if documentStructure is empty", async () => {
|
||||
it("should not error if documentStructure is empty", async () => {
|
||||
const parent = await buildDocument();
|
||||
await buildDocument();
|
||||
const collection = await buildCollection();
|
||||
@@ -66,7 +66,7 @@ describe("getDocumentParents", () => {
|
||||
});
|
||||
|
||||
describe("getDocumentTree", () => {
|
||||
test("should return document tree", async () => {
|
||||
it("should return document tree", async () => {
|
||||
const document = await buildDocument();
|
||||
const collection = await buildCollection({
|
||||
documentStructure: [await document.toNavigationNode()],
|
||||
@@ -76,7 +76,7 @@ describe("getDocumentTree", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should return nested documents in tree", async () => {
|
||||
it("should return nested documents in tree", async () => {
|
||||
const parent = await buildDocument();
|
||||
const document = await buildDocument();
|
||||
const collection = await buildCollection({
|
||||
@@ -99,7 +99,7 @@ describe("getDocumentTree", () => {
|
||||
});
|
||||
|
||||
describe("#addDocumentToStructure", () => {
|
||||
test("should add as last element without index", async () => {
|
||||
it("should add as last element without index", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = uuidv4();
|
||||
const newDocument = await buildDocument({
|
||||
@@ -117,7 +117,7 @@ describe("#addDocumentToStructure", () => {
|
||||
expect(collection.documentStructure!.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should add with an index", async () => {
|
||||
it("should add with an index", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = uuidv4();
|
||||
const newDocument = await buildDocument({
|
||||
@@ -131,7 +131,7 @@ describe("#addDocumentToStructure", () => {
|
||||
expect(collection.documentStructure![0].id).toBe(id);
|
||||
});
|
||||
|
||||
test("should add as a child if with parent", async () => {
|
||||
it("should add as a child if with parent", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -150,7 +150,7 @@ describe("#addDocumentToStructure", () => {
|
||||
expect(collection.documentStructure![0].children[0].id).toBe(id);
|
||||
});
|
||||
|
||||
test("should add as a child if with parent with index", async () => {
|
||||
it("should add as a child if with parent with index", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -176,7 +176,7 @@ describe("#addDocumentToStructure", () => {
|
||||
expect(collection.documentStructure![0].children[0].id).toBe(id);
|
||||
});
|
||||
|
||||
test("should add the document along with its nested document(s)", async () => {
|
||||
it("should add the document along with its nested document(s)", async () => {
|
||||
const collection = await buildCollection();
|
||||
|
||||
const document = await buildDocument({
|
||||
@@ -204,7 +204,7 @@ describe("#addDocumentToStructure", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should add the document along with its archived nested document(s)", async () => {
|
||||
it("should add the document along with its archived nested document(s)", async () => {
|
||||
const collection = await buildCollection();
|
||||
|
||||
const document = await buildDocument({
|
||||
@@ -237,7 +237,7 @@ describe("#addDocumentToStructure", () => {
|
||||
);
|
||||
});
|
||||
describe("options: documentJson", () => {
|
||||
test("should append supplied json over document's own", async () => {
|
||||
it("should append supplied json over document's own", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = uuidv4();
|
||||
const newDocument = await buildDocument({
|
||||
@@ -268,7 +268,7 @@ describe("#addDocumentToStructure", () => {
|
||||
});
|
||||
|
||||
describe("#updateDocument", () => {
|
||||
test("should update root document's data", async () => {
|
||||
it("should update root document's data", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -279,7 +279,7 @@ describe("#updateDocument", () => {
|
||||
expect(collection.documentStructure![0].title).toBe("Updated title");
|
||||
});
|
||||
|
||||
test("should update child document's data", async () => {
|
||||
it("should update child document's data", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -297,7 +297,7 @@ describe("#updateDocument", () => {
|
||||
newDocument.title = "Updated title";
|
||||
await newDocument.save();
|
||||
await collection.updateDocument(newDocument);
|
||||
const reloaded = await Collection.findByPk(collection.id);
|
||||
const reloaded = await collection.reload();
|
||||
expect(reloaded!.documentStructure![0].children[0].title).toBe(
|
||||
"Updated title"
|
||||
);
|
||||
@@ -305,7 +305,7 @@ describe("#updateDocument", () => {
|
||||
});
|
||||
|
||||
describe("#removeDocument", () => {
|
||||
test("should save if removing", async () => {
|
||||
it("should save if removing", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -315,7 +315,7 @@ describe("#removeDocument", () => {
|
||||
expect(collection.save).toBeCalled();
|
||||
});
|
||||
|
||||
test("should remove documents from root", async () => {
|
||||
it("should remove documents from root", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -331,7 +331,7 @@ describe("#removeDocument", () => {
|
||||
expect(collectionDocuments.count).toBe(0);
|
||||
});
|
||||
|
||||
test("should remove a document with child documents", async () => {
|
||||
it("should remove a document with child documents", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -359,7 +359,7 @@ describe("#removeDocument", () => {
|
||||
expect(collectionDocuments.count).toBe(0);
|
||||
});
|
||||
|
||||
test("should remove a child document", async () => {
|
||||
it("should remove a child document", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -380,7 +380,7 @@ describe("#removeDocument", () => {
|
||||
expect(collection.documentStructure![0].children.length).toBe(1);
|
||||
// Remove the document
|
||||
await collection.deleteDocument(newDocument);
|
||||
const reloaded = await Collection.findByPk(collection.id);
|
||||
const reloaded = await collection.reload();
|
||||
expect(reloaded!.documentStructure!.length).toBe(1);
|
||||
expect(reloaded!.documentStructure![0].children.length).toBe(0);
|
||||
const collectionDocuments = await Document.findAndCountAll({
|
||||
@@ -393,7 +393,7 @@ describe("#removeDocument", () => {
|
||||
});
|
||||
|
||||
describe("#membershipUserIds", () => {
|
||||
test("should return collection and group memberships", async () => {
|
||||
it("should return collection and group memberships", async () => {
|
||||
const team = await buildTeam();
|
||||
const teamId = team.id;
|
||||
// Make 6 users
|
||||
@@ -464,47 +464,53 @@ describe("#membershipUserIds", () => {
|
||||
});
|
||||
|
||||
describe("#findByPk", () => {
|
||||
test("should return collection with collection Id", async () => {
|
||||
it("should return collection with collection Id", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id);
|
||||
expect(response!.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
test("should return collection when urlId is present", async () => {
|
||||
it("should not return documentStructure by default", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id);
|
||||
expect(() => response!.documentStructure).toThrow();
|
||||
});
|
||||
|
||||
it("should return collection when urlId is present", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = `${slugify(collection.name)}-${collection.urlId}`;
|
||||
const response = await Collection.findByPk(id);
|
||||
expect(response!.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
test("should return collection when urlId is present, but missing slug", async () => {
|
||||
it("should return collection when urlId is present, but missing slug", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = collection.urlId;
|
||||
const response = await Collection.findByPk(id);
|
||||
expect(response!.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
test("should return null when incorrect uuid type", async () => {
|
||||
it("should return null when incorrect uuid type", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id + "-incorrect");
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
|
||||
test("should return null when incorrect urlId length", async () => {
|
||||
it("should return null when incorrect urlId length", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
|
||||
const response = await Collection.findByPk(id);
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
|
||||
test("should return null when no collection is found with uuid", async () => {
|
||||
it("should return null when no collection is found with uuid", async () => {
|
||||
const response = await Collection.findByPk(
|
||||
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
|
||||
);
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
|
||||
test("should return null when no collection is found with urlId", async () => {
|
||||
it("should return null when no collection is found with urlId", async () => {
|
||||
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
|
||||
const response = await Collection.findByPk(id);
|
||||
expect(response).toBe(null);
|
||||
|
||||
+77
-10
@@ -14,6 +14,8 @@ import {
|
||||
EmptyResultError,
|
||||
type CreateOptions,
|
||||
type UpdateOptions,
|
||||
type ScopeOptions,
|
||||
type SaveOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
Sequelize,
|
||||
@@ -37,6 +39,8 @@ import {
|
||||
AllowNull,
|
||||
BeforeCreate,
|
||||
BeforeUpdate,
|
||||
DefaultScope,
|
||||
AfterSave,
|
||||
} from "sequelize-typescript";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import type { CollectionSort, ProsemirrorData } from "@shared/types";
|
||||
@@ -46,6 +50,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import removeIndexCollision from "@server/utils/removeIndexCollision";
|
||||
import { generateUrlId } from "@server/utils/url";
|
||||
import { ValidateIndex } from "@server/validation";
|
||||
@@ -66,9 +71,17 @@ import Length from "./validators/Length";
|
||||
import NotContainsUrl from "./validators/NotContainsUrl";
|
||||
|
||||
type AdditionalFindOptions = {
|
||||
userId?: string;
|
||||
includeDocumentStructure?: boolean;
|
||||
includeOwner?: boolean;
|
||||
rejectOnEmpty?: boolean | Error;
|
||||
};
|
||||
|
||||
@DefaultScope(() => ({
|
||||
attributes: {
|
||||
exclude: ["documentStructure"],
|
||||
},
|
||||
}))
|
||||
@Scopes(() => ({
|
||||
withAllMemberships: {
|
||||
include: [
|
||||
@@ -121,6 +134,12 @@ type AdditionalFindOptions = {
|
||||
},
|
||||
],
|
||||
}),
|
||||
withDocumentStructure: () => ({
|
||||
attributes: {
|
||||
// resets to include the documentStructure column
|
||||
exclude: [],
|
||||
},
|
||||
}),
|
||||
withMembership: (userId: string) => {
|
||||
if (!userId) {
|
||||
return {};
|
||||
@@ -238,6 +257,7 @@ class Collection extends ParanoidModel<
|
||||
@Column
|
||||
maintainerApprovalRequired: boolean;
|
||||
|
||||
@Default(null)
|
||||
@Column(DataType.JSONB)
|
||||
documentStructure: NavigationNode[] | null;
|
||||
|
||||
@@ -317,6 +337,34 @@ class Collection extends ParanoidModel<
|
||||
if (!model.content) {
|
||||
model.content = await DocumentHelper.toJSON(model);
|
||||
}
|
||||
if (model.changed("documentStructure")) {
|
||||
await CacheHelper.clearData(
|
||||
CacheHelper.getCollectionDocumentsKey(model.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterSave
|
||||
static async cacheDocumentStructure(
|
||||
model: Collection,
|
||||
options: SaveOptions<Collection>
|
||||
) {
|
||||
if (model.changed("documentStructure")) {
|
||||
const setData = () =>
|
||||
CacheHelper.setData(
|
||||
CacheHelper.getCollectionDocumentsKey(model.id),
|
||||
model.documentStructure,
|
||||
60
|
||||
);
|
||||
|
||||
if (options.transaction) {
|
||||
return (options.transaction.parent || options.transaction).afterCommit(
|
||||
setData
|
||||
);
|
||||
}
|
||||
|
||||
await setData();
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeDestroy
|
||||
@@ -379,8 +427,11 @@ class Collection extends ParanoidModel<
|
||||
model: Collection,
|
||||
options: UpdateOptions<Collection>
|
||||
) {
|
||||
if (model.index && model.changed("index")) {
|
||||
model.index = await removeIndexCollision(model.teamId, model.index, {
|
||||
if (
|
||||
(model.index && model.changed("index")) ||
|
||||
(!model.archivedAt && model.changed("archivedAt"))
|
||||
) {
|
||||
model.index = await removeIndexCollision(model.teamId, model.index!, {
|
||||
transaction: options.transaction,
|
||||
});
|
||||
}
|
||||
@@ -453,9 +504,9 @@ class Collection extends ParanoidModel<
|
||||
* @returns userIds
|
||||
*/
|
||||
static async membershipUserIds(collectionId: string) {
|
||||
const collection = await this.scope("withAllMemberships").findByPk(
|
||||
collectionId
|
||||
);
|
||||
const collection = await this.scope("withAllMemberships").findOne({
|
||||
where: { id: collectionId },
|
||||
});
|
||||
if (!collection) {
|
||||
return [];
|
||||
}
|
||||
@@ -472,6 +523,7 @@ class Collection extends ParanoidModel<
|
||||
|
||||
/**
|
||||
* Overrides the standard findByPk behavior to allow also querying by urlId
|
||||
* and loading memberships for a user passed in by `userId`
|
||||
*
|
||||
* @param id uuid or urlId
|
||||
* @param options FindOptions
|
||||
@@ -493,16 +545,31 @@ class Collection extends ParanoidModel<
|
||||
return null;
|
||||
}
|
||||
|
||||
const { includeDocumentStructure, includeOwner, userId, ...rest } = options;
|
||||
|
||||
const scopes: (string | ScopeOptions)[] = [
|
||||
includeDocumentStructure ? "withDocumentStructure" : "defaultScope",
|
||||
{
|
||||
method: ["withMembership", userId],
|
||||
},
|
||||
];
|
||||
|
||||
if (includeOwner) {
|
||||
scopes.push("withUser");
|
||||
}
|
||||
|
||||
const scope = this.scope(scopes);
|
||||
|
||||
if (isUUID(id)) {
|
||||
const collection = await this.findOne({
|
||||
const collection = await scope.findOne({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
...options,
|
||||
...rest,
|
||||
rejectOnEmpty: false,
|
||||
});
|
||||
|
||||
if (!collection && options.rejectOnEmpty) {
|
||||
if (!collection && rest.rejectOnEmpty) {
|
||||
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
|
||||
}
|
||||
|
||||
@@ -511,7 +578,7 @@ class Collection extends ParanoidModel<
|
||||
|
||||
const match = id.match(UrlHelper.SLUG_URL_REGEX);
|
||||
if (match) {
|
||||
const collection = await this.findOne({
|
||||
const collection = await scope.findOne({
|
||||
where: {
|
||||
urlId: match[1],
|
||||
},
|
||||
@@ -519,7 +586,7 @@ class Collection extends ParanoidModel<
|
||||
rejectOnEmpty: false,
|
||||
});
|
||||
|
||||
if (!collection && options.rejectOnEmpty) {
|
||||
if (!collection && rest.rejectOnEmpty) {
|
||||
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
buildUser,
|
||||
buildGuestUser,
|
||||
} from "@server/test/factories";
|
||||
import Collection from "./Collection";
|
||||
import UserMembership from "./UserMembership";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -96,10 +95,8 @@ describe("#delete", () => {
|
||||
|
||||
await document.delete(user);
|
||||
const [newDocument, newCollection] = await Promise.all([
|
||||
Document.findByPk(document.id, {
|
||||
paranoid: false,
|
||||
}),
|
||||
Collection.findByPk(collection.id),
|
||||
document.reload({ paranoid: false }),
|
||||
collection.reload(),
|
||||
]);
|
||||
|
||||
expect(newDocument?.lastModifiedById).toEqual(user.id);
|
||||
|
||||
+49
-41
@@ -13,9 +13,9 @@ import {
|
||||
Transaction,
|
||||
Op,
|
||||
FindOptions,
|
||||
ScopeOptions,
|
||||
WhereOptions,
|
||||
EmptyResultError,
|
||||
Sequelize,
|
||||
} from "sequelize";
|
||||
import {
|
||||
ForeignKey,
|
||||
@@ -72,12 +72,20 @@ import Length from "./validators/Length";
|
||||
|
||||
export const DOCUMENT_VERSION = 2;
|
||||
|
||||
// If content (JSON) is null then we still need to return the state column (BINARY)
|
||||
// as it's used as a fallback for content deserialization for older documents.
|
||||
// This can be removed if content is 100% backfilled.
|
||||
const stateIfContentEmpty = Sequelize.literal(
|
||||
`CASE WHEN document.content IS NULL THEN document.state ELSE NULL END AS state`
|
||||
);
|
||||
|
||||
type AdditionalFindOptions = {
|
||||
userId?: string;
|
||||
includeState?: boolean;
|
||||
rejectOnEmpty?: boolean | Error;
|
||||
};
|
||||
|
||||
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
@@ -102,27 +110,14 @@ type AdditionalFindOptions = {
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
exclude: ["state"],
|
||||
include: [stateIfContentEmpty],
|
||||
},
|
||||
}))
|
||||
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
|
||||
@Scopes(() => ({
|
||||
withCollectionPermissions: (userId: string, paranoid = true) => ({
|
||||
include: [
|
||||
{
|
||||
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
|
||||
model: userId
|
||||
? Collection.scope({
|
||||
method: ["withMembership", userId],
|
||||
})
|
||||
: Collection,
|
||||
as: "collection",
|
||||
paranoid,
|
||||
},
|
||||
],
|
||||
}),
|
||||
withoutState: {
|
||||
attributes: {
|
||||
exclude: ["state"],
|
||||
include: [stateIfContentEmpty],
|
||||
},
|
||||
},
|
||||
withCollection: {
|
||||
@@ -136,7 +131,7 @@ type AdditionalFindOptions = {
|
||||
withState: {
|
||||
attributes: {
|
||||
// resets to include the state column
|
||||
exclude: [],
|
||||
include: [],
|
||||
},
|
||||
},
|
||||
withDrafts: {
|
||||
@@ -169,13 +164,25 @@ type AdditionalFindOptions = {
|
||||
],
|
||||
};
|
||||
},
|
||||
withMembership: (userId: string) => {
|
||||
withMembership: (userId: string, paranoid = true) => {
|
||||
if (!userId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
include: [
|
||||
{
|
||||
model: userId
|
||||
? Collection.scope([
|
||||
"defaultScope",
|
||||
{
|
||||
method: ["withMembership", userId],
|
||||
},
|
||||
])
|
||||
: Collection,
|
||||
as: "collection",
|
||||
paranoid,
|
||||
},
|
||||
{
|
||||
association: "memberships",
|
||||
where: {
|
||||
@@ -420,6 +427,7 @@ class Document extends ArchivableModel<
|
||||
}
|
||||
|
||||
const collection = await Collection.findByPk(model.collectionId, {
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
@@ -444,6 +452,7 @@ class Document extends ArchivableModel<
|
||||
|
||||
return this.sequelize!.transaction(async (transaction: Transaction) => {
|
||||
const collection = await Collection.findByPk(model.collectionId!, {
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
@@ -637,30 +646,29 @@ class Document extends ArchivableModel<
|
||||
return uniq(membershipUserIds);
|
||||
}
|
||||
|
||||
static defaultScopeWithUser(userId: string) {
|
||||
const collectionScope: Readonly<ScopeOptions> = {
|
||||
method: ["withCollectionPermissions", userId],
|
||||
};
|
||||
const viewScope: Readonly<ScopeOptions> = {
|
||||
method: ["withViews", userId],
|
||||
};
|
||||
const membershipScope: Readonly<ScopeOptions> = {
|
||||
method: ["withMembership", userId],
|
||||
};
|
||||
static withMembershipScope(
|
||||
userId: string,
|
||||
options?: FindOptions<Document> & { includeDrafts?: boolean }
|
||||
) {
|
||||
return this.scope([
|
||||
"defaultScope",
|
||||
collectionScope,
|
||||
viewScope,
|
||||
membershipScope,
|
||||
options?.includeDrafts ? "withDrafts" : "defaultScope",
|
||||
"withoutState",
|
||||
{
|
||||
method: ["withViews", userId],
|
||||
},
|
||||
{
|
||||
method: ["withMembership", userId, options?.paranoid],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the standard findByPk behavior to allow also querying by urlId
|
||||
* and loading memberships for a user passed in by `userId`
|
||||
*
|
||||
* @param id uuid or urlId
|
||||
* @param options FindOptions
|
||||
* @returns A promise resolving to a collection instance or null
|
||||
* @returns A promise resolving to a document instance or null
|
||||
*/
|
||||
static async findByPk(
|
||||
id: Identifier,
|
||||
@@ -685,14 +693,12 @@ class Document extends ArchivableModel<
|
||||
// almost every endpoint needs the collection membership to determine policy permissions.
|
||||
const scope = this.scope([
|
||||
"withDrafts",
|
||||
{
|
||||
method: ["withCollectionPermissions", userId, rest.paranoid],
|
||||
},
|
||||
includeState ? "withState" : "withoutState",
|
||||
{
|
||||
method: ["withViews", userId],
|
||||
},
|
||||
{
|
||||
method: ["withMembership", userId],
|
||||
method: ["withMembership", userId, rest.paranoid],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -750,9 +756,6 @@ class Document extends ArchivableModel<
|
||||
const user = userId ? await User.findByPk(userId) : null;
|
||||
const documents = await this.scope([
|
||||
"withDrafts",
|
||||
{
|
||||
method: ["withCollectionPermissions", userId, rest.paranoid],
|
||||
},
|
||||
{
|
||||
method: ["withViews", userId],
|
||||
},
|
||||
@@ -939,6 +942,7 @@ class Document extends ArchivableModel<
|
||||
|
||||
if (!this.template && this.collectionId) {
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
@@ -1006,6 +1010,7 @@ class Document extends ArchivableModel<
|
||||
await this.sequelize.transaction(async (transaction: Transaction) => {
|
||||
const collection = this.collectionId
|
||||
? await Collection.findByPk(this.collectionId, {
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
})
|
||||
@@ -1040,6 +1045,7 @@ class Document extends ArchivableModel<
|
||||
const { transaction } = { ...options };
|
||||
const collection = this.collectionId
|
||||
? await Collection.findByPk(this.collectionId, {
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
lock: transaction?.LOCK.UPDATE,
|
||||
})
|
||||
@@ -1064,6 +1070,7 @@ class Document extends ArchivableModel<
|
||||
const { transaction } = { ...options };
|
||||
const collection = collectionId
|
||||
? await Collection.findByPk(collectionId, {
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
lock: transaction?.LOCK.UPDATE,
|
||||
})
|
||||
@@ -1116,6 +1123,7 @@ class Document extends ArchivableModel<
|
||||
|
||||
if (!this.template && this.collectionId) {
|
||||
const collection = await Collection.findByPk(this.collectionId!, {
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
paranoid: false,
|
||||
|
||||
@@ -408,7 +408,7 @@ class Team extends ParanoidModel<
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
await DeleteAttachmentTask.schedule({
|
||||
await new DeleteAttachmentTask().schedule({
|
||||
attachmentId: attachment.id,
|
||||
teamId: model.id,
|
||||
});
|
||||
|
||||
@@ -717,7 +717,7 @@ class User extends ParanoidModel<
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
await DeleteAttachmentTask.schedule({
|
||||
await new DeleteAttachmentTask().schedule({
|
||||
attachmentId: attachment.id,
|
||||
teamId: model.teamId,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { DocumentPermission, NotificationEventType } from "@shared/types";
|
||||
import { UserMembership } from "@server/models";
|
||||
import {
|
||||
buildComment,
|
||||
buildDocument,
|
||||
buildDraftDocument,
|
||||
buildSubscription,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
@@ -54,6 +56,78 @@ describe("NotificationHelper", () => {
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
|
||||
});
|
||||
|
||||
it("should only return users who have notification enabled for comment creation and are subscribed to the document in case of new thread in draft", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
|
||||
// create a draft
|
||||
const document = await buildDraftDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
collectionId: null,
|
||||
});
|
||||
|
||||
// add a bunch of users as direct members
|
||||
const user = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
const user2 = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
const user3 = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
await UserMembership.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission: DocumentPermission.Read,
|
||||
createdById: user.id,
|
||||
});
|
||||
await UserMembership.create({
|
||||
documentId: document.id,
|
||||
userId: user2.id,
|
||||
permission: DocumentPermission.Read,
|
||||
createdById: user.id,
|
||||
});
|
||||
await UserMembership.create({
|
||||
documentId: document.id,
|
||||
userId: user3.id,
|
||||
permission: DocumentPermission.Read,
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
// Add a subscription for only one of those users
|
||||
await Promise.all([
|
||||
buildSubscription({
|
||||
userId: user.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: user2.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: user3.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const comment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: documentAuthor.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getCommentNotificationRecipients(
|
||||
document,
|
||||
comment,
|
||||
comment.createdById
|
||||
);
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(user3.id);
|
||||
});
|
||||
|
||||
it("should only return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
|
||||
@@ -193,10 +193,16 @@ export default class NotificationHelper {
|
||||
[Op.ne]: actorId,
|
||||
},
|
||||
event: SubscriptionType.Document,
|
||||
[Op.or]: [
|
||||
{ collectionId: document.collectionId },
|
||||
{ documentId: document.id },
|
||||
],
|
||||
...(document.collectionId
|
||||
? {
|
||||
[Op.or]: [
|
||||
{ collectionId: document.collectionId },
|
||||
{ documentId: document.id },
|
||||
],
|
||||
}
|
||||
: {
|
||||
documentId: document.id,
|
||||
}),
|
||||
},
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -1,9 +1,187 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { DeepPartial } from "utility-types";
|
||||
import { MentionType, ProsemirrorData } from "@shared/types";
|
||||
import { buildProseMirrorDoc } from "@server/test/factories";
|
||||
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
|
||||
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
|
||||
|
||||
describe("ProsemirrorHelper", () => {
|
||||
describe("processMentions", () => {
|
||||
it("should handle deleted users", async () => {
|
||||
const user = await buildUser();
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
type: MentionType.User,
|
||||
label: "test.user",
|
||||
actorId: user.id,
|
||||
modelId: user.id,
|
||||
};
|
||||
|
||||
await user.destroy();
|
||||
|
||||
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "a paragraph with ",
|
||||
},
|
||||
{
|
||||
type: "mention",
|
||||
attrs: mentionAttrs,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: " mentioned",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const doc = buildProseMirrorDoc([
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "some content in a paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
mentionedParagraph,
|
||||
]);
|
||||
|
||||
const newDoc = await ProsemirrorHelper.processMentions(doc);
|
||||
expect(newDoc.content?.[1]?.content?.[1].attrs?.label).toEqual("Unknown");
|
||||
});
|
||||
|
||||
it("should handle updated users", async () => {
|
||||
const user = await buildUser();
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
type: MentionType.User,
|
||||
label: "test.user",
|
||||
actorId: user.id,
|
||||
modelId: user.id,
|
||||
};
|
||||
|
||||
await user.update({
|
||||
name: faker.name.firstName(),
|
||||
});
|
||||
|
||||
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "a paragraph with ",
|
||||
},
|
||||
{
|
||||
type: "mention",
|
||||
attrs: mentionAttrs,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: " mentioned",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const doc = buildProseMirrorDoc([
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "some content in a paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
mentionedParagraph,
|
||||
]);
|
||||
|
||||
const newDoc = await ProsemirrorHelper.processMentions(doc);
|
||||
expect(newDoc.content?.[1]?.content?.[1].attrs?.label).toEqual(user.name);
|
||||
});
|
||||
|
||||
it("should handle multiple renamed users", async () => {
|
||||
const firstUser = await buildUser();
|
||||
const secondUser = await buildUser();
|
||||
|
||||
const firstMentionAttrs: MentionAttrs = {
|
||||
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
type: MentionType.User,
|
||||
label: "first.user",
|
||||
actorId: firstUser.id,
|
||||
modelId: firstUser.id,
|
||||
};
|
||||
|
||||
const secondMentionAttrs: MentionAttrs = {
|
||||
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
|
||||
type: MentionType.User,
|
||||
label: "second.user",
|
||||
actorId: secondUser.id,
|
||||
modelId: secondUser.id,
|
||||
};
|
||||
|
||||
const firstNewName = faker.name.firstName();
|
||||
const secondNewName = faker.name.firstName();
|
||||
|
||||
await firstUser.update({
|
||||
name: firstNewName,
|
||||
});
|
||||
|
||||
await secondUser.update({
|
||||
name: secondNewName,
|
||||
});
|
||||
|
||||
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "a paragraph with ",
|
||||
},
|
||||
{
|
||||
type: "mention",
|
||||
attrs: firstMentionAttrs,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: " and ",
|
||||
},
|
||||
{
|
||||
type: "mention",
|
||||
attrs: secondMentionAttrs,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: " mentioned",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const doc = buildProseMirrorDoc([
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "some content in a paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
mentionedParagraph,
|
||||
]);
|
||||
|
||||
const newDoc = await ProsemirrorHelper.processMentions(doc);
|
||||
expect(newDoc.content?.[1]?.content?.[1].attrs?.label).toEqual(
|
||||
firstNewName
|
||||
);
|
||||
expect(newDoc.content?.[1]?.content?.[3].attrs?.label).toEqual(
|
||||
secondNewName
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNodeForMentionEmail", () => {
|
||||
it("should return the paragraph node", () => {
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { schema, parser } from "@server/editor";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
import Attachment from "@server/models/Attachment";
|
||||
import User from "@server/models/User";
|
||||
import FileStorage from "@server/storage/files";
|
||||
|
||||
export type HTMLOptions = {
|
||||
@@ -490,7 +491,7 @@ export class ProsemirrorHelper {
|
||||
// Render the Prosemirror document using virtual DOM and serialize the
|
||||
// result to a string
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>${
|
||||
`<!DOCTYPE html><meta charset="utf-8">${
|
||||
options?.includeStyles === false ? "" : styleTags
|
||||
}${html}`
|
||||
);
|
||||
@@ -558,4 +559,79 @@ export class ProsemirrorHelper {
|
||||
|
||||
return dom.serialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes mentions in the Prosemirror data, ensuring that mentions
|
||||
* for deleted users are displayed as "@unknown" and updated names are
|
||||
* displayed correctly.
|
||||
*
|
||||
* @param data The ProsemirrorData object to process
|
||||
* @returns The processed ProsemirrorData with updated mentions
|
||||
*/
|
||||
static async processMentions(data: ProsemirrorData | Node) {
|
||||
const json = "toJSON" in data ? (data.toJSON() as ProsemirrorData) : data;
|
||||
|
||||
// First pass: collect all user IDs from mentions
|
||||
const userIds: string[] = [];
|
||||
|
||||
function collectUserIds(node: ProsemirrorData) {
|
||||
if (
|
||||
node.type === "mention" &&
|
||||
node.attrs?.type === MentionType.User &&
|
||||
node.attrs?.modelId
|
||||
) {
|
||||
userIds.push(node.attrs.modelId as string);
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
collectUserIds(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectUserIds(json);
|
||||
|
||||
// Load all users in a single query
|
||||
const uniqueUserIds = [...new Set(userIds)];
|
||||
const users = uniqueUserIds.length
|
||||
? await User.findAll({
|
||||
where: {
|
||||
id: uniqueUserIds,
|
||||
},
|
||||
attributes: ["id", "name"],
|
||||
})
|
||||
: [];
|
||||
|
||||
// Create a map for quick lookup
|
||||
const userMap = new Map();
|
||||
users.forEach((user) => {
|
||||
userMap.set(user.id, user.name);
|
||||
});
|
||||
|
||||
// Second pass: transform mentions with loaded user data
|
||||
function transformMentions(node: ProsemirrorData) {
|
||||
if (
|
||||
node.type === "mention" &&
|
||||
node.attrs?.type === MentionType.User &&
|
||||
node.attrs?.modelId
|
||||
) {
|
||||
const userId = node.attrs.modelId as string;
|
||||
node.attrs = {
|
||||
...node.attrs,
|
||||
label: userMap.get(userId) || "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
transformMentions(child);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
return transformMentions(json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,18 +182,9 @@ export default class SearchHelper {
|
||||
},
|
||||
];
|
||||
|
||||
return Document.scope([
|
||||
"withDrafts",
|
||||
{
|
||||
method: ["withViews", user.id],
|
||||
},
|
||||
{
|
||||
method: ["withCollectionPermissions", user.id],
|
||||
},
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findAll({
|
||||
return Document.withMembershipScope(user.id, {
|
||||
includeDrafts: true,
|
||||
}).findAll({
|
||||
where,
|
||||
subQuery: false,
|
||||
order: [["updatedAt", "DESC"]],
|
||||
@@ -273,18 +264,7 @@ export default class SearchHelper {
|
||||
|
||||
// Final query to get associated document data
|
||||
const [documents, count] = await Promise.all([
|
||||
Document.scope([
|
||||
"withDrafts",
|
||||
{
|
||||
method: ["withViews", user.id],
|
||||
},
|
||||
{
|
||||
method: ["withCollectionPermissions", user.id],
|
||||
},
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findAll({
|
||||
Document.withMembershipScope(user.id, { includeDrafts: true }).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
id: map(results, "id"),
|
||||
|
||||
@@ -14,9 +14,9 @@ describe("admin", () => {
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", admin.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: admin.id,
|
||||
});
|
||||
const abilities = serialize(admin, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).toBeTruthy();
|
||||
@@ -36,9 +36,9 @@ describe("admin", () => {
|
||||
permission: null,
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.readDocument).toEqual(false);
|
||||
expect(abilities.updateDocument).toEqual(false);
|
||||
@@ -59,9 +59,9 @@ describe("admin", () => {
|
||||
permission: CollectionPermission.Read,
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.updateDocument).toBeTruthy();
|
||||
@@ -87,9 +87,9 @@ describe("member", () => {
|
||||
},
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", member.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: member.id,
|
||||
});
|
||||
const abilities = serialize(member, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).toBeTruthy();
|
||||
@@ -116,9 +116,9 @@ describe("member", () => {
|
||||
},
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", member.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: member.id,
|
||||
});
|
||||
const abilities = serialize(member, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).toBe(false);
|
||||
@@ -161,9 +161,9 @@ describe("member", () => {
|
||||
permission: CollectionPermission.Read,
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
@@ -189,9 +189,9 @@ describe("member", () => {
|
||||
},
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", member.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: member.id,
|
||||
});
|
||||
const abilities = serialize(member, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).not.toBeTruthy();
|
||||
@@ -232,9 +232,9 @@ describe("member", () => {
|
||||
},
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", member.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: member.id,
|
||||
});
|
||||
const abilities = serialize(member, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
@@ -279,9 +279,9 @@ describe("member", () => {
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
@@ -331,9 +331,9 @@ describe("viewer", () => {
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
@@ -361,9 +361,9 @@ describe("viewer", () => {
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
@@ -409,9 +409,9 @@ describe("viewer", () => {
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
@@ -462,9 +462,9 @@ describe("guest", () => {
|
||||
permission: CollectionPermission.Read,
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const reloaded = await Collection.findByPk(collection.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
|
||||
@@ -17,7 +17,7 @@ export default class AvatarProcessor extends BaseProcessor {
|
||||
});
|
||||
|
||||
if (user.avatarUrl) {
|
||||
await UploadUserAvatarTask.schedule({
|
||||
await new UploadUserAvatarTask().schedule({
|
||||
userId: event.userId,
|
||||
avatarUrl: user.avatarUrl,
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export default class AvatarProcessor extends BaseProcessor {
|
||||
});
|
||||
|
||||
if (team.avatarUrl) {
|
||||
await UploadTeamAvatarTask.schedule({
|
||||
await new UploadTeamAvatarTask().schedule({
|
||||
teamId: event.teamId,
|
||||
avatarUrl: team.avatarUrl,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export default class CollectionsProcessor extends BaseProcessor {
|
||||
];
|
||||
|
||||
async perform(event: CollectionEvent) {
|
||||
await DetachDraftsFromCollectionTask.schedule({
|
||||
await new DetachDraftsFromCollectionTask().schedule({
|
||||
collectionId: event.collectionId,
|
||||
actorId: event.actorId,
|
||||
ip: event.ip,
|
||||
|
||||
@@ -27,7 +27,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
async perform(event: ReceivedEvent) {
|
||||
switch (event.name) {
|
||||
case "collections.remove_user": {
|
||||
await CollectionSubscriptionRemoveUserTask.schedule(event);
|
||||
await new CollectionSubscriptionRemoveUserTask().schedule(event);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
return this.handleRemoveGroupFromCollection(event);
|
||||
|
||||
case "documents.remove_user": {
|
||||
await DocumentSubscriptionRemoveUserTask.schedule(event);
|
||||
await new DocumentSubscriptionRemoveUserTask().schedule(event);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,11 +57,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map((groupUser) =>
|
||||
CollectionSubscriptionRemoveUserTask.schedule({
|
||||
new CollectionSubscriptionRemoveUserTask().schedule({
|
||||
...event,
|
||||
name: "collections.remove_user",
|
||||
userId: groupUser.userId,
|
||||
})
|
||||
} as CollectionUserEvent)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -79,11 +79,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map((groupUser) =>
|
||||
DocumentSubscriptionRemoveUserTask.schedule({
|
||||
new DocumentSubscriptionRemoveUserTask().schedule({
|
||||
...event,
|
||||
name: "documents.remove_user",
|
||||
userId: groupUser.userId,
|
||||
})
|
||||
} as DocumentUserEvent)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user