mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
140 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 | |||
| 4885612661 | |||
| e2dd6221f8 | |||
| 7f513a6950 | |||
| 6440d78b6f | |||
| 7e05fc1017 | |||
| 2bc47cfcef | |||
| e8e46a438c | |||
| 3156f62e94 | |||
| 9274f56ef6 | |||
| 4bb9ac40c7 | |||
| 36772f1444 | |||
| e503225f04 | |||
| 762140e493 | |||
| 21e756c357 | |||
| 2cc5846f1b | |||
| de6c1735d9 | |||
| b7c13f092b | |||
| 298298223b | |||
| 21f37c0d14 | |||
| 18bc93c9c2 | |||
| 6a12822829 | |||
| adcab68b59 | |||
| 943fd7e2e1 | |||
| 01db19a0b1 | |||
| 51cb5bffce | |||
| d37b7fa31e | |||
| f86225c332 | |||
| e53c90f25f | |||
| d84d5a4b09 | |||
| 0031fc1562 | |||
| 9b73635727 | |||
| 5cefb534cc | |||
| 8fb6f7f8c6 | |||
| 6b497cf1ec | |||
| 05a61927af | |||
| 2b07f412e2 | |||
| 65bb3b11f3 | |||
| e1e334dd5f | |||
| 6e9092bcaf | |||
| 09a4b76aae | |||
| 5789d65bf5 | |||
| 03a0f54236 | |||
| 1e7244c737 |
+167
-138
@@ -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,79 +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=
|
||||
|
||||
# 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
|
||||
# The Linear integration allows previewing issue links as rich mentions
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
# 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
|
||||
@@ -198,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
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
|
||||
export const createOAuthClient = createAction({
|
||||
name: ({ t }) => t("New App"),
|
||||
analyticsName: "New App",
|
||||
section: SettingsSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "create",
|
||||
visible: () =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("New Application"),
|
||||
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { ActionContext } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
@@ -44,7 +44,7 @@ export const switchTeam = createAction({
|
||||
section: TeamSection,
|
||||
visible: ({ stores }) =>
|
||||
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
|
||||
children: createTeamsList,
|
||||
children: switchTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
|
||||
@@ -13,6 +13,11 @@ export enum AvatarSize {
|
||||
Upload = 64,
|
||||
}
|
||||
|
||||
export enum AvatarVariant {
|
||||
Round = "round",
|
||||
Square = "square",
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
@@ -23,6 +28,8 @@ export interface IAvatar {
|
||||
type Props = {
|
||||
/** The size of the avatar */
|
||||
size: AvatarSize;
|
||||
/** The variant of the avatar */
|
||||
variant?: AvatarVariant;
|
||||
/** The source of the avatar image, if not passing a model. */
|
||||
src?: string;
|
||||
/** The avatar model, if not passing a source. */
|
||||
@@ -38,14 +45,14 @@ type Props = {
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { model, style, ...rest } = props;
|
||||
const { model, style, variant = AvatarVariant.Round, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative style={style}>
|
||||
<Relative style={style} $variant={variant} $size={props.size}>
|
||||
{src && !error ? (
|
||||
<CircleImg onError={handleError} src={src} {...rest} />
|
||||
<Image onError={handleError} src={src} {...rest} />
|
||||
) : model ? (
|
||||
<Initials color={model.color} {...rest}>
|
||||
{model.initial}
|
||||
@@ -61,19 +68,19 @@ Avatar.defaultProps = {
|
||||
size: AvatarSize.Medium,
|
||||
};
|
||||
|
||||
const Relative = styled.div`
|
||||
const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>`
|
||||
position: relative;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border-radius: ${(props) =>
|
||||
props.$variant === AvatarVariant.Round ? "50%" : `${props.$size / 8}px`};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Image = styled.img<{ size: number }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
`;
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -13,7 +13,6 @@ const Initials = styled(Flex)<{
|
||||
}>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${(props) =>
|
||||
@@ -23,7 +22,6 @@ const Initials = styled(Flex)<{
|
||||
background-color: ${(props) => props.color ?? props.theme.textTertiary};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
// adjust font size down for each additional character
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,13 @@ type Props = {
|
||||
children?: React.ReactNode;
|
||||
document: Document;
|
||||
onlyText?: boolean;
|
||||
reverse?: boolean;
|
||||
/**
|
||||
* Maximum number of items to show in the breadcrumb.
|
||||
* If value is less than or equals to 0, no items will be shown.
|
||||
* If value is undefined, all items will be shown.
|
||||
*/
|
||||
maxDepth?: number;
|
||||
};
|
||||
|
||||
function useCategory(document: Document): MenuInternalLink | null {
|
||||
@@ -54,7 +61,7 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
}
|
||||
|
||||
function DocumentBreadcrumb(
|
||||
{ document, children, onlyText }: Props,
|
||||
{ document, children, onlyText, reverse = false, maxDepth }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const { collections } = useStores();
|
||||
@@ -65,6 +72,7 @@ function DocumentBreadcrumb(
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = usePolicy(collection);
|
||||
const depth = maxDepth === undefined ? undefined : Math.max(0, maxDepth);
|
||||
|
||||
React.useEffect(() => {
|
||||
void document.loadRelations({ withoutPolicies: true });
|
||||
@@ -91,20 +99,23 @@ function DocumentBreadcrumb(
|
||||
};
|
||||
}
|
||||
|
||||
const path = document.pathTo;
|
||||
const path = document.pathTo.slice(0, -1);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const output = [];
|
||||
const output: MenuInternalLink[] = [];
|
||||
|
||||
if (depth === 0) {
|
||||
return output;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
if (collectionNode) {
|
||||
output.push(collectionNode);
|
||||
}
|
||||
|
||||
path.slice(0, -1).forEach((node: NavigationNode) => {
|
||||
path.forEach((node: NavigationNode) => {
|
||||
const title = node.title || t("Untitled");
|
||||
output.push({
|
||||
type: "route",
|
||||
@@ -121,21 +132,43 @@ function DocumentBreadcrumb(
|
||||
},
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [t, path, category, sidebarContext, collectionNode]);
|
||||
|
||||
return reverse
|
||||
? depth !== undefined
|
||||
? output.slice(-depth)
|
||||
: output
|
||||
: depth !== undefined
|
||||
? output.slice(0, depth)
|
||||
: output;
|
||||
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyText === true) {
|
||||
if (onlyText) {
|
||||
if (depth === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const slicedPath = reverse
|
||||
? path.slice(depth && -depth)
|
||||
: path.slice(0, depth);
|
||||
|
||||
const showCollection =
|
||||
collection &&
|
||||
(!reverse || depth === undefined || slicedPath.length < depth);
|
||||
|
||||
return (
|
||||
<>
|
||||
{collection?.name}
|
||||
{path.slice(0, -1).map((node: NavigationNode) => (
|
||||
{showCollection && collection.name}
|
||||
{slicedPath.map((node: NavigationNode, index: number) => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SmallSlash />
|
||||
{showCollection && <SmallSlash />}
|
||||
{node.title || t("Untitled")}
|
||||
{!showCollection && index !== slicedPath.length - 1 && (
|
||||
<SmallSlash />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -46,10 +46,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<PaginatedList
|
||||
<PaginatedList<User>
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(model: User) => {
|
||||
renderItem={(model) => {
|
||||
const view = documentViews.find((v) => v.userId === model.id);
|
||||
const isPresent = presentIds.includes(model.id);
|
||||
const isEditing = editingIds.includes(model.id);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -56,7 +56,7 @@ const FilterOptions = ({
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option: TFilterOption) => (
|
||||
(option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
@@ -174,7 +174,7 @@ const FilterOptions = ({
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
|
||||
<PaginatedList
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { transparentize } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { getTextColor } from "@shared/utils/color";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 10;
|
||||
@@ -33,7 +32,7 @@ export const Title = styled(Text).attrs({ as: "h2", size: "large" })`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
export const Info = styled(StyledText).attrs(() => ({
|
||||
@@ -60,15 +59,27 @@ export const Thumbnail = styled.img`
|
||||
export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
color?: string;
|
||||
}>`
|
||||
background-color: ${(props) =>
|
||||
props.color ?? props.theme.backgroundSecondary};
|
||||
color: ${(props) =>
|
||||
props.color ? getTextColor(props.color) : props.theme.text};
|
||||
border: 1px solid ${(props) => props.theme.divider};
|
||||
width: fit-content;
|
||||
border-radius: 2em;
|
||||
padding: 0 8px;
|
||||
padding: 1px 8px 1px 20px;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) =>
|
||||
props.color || props.theme.backgroundSecondary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const CardContent = styled.div`
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import {
|
||||
IntegrationService,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
@@ -23,6 +29,11 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
const service =
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -31,13 +42,18 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
<CardContent>
|
||||
<Flex gap={2} column>
|
||||
<Title>
|
||||
<IssueStatusIcon status={state.name} color={state.color} />
|
||||
<StyledIssueStatusIcon
|
||||
service={service}
|
||||
state={state}
|
||||
size={18}
|
||||
/>
|
||||
<span>
|
||||
{title} <Text type="tertiary">{id}</Text>
|
||||
<Backticks content={title} />
|
||||
<Text type="tertiary">{id}</Text>
|
||||
</span>
|
||||
</Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<Avatar src={author.avatarUrl} />
|
||||
<Flex align="center" gap={6}>
|
||||
<Avatar src={author.avatarUrl} size={18} />
|
||||
<Info>
|
||||
<Trans>
|
||||
{{ authorName }} created{" "}
|
||||
@@ -62,4 +78,8 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
);
|
||||
});
|
||||
|
||||
const StyledIssueStatusIcon = styled(IssueStatusIcon)`
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
export default HoverPreviewIssue;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
@@ -31,13 +33,14 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
<CardContent>
|
||||
<Flex gap={2} column>
|
||||
<Title>
|
||||
<PullRequestIcon status={state.name} color={state.color} />
|
||||
<StyledPullRequestIcon size={18} state={state} />
|
||||
<span>
|
||||
{title} <Text type="tertiary">{id}</Text>
|
||||
<Backticks content={title} />
|
||||
<Text type="tertiary">{id}</Text>
|
||||
</span>
|
||||
</Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<Avatar src={author.avatarUrl} />
|
||||
<Flex align="center" gap={6}>
|
||||
<Avatar src={author.avatarUrl} size={18} />
|
||||
<Info>
|
||||
<Trans>
|
||||
{{ authorName }} opened{" "}
|
||||
@@ -55,4 +58,8 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
}
|
||||
);
|
||||
|
||||
const StyledPullRequestIcon = styled(PullRequestIcon)`
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
export default HoverPreviewPullRequest;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -79,11 +79,11 @@ function Notifications(
|
||||
</Header>
|
||||
<React.Suspense fallback={null}>
|
||||
<Scrollable ref={ref} flex topShadow>
|
||||
<PaginatedList
|
||||
<PaginatedList<Notification>
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={isOpen ? notifications.orderedData : undefined}
|
||||
renderItem={(item: Notification) => (
|
||||
renderItem={(item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
notification={item}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OAuthClientValidation } from "@shared/validations";
|
||||
import OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import ImageInput from "~/scenes/Settings/components/ImageInput";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Switch from "../Switch";
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
developerName: string;
|
||||
developerUrl: string;
|
||||
description: string;
|
||||
avatarUrl: string;
|
||||
redirectUris: string[];
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
handleSubmit,
|
||||
oauthClient,
|
||||
}: {
|
||||
handleSubmit: (data: FormData) => void;
|
||||
oauthClient?: OAuthClient;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
getValues,
|
||||
setFocus,
|
||||
setError,
|
||||
control,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
name: oauthClient?.name ?? "",
|
||||
description: oauthClient?.description ?? "",
|
||||
avatarUrl: oauthClient?.avatarUrl ?? "",
|
||||
redirectUris: oauthClient?.redirectUris ?? [],
|
||||
published: oauthClient?.published ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<>
|
||||
<label style={{ marginBottom: "1em" }}>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
id: oauthClient?.id,
|
||||
avatarUrl: field.value,
|
||||
initial: getValues().name[0],
|
||||
}}
|
||||
borderRadius={0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("My App")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
maxLength: OAuthClientValidation.maxNameLength,
|
||||
})}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Tagline")}
|
||||
placeholder={t("A short description")}
|
||||
{...register("description", {
|
||||
maxLength: OAuthClientValidation.maxDescriptionLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="textarea"
|
||||
label={t("Callback URLs")}
|
||||
placeholder="https://example.com/callback"
|
||||
ref={field.ref}
|
||||
value={field.value.join("\n")}
|
||||
rows={Math.max(2, field.value.length + 1)}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value.split("\n"));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isCloudHosted && (
|
||||
<Switch
|
||||
{...register("published")}
|
||||
label={t("Published")}
|
||||
note={t("Allow this app to be installed by other workspaces")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
>
|
||||
{oauthClient
|
||||
? formState.isSubmitting
|
||||
? `${t("Saving")}…`
|
||||
: t("Save")
|
||||
: formState.isSubmitting
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { OAuthClientForm, FormData } from "./OAuthClientForm";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const OAuthClientNew = observer(function OAuthClientNew_({
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { oauthClients } = useStores();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
const oauthClient = await oauthClients.save(data);
|
||||
onSubmit?.();
|
||||
history.push(settingsPath("applications", oauthClient.id));
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
[oauthClients, history, onSubmit]
|
||||
);
|
||||
|
||||
return <OAuthClientForm handleSubmit={handleSubmit} />;
|
||||
});
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
fetch: (options: any) => Promise<Document[] | undefined>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
empty?: JSX.Element;
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
@@ -34,7 +34,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PaginatedList
|
||||
<PaginatedList<Document>
|
||||
aria-label={t("Documents")}
|
||||
items={documents}
|
||||
empty={empty}
|
||||
@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderError={(props) => <Error {...props} />}
|
||||
renderItem={(item: Document, _index) => (
|
||||
renderItem={(item, _index) => (
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
empty?: JSX.Element;
|
||||
};
|
||||
|
||||
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import "../stores";
|
||||
import { render } from "@testing-library/react";
|
||||
import { TFunction } from "i18next";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { Component as PaginatedList } from "./PaginatedList";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const i18n = getI18n();
|
||||
const authStore = {};
|
||||
|
||||
const props = {
|
||||
i18n,
|
||||
@@ -17,19 +19,23 @@ describe("PaginatedList", () => {
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const result = render(
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
</Provider>
|
||||
);
|
||||
expect(result.container.innerHTML).toEqual("");
|
||||
});
|
||||
|
||||
it("with no items renders empty prop", async () => {
|
||||
const result = render(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
await expect(
|
||||
result.findAllByText("Sorry, no results")
|
||||
@@ -42,13 +48,15 @@ describe("PaginatedList", () => {
|
||||
id: "one",
|
||||
};
|
||||
render(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
...options,
|
||||
|
||||
+244
-194
@@ -1,265 +1,315 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observable, action, computed } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import withStores from "~/components/withStores";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { dateToHeading } from "~/utils/date";
|
||||
|
||||
/**
|
||||
* Base interface for items that can be paginated
|
||||
* @interface PaginatedItem
|
||||
*/
|
||||
export interface PaginatedItem {
|
||||
/** Unique identifier for the item */
|
||||
id?: string;
|
||||
/** Last update timestamp of the item */
|
||||
updatedAt?: string;
|
||||
/** Creation timestamp of the item */
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
type Props<T> = WithTranslation &
|
||||
RootStore &
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<T[] | undefined> | undefined;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
loading?: React.ReactElement;
|
||||
items?: T[];
|
||||
className?: string;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
renderError?: (options: {
|
||||
error: Error;
|
||||
retry: () => void;
|
||||
}) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
listRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
/**
|
||||
* Props for the PaginatedList component
|
||||
* @template T Type of items in the list, must extend PaginatedItem
|
||||
*/
|
||||
interface Props<T extends PaginatedItem>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Function to fetch paginated data. Should return a promise resolving to an array of items
|
||||
* @param options Pagination and other query options
|
||||
*/
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<unknown[] | undefined> | undefined;
|
||||
|
||||
@observer
|
||||
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
Props<T>
|
||||
> {
|
||||
@observable
|
||||
error?: Error;
|
||||
/** Additional options to pass to the fetch function */
|
||||
options?: Record<string, any>;
|
||||
|
||||
@observable
|
||||
isFetchingMore = false;
|
||||
/** Optional header content to display above the list */
|
||||
heading?: React.ReactNode;
|
||||
|
||||
@observable
|
||||
isFetching = false;
|
||||
/** Content to display when the list is empty */
|
||||
empty?: JSX.Element | null;
|
||||
|
||||
@observable
|
||||
isFetchingInitial = !this.props.items?.length;
|
||||
/** Optional loading state content */
|
||||
loading?: JSX.Element | null;
|
||||
|
||||
@observable
|
||||
fetchCounter = 0;
|
||||
/** Array of items to display in the list */
|
||||
items?: T[];
|
||||
|
||||
@observable
|
||||
renderCount = Pagination.defaultLimit;
|
||||
/** CSS class name to apply to the list container */
|
||||
className?: string;
|
||||
|
||||
@observable
|
||||
offset = 0;
|
||||
/**
|
||||
* Function to render each individual item in the list
|
||||
* @param item The item to render
|
||||
* @param index The index of the item in the list
|
||||
*/
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
|
||||
@observable
|
||||
allowLoadMore = true;
|
||||
/**
|
||||
* Function to render error state
|
||||
* @param options Object containing error details and retry function
|
||||
*/
|
||||
renderError?: (options: {
|
||||
/** Details of the error */
|
||||
error: Error;
|
||||
/** Function to retry the fetch operation */
|
||||
retry: () => void;
|
||||
}) => JSX.Element;
|
||||
|
||||
componentDidMount() {
|
||||
void this.fetchResults();
|
||||
}
|
||||
/**
|
||||
* Function to render section headings (typically date-based)
|
||||
* @param name The heading text or element to render
|
||||
*/
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
|
||||
componentDidUpdate(prevProps: Props<T>) {
|
||||
if (
|
||||
prevProps.fetch !== this.props.fetch ||
|
||||
!isEqual(prevProps.options, this.props.options)
|
||||
) {
|
||||
this.reset();
|
||||
void this.fetchResults();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Handler for escape key press
|
||||
* @param ev Keyboard event object
|
||||
*/
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
|
||||
reset = () => {
|
||||
this.offset = 0;
|
||||
this.allowLoadMore = true;
|
||||
this.renderCount = Pagination.defaultLimit;
|
||||
this.isFetching = false;
|
||||
this.isFetchingInitial = false;
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
/** Reference to the list container element */
|
||||
listRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@action
|
||||
fetchResults = async () => {
|
||||
if (!this.props.fetch) {
|
||||
/**
|
||||
* A reusable component that renders a paginated list with infinite scrolling
|
||||
* and optional date-based section headings.
|
||||
*
|
||||
* @template T Type of the list items, must extend PaginatedItem
|
||||
*/
|
||||
const PaginatedList = <T extends PaginatedItem>({
|
||||
fetch,
|
||||
options,
|
||||
heading,
|
||||
empty = null,
|
||||
loading = null,
|
||||
items = [],
|
||||
className,
|
||||
renderItem,
|
||||
renderError,
|
||||
renderHeading,
|
||||
onEscape,
|
||||
listRef,
|
||||
...rest
|
||||
}: Props<T>): JSX.Element | null => {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const [isFetchingMore, setIsFetchingMore] = React.useState(false);
|
||||
const [isFetching, setIsFetching] = React.useState(false);
|
||||
const [isFetchingInitial, setIsFetchingInitial] = React.useState(
|
||||
!items?.length
|
||||
);
|
||||
const [fetchCounter, setFetchCounter] = React.useState(0);
|
||||
const [renderCount, setRenderCount] = React.useState(Pagination.defaultLimit);
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [allowLoadMore, setAllowLoadMore] = React.useState(true);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setOffset(0);
|
||||
setAllowLoadMore(true);
|
||||
setRenderCount(Pagination.defaultLimit);
|
||||
setIsFetching(false);
|
||||
setIsFetchingInitial(false);
|
||||
setIsFetchingMore(false);
|
||||
}, []);
|
||||
|
||||
const fetchResults = React.useCallback(async () => {
|
||||
if (!fetch) {
|
||||
return;
|
||||
}
|
||||
this.isFetching = true;
|
||||
const counter = ++this.fetchCounter;
|
||||
const limit = this.props.options?.limit ?? Pagination.defaultLimit;
|
||||
this.error = undefined;
|
||||
|
||||
setIsFetching(true);
|
||||
const counter = fetchCounter + 1;
|
||||
setFetchCounter(counter);
|
||||
const limit = options?.limit ?? Pagination.defaultLimit;
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const results = await this.props.fetch({
|
||||
const results = await fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
offset,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (this.offset !== 0) {
|
||||
this.renderCount += limit;
|
||||
if (offset !== 0) {
|
||||
setRenderCount((prevCount) => prevCount + limit);
|
||||
}
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
setAllowLoadMore(false);
|
||||
} else {
|
||||
this.offset += limit;
|
||||
setOffset((prevOffset) => prevOffset + limit);
|
||||
}
|
||||
|
||||
this.isFetchingInitial = false;
|
||||
setIsFetchingInitial(false);
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
setError(err);
|
||||
} finally {
|
||||
// only the most recent fetch should end the loading state
|
||||
if (counter >= this.fetchCounter) {
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
if (counter >= fetchCounter) {
|
||||
setIsFetching(false);
|
||||
setIsFetchingMore(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [fetch, fetchCounter, offset, options]);
|
||||
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re currently fetching
|
||||
if (!this.allowLoadMore || this.isFetching) {
|
||||
const loadMoreResults = React.useCallback(async () => {
|
||||
// Don't paginate if there aren't more results or we're currently fetching
|
||||
if (!allowLoadMore || isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are already cached results that we haven't yet rendered because
|
||||
// of lazy rendering then show another page.
|
||||
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
|
||||
const leftToRender = (items?.length ?? 0) - renderCount;
|
||||
|
||||
if (leftToRender > 0) {
|
||||
this.renderCount += Pagination.defaultLimit;
|
||||
setRenderCount((prevCount) => prevCount + Pagination.defaultLimit);
|
||||
}
|
||||
|
||||
// If there are less than a pages results in the cache go ahead and fetch
|
||||
// another page from the server
|
||||
if (leftToRender <= Pagination.defaultLimit) {
|
||||
this.isFetchingMore = true;
|
||||
await this.fetchResults();
|
||||
setIsFetchingMore(true);
|
||||
await fetchResults();
|
||||
}
|
||||
};
|
||||
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
|
||||
|
||||
@computed
|
||||
get itemsToRender() {
|
||||
return this.props.items?.slice(0, this.renderCount) ?? [];
|
||||
}
|
||||
const prevFetch = usePrevious(fetch);
|
||||
const prevOptions = usePrevious(options);
|
||||
|
||||
render() {
|
||||
const {
|
||||
items = [],
|
||||
heading,
|
||||
auth,
|
||||
empty = null,
|
||||
renderHeading,
|
||||
renderError,
|
||||
onEscape,
|
||||
} = this.props;
|
||||
// Initial fetch on mount
|
||||
React.useEffect(() => {
|
||||
if (fetch) {
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch]);
|
||||
|
||||
const showLoading =
|
||||
this.isFetching &&
|
||||
!this.isFetchingMore &&
|
||||
(!items?.length || (this.fetchCounter <= 1 && this.isFetchingInitial));
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
this.props.loading || (
|
||||
<DelayedMount>
|
||||
<div className={this.props.className}>
|
||||
<PlaceholderList count={5} />
|
||||
</div>
|
||||
</DelayedMount>
|
||||
)
|
||||
);
|
||||
// Handle updates to fetch or options
|
||||
React.useEffect(() => {
|
||||
if (!prevFetch || !prevOptions) {
|
||||
return; // Skip on initial mount since it's handled by the above effect
|
||||
}
|
||||
|
||||
if (items?.length === 0) {
|
||||
if (this.error && renderError) {
|
||||
return renderError({ error: this.error, retry: this.fetchResults });
|
||||
}
|
||||
|
||||
return empty;
|
||||
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
|
||||
reset();
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
|
||||
|
||||
// Computed property equivalent
|
||||
const itemsToRender = React.useMemo(
|
||||
() => items?.slice(0, renderCount) ?? [],
|
||||
[items, renderCount]
|
||||
);
|
||||
|
||||
const showLoading =
|
||||
isFetching &&
|
||||
!isFetchingMore &&
|
||||
(!items?.length || (fetchCounter <= 1 && isFetchingInitial));
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
aria-label={this.props["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={this.props.className}
|
||||
items={this.itemsToRender}
|
||||
ref={this.props.listRef}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
return this.itemsToRender.map((item, index) => {
|
||||
const children = this.props.renderItem(item, index);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
if (!renderHeading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
auth.user?.language
|
||||
);
|
||||
|
||||
// If the heading is different to any previous heading then we
|
||||
// should render it, otherwise the item can go under the previous
|
||||
// heading
|
||||
if (
|
||||
children &&
|
||||
(!previousHeading || currentHeading !== previousHeading)
|
||||
) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment
|
||||
key={"id" in item && item.id ? item.id : index}
|
||||
>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
});
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
loading || (
|
||||
<DelayedMount>
|
||||
<div className={className}>
|
||||
<PlaceholderList count={5} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</DelayedMount>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Component = PaginatedList;
|
||||
if (items?.length === 0) {
|
||||
if (error && renderError) {
|
||||
return renderError({ error, retry: fetchResults });
|
||||
}
|
||||
|
||||
export default withTranslation()(withStores(PaginatedList));
|
||||
return empty;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
aria-label={rest["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={className}
|
||||
items={itemsToRender}
|
||||
ref={listRef}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
return itemsToRender.map((item, index) => {
|
||||
const children = renderItem(item, index);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
if (!renderHeading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
t,
|
||||
user?.language
|
||||
);
|
||||
|
||||
// If the heading is different to any previous heading then we
|
||||
// should render it, otherwise the item can go under the previous
|
||||
// heading
|
||||
if (
|
||||
children &&
|
||||
(!previousHeading || currentHeading !== previousHeading)
|
||||
) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment key={"id" in item && item.id ? item.id : index}>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
});
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{allowLoadMore && (
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={renderCount} onEnter={loadMoreResults} />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginatedList;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -200,7 +200,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
style={{ zIndex: depths.sidebar + 1 }}
|
||||
shrink
|
||||
>
|
||||
<PaginatedList
|
||||
<PaginatedList<SearchResult>
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
@@ -209,7 +209,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
|
||||
}
|
||||
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
|
||||
renderItem={(item: SearchResult, index) => (
|
||||
renderItem={(item, index) => (
|
||||
<SearchListItem
|
||||
key={item.document.id}
|
||||
shareId={shareId}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -12,7 +12,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import TeamMenu from "~/menus/TeamMenu";
|
||||
import { homePath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
@@ -62,7 +62,7 @@ function AppSidebar() {
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragPlaceholder />
|
||||
|
||||
<OrganizationMenu>
|
||||
<TeamMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
@@ -91,7 +91,7 @@ function AppSidebar() {
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -82,12 +82,12 @@ function ArchiveLink() {
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
<PaginatedList<Collection>
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
renderItem={(item) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
@@ -54,7 +54,7 @@ function Collections() {
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
<PaginatedList<Collection>
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
@@ -69,7 +69,7 @@ function Collections() {
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
renderItem={(item, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
|
||||
@@ -148,7 +148,12 @@ function InnerDocumentLink(
|
||||
const color = document?.color || node.color;
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
|
||||
const [{ isDragging }, drag] = useDragDocument(
|
||||
node,
|
||||
depth,
|
||||
document,
|
||||
isEditing
|
||||
);
|
||||
|
||||
// Drop to re-parent
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -270,6 +275,8 @@ function InnerDocumentLink(
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
// @ts-expect-error react-router type is wrong, string component is fine.
|
||||
component={isEditing ? "div" : undefined}
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
@@ -285,6 +292,7 @@ function InnerDocumentLink(
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
location?: Location;
|
||||
strict?: boolean;
|
||||
to: LocationDescriptor;
|
||||
component?: React.ComponentType;
|
||||
onBeforeClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -146,17 +147,22 @@ const NavLink = ({
|
||||
setPreActive(undefined);
|
||||
}, [currentLocation]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
},
|
||||
[navigateTo]
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={isActive ? "active" : "inactive"}
|
||||
ref={linkRef}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-current={(isActive && ariaCurrent) || undefined}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -166,11 +166,13 @@ export function useDropToReorderStar(getIndex?: () => string) {
|
||||
* @param node The NavigationNode model to drag.
|
||||
* @param depth The depth of the node in the sidebar.
|
||||
* @param document The related Document model.
|
||||
* @param isEditing Whether the sidebar item is currently being edited.
|
||||
*/
|
||||
export function useDragDocument(
|
||||
node: NavigationNode,
|
||||
depth: number,
|
||||
document?: Document
|
||||
document?: Document,
|
||||
isEditing?: boolean
|
||||
) {
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
@@ -188,7 +190,7 @@ export function useDragDocument(
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
canDrag: () => !!document?.isActive,
|
||||
canDrag: () => !!document?.isActive && !isEditing,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
|
||||
@@ -335,6 +335,7 @@ const TR = styled.div<{ $columns: string }>`
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
@@ -357,7 +358,8 @@ const TD = styled.span`
|
||||
padding: 10px 6px;
|
||||
font-size: 14px;
|
||||
text-wrap: wrap;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:first-child {
|
||||
font-size: 15px;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { AvatarVariant } from "./Avatar/Avatar";
|
||||
|
||||
const TeamLogo = styled(Avatar)`
|
||||
const TeamLogo = styled(Avatar).attrs({
|
||||
variant: AvatarVariant.Square,
|
||||
})`
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px ${s("divider")};
|
||||
border: 0;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -10,6 +10,7 @@ import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { hideScrollbars, s } from "@shared/styles";
|
||||
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -253,7 +254,14 @@ const LinkEditor: React.FC<Props> = ({
|
||||
onPointerMove={() => setSelectedIndex(index)}
|
||||
selected={index === selectedIndex}
|
||||
key={doc.id}
|
||||
subtitle={doc.collection?.name}
|
||||
subtitle={
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
}
|
||||
title={doc.title}
|
||||
icon={
|
||||
doc.icon ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DocumentsSection,
|
||||
@@ -57,7 +58,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
}, [search, documents, users])
|
||||
}, [search, documents, users, collections])
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -68,7 +69,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (actorId && !loading) {
|
||||
const items = users
|
||||
const items: MentionItem[] = users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
@@ -112,7 +113,14 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collection?.name,
|
||||
subtitle: (
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
),
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getCookie } from "tiny-cookie";
|
||||
|
||||
export type Sessions = Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
url: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function useLoggedInSessions(): Sessions {
|
||||
return JSON.parse(getCookie("sessions") || "{}");
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export default function useRequest<T = unknown>(
|
||||
if (makeRequestOnMount) {
|
||||
void request();
|
||||
}
|
||||
}, [request, makeRequestOnMount]);
|
||||
}, []);
|
||||
|
||||
return { data, loading, loaded, error, request };
|
||||
}
|
||||
|
||||
@@ -8,28 +8,30 @@ import {
|
||||
GlobeIcon,
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
SettingsIcon,
|
||||
ExportIcon,
|
||||
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 PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
|
||||
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
|
||||
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
const Features = lazy(() => import("~/scenes/Settings/Features"));
|
||||
@@ -40,33 +42,40 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
|
||||
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
|
||||
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
|
||||
const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
|
||||
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,24 +92,27 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Notifications"),
|
||||
path: settingsPath("notifications"),
|
||||
component: Notifications,
|
||||
component: Notifications.Component,
|
||||
preload: Notifications.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("personal-api-keys"),
|
||||
component: PersonalApiKeys,
|
||||
enabled: can.createApiKey && !can.listApiKeys,
|
||||
name: t("API & Apps"),
|
||||
path: settingsPath("api-and-apps"),
|
||||
component: APIAndApps.Component,
|
||||
preload: APIAndApps.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: CodeIcon,
|
||||
icon: PadlockIcon,
|
||||
},
|
||||
// Workspace
|
||||
{
|
||||
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,15 +165,26 @@ 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,
|
||||
},
|
||||
{
|
||||
name: t("Applications"),
|
||||
path: settingsPath("applications"),
|
||||
component: Applications.Component,
|
||||
preload: Applications.preload,
|
||||
enabled: can.listOAuthClients,
|
||||
group: t("Workspace"),
|
||||
icon: InternetIcon,
|
||||
},
|
||||
{
|
||||
name: t("Shared Links"),
|
||||
path: settingsPath("shares"),
|
||||
component: Shares,
|
||||
component: Shares.Component,
|
||||
preload: Shares.preload,
|
||||
enabled: can.listShares,
|
||||
group: t("Workspace"),
|
||||
icon: GlobeIcon,
|
||||
@@ -163,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,
|
||||
@@ -171,27 +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: t("Self Hosted"),
|
||||
path: integrationSettingsPath("self-hosted"),
|
||||
component: SelfHosted,
|
||||
enabled: can.update && !isCloudHosted,
|
||||
name: `${t("Install")}…`,
|
||||
path: settingsPath("integrations"),
|
||||
component: Integrations,
|
||||
enabled: true,
|
||||
group: t("Integrations"),
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
{
|
||||
name: "Zapier",
|
||||
path: integrationSettingsPath("zapier"),
|
||||
component: Zapier,
|
||||
enabled: can.update && isCloudHosted,
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** The OAuthAuthentication to associate with the menu */
|
||||
oauthAuthentication: OAuthAuthentication;
|
||||
};
|
||||
|
||||
function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleRevoke = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Revoke {{ appName }}", {
|
||||
appName: oauthAuthentication.oauthClient.name,
|
||||
}),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await oauthAuthentication.deleteAll();
|
||||
dialogs.closeAllModals();
|
||||
}}
|
||||
submitText={t("Revoke")}
|
||||
savingText={`${t("Revoking")}…`}
|
||||
danger
|
||||
>
|
||||
{t("Are you sure you want to revoke access?")}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, oauthAuthentication]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu}>
|
||||
<MenuItem {...menu} onClick={handleRevoke} dangerous>
|
||||
{t("Revoke")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(OAuthAuthenticationMenu);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
/** The oauthClient to associate with the menu */
|
||||
oauthClient: OAuthClient;
|
||||
/** Whether to show the edit button */
|
||||
showEdit?: boolean;
|
||||
};
|
||||
|
||||
function OAuthClientMenu({ oauthClient, showEdit }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDelete = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete app"),
|
||||
content: (
|
||||
<OAuthClientDeleteDialog
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
oauthClient={oauthClient}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, oauthClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "route",
|
||||
title: `${t("Edit")}…`,
|
||||
visible: showEdit,
|
||||
to: settingsPath("applications", oauthClient.id),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
dangerous: true,
|
||||
title: `${t("Delete")}…`,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(OAuthClientMenu);
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "~/actions/definitions/navigation";
|
||||
import {
|
||||
createTeam,
|
||||
createTeamsList,
|
||||
switchTeamsList,
|
||||
desktopLoginTeam,
|
||||
} from "~/actions/definitions/teams";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
@@ -22,7 +22,7 @@ type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const OrganizationMenu: React.FC = ({ children }: Props) => {
|
||||
const TeamMenu: React.FC = ({ children }: Props) => {
|
||||
const menu = useMenuState({
|
||||
unstable_offset: [4, -4],
|
||||
placement: "bottom-start",
|
||||
@@ -44,7 +44,7 @@ const OrganizationMenu: React.FC = ({ children }: Props) => {
|
||||
// menu is not cached at all.
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
...createTeamsList(context),
|
||||
...switchTeamsList(context),
|
||||
createTeam,
|
||||
desktopLoginTeam,
|
||||
separator(),
|
||||
@@ -64,4 +64,4 @@ const OrganizationMenu: React.FC = ({ children }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(OrganizationMenu);
|
||||
export default observer(TeamMenu);
|
||||
@@ -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" })
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { action, observable } from "mobx";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import User from "../User";
|
||||
import ParanoidModel from "../base/ParanoidModel";
|
||||
import Field from "../decorators/Field";
|
||||
import Relation from "../decorators/Relation";
|
||||
import OAuthClient from "./OAuthClient";
|
||||
|
||||
class OAuthAuthentication extends ParanoidModel {
|
||||
static modelName = "OAuthAuthentication";
|
||||
|
||||
/** A list of scopes that this authentication has access to */
|
||||
@Field
|
||||
@observable
|
||||
scope: string[];
|
||||
|
||||
@Relation(() => User)
|
||||
user: User;
|
||||
|
||||
userId: string;
|
||||
|
||||
oauthClient: Pick<OAuthClient, "id" | "name" | "clientId" | "avatarUrl">;
|
||||
|
||||
oauthClientId: string;
|
||||
|
||||
lastActiveAt: string;
|
||||
|
||||
@action
|
||||
public async deleteAll() {
|
||||
await client.post(`/${this.store.apiEndpoint}.delete`, {
|
||||
oauthClientId: this.oauthClientId,
|
||||
scope: this.scope,
|
||||
});
|
||||
|
||||
return this.store.remove(this.id);
|
||||
}
|
||||
}
|
||||
|
||||
export default OAuthAuthentication;
|
||||
@@ -0,0 +1,92 @@
|
||||
import invariant from "invariant";
|
||||
import { observable, runInAction } from "mobx";
|
||||
import queryString from "query-string";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import User from "../User";
|
||||
import ParanoidModel from "../base/ParanoidModel";
|
||||
import Field from "../decorators/Field";
|
||||
import Relation from "../decorators/Relation";
|
||||
|
||||
class OAuthClient extends ParanoidModel {
|
||||
static modelName = "OAuthClient";
|
||||
|
||||
/** The human-readable name of this app */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/** A short description of this app */
|
||||
@Field
|
||||
@observable
|
||||
description: string | null;
|
||||
|
||||
/** The name of the developer of this app */
|
||||
@Field
|
||||
@observable
|
||||
developerName: string | null;
|
||||
|
||||
/** The URL of the developer of this app */
|
||||
@Field
|
||||
@observable
|
||||
developerUrl: string | null;
|
||||
|
||||
/** The URL of the avatar of the developer of this app */
|
||||
@Field
|
||||
@observable
|
||||
avatarUrl: string | null;
|
||||
|
||||
/** The public identifier of this app */
|
||||
@Field
|
||||
clientId: string;
|
||||
|
||||
/** The secret key used to authenticate this app */
|
||||
@Field
|
||||
@observable
|
||||
clientSecret: string;
|
||||
|
||||
/** Whether this app is published (available to other workspaces) */
|
||||
@Field
|
||||
@observable
|
||||
published: boolean;
|
||||
|
||||
/** A list of valid redirect URIs for this app */
|
||||
@Field
|
||||
@observable
|
||||
redirectUris: string[];
|
||||
|
||||
@Relation(() => User)
|
||||
createdBy: User;
|
||||
|
||||
createdById: string;
|
||||
|
||||
// instance methods
|
||||
|
||||
public async rotateClientSecret() {
|
||||
const res = await client.post("/oauthClients.rotate_secret", {
|
||||
id: this.id,
|
||||
});
|
||||
invariant(res.data, "Failed to rotate client secret");
|
||||
|
||||
runInAction("OAuthClient#rotateSecret", () => {
|
||||
this.clientSecret = res.data.clientSecret;
|
||||
});
|
||||
}
|
||||
|
||||
public get initial() {
|
||||
return this.name[0];
|
||||
}
|
||||
|
||||
public get authorizationUrl(): string {
|
||||
const params = {
|
||||
client_id: this.clientId,
|
||||
redirect_uri: this.redirectUris[0],
|
||||
response_type: "code",
|
||||
scope: "read",
|
||||
};
|
||||
|
||||
return `${env.URL}/oauth/authorize?${queryString.stringify(params)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default OAuthClient;
|
||||
@@ -42,55 +42,77 @@ const RedirectDocument = ({
|
||||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* The authenticated routes are all the routes of the application that require
|
||||
* the user to be logged in.
|
||||
*/
|
||||
function AuthenticatedRoutes() {
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<WebsocketProvider>
|
||||
<AuthenticatedLayout>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<PlaceholderDocument />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
{can.createDocument && (
|
||||
<Route exact path={draftsPath()} component={Drafts} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path={archivePath()} component={Archive} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path={trashPath()} component={Trash} />
|
||||
)}
|
||||
<Route path={`${homePath()}/:tab?`} component={Home} />
|
||||
<Redirect from="/dashboard" to={homePath()} />
|
||||
<Redirect exact from="/starred" to={homePath()} />
|
||||
<Redirect exact from="/templates" to={settingsPath("templates")} />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collection/:id/:tab?" component={Collection} />
|
||||
<Route exact path="/doc/new" component={DocumentNew} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/insights`} component={Document} />
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route exact path={`${searchPath()}/:query?`} component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<SettingsRoutes />
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</AuthenticatedLayout>
|
||||
</WebsocketProvider>
|
||||
<Switch>
|
||||
<WebsocketProvider>
|
||||
<AuthenticatedLayout>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<PlaceholderDocument />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
{can.createDocument && (
|
||||
<Route exact path={draftsPath()} component={Drafts} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path={archivePath()} component={Archive} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path={trashPath()} component={Trash} />
|
||||
)}
|
||||
<Route path={`${homePath()}/:tab?`} component={Home} />
|
||||
<Redirect from="/dashboard" to={homePath()} />
|
||||
<Redirect exact from="/starred" to={homePath()} />
|
||||
<Redirect
|
||||
exact
|
||||
from="/templates"
|
||||
to={settingsPath("templates")}
|
||||
/>
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route
|
||||
exact
|
||||
path="/collection/:id/:tab?"
|
||||
component={Collection}
|
||||
/>
|
||||
<Route exact path="/doc/new" component={DocumentNew} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/insights`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route
|
||||
exact
|
||||
path={`${searchPath()}/:query?`}
|
||||
component={Search}
|
||||
/>
|
||||
<Route path="/404" component={Error404} />
|
||||
<SettingsRoutes />
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</AuthenticatedLayout>
|
||||
</WebsocketProvider>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,15 @@ import FullscreenLoading from "~/components/FullscreenLoading";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import env from "~/env";
|
||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
||||
|
||||
const Authenticated = lazyWithRetry(() => import("~/components/Authenticated"));
|
||||
const AuthenticatedRoutes = lazyWithRetry(() => import("./authenticated"));
|
||||
const SharedDocument = lazyWithRetry(() => import("~/scenes/Document/Shared"));
|
||||
const Login = lazyWithRetry(() => import("~/scenes/Login"));
|
||||
const Logout = lazyWithRetry(() => import("~/scenes/Logout"));
|
||||
const Authenticated = lazy(() => import("~/components/Authenticated"));
|
||||
const AuthenticatedRoutes = lazy(() => import("./authenticated"));
|
||||
const SharedDocument = lazy(() => import("~/scenes/Document/Shared"));
|
||||
const Login = lazy(() => import("~/scenes/Login"));
|
||||
const Logout = lazy(() => import("~/scenes/Logout"));
|
||||
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
|
||||
|
||||
export default function Routes() {
|
||||
useQueryNotices();
|
||||
@@ -43,6 +44,7 @@ export default function Routes() {
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
|
||||
<Route exact path="/oauth/authorize" component={OAuthAuthorize} />
|
||||
|
||||
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
|
||||
<Route exact path="/s/:shareId" component={SharedDocument} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
const Application = lazy(() => import("~/scenes/Settings/Application"));
|
||||
const Document = lazy(() => import("~/scenes/Document"));
|
||||
|
||||
export default function SettingsRoutes() {
|
||||
@@ -22,6 +23,12 @@ export default function SettingsRoutes() {
|
||||
component={config.component}
|
||||
/>
|
||||
))}
|
||||
{/* TODO: Refactor these exceptions into config? */}
|
||||
<Route
|
||||
exact
|
||||
path={`${settingsPath("applications")}/:id`}
|
||||
component={Application}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`${settingsPath("templates")}/${matchDocumentSlug}`}
|
||||
|
||||
+26
-5
@@ -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,11 +86,11 @@ 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}
|
||||
editorStyle={editorStyle}
|
||||
embedsDisabled
|
||||
/>
|
||||
<div ref={childRef} />
|
||||
</React.Suspense>
|
||||
@@ -84,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
|
||||
|
||||
@@ -144,10 +144,10 @@ function Insights() {
|
||||
small
|
||||
/>
|
||||
)}
|
||||
<PaginatedList
|
||||
<PaginatedList<User>
|
||||
aria-label={t("Contributors")}
|
||||
items={document.collaborators}
|
||||
renderItem={(model: User) => (
|
||||
renderItem={(model) => (
|
||||
<ListItem
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Config } from "~/stores/AuthStore";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import ChangeLanguage from "~/components/ChangeLanguage";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import OutlineIcon from "~/components/Icons/OutlineIcon";
|
||||
@@ -30,21 +29,23 @@ import {
|
||||
} from "~/hooks/useLastVisitedPath";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { detectLanguage } from "~/utils/language";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
import AuthenticationProvider from "./components/AuthenticationProvider";
|
||||
import BackButton from "./components/BackButton";
|
||||
import Notices from "./components/Notices";
|
||||
import { BackButton } from "./components/BackButton";
|
||||
import { Background } from "./components/Background";
|
||||
import { Centered } from "./components/Centered";
|
||||
import { Notices } from "./components/Notices";
|
||||
import { getRedirectUrl, navigateToSubdomain } from "./urls";
|
||||
|
||||
type Props = {
|
||||
children?: (config?: Config) => React.ReactNode;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
function Login({ children }: Props) {
|
||||
function Login({ children, onBack }: Props) {
|
||||
const location = useLocation();
|
||||
const query = useQuery();
|
||||
const notice = query.get("notice");
|
||||
@@ -110,9 +111,9 @@ function Login({ children }: Props) {
|
||||
if (error) {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton />
|
||||
<BackButton onBack={onBack} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<Centered>
|
||||
<PageTitle title={t("Login")} />
|
||||
<Heading centered>{t("Error")}</Heading>
|
||||
<Note>
|
||||
@@ -142,9 +143,9 @@ function Login({ children }: Props) {
|
||||
if (isCloudHosted && isCustomDomain && !config.name) {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton config={config} />
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<Centered>
|
||||
<PageTitle title={t("Custom domain setup")} />
|
||||
<Heading centered>{t("Almost there")}…</Heading>
|
||||
<Note>
|
||||
@@ -160,17 +161,10 @@ function Login({ children }: Props) {
|
||||
if (Desktop.isElectron() && notice === "domain-required") {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton config={config} />
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
|
||||
<Centered
|
||||
as="form"
|
||||
onSubmit={handleGoSubdomain}
|
||||
align="center"
|
||||
justify="center"
|
||||
column
|
||||
auto
|
||||
>
|
||||
<Centered as="form" onSubmit={handleGoSubdomain}>
|
||||
<Heading centered>{t("Choose workspace")}</Heading>
|
||||
<Note>
|
||||
{t(
|
||||
@@ -206,8 +200,8 @@ function Login({ children }: Props) {
|
||||
if (emailLinkSentTo) {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton config={config} />
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<Centered>
|
||||
<PageTitle title={t("Check your email")} />
|
||||
<CheckEmailIcon size={38} />
|
||||
<Heading centered>{t("Check your email")}</Heading>
|
||||
@@ -241,10 +235,10 @@ function Login({ children }: Props) {
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<BackButton config={config} />
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
|
||||
<Centered align="center" justify="center" gap={12} column auto>
|
||||
<Centered gap={12}>
|
||||
<PageTitle
|
||||
title={config.name ? `${config.name} – ${t("Login")}` : t("Login")}
|
||||
/>
|
||||
@@ -336,14 +330,6 @@ const CheckEmailIcon = styled(EmailIcon)`
|
||||
margin-bottom: -1.5em;
|
||||
`;
|
||||
|
||||
const Background = styled(Fade)`
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
background: ${s("background")};
|
||||
display: flex;
|
||||
${draggableOnDesktop()}
|
||||
`;
|
||||
|
||||
const Logo = styled.div`
|
||||
margin-bottom: -4px;
|
||||
`;
|
||||
@@ -389,12 +375,4 @@ const Or = styled.hr`
|
||||
}
|
||||
`;
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
user-select: none;
|
||||
width: 90vw;
|
||||
height: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
export default observer(Login);
|
||||
@@ -0,0 +1,260 @@
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
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";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
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";
|
||||
import { OAuthScopeHelper } from "./OAuthScopeHelper";
|
||||
import { Background } from "./components/Background";
|
||||
import { Centered } from "./components/Centered";
|
||||
import { ConnectHeader } from "./components/ConnectHeader";
|
||||
import { TeamSwitcher } from "./components/TeamSwitcher";
|
||||
|
||||
export default function OAuthAuthorize() {
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const sessions = useLoggedInSessions();
|
||||
|
||||
// We're self-hosted or on a team subdomain already, just show the authorize screen.
|
||||
if (team) {
|
||||
return <Authorize />;
|
||||
}
|
||||
|
||||
// Cloud hosted and on root domain – show the workspace switcher.
|
||||
const isAppRoot =
|
||||
parseDomain(window.location.hostname).host === parseDomain(env.URL).host;
|
||||
const hasLoggedInSessions = Object.keys(sessions).length > 0;
|
||||
if (isCloudHosted && hasLoggedInSessions && isAppRoot) {
|
||||
return <TeamSwitcher sessions={sessions} />;
|
||||
}
|
||||
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize component is responsible for handling the OAuth authorization process.
|
||||
* It retrieves the OAuth client information, displays the authorization request,
|
||||
* and allows the user to either authorize or cancel the request.
|
||||
*/
|
||||
function Authorize() {
|
||||
const team = useCurrentTeam();
|
||||
const params = useQuery();
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const timeoutRef = React.useRef<number>();
|
||||
const {
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: responseType,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
state,
|
||||
scope,
|
||||
} = Object.fromEntries(params);
|
||||
const [scopes] = React.useState(() => scope?.split(" ") ?? []);
|
||||
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 {
|
||||
window.location.href = "/";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
setIsSubmitting(true);
|
||||
timeoutRef.current = window.setTimeout(() => setIsSubmitting(false), 5000);
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
timeoutRef.current && window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const missingParams = [
|
||||
!clientId && "client_id",
|
||||
!redirectUri && "redirect_uri",
|
||||
!responseType && "response_type",
|
||||
!scope && "scope",
|
||||
!state && "state",
|
||||
].filter(Boolean);
|
||||
|
||||
if (missingParams.length || clientError) {
|
||||
return (
|
||||
<Background>
|
||||
<Centered>
|
||||
<StyledHeading>{t("An error occurred")}</StyledHeading>
|
||||
{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")}
|
||||
<Pre>
|
||||
{missingParams.map((param) => (
|
||||
<>
|
||||
{param}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</Pre>
|
||||
</Text>
|
||||
)}
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
const { name, developerName, developerUrl } = response.data;
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<PageTitle title={t("Authorize")} />
|
||||
<Centered gap={12}>
|
||||
<ConnectHeader team={team} oauthClient={response.data} />
|
||||
<StyledHeading>
|
||||
{t(`{{ appName }} wants to access {{ teamName }}`, {
|
||||
appName: name,
|
||||
teamName: team.name,
|
||||
})}
|
||||
</StyledHeading>
|
||||
{developerName && (
|
||||
<Text type="secondary" as="p" style={{ marginTop: -12 }}>
|
||||
<Trans
|
||||
defaults="By <em>{{ developerName }}</em>"
|
||||
values={{
|
||||
developerName,
|
||||
}}
|
||||
components={{
|
||||
em: developerUrl ? (
|
||||
<Text
|
||||
as="a"
|
||||
type="secondary"
|
||||
weight="bold"
|
||||
href={developerUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
) : (
|
||||
<strong />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
<Text type="tertiary" as="p">
|
||||
{t(
|
||||
"{{ appName }} will be able to access your account and perform the following actions",
|
||||
{
|
||||
appName: name,
|
||||
}
|
||||
)}
|
||||
:
|
||||
</Text>
|
||||
<ul style={{ width: "100%", paddingLeft: "1em", marginTop: 0 }}>
|
||||
{OAuthScopeHelper.normalizeScopes(scopes, t).map((item) => (
|
||||
<li key={item}>
|
||||
<Text type="secondary">{item}</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form
|
||||
method="POST"
|
||||
action="/oauth/authorize"
|
||||
style={{ width: "100%" }}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input type="hidden" name="client_id" value={clientId ?? ""} />
|
||||
<input type="hidden" name="redirect_uri" value={redirectUri ?? ""} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="response_type"
|
||||
value={responseType ?? ""}
|
||||
/>
|
||||
<input type="hidden" name="state" value={state ?? ""} />
|
||||
<input type="hidden" name="scope" value={scope ?? ""} />
|
||||
{codeChallenge && (
|
||||
<input type="hidden" name="code_challenge" value={codeChallenge} />
|
||||
)}
|
||||
{codeChallengeMethod && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="code_challenge_method"
|
||||
value={codeChallengeMethod}
|
||||
/>
|
||||
)}
|
||||
<Flex gap={8} justify="space-between">
|
||||
<Button type="button" onClick={handleCancel} neutral>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{t("Authorize")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
const Button = styled(ButtonLarge)`
|
||||
width: calc(50% - 4px);
|
||||
`;
|
||||
|
||||
const StyledHeading = styled(Heading).attrs({
|
||||
as: "h2",
|
||||
centered: true,
|
||||
})`
|
||||
margin-top: 0;
|
||||
`;
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${s("backgroundSecondary")};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import uniq from "lodash/uniq";
|
||||
import { Scope } from "@shared/types";
|
||||
|
||||
export class OAuthScopeHelper {
|
||||
public static normalizeScopes(scopes: string[], t: TFunction): string[] {
|
||||
const methodToReadable = {
|
||||
list: t("read"),
|
||||
info: t("read"),
|
||||
read: t("read"),
|
||||
write: t("write"),
|
||||
create: t("write"),
|
||||
update: t("write"),
|
||||
delete: t("write"),
|
||||
"*": t("read and write"),
|
||||
};
|
||||
|
||||
const translatedNamespaces = {
|
||||
apiKeys: t("API keys"),
|
||||
attachments: t("attachments"),
|
||||
collections: t("collections"),
|
||||
comments: t("comments"),
|
||||
documents: t("documents"),
|
||||
events: t("events"),
|
||||
groups: t("groups"),
|
||||
integrations: t("integrations"),
|
||||
notifications: t("notifications"),
|
||||
reactions: t("reactions"),
|
||||
pins: t("pins"),
|
||||
shares: t("shares"),
|
||||
users: t("users"),
|
||||
teams: t("teams"),
|
||||
"*": t("workspace"),
|
||||
};
|
||||
|
||||
const normalizedScopes = scopes.map((scope) => {
|
||||
if (scope === Scope.Read) {
|
||||
return t("Read all data");
|
||||
}
|
||||
if (scope === Scope.Write) {
|
||||
return t("Write all data");
|
||||
}
|
||||
|
||||
const [namespace, method] = scope.replace("/api/", "").split(/[:\.]/g);
|
||||
const readableMethod =
|
||||
methodToReadable[method as keyof typeof methodToReadable] ?? method;
|
||||
const translatedNamespace =
|
||||
translatedNamespaces[namespace as keyof typeof translatedNamespaces] ??
|
||||
namespace;
|
||||
return capitalize(`${readableMethod} ${translatedNamespace}`);
|
||||
});
|
||||
|
||||
return uniq(normalizedScopes);
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,21 @@ import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = {
|
||||
config?: Config;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export default function BackButton({ config }: Props) {
|
||||
export function BackButton({ onBack, config }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isSubdomain = !!config?.hostname;
|
||||
|
||||
if (onBack) {
|
||||
return (
|
||||
<Link onClick={onBack}>
|
||||
<BackIcon /> {t("Back")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Fade from "~/components/Fade";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
|
||||
export const Background = styled(Fade)`
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
background: ${s("background")};
|
||||
display: flex;
|
||||
${draggableOnDesktop()}
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
|
||||
export const Centered = styled(Flex).attrs({
|
||||
align: "center",
|
||||
justify: "center",
|
||||
column: true,
|
||||
auto: true,
|
||||
})`
|
||||
user-select: none;
|
||||
width: 90vw;
|
||||
height: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Text from "@shared/components/Text";
|
||||
import type Team from "~/models/Team";
|
||||
import type OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { AvatarSize, AvatarVariant } from "~/components/Avatar/Avatar";
|
||||
|
||||
type Props = {
|
||||
team: Team;
|
||||
oauthClient: OAuthClient;
|
||||
};
|
||||
|
||||
export function ConnectHeader({ team, oauthClient }: Props) {
|
||||
return (
|
||||
<Text type="tertiary">
|
||||
<Flex gap={12} align="center">
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={{
|
||||
avatarUrl: oauthClient.avatarUrl,
|
||||
initial: oauthClient.name[0],
|
||||
}}
|
||||
size={AvatarSize.XXLarge}
|
||||
alt={oauthClient.name}
|
||||
/>
|
||||
|
||||
<MoreIcon />
|
||||
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={team}
|
||||
size={AvatarSize.XXLarge}
|
||||
alt={team.name}
|
||||
/>
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,10 @@ export function LoginDialog() {
|
||||
maxLength={255}
|
||||
autoComplete="off"
|
||||
placeholder={t("subdomain")}
|
||||
{...register("subdomain", { required: true, pattern: /^[a-z\d-]+$/ })}
|
||||
{...register("subdomain", {
|
||||
required: true,
|
||||
pattern: /^[a-z\d-]{1,63}$/,
|
||||
})}
|
||||
>
|
||||
<Domain>.getoutline.com</Domain>
|
||||
</Input>
|
||||
|
||||
@@ -123,7 +123,7 @@ function Message({ notice }: { notice: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function Notices() {
|
||||
export function Notices() {
|
||||
const query = useQuery();
|
||||
const notice = query.get("notice");
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ArrowIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Text from "@shared/components/Text";
|
||||
import { s } from "@shared/styles";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Avatar, { AvatarVariant } from "~/components/Avatar/Avatar";
|
||||
import ChangeLanguage from "~/components/ChangeLanguage";
|
||||
import Heading from "~/components/Heading";
|
||||
import OutlineIcon from "~/components/Icons/OutlineIcon";
|
||||
import env from "~/env";
|
||||
import type { Sessions } from "~/hooks/useLoggedInSessions";
|
||||
import { detectLanguage } from "~/utils/language";
|
||||
import Login from "../Login";
|
||||
import { Background } from "./Background";
|
||||
import { Centered } from "./Centered";
|
||||
|
||||
type Props = { sessions: Sessions };
|
||||
|
||||
export function TeamSwitcher({ sessions }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [showLogin, setShowLogin] = React.useState(false);
|
||||
const url = new URL(window.location.href);
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
if (showLogin) {
|
||||
return <Login onBack={() => setShowLogin(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered>
|
||||
<OutlineIcon size={AvatarSize.XXLarge} />
|
||||
|
||||
<StyledHeading>{t("Choose a workspace")}</StyledHeading>
|
||||
<Text type="tertiary" as="p">
|
||||
{t(
|
||||
"Choose an {{ appName }} workspace or login to continue connecting this app",
|
||||
{ appName }
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
{Object.keys(sessions)?.map((teamId) => {
|
||||
const session = sessions[teamId];
|
||||
const location = session.url + url.pathname + url.search;
|
||||
return (
|
||||
<TeamLink href={location} key={session.url}>
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={{
|
||||
avatarUrl: session.logoUrl,
|
||||
initial: session.name[0],
|
||||
}}
|
||||
size={AvatarSize.Large}
|
||||
alt={session.name}
|
||||
/>
|
||||
{session.name}
|
||||
<StyledArrowIcon />
|
||||
</TeamLink>
|
||||
);
|
||||
})}
|
||||
<TeamLink onClick={() => setShowLogin(true)}>
|
||||
<ArrowIcon size={AvatarSize.Large} />
|
||||
{t("Login to workspace")}
|
||||
</TeamLink>
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledArrowIcon = styled(ArrowIcon)`
|
||||
position: absolute;
|
||||
transition: all 0.2s ease-in-out;
|
||||
opacity: 0;
|
||||
right: 12px;
|
||||
`;
|
||||
|
||||
const TeamLink = styled.a`
|
||||
position: relative;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
margin: 4px;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
color: ${s("text")};
|
||||
font-weight: ${s("fontWeightMedium")};
|
||||
|
||||
&:hover {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
|
||||
${StyledArrowIcon} {
|
||||
opacity: 1;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledHeading = styled(Heading).attrs({
|
||||
as: "h2",
|
||||
centered: true,
|
||||
})`
|
||||
margin-top: 0;
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import Login from "./Login";
|
||||
|
||||
export default Login;
|
||||
@@ -3,6 +3,15 @@ import { parseDomain } from "@shared/utils/domains";
|
||||
import env from "~/env";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
function validateAndEncodeSubdomain(subdomain: string): string {
|
||||
const encodedSubdomain = encodeURIComponent(subdomain);
|
||||
const urlPattern = /^[a-z\d-]{1,63}$/;
|
||||
if (!urlPattern.test(encodedSubdomain)) {
|
||||
throw new Error("Invalid subdomain");
|
||||
}
|
||||
return `https://${encodedSubdomain}.getoutline.com`;
|
||||
}
|
||||
|
||||
/**
|
||||
* If we're on a custom domain or a subdomain then the auth must point to the
|
||||
* apex (env.URL) for authentication so that the state cookie can be set and read.
|
||||
@@ -36,7 +45,7 @@ export async function navigateToSubdomain(subdomain: string) {
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "");
|
||||
const host = `https://${normalizedSubdomain}.getoutline.com`;
|
||||
const host = validateAndEncodeSubdomain(normalizedSubdomain);
|
||||
await Desktop.bridge?.addCustomHost(host);
|
||||
window.location.href = host;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PadlockIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import env from "~/env";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
import OAuthAuthenticationListItem from "./components/OAuthAuthenticationListItem";
|
||||
|
||||
function APIAndApps() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys, oauthAuthentications } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API & Apps")}
|
||||
icon={<PadlockIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API & Apps")}</Heading>
|
||||
<h2>{t("API keys")}</h2>
|
||||
{can.createApiKey ? (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
href="https://www.getoutline.com/developers"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
) : (
|
||||
<Trans>API keys have been disabled by an admin for your account</Trans>
|
||||
)}
|
||||
<PaginatedList<ApiKey>
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
renderItem={(apiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
<PaginatedList
|
||||
fetch={oauthAuthentications.fetchPage}
|
||||
items={oauthAuthentications.orderedData}
|
||||
heading={
|
||||
<>
|
||||
<h2>{t("Application access")}</h2>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Manage which third-party and internal applications have been granted access to your {{ appName }} account.",
|
||||
{ appName }
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
renderItem={(oauthAuthentication: OAuthAuthentication) => (
|
||||
<OAuthAuthenticationListItem
|
||||
key={oauthAuthentication.id}
|
||||
oauthAuthentication={oauthAuthentication}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(APIAndApps);
|
||||
@@ -58,11 +58,10 @@ function ApiKeys() {
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
<PaginatedList<ApiKey>
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<h2>{t("All")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
renderItem={(apiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, InternetIcon, ReplaceIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { OAuthClientValidation } from "@shared/validations";
|
||||
import OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Button from "~/components/Button";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ContentEditable from "~/components/ContentEditable";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { FormData } from "~/components/OAuthClient/OAuthClientForm";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OAuthClientMenu from "~/menus/OAuthClientMenu";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { ActionRow } from "./components/ActionRow";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
type Props = {
|
||||
oauthClient: OAuthClient;
|
||||
};
|
||||
|
||||
const LoadingState = observer(function LoadingState() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { oauthClients } = useStores();
|
||||
const oauthClient = oauthClients.get(id);
|
||||
const { request } = useRequest(() => oauthClients.fetch(id));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!oauthClient) {
|
||||
void request();
|
||||
}
|
||||
}, [oauthClient]);
|
||||
|
||||
if (!oauthClient) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <Application oauthClient={oauthClient} />;
|
||||
});
|
||||
|
||||
const Application = observer(function Application({ oauthClient }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
getValues,
|
||||
setError,
|
||||
control,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
name: oauthClient.name ?? "",
|
||||
developerName: oauthClient.developerName ?? "",
|
||||
developerUrl: oauthClient.developerUrl ?? "",
|
||||
description: oauthClient.description ?? "",
|
||||
avatarUrl: oauthClient.avatarUrl ?? "",
|
||||
redirectUris: oauthClient.redirectUris ?? [],
|
||||
published: oauthClient.published ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
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, t]
|
||||
);
|
||||
|
||||
const handleRotateSecret = React.useCallback(async () => {
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
await oauthClient.rotateClientSecret();
|
||||
toast.success(t("Client secret rotated"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Rotate secret"),
|
||||
content: (
|
||||
<ConfirmationDialog onSubmit={onDelete} danger>
|
||||
{t(
|
||||
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, oauthClient]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={oauthClient.name}
|
||||
left={
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
type: "route",
|
||||
title: t("Applications"),
|
||||
to: settingsPath("applications"),
|
||||
icon: <InternetIcon />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
actions={<OAuthClientMenu oauthClient={oauthClient} showEdit={false} />}
|
||||
>
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<Heading>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<ContentEditable
|
||||
value={field.value}
|
||||
placeholder={t("Name")}
|
||||
onChange={field.onChange}
|
||||
maxLength={OAuthClientValidation.maxNameLength}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Heading>
|
||||
|
||||
<SettingRow
|
||||
label={t("Icon")}
|
||||
name="avatarUrl"
|
||||
description={t("Displayed to users when authorizing")}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
id: oauthClient.id,
|
||||
avatarUrl: field.value,
|
||||
initial: getValues().name[0],
|
||||
}}
|
||||
borderRadius={0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
name="description"
|
||||
label={t("Tagline")}
|
||||
description={t("A short description")}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
{...register("description", {
|
||||
maxLength: OAuthClientValidation.maxDescriptionLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
name="details"
|
||||
label={t("Details")}
|
||||
description={t(
|
||||
"Developer information shown to users when authorizing"
|
||||
)}
|
||||
border={isCloudHosted}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Developer name")}
|
||||
{...register("developerName", {
|
||||
maxLength: OAuthClientValidation.maxDeveloperNameLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Developer URL")}
|
||||
{...register("developerUrl", {
|
||||
maxLength: OAuthClientValidation.maxDeveloperUrlLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
</SettingRow>
|
||||
{isCloudHosted && (
|
||||
<SettingRow
|
||||
name="published"
|
||||
label={t("Published")}
|
||||
description={t(
|
||||
"Allow users from other workspaces to authorize this app"
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Switch id="published" {...register("published")} />
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<h2>{t("Credentials")}</h2>
|
||||
<SettingRow
|
||||
name="clientId"
|
||||
label={t("OAuth client ID")}
|
||||
description={t("The public identifier for this app")}
|
||||
>
|
||||
<Input id="clientId" value={oauthClient.clientId} readOnly>
|
||||
<CopyButton
|
||||
value={oauthClient.clientId}
|
||||
success={t("Copied to clipboard")}
|
||||
tooltip={t("Copy")}
|
||||
icon={<CopyIcon size={20} />}
|
||||
/>
|
||||
</Input>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="clientSecret"
|
||||
label={t("OAuth client secret")}
|
||||
description={t(
|
||||
"Store this value securely, do not expose it publicly"
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
value={oauthClient.clientSecret}
|
||||
readOnly
|
||||
>
|
||||
<Tooltip content={t("Rotate secret")} placement="top">
|
||||
<NudeButton type="button" onClick={handleRotateSecret}>
|
||||
<ReplaceIcon size={20} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
|
||||
<CopyButton
|
||||
value={oauthClient.clientSecret}
|
||||
success={t("Copied to clipboard")}
|
||||
tooltip={t("Copy")}
|
||||
icon={<CopyIcon size={20} />}
|
||||
/>
|
||||
</Input>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="redirectUris"
|
||||
label={t("Callback URLs")}
|
||||
description={t(
|
||||
"Where users are redirected after authorizing this app"
|
||||
)}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="redirectUris"
|
||||
type="textarea"
|
||||
placeholder="https://example.com/callback"
|
||||
ref={field.ref}
|
||||
value={field.value.join("\n")}
|
||||
rows={Math.max(2, field.value.length + 1)}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value.split("\n"));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="authorizationUrl"
|
||||
label={t("Authorization URL")}
|
||||
description={t("Where users are redirected to authorize this app")}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
id="authorizationUrl"
|
||||
value={oauthClient.authorizationUrl}
|
||||
readOnly
|
||||
>
|
||||
<CopyButton
|
||||
value={oauthClient.authorizationUrl}
|
||||
success={t("Copied to clipboard")}
|
||||
tooltip={t("Copy link")}
|
||||
/>
|
||||
</Input>
|
||||
</SettingRow>
|
||||
|
||||
<ActionRow>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
});
|
||||
|
||||
export default LoadingState;
|
||||
@@ -1,42 +1,40 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import { InternetIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import { createOAuthClient } from "~/actions/definitions/oauthClients";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
import OAuthClientListItem from "./components/OAuthClientListItem";
|
||||
|
||||
function PersonalApiKeys() {
|
||||
function Applications() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const { oauthClients } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
icon={<CodeIcon />}
|
||||
title={t("Applications")}
|
||||
icon={<InternetIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
{can.createOAuthClient && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
value={`${t("New App")}…`}
|
||||
action={createOAuthClient}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
@@ -44,12 +42,10 @@ function PersonalApiKeys() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Heading>{t("Applications")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
defaults="Applications allow you to build internal or public integrations with Outline and provide secure access via OAuth. For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
@@ -61,17 +57,15 @@ function PersonalApiKeys() {
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
<PaginatedList<OAuthClient>
|
||||
fetch={oauthClients.fetchPage}
|
||||
items={oauthClients.orderedData}
|
||||
renderItem={(oauthClient) => (
|
||||
<OAuthClientListItem key={oauthClient.id} oauthClient={oauthClient} />
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(PersonalApiKeys);
|
||||
export default observer(Applications);
|
||||
@@ -108,7 +108,16 @@ function Details() {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[team, name, subdomain, defaultCollectionId, publicBranding, customTheme, t]
|
||||
[
|
||||
tocPosition,
|
||||
team,
|
||||
name,
|
||||
subdomain,
|
||||
defaultCollectionId,
|
||||
publicBranding,
|
||||
customTheme,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback(
|
||||
|
||||
@@ -48,7 +48,7 @@ function Export() {
|
||||
{t("Export data")}…
|
||||
</Button>
|
||||
<br />
|
||||
<PaginatedList
|
||||
<PaginatedList<FileOperation>
|
||||
items={fileOperations.exports}
|
||||
fetch={fileOperations.fetchPage}
|
||||
options={{
|
||||
@@ -59,7 +59,7 @@ function Export() {
|
||||
<Trans>Recent exports</Trans>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item: FileOperation) => (
|
||||
renderItem={(item) => (
|
||||
<FileOperationListItem key={item.id} fileOperation={item} />
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -183,7 +183,7 @@ function Import() {
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
<PaginatedList
|
||||
<PaginatedList<ImportModel | FileOperation>
|
||||
items={allImports}
|
||||
fetch={fetchImports}
|
||||
heading={
|
||||
@@ -191,7 +191,7 @@ function Import() {
|
||||
<Trans>Recent imports</Trans>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item: ImportModel | FileOperation) =>
|
||||
renderItem={(item) =>
|
||||
item instanceof ImportModel ? (
|
||||
<ImportListItem key={item.id} importModel={item} />
|
||||
) : (
|
||||
|
||||
@@ -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%";
|
||||
`;
|
||||
@@ -1,140 +0,0 @@
|
||||
import find from "lodash/find";
|
||||
import { observer } from "mobx-react";
|
||||
import { BuildingBlocksIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
type FormData = {
|
||||
drawIoUrl: string;
|
||||
gristUrl: string;
|
||||
};
|
||||
|
||||
function SelfHosted() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const integrationDiagrams = find(integrations.orderedData, {
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Diagrams,
|
||||
}) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
const integrationGrist = find(integrations.orderedData, {
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Grist,
|
||||
}) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
drawIoUrl: integrationDiagrams?.settings.url,
|
||||
gristUrl: integrationGrist?.settings.url,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchPage({
|
||||
type: IntegrationType.Embed,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
React.useEffect(() => {
|
||||
reset({
|
||||
drawIoUrl: integrationDiagrams?.settings.url,
|
||||
gristUrl: integrationGrist?.settings.url,
|
||||
});
|
||||
}, [integrationDiagrams, integrationGrist, reset]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
if (data.drawIoUrl) {
|
||||
await integrations.save({
|
||||
id: integrationDiagrams?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Diagrams,
|
||||
settings: {
|
||||
url: data.drawIoUrl,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await integrationDiagrams?.delete();
|
||||
}
|
||||
|
||||
if (data.gristUrl) {
|
||||
await integrations.save({
|
||||
id: integrationGrist?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Grist,
|
||||
settings: {
|
||||
url: data.gristUrl,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await integrationGrist?.delete();
|
||||
}
|
||||
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[integrations, integrationDiagrams, integrationGrist, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
|
||||
<Heading>{t("Self Hosted")}</Heading>
|
||||
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<SettingRow
|
||||
label={t("Draw.io deployment")}
|
||||
name="drawIoUrl"
|
||||
description={t(
|
||||
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://app.diagrams.net/"
|
||||
pattern="https?://.*"
|
||||
{...register("drawIoUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("Grist deployment")}
|
||||
name="gristUrl"
|
||||
description={t("Add your self-hosted grist installation URL here.")}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://docs.getgrist.com/"
|
||||
pattern="https?://.*"
|
||||
{...register("gristUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(SelfHosted);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
/** The value to be copied */
|
||||
value: string;
|
||||
/** The message to show when the value is copied */
|
||||
success: string;
|
||||
/** The tooltip message */
|
||||
tooltip: string;
|
||||
/** An optional icon */
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A button that copies a value to the clipboard when clicked and shows a
|
||||
* single icon.
|
||||
*/
|
||||
export function CopyButton({
|
||||
value,
|
||||
success,
|
||||
tooltip,
|
||||
icon = <LinkIcon size={20} />,
|
||||
}: Props) {
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopied = React.useCallback(() => {
|
||||
timeout.current = setTimeout(() => {
|
||||
toast.message(success);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
};
|
||||
}, [success]);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} placement="top">
|
||||
<CopyToClipboard text={value} onCopy={handleCopied}>
|
||||
<NudeButton type="button">{icon}</NudeButton>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -268,14 +268,14 @@ export const ViewGroupMembersDialog = observer(function ({
|
||||
<Subheading>
|
||||
<Trans>Members</Trans>
|
||||
</Subheading>
|
||||
<PaginatedList
|
||||
<PaginatedList<User>
|
||||
items={users.inGroup(group.id)}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={{
|
||||
id: group.id,
|
||||
}}
|
||||
empty={<Empty>{t("This group has no members.")}</Empty>}
|
||||
renderItem={(user: User) => (
|
||||
renderItem={(user) => (
|
||||
<GroupMemberListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
@@ -382,7 +382,7 @@ const AddPeopleToGroupDialog = observer(function ({
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
) : (
|
||||
<PaginatedList
|
||||
<PaginatedList<User>
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
@@ -392,7 +392,7 @@ const AddPeopleToGroupDialog = observer(function ({
|
||||
}
|
||||
items={users.notInGroup(group.id, query)}
|
||||
fetch={query ? undefined : users.fetchPage}
|
||||
renderItem={(item: User) => (
|
||||
renderItem={(item) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Avatar, AvatarSize, IAvatar } from "~/components/Avatar";
|
||||
import { AvatarVariant } from "~/components/Avatar/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload";
|
||||
@@ -17,10 +19,18 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
|
||||
return (
|
||||
<Flex gap={8} justify="space-between">
|
||||
<ImageBox>
|
||||
<ImageUpload onSuccess={onSuccess} {...rest}>
|
||||
<StyledAvatar model={model} size={AvatarSize.Upload} />
|
||||
<ImageUpload
|
||||
onSuccess={onSuccess}
|
||||
submitText={t("Crop Image")}
|
||||
{...rest}
|
||||
>
|
||||
<Avatar
|
||||
model={model}
|
||||
size={AvatarSize.Upload}
|
||||
variant={AvatarVariant.Square}
|
||||
/>
|
||||
<Flex auto align="center" justify="center" className="upload">
|
||||
{t("Upload")}
|
||||
<EditIcon />
|
||||
</Flex>
|
||||
</ImageUpload>
|
||||
</ImageBox>
|
||||
@@ -38,10 +48,6 @@ const avatarStyles = `
|
||||
height: ${AvatarSize.Upload}px;
|
||||
`;
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const ImageBox = styled(Flex)`
|
||||
${avatarStyles};
|
||||
position: relative;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import compact from "lodash/compact";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Text from "@shared/components/Text";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import UserMenu from "~/menus/UserMenu";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
|
||||
@@ -27,6 +29,7 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
export function MembersTable({ canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const columns = React.useMemo<TableColumn<User>[]>(
|
||||
() =>
|
||||
@@ -38,13 +41,20 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
accessor: (user) => user.name,
|
||||
component: (user) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
|
||||
{currentUser.id === user.id && `(${t("You")})`}
|
||||
<Avatar model={user} size={AvatarSize.Large} />{" "}
|
||||
<Flex column>
|
||||
<Text>
|
||||
{user.name} {currentUser.id === user.id && `(${t("You")})`}
|
||||
</Text>
|
||||
{isMobile && canManage && (
|
||||
<Text type="tertiary">{user.email}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
width: "4fr",
|
||||
},
|
||||
canManage
|
||||
canManage && !isMobile
|
||||
? {
|
||||
type: "data",
|
||||
id: "email",
|
||||
@@ -54,17 +64,19 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
width: "4fr",
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
header: t("Last active"),
|
||||
accessor: (user) => user.lastActiveAt,
|
||||
component: (user) =>
|
||||
user.lastActiveAt ? (
|
||||
<Time dateTime={user.lastActiveAt} addSuffix />
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
isMobile
|
||||
? undefined
|
||||
: {
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
header: t("Last active"),
|
||||
accessor: (user) => user.lastActiveAt,
|
||||
component: (user) =>
|
||||
user.lastActiveAt ? (
|
||||
<Time dateTime={user.lastActiveAt} addSuffix />
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "role",
|
||||
@@ -85,7 +97,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
</Badges>
|
||||
),
|
||||
width: "2fr",
|
||||
width: "80px",
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
@@ -97,7 +109,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
}
|
||||
: undefined,
|
||||
]),
|
||||
[t, currentUser, canManage]
|
||||
[t, currentUser, canManage, isMobile]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user