Compare commits

...

128 Commits

Author SHA1 Message Date
Tom Moor bff6a80b67 styles 2022-06-25 19:07:18 +02:00
Tom Moor 07ad87f65f fix: Don't send email notification with empty diff 2022-06-24 09:57:53 +02:00
Tom Moor dd471328db fix: Collection name missing in notification email
fix: Email styles
2022-06-24 09:50:39 +02:00
Tom Moor 04f0983e20 Bringing across still relevant work from email-diff branch 2022-06-23 10:54:11 +02:00
Tom Moor 50456c3b89 fix: Custom domain authentication, regressed in:
https://github.com/outline/outline/pull/3652
2022-06-22 21:58:05 +02:00
Tom Moor 51230a55e5 fix: Post-auth subdomain redirect 2022-06-22 19:51:37 +02:00
Tom Moor 6d4da176d1 chore: Move provisionSubdomain from Team model to teamCreator command 2022-06-22 11:09:20 +02:00
Tom Moor 88b3b50333 Enable turning off collaborative editing when self-hosted with warning 2022-06-22 09:15:14 +02:00
Tom Moor 305de71e8b chore: Block all email providers from being added as team domains (#3678) 2022-06-21 01:29:43 -07:00
Tom Moor 9cd3ec0868 chore: Simplify model save codepath, prevents text from being sent ever when collab editing enabled 2022-06-20 22:55:37 +02:00
Tom Moor 6975d76faf fix: Paste without formatting not respected
closes #3675
2022-06-20 15:58:07 +02:00
Tom Moor 4b27feff61 fix: Enable documents.update with collab editing (#3647)
* fix: Enable documents.update with collab editing

* jest cannot deal with ESM deps
2022-06-20 06:36:25 -07:00
Nan Yu e0d2b6cace feat: allow personal gmail accounts to be used to sign into teams with an existing invite (#3652)
* feat: allow personal gmail accounts to be used to sign into teams with an existing invite

* address comments

* add comment for appDomain

* address comments
2022-06-20 01:33:16 -07:00
Translate-O-Tron 188c1e409b New Crowdin updates (#3648)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]
2022-06-19 14:24:53 -07:00
Nan Yu 9faa5dd011 chore: minor version bump (#3654) 2022-06-12 22:57:59 -07:00
Tom Moor 1a62926909 fix: Allow soft breaks in paragraphs with Shift-Enter 2022-06-09 21:41:15 +02:00
Tom Moor c4edfb8ebc fix: Improve embed option visibility in dark mode 2022-06-09 21:38:24 +02:00
Tom Moor 8421e1f773 fix: Allow soft breaks in paragraphs with Shift-Enter
closes #3276
2022-06-09 21:24:11 +02:00
Tom Moor 118e5da345 fix: Unpublished does not appear in document history
closes #3429
2022-06-09 21:16:37 +02:00
Tom Moor 1c7c478a4a fix: Newlines should be interpreted as paragraphs when pasting
closes #3421
2022-06-09 20:58:52 +02:00
Tom Moor 32cdb3f961 fix: Do not error when moving document into alphabetically ordered collection
closes #3649
2022-06-09 20:33:44 +02:00
Tom Moor d99d84d97d fix: Email cannot be found for some Azure sign-in accounts 2022-06-09 09:22:12 +02:00
Tom Moor aed8d7a649 fix: SSR meta data for nested shared documents (#3646) 2022-06-08 01:38:34 -07:00
Tom Moor 80ad6cfec8 fix: Expired refreshToken should invalidate session, not check SSO retry task 2022-06-08 08:55:58 +02:00
Translate-O-Tron 892146a563 New Crowdin updates (#3631) 2022-06-07 13:57:44 -07:00
Tom Moor 56393f39b7 fix: Previously provisioned JWT's should be revoked on signout (#3639)
* feat: auth.delete endpoint

* test
2022-06-07 13:57:17 -07:00
Tom Moor 0de6650aa5 chore: Suppress unneccessary model warnings from Sequelize upgrade 2022-06-07 09:38:00 +02:00
Tom Moor ac551a3c44 chore: Bump workbox-webpack-plugin dependency 2022-06-06 22:06:37 +02:00
Tom Moor 14b9259a47 fix: Always strip trailing slash on canonical links 2022-06-06 22:04:12 +02:00
Tom Moor e5b524e4c2 chore: Upgrade sequelize dependency 2022-06-06 21:54:54 +02:00
Tom Moor 4bccb4c4ec chore: Bump bull-board dependencies 2022-06-06 21:18:22 +02:00
Tom Moor cdd4f0f315 fix: Add postgresql as valid database protocol 2022-06-06 12:13:03 -07:00
Tom Moor 728790e38f feat: Validate Google, Azure, OIDC SSO access (#3590)
* chore: Store expiresAt on UserAuthentications. This represents the time that the accessToken is no longer valid and should be exchanged using the refreshToken

* feat: Check and expire Google SSO

* fix: Better handling of multiple auth methods
Added more docs

* fix: Retry access validation with network errors

* Small refactor, add Azure token validation support

* doc

* test

* lint

* OIDC refresh support

* CheckSSOAccessTask -> ValidateSSOAccessTask
Added lastValidatedAt column
Skip checks if validated within 5min
Some edge cases around encrypted columns
2022-06-05 13:18:51 -07:00
Tom Moor c4c5b6289e fix: Gap and grammar in Notification settings 2022-06-05 11:47:40 +02:00
Tom Moor e337123cfd fix: Add application/x-zip-compressed as acceptable mimetype for bulk import upload
related #3632
2022-06-05 11:01:37 +02:00
Tom Moor ac07724f21 chore: Synchronizing refactor and small fixes from enterprise codebase (#3634)
* chore: Syncronizing refactor and small fixes from enterprise codebase

* fix
2022-06-05 00:59:41 -07:00
Tom Moor 28439d315d fix: Self-hosted should show signin options for all configured authentication methods (#2986) 2022-06-04 10:46:03 -07:00
Tom Moor 4eb3b61c7a fix: Lazily polyfill ResizeObserver for old iOS (#3629)
* fix: Lazily polyfill ResizeObserver for old iOS

* fix: Prevent child rendering until polyfills are loaded

* tsc
2022-06-04 09:06:07 -07:00
Translate-O-Tron 6fc608c8c1 New Crowdin updates (#3622) 2022-06-04 08:15:54 -07:00
Tom Moor 2dc930bfe2 fix: Context menus not scrollable on iOS (#3626)
closes #3588
2022-06-04 08:15:43 -07:00
Tom Moor bf233b209b fix: Alternative fix to #3583, addresses some bugs that were introduced 2022-06-03 11:03:44 +02:00
Tom Moor 1dfd1e0681 fix: Reference error visiting share link for deleted team 2022-06-03 08:58:31 +02:00
dependabot[bot] 4054afe6f9 chore(deps): bump protobufjs from 6.11.2 to 6.11.3 (#3623) 2022-06-02 23:17:50 -07:00
Tom Moor 2d7dd558a1 fix: Unable to delete user via API (#3619)
Remove requirement to pass 'confirmation' to users.delete
closes #3604
2022-06-02 12:56:27 -07:00
Tom Moor 68dd76cfa3 chore: Update documentImporter with changes from enterprise, improved Confluence compat 2022-06-02 21:42:32 +02:00
Tom Moor 9113989635 fix: Members list does not update when viewing while underlying users changes
closes #3616
2022-06-02 18:43:07 +02:00
Translate-O-Tron 293ce2ba72 New Crowdin updates (#3608) 2022-06-02 09:30:28 -07:00
Nan Yu fa1ce950e8 fix: infinite redirects when hosted subdomain is changed back and forth between two values (#3615) 2022-06-02 09:30:13 -07:00
Tom Moor 0a77733500 fix: Update canonical url when moving between pages of shared document 2022-06-01 21:27:18 +02:00
Nan Yu 41e425756d chore: refactor domain parsing to be more general (#3448)
* change the api of domain parsing to just parseDomain and getCookieDomain
* adds getBaseDomain as the method to get the domain after any official subdomains
2022-05-31 18:48:23 -07:00
Translate-O-Tron 876f788f59 New Crowdin updates (#3597)
* fix: New German translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]
2022-05-30 10:33:47 -07:00
Lennart Lösche 0ae559f7bf Update redis port in sample env file (#3596)
* fix redis port

The wrong Redis port is specified in the sample file, we fixed that

* adjust redis port in docker-compose
2022-05-30 10:06:10 -07:00
Tom Moor da87fd422d Remove hover styles on mobile context menus 2022-05-28 10:05:17 +02:00
Tom Moor 1e84872bab fix: Only consider enabled AuthenticationProviders for Slack hooks 2022-05-28 09:36:22 +02:00
Tom Moor 4f0051ed5e fix: Right click on links in editor opens them
closes #3594
2022-05-28 09:23:18 +02:00
Tom Moor 317ed1f041 fix: More env validation improvements
closes #3593
2022-05-28 09:11:02 +02:00
Tom Moor 8a29a3523a 0.64.3 2022-05-25 06:07:02 +01:00
Tom Moor 2babf42cda fix: Headings missing in TOC on publicly shared pages
closes #3583
2022-05-24 22:11:49 +01:00
Tom Moor df14da01b0 fix: Allow docker urls for OIDC, closes #3582 2022-05-24 21:20:18 +01:00
Tom Moor 62bb13047a 0.64.2 2022-05-24 08:00:08 +01:00
Tom Moor 6413797c34 fix: Empty string not parsed as false boolean in env validation
closes #3581
2022-05-24 07:59:52 +01:00
Tom Moor ef5e3f0b29 fix: Empty environment variables should not trigger validations
Add deprecation notice for SLACK_KEY, SLACK_SECRET
closes #3578
2022-05-23 21:37:27 +01:00
Baptiste Mille-Mathias 51249fd6f7 upgrade CodeQL action to v2 (#3572)
v1 will be declared deprecated starting dec' 22
https://github.blog/changelog/2022-04-27-code-scanning-deprecation-of-codeql-action-v1/
2022-05-23 12:51:33 -07:00
Tom Moor 151c2c731a 0.64.1 2022-05-23 13:19:49 +01:00
Tom Moor 519ed1ac2c fix: Environment variables always interpreted as true,
closes #3573
2022-05-23 13:19:38 +01:00
Tom Moor f1ce28cd8f fix: Allow underscores in Postgres and Redis hostnames for docker support
closes #3574
2022-05-23 13:11:52 +01:00
Tom Moor adb56a3c31 Update LICENSE 2022-05-23 01:56:46 -07:00
Tom Moor 280e1c1d86 0.64.0 2022-05-23 09:55:20 +01:00
Baptiste Mille-Mathias 3c8b9725e1 Fix github action for stale issues (#3569) 2022-05-23 01:42:30 -07:00
Tom Moor 73de15fd5d fix: documentUpdater called without change can result in lastModifiedById being updated 2022-05-22 22:39:54 +01:00
Tom Moor a78ad8dec2 fix: Escape user defined values (regressed just now bc7052b7ca) 2022-05-22 11:10:59 +01:00
Tom Moor 45c082f137 fix: Notices dark theme 2022-05-22 09:33:30 +01:00
Tom Moor 4a9892c2e1 robots 2022-05-22 08:58:44 +01:00
Tom Moor 6d7f008af0 fix: Sidebar missing on public documents when accessing with valid team token 2022-05-22 08:51:47 +01:00
Tom Moor bc7052b7ca feat: Inject description and canonical url into public share links 2022-05-22 08:46:57 +01:00
Tom Moor c4006cef7b perf: Remove markdown serialize from editor render path (#3567)
* perf: Remove markdown serialize from editor render path

* fix: Simplify heading equality check

* perf: Add cache for slugified headings

* tsc
2022-05-21 12:50:27 -07:00
Tom Moor 2a6d6f5804 chore: Restore more flexible SMTP env email validation 2022-05-21 14:01:57 +01:00
Tom Moor bf0ff6c823 chore: Casing of logger -> Logger as it's an instantiated class 2022-05-21 13:59:23 +01:00
Tom Moor 6c8b127ff9 chore: isHosted -> isCloudHosted for clarity 2022-05-21 13:34:52 +01:00
Tom Moor f2be756cf4 feat: Improved error for community edition when database columns cannot be decrypted 2022-05-21 13:25:55 +01:00
rusakovdenis 67049a7868 fix: simplify transformation (#3548)
* fix: simplify transformation

Functions (isDragging, isOver, canDrop) always return a boolean value

* fix: type

In browserslist must be either an array or an object
2022-05-21 05:14:53 -07:00
Translate-O-Tron d9706d4735 New Crowdin updates (#3556) 2022-05-21 05:14:34 -07:00
Tom Moor ec748f9914 fix: Floating toolbar should not appear until mouseup when selecting with mouse
closes #3564
2022-05-21 12:57:29 +01:00
Tom Moor ef668c2fa0 Tweak design of notices 2022-05-21 11:06:35 +01:00
Tom Moor 594a004c0f chore: Move to GitHub action from Probot for stale issue/pr management 2022-05-21 10:05:41 +01:00
Tom Moor 468478d06d fix: Another timestamp crash 2022-05-21 10:05:41 +01:00
Tom Moor 02caf88d2a chore: AuthenticationProvider component to function 2022-05-21 10:05:41 +01:00
github-actions[bot] 50f26929a1 chore: Compressed inefficient images automatically (#3563)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2022-05-21 01:44:17 -07:00
Tom Moor 0f93e92bc6 feat: Add 'Scribe' embed support 2022-05-21 09:28:04 +01:00
Tom Moor c08940ca3c feat: Add optional replyTo for email sending 2022-05-21 08:36:37 +01:00
Tom Moor ee8324ad73 fix: Remove additional scope requests for now 2022-05-20 23:59:33 +01:00
Tom Moor 96a32c98e7 fix: Remove email validation to allow for Name <email> format 2022-05-20 22:18:21 +01:00
Tom Moor 5c741e3d98 fix: Crash render timestamp on some languages 2022-05-20 18:58:23 +01:00
Tom Moor ba7b3fff05 fix: Emojis and embeds cannot be copied to plain text clipboard (#3561) 2022-05-20 09:47:13 -07:00
Tom Moor 90ca8655af fix: Collapsed header button unclickable when full-width document option is selected
closes #3558
2022-05-20 10:04:36 +01:00
Tom Moor 0577c73f06 fix: Links with anchors are broken when pages are renamed
closes #3553
2022-05-20 09:43:54 +01:00
Tom Moor 39e146b4e6 fix: Minor usability improves to team domain management 2022-05-19 18:28:19 +01:00
Tom Moor 34576dd008 fix: Allow COLLABORATION_URL set with websocket protocol 2022-05-19 16:34:58 +01:00
Translate-O-Tron 585a34d27e New Crowdin updates (#3535) 2022-05-19 08:05:35 -07:00
Tom Moor 3c002f82cc chore: Centralize env parsing, validation, defaults, and deprecation notices (#3487)
* chore: Centralize env parsing, defaults, deprecation

* wip

* test

* test

* tsc

* docs, more validation

* fix: Allow empty REDIS_URL (defaults to localhost)

* test

* fix: SLACK_MESSAGE_ACTIONS not bool

* fix: Add SMTP port validation
2022-05-19 08:05:11 -07:00
Corey Alexander 51001cfac1 feat: Migrate allowedDomains to a Team Level Settings (#3489)
Fixes #3412

Previously the only way to restrict the domains for a Team were with the ALLOWED_DOMAINS environment variable for self hosted instances.
This PR migrates this to be a database backed setting on the Team object. This is done through the creation of a TeamDomain model that is associated with the Team and contains the domain name

This settings is updated on the Security Tab. Here domains can be added or removed from the Team.

On the server side, we take the code paths that previously were using ALLOWED_DOMAINS and switched them to use the Team allowed domains instead
2022-05-17 20:26:29 -04:00
Tom Moor 18e0d936ef feat: Match incoming search requests using confirmed email as fallback (#3538) 2022-05-17 13:49:23 -07:00
Limezy 5658090d7e Trying to chase missing translations (#3441) 2022-05-17 13:01:00 -07:00
Tom Moor 19de348c85 fix: null ref usage, closes #3456 2022-05-16 22:58:59 +01:00
Tom Moor b8a02df7ba chore: utils.gc -> cron.daily (#3543) 2022-05-16 12:44:22 -07:00
Tom Moor 4c15f27bb2 fix: Focus submit button by default in confirmation dialogs
fix: Move collection delete to use confirmation dialog
closes #3446
2022-05-15 16:21:42 +01:00
Tom Moor b152b9f17b fix: Possible extra separator in filtered context menus
Todo: We need to combine this logic with the menus in the editor, but not today
closes #3506
2022-05-15 15:40:49 +01:00
Tom Moor 40e41b26a1 fix: Missing not found page
closes #3476
closes #3531
2022-05-15 15:10:34 +01:00
Translate-O-Tron 4c01f6268e New Crowdin updates (#3462) 2022-05-15 06:46:40 -07:00
Tom Moor 8815a58ff5 perf: Requesting less db columns when calculating collection permissions (#3498)
perf: Not looping collection documentStructure for unpublish permission calculation
2022-05-15 06:46:24 -07:00
Tom Moor 36a3ae4b01 fix: Don't show suspended users in document facepile or list of viewers (#3497) 2022-05-15 06:05:40 -07:00
Tom Moor bca66f7415 fix: Exports show as 0 bytes 2022-05-15 07:10:35 +01:00
Tom Moor 06d966ad0c fix: Spacing on login form
fix: signup query params overridden unneccessarily
closes #3516
2022-05-15 06:57:35 +01:00
Tom Moor c205ffbfe9 Merge branch 'main' of github.com:outline/outline 2022-05-11 09:30:08 +01:00
Tom Moor b75a6928cb Revert "fix: Fade out navigation when editing and mouse hasn't moved (#3256)" (#3502)
This reverts commit e0cf873a36.
2022-05-06 13:28:37 -07:00
Tom Moor 0ba792317b Merge branch 'main' of github.com:outline/outline 2022-05-06 13:01:15 -07:00
Saumya Pandey e0cf873a36 fix: Fade out navigation when editing and mouse hasn't moved (#3256)
* fix: hide header when editing

* fix: settings collab switch

* Update app/hooks/useMouseMove.ts

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* fix: accept timeout parameter

* fix: don't hide observing banner

* fix: hide on focused and observing

* perf: memo

* hide References too

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-05-07 00:17:09 +05:30
Tom Moor 1782c08195 fix: Touch lastViewedAt timestamp on document to prevent flash of order repositioning 2022-05-05 23:51:47 -07:00
Tom Moor d9e7baf072 chore: Update caniuse browser support 2022-05-05 22:29:10 -07:00
Tom Moor ec1bc801a4 fix: Write revision on document publish 2022-05-04 22:03:04 -07:00
Nan Yu 9117b7479f fix: paginated list history headings were not rendering when there was only one unique heading (#3496)
* fix: paginated list history headings were not rendering when there was only one unique heading

* minor bug
2022-05-04 21:08:50 -07:00
Tom Moor eeb8008927 chore: Refactor collection export to match import (#3483)
* chore: Refactor collection export to use FileOperations processor and task

* Tweak options
2022-05-01 21:06:07 -07:00
Tom Moor 669575fc89 fix: Account for null collection.documentStructure again 2022-05-01 09:30:47 -07:00
Felix Heilmeyer 247208e5f5 feat: make ioredis configurable via environment variables (#3365)
* feat: expose ioredis client options

* run linter

* refactor redis client init into class extension

* explicitly handle constructor errors

* rename singletons
2022-05-01 08:44:35 -07:00
Tom Moor 25dce04046 perf: Move collection sorting to frontend (#3475)
* perf: Move collection sorting to frontend, on demand, memoized

* fix: Add default
2022-05-01 08:30:16 -07:00
Tom Moor 5cd4ecd34a fix: CRDT creation touches document updated timestamp (#3482)
fix: Race condition in collaboration document persistence
2022-05-01 08:30:07 -07:00
Tom Moor bb074edb0d perf: Improve speed of Azure login (parallelize two slow API requests)
chore: Improved types around passport
2022-04-30 16:57:58 -07:00
Tom Moor a736022c39 chore: cleanup 2022-04-30 09:10:35 -07:00
306 changed files with 7739 additions and 3832 deletions
+10 -6
View File
@@ -16,7 +16,15 @@ 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://localhost:6379
# or alternatively, if you would like to provide addtional 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.
@@ -57,8 +65,8 @@ AWS_S3_ACL=private
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
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
@@ -129,10 +137,6 @@ MAXIMUM_IMPORT_SIZE=5120000
# requests and this ends up being duplicative
DEBUG=http
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
ALLOWED_DOMAINS=
# 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
-22
View File
@@ -1,22 +0,0 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 120
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 14
# Issues with these labels will never be considered stale
exemptLabels:
- security
- pinned
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
Hey! The issue has been automatically marked as stale because it has not had
recent activity. It will be closed soon if no further activity occurs. Please
reply here if you wish for the issue to be kept open.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -53,7 +53,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
uses: github/codeql-action/autobuild@v2
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -67,4 +67,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@v2
+29
View File
@@ -0,0 +1,29 @@
name: "Close Stale PRs"
on:
workflow_dispatch:
schedule:
- cron: "30 1 * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v5
with:
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
close-pr-message: "Automatically closed due to inactivity"
close-issue-message: "Automatically closed due to inactivity"
days-before-issue-stale: 120
days-before-pr-stale: 60
days-before-close: 5
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.63.0
Licensed Work: Outline 0.64.0
The Licensed Work is (c) 2020 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: 2026-04-15
Change Date: 2026-05-23
Change License: Apache License, Version 2.0
+3 -7
View File
@@ -43,10 +43,6 @@
"value": "true",
"required": true
},
"ALLOWED_DOMAINS": {
"description": "Comma separated list of domains to be allowed (optional). If not set, all domains are allowed by default when using Google OAuth to signin. Consider putting {your app name}.herokuapp.com and any domain you are binding on in this list.",
"required": false
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
@@ -106,11 +102,11 @@
"value": "openid profile email",
"required": false
},
"SLACK_KEY": {
"SLACK_CLIENT_ID": {
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
"required": false
},
"SLACK_SECRET": {
"SLACK_CLIENT_SECRET": {
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
"required": false
},
@@ -209,4 +205,4 @@
"required": false
}
}
}
}
-20
View File
@@ -2,9 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "@shared/utils/domains";
import LoadingIndicator from "~/components/LoadingIndicator";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
@@ -25,29 +23,11 @@ const Authenticated = ({ children }: Props) => {
if (auth.authenticated) {
const { user, team } = auth;
const { hostname } = window.location;
if (!team || !user) {
return <LoadingIndicator />;
}
// If we're authenticated but viewing a domain that doesn't match the
// current team then kick the user to the teams correct domain.
if (team.domain) {
if (team.domain !== hostname) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
} else if (
env.SUBDOMAINS_ENABLED &&
team.subdomain &&
isCustomSubdomain(hostname) &&
!hostname.startsWith(`${team.subdomain}.`)
) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
return children;
}
+1 -1
View File
@@ -12,7 +12,7 @@ const Container = styled.div<Props>`
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")`
padding: ${(props: any) =>
padding: ${(props: Props) =>
props.withStickyHeader ? "4px 44px 60px" : "60px 44px"};
`};
`;
+3 -2
View File
@@ -42,8 +42,9 @@ function Collaborators(props: Props) {
filter(
users.orderedData,
(user) =>
presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)
(presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)) &&
!user.isSuspended
),
(user) => presentIds.includes(user.id)
),
@@ -3,12 +3,10 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { homePath } from "~/utils/routeHelpers";
type Props = {
@@ -16,39 +14,29 @@ type Props = {
onSubmit: () => void;
};
function CollectionDelete({ collection, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
function CollectionDeleteDialog({ collection, onSubmit }: Props) {
const team = useCurrentTeam();
const { showToast } = useToasts();
const { ui } = useStores();
const history = useHistory();
const { t } = useTranslation();
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsDeleting(true);
try {
const redirect = collection.id === ui.activeCollectionId;
await collection.delete();
onSubmit();
if (redirect) {
history.push(homePath());
}
} catch (err) {
showToast(err.message, {
type: "error",
});
} finally {
setIsDeleting(false);
}
},
[collection, history, onSubmit, showToast, ui.activeCollectionId]
);
const handleSubmit = async () => {
const redirect = collection.id === ui.activeCollectionId;
await collection.delete();
onSubmit();
if (redirect) {
history.push(homePath());
}
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<>
<Text type="secondary">
<Trans
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
@@ -73,12 +61,9 @@ function CollectionDelete({ collection, onSubmit }: Props) {
/>
</Text>
) : null}
<Button type="submit" disabled={isDeleting} autoFocus danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure Delete")}
</Button>
</form>
</Flex>
</>
</ConfirmationDialog>
);
}
export default observer(CollectionDelete);
export default observer(CollectionDeleteDialog);
+1 -1
View File
@@ -5,7 +5,7 @@ import * as React from "react";
import Collection from "~/models/Collection";
import { icons } from "~/components/IconPicker";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/logger";
import Logger from "~/utils/Logger";
type Props = {
collection: Collection;
+8 -5
View File
@@ -7,20 +7,23 @@ import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {
onSubmit: () => void;
children: JSX.Element;
/** Callback when the dialog is submitted */
onSubmit: () => Promise<void> | void;
/** Text to display on the submit button */
submitText?: string;
/** Text to display while the form is saving */
savingText?: string;
/** If true, the submit button will be a dangerous red */
danger?: boolean;
};
function ConfirmationDialog({
const ConfirmationDialog: React.FC<Props> = ({
onSubmit,
children,
submitText,
savingText,
danger,
}: Props) {
}) => {
const [isSaving, setIsSaving] = React.useState(false);
const { dialogs } = useStores();
const { showToast } = useToasts();
@@ -53,6 +56,6 @@ function ConfirmationDialog({
</form>
</Flex>
);
}
};
export default observer(ConfirmationDialog);
+11 -10
View File
@@ -3,7 +3,6 @@ import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { hover } from "~/styles";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {
@@ -132,16 +131,18 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
? "pointer-events: none;"
: `
&:${hover},
&:focus,
&.focus-visible {
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: pointer;
@media (hover: hover) {
&:hover,
&:focus,
&.focus-visible {
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: pointer;
svg {
fill: ${props.theme.white};
svg {
fill: ${props.theme.white};
}
}
}
`};
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: any) {
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
+21 -23
View File
@@ -69,29 +69,27 @@ const Submenu = React.forwardRef(
);
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unnecessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) {
return acc;
}
if (item.type === "separator" && index === filtered.length - 1) {
return acc;
}
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator") {
return acc;
}
// otherwise, continue
return [...acc, item];
}, []);
return filtered;
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
function Template({ items, actions, context, ...menu }: Props) {
+15 -1
View File
@@ -1,3 +1,4 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
@@ -92,6 +93,19 @@ const ContextMenu: React.FC<Props> = ({
t,
]);
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (rest.visible && scrollElement) {
disableBodyScroll(scrollElement);
}
return () => {
scrollElement && enableBodyScroll(scrollElement);
};
}, [rest.visible]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
@@ -101,7 +115,7 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
+2 -1
View File
@@ -1,5 +1,6 @@
import copy from "copy-to-clipboard";
import * as React from "react";
import env from "~/env";
type Props = {
text: string;
@@ -14,7 +15,7 @@ class CopyToClipboard extends React.PureComponent<Props> {
const elem = React.Children.only(children);
copy(text, {
debug: process.env.NODE_ENV !== "production",
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { useObserver } from "mobx-react";
import { observer, useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
@@ -83,4 +83,4 @@ const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
}
`;
export default DocumentMetaWithViews;
export default observer(DocumentMetaWithViews);
+2 -1
View File
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, TFunction } from "react-i18next";
@@ -60,4 +61,4 @@ const Done = styled(DoneIcon)<{ $animated: boolean }>`
transform-origin: center center;
`;
export default DocumentTasks;
export default observer(DocumentTasks);
+42 -5
View File
@@ -2,9 +2,11 @@ import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import mergeRefs from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds";
import { Heading } from "@shared/editor/lib/getHeadings";
import { supportedImageMimeTypes } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
@@ -45,12 +47,13 @@ export type Props = Optional<
shareId?: string | undefined;
embedsDisabled?: boolean;
grow?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => any;
};
function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
const { id, shareId } = props;
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onHeadingsChange } = props;
const { documents } = useStores();
const { showToast } = useToasts();
const dictionary = useDictionary();
@@ -58,6 +61,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
activeLinkEvent,
setActiveLinkEvent,
] = React.useState<MouseEvent | null>(null);
const previousHeadings = React.useRef<Heading[] | null>(null);
const handleLinkActive = React.useCallback((event: MouseEvent) => {
setActiveLinkEvent(event);
@@ -165,7 +169,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
);
const focusAtEnd = React.useCallback(() => {
ref.current?.focusAtEnd();
ref?.current?.focusAtEnd();
}, [ref]);
const handleDrop = React.useCallback(
@@ -173,7 +177,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
event.preventDefault();
event.stopPropagation();
const files = getDataTransferFiles(event);
const view = ref.current?.view;
const view = ref?.current?.view;
if (!view) {
return;
}
@@ -216,11 +220,43 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
[]
);
// Calculate if headings have changed and trigger callback if so
const updateHeadings = React.useCallback(() => {
if (onHeadingsChange) {
const headings = ref?.current?.getHeadings();
if (
headings &&
headings.map((h) => h.level + h.title).join("") !==
previousHeadings.current?.map((h) => h.level + h.title).join("")
) {
previousHeadings.current = headings;
onHeadingsChange(headings);
}
}
}, [ref, onHeadingsChange]);
const handleChange = React.useCallback(
(event) => {
onChange?.(event);
updateHeadings();
},
[onChange, updateHeadings]
);
const handleRefChanged = React.useCallback(
(node: SharedEditor | null) => {
if (node && !previousHeadings.current) {
updateHeadings();
}
},
[updateHeadings]
);
return (
<ErrorBoundary reloadOnChunkMissing>
<>
<LazyLoadedEditor
ref={ref}
ref={mergeRefs([ref, handleRefChanged])}
uploadFile={onUploadFile}
onShowToast={showToast}
embeds={embeds}
@@ -229,6 +265,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
onHoverLink={handleLinkActive}
onClickLink={onClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
+3 -3
View File
@@ -9,8 +9,8 @@ import CenteredContent from "~/components/CenteredContent";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
import isHosted from "~/utils/isHosted";
import Logger from "~/utils/logger";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
type Props = WithTranslation & {
reloadOnChunkMissing?: boolean;
@@ -59,7 +59,7 @@ class ErrorBoundary extends React.Component<Props> {
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && isHosted;
const isReported = !!env.SENTRY_DSN && isCloudHosted;
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
+10 -1
View File
@@ -5,6 +5,7 @@ import {
PublishIcon,
MoveIcon,
CheckboxIcon,
UnpublishIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -85,6 +86,11 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
@@ -113,7 +119,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
<Time
dateTime={event.createdAt}
tooltipDelay={500}
format="MMM do, h:mm a"
format={{
en_US: "MMM do, h:mm a",
fr_FR: "'Le 'd MMMM 'à' H:mm",
}}
relative={false}
addSuffix
onClick={handleTimeClick}
+9 -1
View File
@@ -11,11 +11,19 @@ const Flex = styled.div<{
align?: AlignValues;
justify?: JustifyValues;
shrink?: boolean;
reverse?: boolean;
gap?: number;
}>`
display: flex;
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
flex-direction: ${({ column }) => (column ? "column" : "row")};
flex-direction: ${({ column, reverse }) =>
reverse
? column
? "column-reverse"
: "row-reverse"
: column
? "column"
: "row"};
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
+1 -1
View File
@@ -19,7 +19,7 @@ type Props = RootStore & {
membership?: CollectionGroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (arg0: { openMembersModal: () => void }) => React.ReactNode;
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
};
@observer
+9 -1
View File
@@ -38,6 +38,13 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
color: ${(props) => props.theme.placeholder};
}
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0px 1000px ${(props) => props.theme.background}
inset;
}
&::-webkit-search-cancel-button {
-webkit-appearance: none;
}
@@ -97,7 +104,7 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = React.HTMLAttributes<HTMLInputElement> & {
export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
value?: string;
label?: string;
@@ -108,6 +115,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
margin?: string | number;
icon?: React.ReactNode;
name?: string;
pattern?: string;
minLength?: number;
maxLength?: number;
autoFocus?: boolean;
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { loadPolyfills } from "~/utils/polyfills";
/**
* Asyncronously load required polyfills. Should wrap the React tree.
*/
export const LazyPolyfill: React.FC = ({ children }) => {
const [isLoaded, setIsLoaded] = React.useState(false);
React.useEffect(() => {
loadPolyfills().then(() => {
setIsLoaded(true);
});
}, []);
if (!isLoaded) {
return null;
}
return <>{children}</>;
};
export default LazyPolyfill;
+13 -11
View File
@@ -2,7 +2,7 @@ import { format as formatDate, formatDistanceToNow } from "date-fns";
import * as React from "react";
import Tooltip from "~/components/Tooltip";
import useUserLocale from "~/hooks/useUserLocale";
import { dateLocale } from "~/utils/i18n";
import { dateLocale, locales } from "~/utils/i18n";
let callbacks: (() => void)[] = [];
@@ -26,7 +26,7 @@ type Props = {
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
format?: string;
format?: Partial<Record<keyof typeof locales, string>>;
};
const LocaleTime: React.FC<Props> = ({
@@ -38,7 +38,13 @@ const LocaleTime: React.FC<Props> = ({
relative,
tooltipDelay,
}) => {
const userLocale = useUserLocale();
const userLocale: string = useUserLocale() || "";
const dateFormatLong = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
@@ -66,17 +72,13 @@ const LocaleTime: React.FC<Props> = ({
.replace("minute", "min");
}
const tooltipContent = formatDate(
Date.parse(dateTime),
"MMMM do, yyyy h:mm a",
{
locale,
}
);
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(Date.parse(dateTime), format || "MMMM do, yyyy h:mm a", {
: formatDate(Date.parse(dateTime), formatLocale, {
locale,
});
+7 -2
View File
@@ -67,6 +67,7 @@ const Modal: React.FC<Props> = ({
<Backdrop $isCentered={isCentered} {...props}>
<Dialog
{...dialog}
aria-label={typeof title === "string" ? title : undefined}
preventBodyScroll
hideOnEsc
hideOnClickOutside={!!isCentered}
@@ -75,7 +76,12 @@ const Modal: React.FC<Props> = ({
{(props) =>
isCentered && !isMobile ? (
<Small {...props}>
<Centered onClick={(ev) => ev.stopPropagation()} column>
<Centered
onClick={(ev) => ev.stopPropagation()}
column
reverse
>
<SmallContent shadow>{children}</SmallContent>
<Header>
{title && (
<Text as="span" size="large">
@@ -88,7 +94,6 @@ const Modal: React.FC<Props> = ({
</NudeButton>
</Text>
</Header>
<SmallContent shadow>{children}</SmallContent>
</Centered>
</Small>
) : (
+9 -2
View File
@@ -1,8 +1,15 @@
import * as React from "react";
import { NavLink, Route } from "react-router-dom";
import { match, NavLink, Route } from "react-router-dom";
type Props = React.ComponentProps<typeof NavLink> & {
children?: (match: any) => React.ReactNode;
children?: (
match:
| match<{
[x: string]: string | undefined;
}>
| boolean
| null
) => React.ReactNode;
exact?: boolean;
activeStyle?: React.CSSProperties;
to: string;
+6 -6
View File
@@ -119,7 +119,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
// of lazy rendering then show another page.
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
if (leftToRender > 1) {
if (leftToRender > 0) {
this.renderCount += DEFAULT_PAGINATION_LIMIT;
}
@@ -140,7 +140,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
renderHeading,
onEscape,
} = this.props;
let previousHeading = "";
const showLoading =
this.isFetching &&
@@ -168,8 +167,9 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
aria-label={this.props["aria-label"]}
onEscape={onEscape}
>
{(composite: CompositeStateReturn) =>
items.slice(0, this.renderCount).map((item, index) => {
{(composite: CompositeStateReturn) => {
let previousHeading = "";
return items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index, composite);
// If there is no renderHeading method passed then no date
@@ -202,8 +202,8 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
return children;
})
}
});
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
+1 -1
View File
@@ -14,7 +14,7 @@ type Props = {
collection: Collection | null | undefined;
onSuccess?: () => void;
style?: React.CSSProperties;
ref?: (arg0: React.ElementRef<"div"> | null | undefined) => void;
ref?: (element: React.ElementRef<"div"> | null | undefined) => void;
};
@observer
+2 -2
View File
@@ -8,7 +8,7 @@ import styled from "styled-components";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
import isHosted from "~/utils/isHosted";
import isCloudHosted from "~/utils/isCloudHosted";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
import Section from "./components/Section";
@@ -51,7 +51,7 @@ function SettingsSidebar() {
</Header>
</Section>
))}
{!isHosted && (
{!isCloudHosted && (
<Section>
<Header title={t("Installation")} />
<Version />
+1 -2
View File
@@ -65,8 +65,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
const handleStopDrag = React.useCallback(() => {
setResizing(false);
if (document.activeElement) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
@@ -37,7 +37,7 @@ function CollectionLinkChildren({
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
accept: "document",
drop: (item: DragObject) => {
if (!manualSort) {
if (!manualSort && item.collectionId === collection?.id) {
showToast(
t(
"You can't reorder documents in an alphabetically sorted collection"
@@ -148,7 +148,7 @@ function InnerDocumentLink(
collectionId: collection?.id || "",
}),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
isDragging: monitor.isDragging(),
}),
canDrag: () => {
return (
@@ -213,7 +213,7 @@ function InnerDocumentLink(
}
},
collect: (monitor) => ({
isOverReparent: !!monitor.isOver({
isOverReparent: monitor.isOver({
shallow: true,
}),
canDropToReparent: monitor.canDrop(),
@@ -252,25 +252,24 @@ function InnerDocumentLink(
documents.move(item.id, collection.id, parentId, index + 1);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
isDraggingAnyDocument: !!monitor.canDrop(),
isOverReorder: monitor.isOver(),
isDraggingAnyDocument: monitor.canDrop(),
}),
});
const nodeChildren = React.useMemo(() => {
if (
collection &&
const insertDraftDocument =
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id
) {
return sortNavigationNodes(
[activeDocument?.asNavigationNode, ...node.children],
collection.sort
);
}
activeDocument?.parentDocumentId === node.id;
return node.children;
return collection && insertDraftDocument
? sortNavigationNodes(
[activeDocument?.asNavigationNode, ...node.children],
collection.sort,
false
)
: node.children;
}, [
activeDocument?.isActive,
activeDocument?.isDraft,
@@ -1,4 +1,5 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Badge from "~/components/Badge";
import { version } from "../../../../package.json";
@@ -6,6 +7,7 @@ import SidebarLink from "./SidebarLink";
export default function Version() {
const [releasesBehind, setReleasesBehind] = React.useState(0);
const { t } = useTranslation();
React.useEffect(() => {
async function loadReleases() {
@@ -37,10 +39,11 @@ export default function Version() {
<br />
<LilBadge>
{releasesBehind === 0
? "Up to date"
: `${releasesBehind} version${
releasesBehind === 1 ? "" : "s"
} behind`}
? t("Up to date")
: t(`{{ releasesBehind }} versions behind`, {
releasesBehind,
count: releasesBehind,
})}
</LilBadge>
</>
}
@@ -12,19 +12,19 @@ export default function useCollectionDocuments(
return [];
}
if (
const insertDraftDocument =
activeDocument?.isActive &&
activeDocument?.isDraft &&
activeDocument?.collectionId === collection.id &&
!activeDocument?.parentDocumentId
) {
return sortNavigationNodes(
[activeDocument.asNavigationNode, ...collection.documents],
collection.sort
);
}
!activeDocument?.parentDocumentId;
return collection.documents;
return insertDraftDocument
? sortNavigationNodes(
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
collection.sort,
false
)
: collection.sortedDocuments;
}, [
activeDocument?.isActive,
activeDocument?.isDraft,
@@ -32,7 +32,7 @@ export default function useCollectionDocuments(
activeDocument?.parentDocumentId,
activeDocument?.asNavigationNode,
collection,
collection?.documents,
collection?.sortedDocuments,
collection?.id,
collection?.sort,
]);
+1 -1
View File
@@ -21,7 +21,7 @@ const searcher = new FuzzySearch<{
sort: true,
});
class EmojiMenu extends React.Component<
class EmojiMenu extends React.PureComponent<
Omit<
Props<Emoji>,
| "renderMenuItem"
+6 -12
View File
@@ -42,7 +42,7 @@ type Props = {
function isVisible(props: Props) {
const { view } = props;
const { selection } = view.state;
const { selection, doc } = view.state;
if (isMarkActive(view.state.schema.marks.link)(view.state)) {
return true;
@@ -63,6 +63,11 @@ function isVisible(props: Props) {
return false;
}
const selectionText = doc.cut(selection.from, selection.to).textContent;
if (selection instanceof TextSelection && !selectionText) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
@@ -192,7 +197,6 @@ export default class SelectionToolbar extends React.Component<Props> {
const link = isMarkActive(state.schema.marks.link)(state);
const range = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection = selection.node?.type?.name === "image";
let isTextSelection = false;
let items: MenuItem[] = [];
if (isTableSelection) {
@@ -207,7 +211,6 @@ export default class SelectionToolbar extends React.Component<Props> {
items = getDividerMenuItems(state, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, dictionary);
isTextSelection = true;
}
// Some extensions may be disabled, remove corresponding items
@@ -226,15 +229,6 @@ export default class SelectionToolbar extends React.Component<Props> {
return null;
}
const selectionText = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
if (isTextSelection && !selectionText && !link) {
return null;
}
return (
<FloatingToolbar
view={view}
+23 -6
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-irregular-whitespace */
import { lighten } from "polished";
import { lighten, transparentize } from "polished";
import styled from "styled-components";
const EditorStyles = styled.div<{
@@ -403,7 +403,9 @@ const EditorStyles = styled.div<{
padding: 0;
&.collapsed {
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
svg {
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
}
transition-delay: 0.1s;
opacity: 1;
}
@@ -429,10 +431,12 @@ const EditorStyles = styled.div<{
.notice-block {
display: flex;
align-items: center;
background: ${(props) => props.theme.noticeInfoBackground};
background: ${(props) =>
transparentize(0.9, props.theme.noticeInfoBackground)};
border-left: 4px solid ${(props) => props.theme.noticeInfoBackground};
color: ${(props) => props.theme.noticeInfoText};
border-radius: 4px;
padding: 8px 16px;
padding: 8px 10px 8px 8px;
margin: 8px 0;
a {
@@ -462,21 +466,34 @@ const EditorStyles = styled.div<{
height: 24px;
align-self: flex-start;
margin-${(props) => (props.rtl ? "left" : "right")}: 4px;
color: ${(props) => props.theme.noticeInfoBackground};
}
.notice-block.tip {
background: ${(props) => props.theme.noticeTipBackground};
background: ${(props) =>
transparentize(0.9, props.theme.noticeTipBackground)};
border-left: 4px solid ${(props) => props.theme.noticeTipBackground};
color: ${(props) => props.theme.noticeTipText};
.icon {
color: ${(props) => props.theme.noticeTipBackground};
}
a {
color: ${(props) => props.theme.noticeTipText};
}
}
.notice-block.warning {
background: ${(props) => props.theme.noticeWarningBackground};
background: ${(props) =>
transparentize(0.9, props.theme.noticeWarningBackground)};
border-left: 4px solid ${(props) => props.theme.noticeWarningBackground};
color: ${(props) => props.theme.noticeWarningText};
.icon {
color: ${(props) => props.theme.noticeWarningBackground};
}
a {
color: ${(props) => props.theme.noticeWarningText};
}
+8 -30
View File
@@ -18,7 +18,8 @@ import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components";
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import headingToSlug from "@shared/editor/lib/headingToSlug";
import getHeadings from "@shared/editor/lib/getHeadings";
import getTasks from "@shared/editor/lib/getTasks";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
@@ -28,7 +29,7 @@ import { EmbedDescriptor, EventType } from "@shared/editor/types";
import EventEmitter from "@shared/utils/events";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/logger";
import Logger from "~/utils/Logger";
import BlockMenu from "./components/BlockMenu";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
@@ -471,7 +472,7 @@ export class Editor extends React.PureComponent<
try {
const element = document.querySelector(hash);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
setTimeout(() => element.scrollIntoView({ behavior: "smooth" }), 0);
}
} catch (err) {
// querySelector will throw an error if the hash begins with a number
@@ -575,34 +576,11 @@ export class Editor extends React.PureComponent<
};
public getHeadings = () => {
const headings: { title: string; level: number; id: string }[] = [];
const previouslySeen = {};
return getHeadings(this.view.state.doc);
};
this.view.state.doc.forEach((node) => {
if (node.type.name === "heading") {
// calculate the optimal slug
const slug = headingToSlug(node);
let id = slug;
// check if we've already used it, and if so how many times?
// Make the new id based on that number ensuring that we have
// unique ID's even when headings are identical
if (previouslySeen[slug] > 0) {
id = headingToSlug(node, previouslySeen[slug]);
}
// record that we've seen this slug for the next loop
previouslySeen[slug] =
previouslySeen[slug] !== undefined ? previouslySeen[slug] + 1 : 1;
headings.push({
title: node.textContent,
level: node.attrs.level,
id,
});
}
});
return headings;
public getTasks = () => {
return getTasks(this.view.state.doc);
};
public render() {
+3 -3
View File
@@ -29,7 +29,7 @@ import Zapier from "~/scenes/Settings/Zapier";
import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import isHosted from "~/utils/isHosted";
import isCloudHosted from "~/utils/isCloudHosted";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
@@ -163,7 +163,7 @@ const useAuthorizedSettingsConfig = () => {
name: "Slack",
path: "/settings/integrations/slack",
component: Slack,
enabled: can.update && (!!env.SLACK_KEY || isHosted),
enabled: can.update && (!!env.SLACK_CLIENT_ID || isCloudHosted),
group: t("Integrations"),
icon: SlackIcon,
},
@@ -171,7 +171,7 @@ const useAuthorizedSettingsConfig = () => {
name: "Zapier",
path: "/settings/integrations/zapier",
component: Zapier,
enabled: can.update && isHosted,
enabled: can.update && isCloudHosted,
group: t("Integrations"),
icon: ZapierIcon,
},
+4 -5
View File
@@ -1,11 +1,11 @@
import * as React from "react";
export default function useDebouncedCallback(
callback: (arg0: any) => unknown,
export default function useDebouncedCallback<T>(
callback: (...params: T[]) => unknown,
wait: number
) {
// track args & timeout handle between calls
const argsRef = React.useRef();
const argsRef = React.useRef<T[]>();
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
function cleanup() {
@@ -16,12 +16,11 @@ export default function useDebouncedCallback(
// make sure our timeout gets cleared if consuming component gets unmounted
React.useEffect(() => cleanup, []);
return function (...args: any) {
return function (...args: T[]) {
argsRef.current = args;
cleanup();
timeout.current = setTimeout(() => {
if (argsRef.current) {
// @ts-expect-error ts-migrate(2556) FIXME: Expected 1 arguments, but got 0 or more.
callback(...argsRef.current);
}
}, wait);
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { Primitive } from "utility-types";
import Logger from "~/utils/Logger";
import Storage from "~/utils/Storage";
import Logger from "~/utils/logger";
import useEventListener from "./useEventListener";
/**
+16 -13
View File
@@ -15,9 +15,10 @@ import ScrollToTop from "~/components/ScrollToTop";
import Theme from "~/components/Theme";
import Toasts from "~/components/Toasts";
import env from "~/env";
import LazyPolyfill from "./components/LazyPolyfills";
import Routes from "./routes";
import Logger from "./utils/Logger";
import history from "./utils/history";
import Logger from "./utils/logger";
import { initSentry } from "./utils/sentry";
initI18n();
@@ -75,18 +76,20 @@ if (element) {
<Theme>
<ErrorBoundary>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
</>
</Router>
</LazyMotion>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
</>
</Router>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</Theme>
+45 -58
View File
@@ -18,14 +18,13 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import Collection from "~/models/Collection";
import CollectionDelete from "~/scenes/CollectionDelete";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionExport from "~/scenes/CollectionExport";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ContextMenu, { Placement } from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import Modal from "~/components/Modal";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -54,27 +53,43 @@ function CollectionMenu({
modal,
placement,
});
const [renderModals, setRenderModals] = React.useState(false);
const team = useCurrentTeam();
const { documents, dialogs } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const [
showCollectionPermissions,
setShowCollectionPermissions,
] = React.useState(false);
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
const handleOpen = React.useCallback(() => {
setRenderModals(true);
const handlePermissions = React.useCallback(() => {
dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collection={collection} />,
});
}, [collection, dialogs, t]);
if (onOpen) {
onOpen();
}
}, [onOpen]);
const handleEdit = React.useCallback(() => {
dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
collectionId={collection.id}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [collection.id, dialogs, t]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
content: (
<CollectionExport
collection={collection}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [collection, dialogs, t]);
const handleNewDocument = React.useCallback(
(ev: React.SyntheticEvent) => {
@@ -145,7 +160,7 @@ function CollectionMenu({
isCentered: true,
title: t("Delete collection"),
content: (
<CollectionDelete
<CollectionDeleteDialog
collection={collection}
onSubmit={dialogs.closeAllModals}
/>
@@ -238,21 +253,21 @@ function CollectionMenu({
type: "button",
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
onClick: handleEdit,
icon: <EditIcon />,
},
{
type: "button",
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
onClick: handlePermissions,
icon: <PadlockIcon />,
},
{
type: "button",
title: `${t("Export")}`,
visible: !!(collection && canUserInTeam.export),
onClick: () => setShowCollectionExport(true),
onClick: handleExport,
icon: <ExportIcon />,
},
{
@@ -269,19 +284,22 @@ function CollectionMenu({
],
[
t,
handleUnstar,
collection,
can.unstar,
can.star,
can.update,
can.delete,
can.star,
can.unstar,
handleStar,
handleUnstar,
alphabeticalSort,
handleChangeSort,
handleNewDocument,
handleImportDocument,
handleDelete,
collection,
alphabeticalSort,
handleEdit,
handlePermissions,
canUserInTeam.export,
handleExport,
handleDelete,
handleChangeSort,
]
);
@@ -311,43 +329,12 @@ function CollectionMenu({
)}
<ContextMenu
{...menu}
onOpen={handleOpen}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Collection")}
>
<Template {...menu} items={items} />
</ContextMenu>
{renderModals && (
<>
<Modal
title={t("Collection permissions")}
onRequestClose={() => setShowCollectionPermissions(false)}
isOpen={showCollectionPermissions}
>
<CollectionPermissions collection={collection} />
</Modal>
<Modal
title={t("Edit collection")}
isOpen={showCollectionEdit}
onRequestClose={() => setShowCollectionEdit(false)}
>
<CollectionEdit
onSubmit={() => setShowCollectionEdit(false)}
collectionId={collection.id}
/>
</Modal>
<Modal
title={t("Export collection")}
isOpen={showCollectionExport}
onRequestClose={() => setShowCollectionExport(false)}
>
<CollectionExport
onSubmit={() => setShowCollectionExport(false)}
collection={collection}
/>
</Modal>
</>
)}
</>
);
}
+1 -3
View File
@@ -108,9 +108,7 @@ function UserMenu({ user }: Props) {
const handleRevoke = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
users.delete(user, {
confirmation: true,
});
users.delete(user);
},
[users, user]
);
+5 -2
View File
@@ -20,7 +20,10 @@ export default abstract class BaseModel {
this.store = store;
}
save = async (params?: Record<string, any>) => {
save = async (
params?: Record<string, any>,
options?: Record<string, string | boolean | number | undefined>
) => {
this.isSaving = true;
try {
@@ -29,7 +32,7 @@ export default abstract class BaseModel {
params = this.toAPI();
}
const model = await this.store.save({ ...params, id: this.id });
const model = await this.store.save({ ...params, id: this.id }, options);
// if saving is successful set the new values on the model itself
set(this, { ...params, ...model });
+7 -1
View File
@@ -1,5 +1,6 @@
import { trim } from "lodash";
import { action, computed, observable } from "mobx";
import { sortNavigationNodes } from "@shared/utils/collections";
import CollectionsStore from "~/stores/CollectionsStore";
import Document from "~/models/Document";
import ParanoidModel from "~/models/ParanoidModel";
@@ -95,6 +96,11 @@ export default class Collection extends ParanoidModel {
);
}
@computed
get sortedDocuments() {
return sortNavigationNodes(this.documents, this.sort);
}
@action
updateDocument(document: Document) {
const travelNodes = (nodes: NavigationNode[]) =>
@@ -130,7 +136,7 @@ export default class Collection extends ParanoidModel {
};
if (this.documents) {
travelNodes(this.documents);
travelNodes(this.sortedDocuments);
}
return result;
+28 -67
View File
@@ -1,11 +1,11 @@
import { addDays, differenceInDays } from "date-fns";
import { floor } from "lodash";
import { action, autorun, computed, observable } from "mobx";
import { action, autorun, computed, observable, set } from "mobx";
import parseTitle from "@shared/utils/parseTitle";
import unescape from "@shared/utils/unescape";
import DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import { NavigationNode } from "~/types";
import type { NavigationNode } from "~/types";
import Storage from "~/utils/Storage";
import ParanoidModel from "./ParanoidModel";
import View from "./View";
@@ -63,7 +63,6 @@ export default class Document extends ParanoidModel {
@observable
title: string;
@Field
@observable
template: boolean;
@@ -219,6 +218,13 @@ export default class Document extends ParanoidModel {
return floor((this.tasks.completed / this.tasks.total) * 100);
}
@action
updateTasks(total: number, completed: number) {
if (total !== this.tasks.total || completed !== this.tasks.completed) {
this.tasks = { total, completed };
}
}
@action
share = async () => {
return this.store.rootStore.shares.create({
@@ -285,6 +291,8 @@ export default class Document extends ParanoidModel {
return;
}
this.lastViewedAt = new Date().toString();
return this.store.rootStore.views.create({
documentId: this.id,
});
@@ -300,80 +308,33 @@ export default class Document extends ParanoidModel {
return this.store.templatize(this.id);
};
@action
update = async (
options: SaveOptions & {
title?: string;
lastRevision?: number;
}
) => {
if (this.isSaving) {
return this;
}
this.isSaving = true;
try {
if (options.lastRevision) {
return await this.store.update(
{
id: this.id,
title: options.title || this.title,
fullWidth: this.fullWidth,
},
{
lastRevision: options.lastRevision,
publish: options?.publish,
done: options?.done,
}
);
}
throw new Error("Attempting to update without a lastRevision");
} finally {
this.isSaving = false;
}
};
@action
save = async (options?: SaveOptions | undefined) => {
if (this.isSaving) {
return this;
const params = this.toAPI();
const collaborativeEditing = this.store.rootStore.auth.team
?.collaborativeEditing;
if (collaborativeEditing) {
delete params.text;
}
const isCreating = !this.id;
this.isSaving = true;
try {
if (isCreating) {
return await this.store.create(
{
parentDocumentId: this.parentDocumentId,
collectionId: this.collectionId,
title: this.title,
text: this.text,
},
{
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
}
);
}
return await this.store.update(
{
id: this.id,
title: this.title,
text: this.text,
fullWidth: this.fullWidth,
templateId: this.templateId,
},
const model = await this.store.save(
{ ...params, id: this.id },
{
lastRevision: options?.lastRevision || this.revision,
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
...options,
}
);
// if saving is successful set the new values on the model itself
set(this, { ...params, ...model });
this.persistedAttributes = this.toAPI();
return model;
} finally {
this.isSaving = false;
}
+4
View File
@@ -55,6 +55,10 @@ class Team extends BaseModel {
url: string;
@Field
@observable
allowedDomains: string[] | null | undefined;
@computed
get signinMethods(): string {
return "SSO";
+15 -5
View File
@@ -1,11 +1,9 @@
import * as React from "react";
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
import Archive from "~/scenes/Archive";
import Collection from "~/scenes/Collection";
import DocumentNew from "~/scenes/DocumentNew";
import Drafts from "~/scenes/Drafts";
import Error404 from "~/scenes/Error404";
import Search from "~/scenes/Search";
import Templates from "~/scenes/Templates";
import Trash from "~/scenes/Trash";
import Layout from "~/components/AuthenticatedLayout";
@@ -29,6 +27,13 @@ const Document = React.lazy(
"~/scenes/Document"
)
);
const Collection = React.lazy(
() =>
import(
/* webpackChunkName: "collection" */
"~/scenes/Collection"
)
);
const Home = React.lazy(
() =>
import(
@@ -36,8 +41,13 @@ const Home = React.lazy(
"~/scenes/Home"
)
);
const NotFound = () => <Search notFound />;
const Search = React.lazy(
() =>
import(
/* webpackChunkName: "search" */
"~/scenes/Search"
)
);
const RedirectDocument = ({
match,
@@ -86,7 +96,7 @@ export default function AuthenticatedRoutes() {
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={NotFound} />
<Route component={Error404} />
</Switch>
</React.Suspense>
</Layout>
+2
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import Error404 from "~/scenes/Error404";
import Route from "~/components/ProfiledRoute";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
@@ -20,6 +21,7 @@ export default function SettingsRoutes() {
<Redirect from="/settings/import-export" to="/settings/export" />
<Redirect from="/settings/people" to="/settings/members" />
<Redirect from="/settings/profile" to="/settings" />
<Route component={Error404} />
</Switch>
);
}
+1 -1
View File
@@ -230,7 +230,7 @@ function CollectionScene() {
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: "ASC",
direction: collection.sort.direction,
}}
showParentDocuments
/>
+1 -1
View File
@@ -104,7 +104,7 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
label={t("Sort in sidebar")}
options={[
{
label: t("Alphabetical"),
label: t("Alphabetical sort"),
value: "title.asc",
},
{
+28 -10
View File
@@ -1,7 +1,8 @@
import { Location } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import { RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet";
import { RouteComponentProps, useLocation } from "react-router-dom";
import { useTheme } from "styled-components";
import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Error404";
@@ -28,6 +29,14 @@ type Props = RouteComponentProps<{
location: Location<{ title?: string }>;
};
// Parse the canonical origin from the SSR HTML, only needs to be done once.
const canonicalUrl = document
.querySelector("link[rel=canonical]")
?.getAttribute("href");
const canonicalOrigin = canonicalUrl
? new URL(canonicalUrl).origin
: window.location.origin;
/**
* Find the document UUID from the slug given the sharedTree
*
@@ -63,6 +72,7 @@ function useDocumentId(documentSlug: string, response?: Response) {
function SharedDocumentScene(props: Props) {
const { ui } = useStores();
const theme = useTheme();
const location = useLocation();
const [response, setResponse] = React.useState<Response>();
const [error, setError] = React.useState<Error | null | undefined>();
const { documents } = useStores();
@@ -107,15 +117,23 @@ function SharedDocumentScene(props: Props) {
) : undefined;
return (
<Layout title={response.document.title} sidebar={sidebar}>
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
readOnly
/>
</Layout>
<>
<Helmet>
<link
rel="canonical"
href={canonicalOrigin + location.pathname.replace(/\/$/, "")}
/>
</Helmet>
<Layout title={response.document.title} sidebar={sidebar}>
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
readOnly
/>
</Layout>
</>
);
}
+6 -2
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Text from "~/components/Text";
@@ -48,11 +49,12 @@ export default function Contents({ headings, isFullWidth }: Props) {
Infinity
);
const headingAdjustment = minHeading - 1;
const { t } = useTranslation();
return (
<Wrapper isFullWidth={isFullWidth}>
<Sticky>
<Heading>Contents</Heading>
<Heading>{t("Contents")}</Heading>
{headings.length ? (
<List>
{headings.map((heading) => (
@@ -66,7 +68,9 @@ export default function Contents({ headings, isFullWidth }: Props) {
))}
</List>
) : (
<Empty>Headings you add to the document will appear here</Empty>
<Empty>
{t("Headings you add to the document will appear here")}
</Empty>
)}
</Sticky>
</Wrapper>
+30 -35
View File
@@ -14,6 +14,8 @@ import {
} from "react-router";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Heading } from "@shared/editor/lib/getHeadings";
import { parseDomain } from "@shared/utils/domains";
import getTasks from "@shared/utils/getTasks";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
@@ -29,9 +31,9 @@ import PageTitle from "~/components/PageTitle";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import withStores from "~/components/withStores";
import type { Editor as TEditor } from "~/editor";
import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
import { isCustomDomain } from "~/utils/domains";
import { emojiToUrl } from "~/utils/emoji";
import { isModKey } from "~/utils/keyboard";
import {
@@ -73,7 +75,7 @@ type Props = WithTranslation &
@observer
class DocumentScene extends React.Component<Props> {
@observable
editor = React.createRef<typeof Editor>();
editor = React.createRef<TEditor>();
@observable
isUploading = false;
@@ -96,6 +98,9 @@ class DocumentScene extends React.Component<Props> {
@observable
title: string = this.props.document.title;
@observable
headings: Heading[] = [];
getEditorText: () => string = () => this.props.document.text;
componentDidMount() {
@@ -158,7 +163,6 @@ class DocumentScene extends React.Component<Props> {
return;
}
// @ts-expect-error ts-migrate(2339) FIXME: Property 'view' does not exist on type 'unknown'.
const { view, parser } = editorRef;
view.dispatch(
view.state.tr
@@ -281,7 +285,7 @@ class DocumentScene extends React.Component<Props> {
autosave?: boolean;
} = {}
) => {
const { document, auth } = this.props;
const { document } = this.props;
// prevent saves when we are already saving
if (document.isSaving) {
return;
@@ -307,22 +311,10 @@ class DocumentScene extends React.Component<Props> {
this.isPublishing = !!options.publish;
try {
let savedDocument = document;
if (auth.team?.collaborativeEditing) {
// update does not send "text" field to the API, this is a workaround
// while the multiplayer editor is toggleable. Once it's finalized
// this can be cleaned up to single code path
savedDocument = await document.update({
...options,
lastRevision: this.lastRevision,
});
} else {
savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
}
const savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
this.isEditorDirty = false;
this.lastRevision = savedDocument.revision;
@@ -375,13 +367,15 @@ class DocumentScene extends React.Component<Props> {
const { document, auth } = this.props;
this.getEditorText = getEditorText;
// If the multiplayer editor is enabled then we still want to keep the local
// text value in sync as it is used as a cache.
// Keep derived task list in sync
const tasks = this.editor.current?.getTasks();
const total = tasks?.length ?? 0;
const completed = tasks?.filter((t) => t.completed).length ?? 0;
document.updateTasks(total, completed);
// If the multiplayer editor is enabled we're done here as changes are saved
// through the persistence protocol. The rest of this method is legacy.
if (auth.team?.collaborativeEditing) {
action(() => {
document.text = this.getEditorText();
document.tasks = getTasks(document.text);
})();
return;
}
@@ -399,6 +393,10 @@ class DocumentScene extends React.Component<Props> {
}
};
onHeadingsChange = (headings: Heading[]) => {
this.headings = headings;
};
onChangeTitle = action((value: string) => {
this.title = value;
this.props.document.title = value;
@@ -429,12 +427,7 @@ class DocumentScene extends React.Component<Props> {
const embedsDisabled =
(team && team.documentEmbeds === false) || document.embedsDisabled;
const headings = this.editor.current
? // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
this.editor.current.getHeadings()
: [];
const hasHeadings = headings.length > 0;
const hasHeadings = this.headings.length > 0;
const showContents =
ui.tocVisible &&
((readOnly && hasHeadings) || team?.collaborativeEditing);
@@ -456,6 +449,7 @@ class DocumentScene extends React.Component<Props> {
to={{
pathname: canonicalUrl,
state: this.props.location.state,
hash: this.props.location.hash,
}}
/>
)}
@@ -548,7 +542,7 @@ class DocumentScene extends React.Component<Props> {
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceDocument}
onSave={this.onSave}
headings={headings}
headings={this.headings}
/>
<MaxWidth
archived={document.isArchived}
@@ -563,7 +557,7 @@ class DocumentScene extends React.Component<Props> {
<Flex auto={!readOnly}>
{showContents && (
<Contents
headings={headings}
headings={this.headings}
isFullWidth={document.fullWidth}
/>
)}
@@ -587,6 +581,7 @@ class DocumentScene extends React.Component<Props> {
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
@@ -614,7 +609,7 @@ class DocumentScene extends React.Component<Props> {
</Flex>
</React.Suspense>
</MaxWidth>
{isShare && !isCustomDomain() && (
{isShare && !parseDomain(window.location.origin).custom && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
</Container>
@@ -15,8 +15,8 @@ import usePageVisibility from "~/hooks/usePageVisibility";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import MultiplayerExtension from "~/multiplayer/MultiplayerExtension";
import Logger from "~/utils/Logger";
import { supportsPassiveListener } from "~/utils/browser";
import Logger from "~/utils/logger";
import { homePath } from "~/utils/routeHelpers";
type Props = EditorProps & {
@@ -139,9 +139,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
});
if (debug) {
provider.on("status", (ev: ConnectionStatusEvent) =>
Logger.debug("collaboration", "status", ev)
);
provider.on("message", (ev: MessageEvent) =>
Logger.debug("collaboration", "incoming", {
message: ev.message,
@@ -241,8 +238,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
// we must prevent the user from continuing to edit as their changes will not
// be persisted. See: https://github.com/yjs/yjs/issues/303
React.useEffect(() => {
function onUnhandledError(err: any) {
if (err.message.includes("URIError: URI malformed")) {
function onUnhandledError(event: ErrorEvent) {
if (event.message.includes("URIError: URI malformed")) {
showToast(
t(
"Sorry, the last change could not be persisted please reload the page"
@@ -18,6 +18,8 @@ import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import useUserLocale from "~/hooks/useUserLocale";
import { dateLocale } from "~/utils/i18n";
type Props = {
document: Document;
@@ -109,6 +111,9 @@ function SharePopover({
}, 250);
}, [t, onRequestClose, showToast]);
const userLocale = useUserLocale();
const locale = userLocale ? dateLocale(userLocale) : undefined;
return (
<>
<Heading>
@@ -156,6 +161,7 @@ function SharePopover({
Date.parse(share?.lastAccessedAt),
{
addSuffix: true,
locale,
}
),
})}
+1 -1
View File
@@ -223,7 +223,7 @@ function Invite({ onSubmit }: Props) {
required={!!invite.email}
/>
<InputSelectRole
onChange={(role: any) => handleRoleChange(role as Role, index)}
onChange={(role: Role) => handleRoleChange(role, index)}
value={invite.role}
labelHidden={index !== 0}
short
+1 -1
View File
@@ -445,4 +445,4 @@ const Label = styled.dd`
color: ${(props) => props.theme.textSecondary};
`;
export default KeyboardShortcuts;
export default React.memo(KeyboardShortcuts);
+123
View File
@@ -0,0 +1,123 @@
import { EmailIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { parseDomain } from "@shared/utils/domains";
import AuthLogo from "~/components/AuthLogo";
import ButtonLarge from "~/components/ButtonLarge";
import InputLarge from "~/components/InputLarge";
import env from "~/env";
import { client } from "~/utils/ApiClient";
type Props = {
id: string;
name: string;
authUrl: string;
isCreate: boolean;
onEmailSuccess: (email: string) => void;
};
function AuthenticationProvider(props: Props) {
const { t } = useTranslation();
const [showEmailSignin, setShowEmailSignin] = React.useState(false);
const [isSubmitting, setSubmitting] = React.useState(false);
const [email, setEmail] = React.useState("");
const { isCreate, id, name, authUrl } = props;
const handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.target.value);
};
const handleSubmitEmail = async (
event: React.SyntheticEvent<HTMLFormElement>
) => {
event.preventDefault();
if (showEmailSignin && email) {
setSubmitting(true);
try {
const response = await client.post(event.currentTarget.action, {
email,
});
if (response.redirect) {
window.location.href = response.redirect;
} else {
props.onEmailSuccess(email);
}
} finally {
setSubmitting(false);
}
} else {
setShowEmailSignin(true);
}
};
if (id === "email") {
if (isCreate) {
return null;
}
return (
<Wrapper>
<Form method="POST" action="/auth/email" onSubmit={handleSubmitEmail}>
{showEmailSignin ? (
<>
<InputLarge
type="email"
name="email"
placeholder="me@domain.com"
value={email}
onChange={handleChangeEmail}
disabled={isSubmitting}
autoFocus
required
short
/>
<ButtonLarge type="submit" disabled={isSubmitting}>
{t("Sign In")}
</ButtonLarge>
</>
) : (
<ButtonLarge type="submit" icon={<EmailIcon />} fullwidth>
{t("Continue with Email")}
</ButtonLarge>
)}
</Form>
</Wrapper>
);
}
// If we're on a custom domain then the auth must point to the root
// app.getoutline.com for authentication so that the state cookie can be set
// and read.
const isCustomDomain = parseDomain(window.location.origin).custom;
const href = `${isCustomDomain ? env.URL : ""}${authUrl}`;
return (
<Wrapper>
<ButtonLarge
onClick={() => (window.location.href = href)}
icon={<AuthLogo providerName={id} />}
fullwidth
>
{t("Continue with {{ authProviderName }}", {
authProviderName: name,
})}
</ButtonLarge>
</Wrapper>
);
}
const Wrapper = styled.div`
width: 100%;
`;
const Form = styled.form`
width: 100%;
display: flex;
justify-content: space-between;
`;
export default AuthenticationProvider;
+14 -11
View File
@@ -9,10 +9,13 @@ export default function Notices() {
return (
<>
{notice === "google-hd" && (
{notice === "domain-required" && (
<NoticeAlert>
Sorry, Google sign in cannot be used with a personal email. Please try
signing in with your Google Workspace account.
Unable to sign-in. Please navigate to your team's custom URL, then try
to sign-in again.
<hr />
If you were invited to a team, you will find a link to it in the
invite email.
</NoticeAlert>
)}
{notice === "maximum-teams" && (
@@ -21,13 +24,7 @@ export default function Notices() {
installation. Try another?
</NoticeAlert>
)}
{notice === "hd-not-allowed" && (
<NoticeAlert>
Sorry, your Google apps domain is not allowed. Please try again with
an allowed team domain.
</NoticeAlert>
)}
{notice === "malformed_user_info" && (
{notice === "malformed-user-info" && (
<NoticeAlert>
We could not read the user info supplied by your identity provider.
</NoticeAlert>
@@ -44,7 +41,7 @@ export default function Notices() {
try again in a few minutes.
</NoticeAlert>
)}
{notice === "auth-error" &&
{(notice === "auth-error" || notice === "state-mismatch") &&
(description ? (
<NoticeAlert>{description}</NoticeAlert>
) : (
@@ -79,6 +76,12 @@ export default function Notices() {
Please request an invite from your team admin and try again.
</NoticeAlert>
)}
{notice === "domain-not-allowed" && (
<NoticeAlert>
Sorry, your domain is not allowed. Please try again with an allowed
team domain.
</NoticeAlert>
)}
</>
);
}
-136
View File
@@ -1,136 +0,0 @@
import { EmailIcon } from "outline-icons";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import styled from "styled-components";
import AuthLogo from "~/components/AuthLogo";
import ButtonLarge from "~/components/ButtonLarge";
import InputLarge from "~/components/InputLarge";
import { client } from "~/utils/ApiClient";
type Props = WithTranslation & {
id: string;
name: string;
authUrl: string;
isCreate: boolean;
onEmailSuccess: (email: string) => void;
};
type State = {
showEmailSignin: boolean;
isSubmitting: boolean;
email: string;
};
class Provider extends React.Component<Props, State> {
state = {
showEmailSignin: false,
isSubmitting: false,
email: "",
};
handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({
email: event.target.value,
});
};
handleSubmitEmail = async (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
if (this.state.showEmailSignin && this.state.email) {
this.setState({
isSubmitting: true,
});
try {
const response = await client.post(event.currentTarget.action, {
email: this.state.email,
});
if (response.redirect) {
window.location.href = response.redirect;
} else {
this.props.onEmailSuccess(this.state.email);
}
} finally {
this.setState({
isSubmitting: false,
});
}
} else {
this.setState({
showEmailSignin: true,
});
}
};
render() {
const { isCreate, id, name, authUrl, t } = this.props;
if (id === "email") {
if (isCreate) {
return null;
}
return (
<Wrapper key="email">
<Form
method="POST"
action="/auth/email"
onSubmit={this.handleSubmitEmail}
>
{this.state.showEmailSignin ? (
<>
<InputLarge
type="email"
name="email"
placeholder="me@domain.com"
value={this.state.email}
onChange={this.handleChangeEmail}
disabled={this.state.isSubmitting}
autoFocus
required
short
/>
<ButtonLarge type="submit" disabled={this.state.isSubmitting}>
{t("Sign In")}
</ButtonLarge>
</>
) : (
<ButtonLarge type="submit" icon={<EmailIcon />} fullwidth>
{t("Continue with Email")}
</ButtonLarge>
)}
</Form>
</Wrapper>
);
}
return (
<Wrapper key={id}>
<ButtonLarge
onClick={() => (window.location.href = authUrl)}
icon={<AuthLogo providerName={id} />}
fullwidth
>
{t("Continue with {{ authProviderName }}", {
authProviderName: name,
})}
</ButtonLarge>
</Wrapper>
);
}
}
const Wrapper = styled.div`
margin-bottom: 1em;
width: 100%;
`;
const Form = styled.form`
width: 100%;
display: flex;
justify-content: space-between;
`;
export default withTranslation()(Provider);
+31 -25
View File
@@ -5,12 +5,14 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useLocation, Link, Redirect } from "react-router-dom";
import styled from "styled-components";
import { setCookie } from "tiny-cookie";
import { getCookie, setCookie } from "tiny-cookie";
import { parseDomain } from "@shared/utils/domains";
import { Config } from "~/stores/AuthStore";
import ButtonLarge from "~/components/ButtonLarge";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import LoadingIndicator from "~/components/LoadingIndicator";
import NoticeAlert from "~/components/NoticeAlert";
import OutlineLogo from "~/components/OutlineLogo";
import PageTitle from "~/components/PageTitle";
@@ -19,17 +21,16 @@ import Text from "~/components/Text";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { isCustomDomain } from "~/utils/domains";
import isHosted from "~/utils/isHosted";
import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
import AuthenticationProvider from "./AuthenticationProvider";
import Notices from "./Notices";
import Provider from "./Provider";
function Header({ config }: { config?: Config | undefined }) {
const { t } = useTranslation();
const isSubdomain = !!config?.hostname;
if (!isHosted || isCustomDomain()) {
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
return null;
}
@@ -77,11 +78,11 @@ function Login() {
React.useEffect(() => {
const entries = Object.fromEntries(query.entries());
const existing = getCookie("signupQueryParams");
// We don't want to override this cookie if we're viewing an error notice
// sent back from the server via query string (notice=), or if there are no
// query params at all.
if (Object.keys(entries).length && !query.get("notice")) {
// We don't want to set this cookie if we're viewing an error notice via
// query string(notice =), if there are no query params, or it's already set
if (Object.keys(entries).length && !query.get("notice") && !existing) {
setCookie("signupQueryParams", JSON.stringify(entries));
}
}, [query]);
@@ -102,10 +103,11 @@ function Login() {
<PageTitle title={t("Login")} />
<NoticeAlert>
{t("Failed to load configuration.")}
{!isHosted && (
{!isCloudHosted && (
<p>
Check the network requests and server logs for full details of
the error.
{t(
"Check the network requests and server logs for full details of the error."
)}
</p>
)}
</NoticeAlert>
@@ -114,9 +116,10 @@ function Login() {
);
}
// we're counting on the config request being fast, so display nothing while waiting
// we're counting on the config request being fast, so just a simple loading
// indicator here that's delayed by 250ms
if (!config) {
return null;
return <LoadingIndicator />;
}
const hasMultipleProviders = config.providers.length > 1;
@@ -152,10 +155,10 @@ function Login() {
return (
<Background>
<Header config={config} />
<Centered align="center" justify="center" column auto>
<Centered align="center" justify="center" gap={12} column auto>
<PageTitle title={t("Login")} />
<Logo>
{env.TEAM_LOGO && !isHosted ? (
{env.TEAM_LOGO && !isCloudHosted ? (
<TeamLogo src={env.TEAM_LOGO} />
) : (
<OutlineLogo size={38} fill="currentColor" />
@@ -163,7 +166,7 @@ function Login() {
</Logo>
{isCreate ? (
<>
<Heading centered>{t("Create an account")}</Heading>
<StyledHeading centered>{t("Create an account")}</StyledHeading>
<GetStarted>
{t(
"Get started by choosing a sign-in method for your new team below…"
@@ -171,16 +174,16 @@ function Login() {
</GetStarted>
</>
) : (
<Heading centered>
<StyledHeading centered>
{t("Login to {{ authProviderName }}", {
authProviderName: config.name || "Outline",
})}
</Heading>
</StyledHeading>
)}
<Notices />
{defaultProvider && (
<React.Fragment key={defaultProvider.id}>
<Provider
<AuthenticationProvider
isCreate={isCreate}
onEmailSuccess={handleEmailSuccess}
{...defaultProvider}
@@ -192,18 +195,18 @@ function Login() {
authProviderName: defaultProvider.name,
})}
</Note>
<Or />
<Or data-text={t("Or")} />
</>
)}
</React.Fragment>
)}
{config.providers.map((provider: any) => {
{config.providers.map((provider) => {
if (defaultProvider && provider.id === defaultProvider.id) {
return null;
}
return (
<Provider
<AuthenticationProvider
key={provider.id}
isCreate={isCreate}
onEmailSuccess={handleEmailSuccess}
@@ -223,6 +226,10 @@ function Login() {
);
}
const StyledHeading = styled(Heading)`
margin: 0;
`;
const CheckEmailIcon = styled(EmailIcon)`
margin-bottom: -1.5em;
`;
@@ -235,7 +242,6 @@ const Background = styled(Fade)`
`;
const Logo = styled.div`
margin-bottom: -1.5em;
height: 38px;
`;
@@ -279,7 +285,7 @@ const Or = styled.hr`
width: 100%;
&:after {
content: "Or";
content: attr(data-text);
display: block;
position: absolute;
left: 50%;
+1 -1
View File
@@ -23,7 +23,7 @@ import RegisterKeyDown from "~/components/RegisterKeyDown";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import withStores from "~/components/withStores";
import Logger from "~/utils/logger";
import Logger from "~/utils/Logger";
import { searchPath } from "~/utils/routeHelpers";
import { decodeURIComponentSafe } from "~/utils/urls";
import CollectionFilter from "./components/CollectionFilter";
+6 -3
View File
@@ -3,6 +3,7 @@ import { TeamIcon } from "outline-icons";
import { useRef, useState } from "react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { getBaseDomain } from "@shared/utils/domains";
import Button from "~/components/Button";
import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSelect";
import Heading from "~/components/Heading";
@@ -13,7 +14,7 @@ import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import isHosted from "~/utils/isHosted";
import isCloudHosted from "~/utils/isCloudHosted";
import ImageInput from "./components/ImageInput";
import SettingRow from "./components/SettingRow";
@@ -134,14 +135,16 @@ function Details() {
/>
</SettingRow>
<SettingRow
visible={env.SUBDOMAINS_ENABLED && isHosted}
visible={env.SUBDOMAINS_ENABLED && isCloudHosted}
label={t("Subdomain")}
name="subdomain"
description={
subdomain ? (
<>
<Trans>Your knowledge base will be accessible at</Trans>{" "}
<strong>{subdomain}.getoutline.com</strong>
<strong>
{subdomain}.{getBaseDomain()}
</strong>
</>
) : (
t("Choose a subdomain to enable a login page just for your team.")
+64 -15
View File
@@ -2,7 +2,8 @@ import { observer } from "mobx-react";
import { BeakerIcon } from "outline-icons";
import { useState } from "react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
@@ -10,10 +11,11 @@ import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import isCloudHosted from "~/utils/isCloudHosted";
import SettingRow from "./components/SettingRow";
function Features() {
const { auth } = useStores();
const { auth, dialogs } = useStores();
const team = useCurrentTeam();
const { t } = useTranslation();
const { showToast } = useToasts();
@@ -21,18 +23,35 @@ function Features() {
collaborativeEditing: team.collaborativeEditing,
});
const handleChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const newData = { ...data, [ev.target.name]: ev.target.checked };
setData(newData);
const handleChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
const newData = { ...data, [ev.target.name]: ev.target.checked };
setData(newData);
await auth.updateTeam(newData);
showToast(t("Settings saved"), {
type: "success",
});
},
[auth, data, showToast, t]
);
await auth.updateTeam(newData);
showToast(t("Settings saved"), {
type: "success",
});
};
const handleCollabDisable = async () => {
const newData = { ...data, collaborativeEditing: false };
setData(newData);
await auth.updateTeam(newData);
showToast(t("Settings saved"), {
type: "success",
});
};
const handleCollabDisableConfirm = () => {
dialogs.openModal({
isCentered: true,
title: t("Are you sure you want to disable collaborative editing?"),
content: (
<DisableCollaborativeEditingDialog onSubmit={handleCollabDisable} />
),
});
};
return (
<Scene title={t("Features")} icon={<BeakerIcon color="currentColor" />}>
@@ -54,12 +73,42 @@ function Features() {
id="collaborativeEditing"
name="collaborativeEditing"
checked={data.collaborativeEditing}
disabled={data.collaborativeEditing}
onChange={handleChange}
disabled={data.collaborativeEditing && isCloudHosted}
onChange={
data.collaborativeEditing
? handleCollabDisableConfirm
: handleChange
}
/>
</SettingRow>
</Scene>
);
}
function DisableCollaborativeEditingDialog({
onSubmit,
}: {
onSubmit: () => void;
}) {
const { t } = useTranslation();
return (
<ConfirmationDialog
onSubmit={onSubmit}
submitText={t("Im sure Disable")}
danger
>
<>
<Text type="secondary">
<Trans>
Enabling collaborative editing again in the future may cause some
documents to revert to this point in time. It is not advised to
disable this feature.
</Trans>
</Text>
</>
</ConfirmationDialog>
);
}
export default observer(Features);
+1 -1
View File
@@ -71,7 +71,7 @@ function Members() {
};
fetchData();
}, [query, sort, filter, page, direction, users]);
}, [query, sort, filter, page, direction, users, users.counts.all]);
React.useEffect(() => {
let filtered = users.orderedData;
+4 -11
View File
@@ -13,7 +13,7 @@ import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import isHosted from "~/utils/isHosted";
import isCloudHosted from "~/utils/isCloudHosted";
import SettingRow from "./components/SettingRow";
function Notifications() {
@@ -45,18 +45,15 @@ function Notifications() {
),
},
{
separator: true,
},
{
visible: isHosted,
visible: isCloudHosted,
event: "emails.onboarding",
title: t("Getting started"),
description: t(
"Tips on getting started with Outline`s features and functionality"
"Tips on getting started with Outlines features and functionality"
),
},
{
visible: isHosted,
visible: isCloudHosted,
event: "emails.features",
title: t("New features"),
description: t("Receive an email when new features of note are added"),
@@ -121,10 +118,6 @@ function Notifications() {
<h2>{t("Notifications")}</h2>
{options.map((option) => {
if (option.separator || !option.event) {
return <br />;
}
const setting = notificationSettings.getByEvent(option.event);
return (
+108 -3
View File
@@ -1,20 +1,27 @@
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { PadlockIcon } from "outline-icons";
import { CloseIcon, PadlockIcon } from "outline-icons";
import { useState } from "react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import Button from "~/components/Button";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import InputSelect from "~/components/InputSelect";
import NudeButton from "~/components/NudeButton";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import isHosted from "~/utils/isHosted";
import isCloudHosted from "~/utils/isCloudHosted";
import SettingRow from "./components/SettingRow";
function Security() {
@@ -29,6 +36,7 @@ function Security() {
defaultUserRole: team.defaultUserRole,
memberCollectionCreate: team.memberCollectionCreate,
inviteRequired: team.inviteRequired,
allowedDomains: team.allowedDomains,
});
const authenticationMethods = team.signinMethods;
@@ -43,13 +51,17 @@ function Security() {
[showToast, t]
);
const [domainsChanged, setDomainsChanged] = useState(false);
const saveData = React.useCallback(
async (newData) => {
try {
setData(newData);
await auth.updateTeam(newData);
showSuccessMessage();
setDomainsChanged(false);
} catch (err) {
setDomainsChanged(true);
showToast(err.message, {
type: "error",
});
@@ -110,6 +122,35 @@ function Security() {
[data, saveData, t, dialogs, authenticationMethods]
);
const handleRemoveDomain = async (index: number) => {
const newData = {
...data,
};
newData.allowedDomains && newData.allowedDomains.splice(index, 1);
setData(newData);
setDomainsChanged(true);
};
const handleAddDomain = () => {
const newData = {
...data,
allowedDomains: [...(data.allowedDomains || []), ""],
};
setData(newData);
};
const createOnDomainChangedHandler = (index: number) => (
ev: React.ChangeEvent<HTMLInputElement>
) => {
const newData = { ...data };
newData.allowedDomains![index] = ev.currentTarget.value;
setData(newData);
setDomainsChanged(true);
};
return (
<Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}>
<Heading>{t("Security")}</Heading>
@@ -171,7 +212,7 @@ function Security() {
onChange={handleChange}
/>
</SettingRow>
{isHosted && (
{isCloudHosted && (
<SettingRow
label={t("Allow authorized signups")}
name="allowSignups"
@@ -220,8 +261,72 @@ function Security() {
short
/>
</SettingRow>
<SettingRow
label={t("Allowed Domains")}
name="allowedDomains"
description={t(
"The domains which should be allowed to create accounts. This applies to both SSO and Email logins. Changing this setting does not affect existing user accounts."
)}
>
{data.allowedDomains &&
data.allowedDomains.map((domain, index) => (
<Flex key={index} gap={4}>
<Input
key={index}
id={`allowedDomains${index}`}
value={domain}
autoFocus={!domain}
placeholder="example.com"
required
flex
onChange={createOnDomainChangedHandler(index)}
/>
<Remove>
<Tooltip tooltip={t("Remove domain")} placement="top">
<NudeButton onClick={() => handleRemoveDomain(index)}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Remove>
</Flex>
))}
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
{!data.allowedDomains?.length ||
data.allowedDomains[data.allowedDomains.length - 1] !== "" ? (
<Fade>
<Button type="button" onClick={handleAddDomain} neutral>
{data.allowedDomains?.length ? (
<Trans>Add another</Trans>
) : (
<Trans>Add a domain</Trans>
)}
</Button>
</Fade>
) : (
<span />
)}
{domainsChanged && (
<Fade>
<Button
type="button"
onClick={handleChange}
disabled={auth.isSaving}
>
<Trans>Save changes</Trans>
</Button>
</Fade>
)}
</Flex>
</SettingRow>
</Scene>
);
}
const Remove = styled("div")`
margin-top: 6px;
`;
export default observer(Security);
+9 -2
View File
@@ -83,7 +83,7 @@ function Slack() {
}}
/>
</Text>
{env.SLACK_KEY ? (
{env.SLACK_CLIENT_ID ? (
<>
<p>
{commandIntegration ? (
@@ -92,7 +92,14 @@ function Slack() {
</Button>
) : (
<SlackButton
scopes={["commands", "links:read", "links:write"]}
scopes={[
"commands",
"links:read",
"links:write",
// TODO: Wait forever for Slack to approve these scopes.
//"users:read",
//"users:read.email",
]}
redirectUri={`${env.URL}/auth/slack.commands`}
state={team.id}
icon={<SlackIcon color="currentColor" />}
@@ -72,7 +72,7 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) {
<>
{isImporting && <LoadingIndicator />}
<Dropzone
accept="application/zip"
accept="application/zip, application/x-zip-compressed"
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
disabled={isImporting}
@@ -14,8 +14,6 @@ import withStores from "~/components/withStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile, dataUrlToBlob } from "~/utils/files";
const EMPTY_OBJECT = {};
export type Props = {
onSuccess: (url: string) => void | Promise<void>;
onError: (error: string) => void;
@@ -84,7 +82,7 @@ class ImageUpload extends React.Component<RootStore & Props> {
this.isCropping = false;
};
handleZoom = (event: React.DragEvent<any>) => {
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
@@ -119,7 +117,6 @@ class ImageUpload extends React.Component<RootStore & Props> {
max="2"
step="0.01"
defaultValue="1"
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
onChange={this.handleZoom}
/>
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
@@ -139,9 +136,6 @@ class ImageUpload extends React.Component<RootStore & Props> {
<Dropzone
accept="image/png, image/jpeg"
onDropAccepted={this.onDropAccepted}
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ children: ({ getRootProps, getInputProps }... Remove this comment to see the full error message
style={EMPTY_OBJECT}
disablePreview
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
@@ -89,4 +89,4 @@ const Badges = styled.div`
margin-left: -10px;
`;
export default PeopleTable;
export default observer(PeopleTable);
@@ -41,6 +41,10 @@ const Column = styled.div`
min-width: 60%;
}
&:last-child {
min-width: 0;
}
${breakpoint("tablet")`
p {
margin-bottom: 0;
@@ -15,13 +15,18 @@ type Props = {
function SlackButton({ state = "", scopes, redirectUri, label, icon }: Props) {
const { t } = useTranslation();
const handleClick = () =>
(window.location.href = slackAuth(
const handleClick = () => {
if (!env.SLACK_CLIENT_ID) {
return;
}
window.location.href = slackAuth(
state,
scopes,
env.SLACK_KEY,
env.SLACK_CLIENT_ID,
redirectUri
));
);
};
return (
<Button onClick={handleClick} icon={icon} neutral>
+25 -4
View File
@@ -2,6 +2,7 @@ import * as Sentry from "@sentry/react";
import invariant from "invariant";
import { observable, action, computed, autorun, runInAction } from "mobx";
import { getCookie, setCookie, removeCookie } from "tiny-cookie";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import RootStore from "~/stores/RootStore";
import Policy from "~/models/Policy";
import Team from "~/models/Team";
@@ -9,7 +10,6 @@ import User from "~/models/User";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import Storage from "~/utils/Storage";
import { getCookieDomain } from "~/utils/domains";
const AUTH_STORE = "AUTH_STORE";
const NO_REDIRECT_PATHS = ["/", "/create", "/home"];
@@ -162,6 +162,23 @@ export default class AuthStore {
});
}
// Redirect to the correct custom domain or team subdomain if needed
// Occurs when the (sub)domain is changed in admin and the user hits an old url
const { hostname, pathname } = window.location;
if (this.team.domain) {
if (this.team.domain !== hostname) {
window.location.href = `${team.url}${pathname}`;
return;
}
} else if (
env.SUBDOMAINS_ENABLED &&
parseDomain(hostname).teamSubdomain !== (team.subdomain ?? "")
) {
window.location.href = `${team.url}${pathname}`;
return;
}
// If we came from a redirect then send the user immediately there
const postLoginRedirectPath = getCookie("postLoginRedirectPath");
@@ -183,9 +200,7 @@ export default class AuthStore {
@action
deleteUser = async () => {
await client.post(`/users.delete`, {
confirmation: true,
});
await client.post(`/users.delete`);
runInAction("AuthStore#updateUser", () => {
this.user = null;
this.team = null;
@@ -238,6 +253,12 @@ export default class AuthStore {
@action
logout = async (savePath = false) => {
if (!this.token) {
return;
}
client.post(`/auth.delete`);
// remove user and team from localStorage
Storage.set(AUTH_STORE, {
user: null,
+6 -3
View File
@@ -106,11 +106,14 @@ export default abstract class BaseStore<T extends BaseModel> {
this.data.delete(id);
}
save(params: Partial<T>): Promise<T> {
save(
params: Partial<T>,
options?: Record<string, string | boolean | number | undefined>
): Promise<T> {
if (params.id) {
return this.update(params);
return this.update(params, options);
}
return this.create(params);
return this.create(params, options);
}
get(id: string): T | undefined {
+20 -12
View File
@@ -147,7 +147,7 @@ export default class DocumentsStore extends BaseStore<Document> {
return compact([
...drafts,
...collection.documents.map((node) => this.get(node.id)),
...collection.sortedDocuments.map((node) => this.get(node.id)),
]);
}
@@ -303,27 +303,31 @@ export default class DocumentsStore extends BaseStore<Document> {
};
@action
fetchArchived = async (options?: PaginationParams): Promise<any> => {
fetchArchived = async (options?: PaginationParams): Promise<Document[]> => {
return this.fetchNamedPage("archived", options);
};
@action
fetchDeleted = async (options?: PaginationParams): Promise<any> => {
fetchDeleted = async (options?: PaginationParams): Promise<Document[]> => {
return this.fetchNamedPage("deleted", options);
};
@action
fetchRecentlyUpdated = async (options?: PaginationParams): Promise<any> => {
fetchRecentlyUpdated = async (
options?: PaginationParams
): Promise<Document[]> => {
return this.fetchNamedPage("list", options);
};
@action
fetchTemplates = async (options?: PaginationParams): Promise<any> => {
fetchTemplates = async (options?: PaginationParams): Promise<Document[]> => {
return this.fetchNamedPage("list", { ...options, template: true });
};
@action
fetchAlphabetical = async (options?: PaginationParams): Promise<any> => {
fetchAlphabetical = async (
options?: PaginationParams
): Promise<Document[]> => {
return this.fetchNamedPage("list", {
sort: "title",
direction: "ASC",
@@ -334,7 +338,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@action
fetchLeastRecentlyUpdated = async (
options?: PaginationParams
): Promise<any> => {
): Promise<Document[]> => {
return this.fetchNamedPage("list", {
sort: "updatedAt",
direction: "ASC",
@@ -343,7 +347,9 @@ export default class DocumentsStore extends BaseStore<Document> {
};
@action
fetchRecentlyPublished = async (options?: PaginationParams): Promise<any> => {
fetchRecentlyPublished = async (
options?: PaginationParams
): Promise<Document[]> => {
return this.fetchNamedPage("list", {
sort: "publishedAt",
direction: "DESC",
@@ -352,22 +358,24 @@ export default class DocumentsStore extends BaseStore<Document> {
};
@action
fetchRecentlyViewed = async (options?: PaginationParams): Promise<any> => {
fetchRecentlyViewed = async (
options?: PaginationParams
): Promise<Document[]> => {
return this.fetchNamedPage("viewed", options);
};
@action
fetchStarred = (options?: PaginationParams): Promise<any> => {
fetchStarred = (options?: PaginationParams): Promise<Document[]> => {
return this.fetchNamedPage("starred", options);
};
@action
fetchDrafts = (options?: PaginationParams): Promise<any> => {
fetchDrafts = (options?: PaginationParams): Promise<Document[]> => {
return this.fetchNamedPage("drafts", options);
};
@action
fetchOwned = (options?: PaginationParams): Promise<any> => {
fetchOwned = (options?: PaginationParams): Promise<Document[]> => {
return this.fetchNamedPage("list", options);
};
+2 -2
View File
@@ -4,7 +4,7 @@ import { trim } from "lodash";
import queryString from "query-string";
import EDITOR_VERSION from "@shared/editor/version";
import stores from "~/stores";
import isHosted from "~/utils/isHosted";
import isCloudHosted from "~/utils/isCloudHosted";
import download from "./download";
import {
AuthorizationError,
@@ -107,7 +107,7 @@ class ApiClient {
// not needed for authentication this offers a performance increase.
// For self-hosted we include them to support a wide variety of
// authenticated proxies, e.g. Pomerium, Cloudflare Access etc.
credentials: isHosted ? "omit" : "same-origin",
credentials: isCloudHosted ? "omit" : "same-origin",
cache: "no-cache",
});
} catch (err) {
-14
View File
@@ -1,14 +0,0 @@
import { parseDomain, stripSubdomain } from "@shared/utils/domains";
import env from "~/env";
export function getCookieDomain(domain: string) {
return env.SUBDOMAINS_ENABLED ? stripSubdomain(domain) : domain;
}
export function isCustomDomain() {
const parsed = parseDomain(window.location.origin);
const main = parseDomain(env.URL);
return (
parsed && main && (main.domain !== parsed.domain || main.tld !== parsed.tld)
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import invariant from "invariant";
import { client } from "./ApiClient";
import Logger from "./logger";
import Logger from "./Logger";
type UploadOptions = {
/** The user facing name of the file */
+2
View File
@@ -39,3 +39,5 @@ const locales = {
export function dateLocale(userLocale: string | null | undefined) {
return userLocale ? locales[userLocale] : undefined;
}
export { locales };
+8
View File
@@ -0,0 +1,8 @@
import env from "~/env";
/**
* True if the current installation is the cloud hosted version at getoutline.com
*/
const isCloudHosted = env.DEPLOYMENT === "hosted";
export default isCloudHosted;
-5
View File
@@ -1,5 +0,0 @@
import env from "~/env";
const isHosted = env.DEPLOYMENT === "hosted";
export default isHosted;
+31
View File
@@ -0,0 +1,31 @@
/**
* Loads required polyfills.
*
* @returns A promise that resolves when all required polyfills are loaded
*/
export async function loadPolyfills() {
const polyfills = [];
if (!supportsResizeObserver()) {
polyfills.push(
import("@juggle/resize-observer").then((module) => {
window.ResizeObserver = module.ResizeObserver;
})
);
}
return Promise.all(polyfills);
}
/**
* Detect ResizeObserver compatability.
*
* @returns true if the current browser supports ResizeObserver
*/
function supportsResizeObserver() {
return (
"ResizeObserver" in global &&
"ResizeObserverEntry" in global &&
"contentRect" in ResizeObserverEntry.prototype
);
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function initSentry(history: History) {
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),
}),
],
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
tracesSampleRate: env.ENVIRONMENT === "production" ? 0.1 : 1,
ignoreErrors: [
"ResizeObserver loop completed with undelivered notifications",
"ResizeObserver loop limit exceeded",
+3 -1
View File
@@ -3,7 +3,8 @@ services:
redis:
image: redis
ports:
- "127.0.0.1:6479:6379"
- "127.0.0.1:6379:6379"
user: "redis:redis"
postgres:
image: postgres
ports:
@@ -12,6 +13,7 @@ services:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: outline
user: "postgres:postgres"
s3:
image: lphoward/fake-s3
ports:
+18 -9
View File
@@ -38,7 +38,9 @@
"type": "git",
"url": "git+ssh://git@github.com/outline/outline.git"
},
"browserslist": "> 0.25%, not dead",
"browserslist": [
"> 0.25%, not dead"
],
"dependencies": {
"@babel/core": "^7.16.0",
"@babel/plugin-proposal-decorators": "^7.10.5",
@@ -46,14 +48,16 @@
"@babel/plugin-transform-regenerator": "^7.10.4",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.16.0",
"@bull-board/api": "^3.5.0",
"@bull-board/koa": "^3.5.0",
"@bull-board/api": "^3.11.1",
"@bull-board/koa": "^3.11.1",
"@dnd-kit/core": "^4.0.3",
"@dnd-kit/modifiers": "^4.0.0",
"@dnd-kit/sortable": "^5.1.0",
"@getoutline/y-prosemirror": "^1.0.18",
"@hocuspocus/provider": "^1.0.0-alpha.36",
"@hocuspocus/server": "^1.0.0-alpha.102",
"@joplin/turndown-plugin-gfm": "^1.0.44",
"@juggle/resize-observer": "^3.3.1",
"@outlinewiki/koa-passport": "^4.1.4",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@renderlesskit/react": "^0.6.0",
@@ -68,9 +72,11 @@
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-styled-components": "^1.11.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"body-scroll-lock": "^4.0.0-beta.0",
"bull": "^3.29.0",
"cancan": "3.1.0",
"chalk": "^4.1.0",
"class-validator": "^0.13.2",
"compressorjs": "^1.0.7",
"copy-to-clipboard": "^3.3.1",
"core-js": "^3.10.2",
@@ -78,6 +84,7 @@
"datadog-metrics": "^0.9.3",
"date-fns": "^2.25.0",
"dotenv": "^4.0.0",
"email-providers": "^1.13.1",
"emoji-regex": "^10.0.0",
"es6-error": "^4.1.1",
"exports-loader": "^0.6.4",
@@ -97,7 +104,6 @@
"invariant": "^2.2.4",
"ioredis": "^4.28.0",
"is-printable-key-event": "^1.0.0",
"joplin-turndown-plugin-gfm": "^1.0.12",
"json-loader": "0.5.4",
"jsonwebtoken": "^8.5.0",
"jszip": "^3.7.1",
@@ -124,6 +130,7 @@
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
"natural-sort": "^1.0.0",
"node-htmldiff": "^0.9.3",
"nodemailer": "^6.6.1",
"outline-icons": "^1.42.0",
"oy-vey": "^0.10.0",
@@ -162,6 +169,7 @@
"react-helmet": "^6.1.0",
"react-i18next": "^11.16.6",
"react-medium-image-zoom": "^3.1.3",
"react-merge-refs": "^1.1.0",
"react-portal": "^4.2.0",
"react-router-dom": "^5.2.0",
"react-table": "^7.7.0",
@@ -173,10 +181,10 @@
"refractor": "^3.5.0",
"regenerator-runtime": "^0.13.7",
"semver": "^7.3.2",
"sequelize": "^6.9.0",
"sequelize-cli": "^6.3.0",
"sequelize": "^6.20.1",
"sequelize-cli": "^6.4.1",
"sequelize-encrypted": "^1.0.0",
"sequelize-typescript": "^2.1.1",
"sequelize-typescript": "^2.1.3",
"slate": "0.45.0",
"slate-md-serializer": "5.5.4",
"slug": "^4.0.4",
@@ -208,6 +216,7 @@
"@babel/preset-typescript": "^7.16.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@relative-ci/agent": "^3.0.0",
"@types/body-scroll-lock": "^3.1.0",
"@types/bull": "^3.15.5",
"@types/crypto-js": "^4.1.0",
"@types/datadog-metrics": "^0.6.2",
@@ -316,7 +325,7 @@
"webpack-cli": "^3.3.12",
"webpack-manifest-plugin": "^3.0.0",
"webpack-pwa-manifest": "^4.3.0",
"workbox-webpack-plugin": "^6.3.0",
"workbox-webpack-plugin": "^6.5.3",
"yarn-deduplicate": "^3.1.0"
},
"resolutions": {
@@ -326,5 +335,5 @@
"dot-prop": "^5.2.0",
"js-yaml": "^3.14.1"
},
"version": "0.63.0"
"version": "0.64.3"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ import {
onLoadDocumentPayload,
Extension,
} from "@hocuspocus/server";
import Logger from "@server/logging/logger";
import Logger from "@server/logging/Logger";
export default class LoggerExtension implements Extension {
async onLoadDocument(data: onLoadDocumentPayload) {
+2 -1
View File
@@ -6,7 +6,7 @@ import {
import invariant from "invariant";
import * as Y from "yjs";
import { sequelize } from "@server/database/sequelize";
import Logger from "@server/logging/logger";
import Logger from "@server/logging/Logger";
import { APM } from "@server/logging/tracing";
import Document from "@server/models/Document";
import documentCollaborativeUpdater from "../commands/documentCollaborativeUpdater";
@@ -54,6 +54,7 @@ export default class PersistenceExtension implements Extension {
state: Buffer.from(state),
},
{
silent: true,
hooks: false,
transaction,
}
+184 -9
View File
@@ -1,8 +1,10 @@
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import env from "@server/env";
import { TeamDomain } from "@server/models";
import Collection from "@server/models/Collection";
import UserAuthentication from "@server/models/UserAuthentication";
import { buildUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import { flushdb, seed } from "@server/test/support";
import accountProvisioner from "./accountProvisioner";
beforeEach(() => {
@@ -13,12 +15,14 @@ describe("accountProvisioner", () => {
const ip = "127.0.0.1";
it("should create a new user and team", async () => {
env.DEPLOYMENT = "hosted";
const spy = jest.spyOn(WelcomeEmail, "schedule");
const { user, team, isNewTeam, isNewUser } = await accountProvisioner({
ip,
user: {
name: "Jenny Tester",
email: "jenny@example.com",
email: "jenny@example-company.com",
avatarUrl: "https://example.com/avatar.png",
username: "jtester",
},
@@ -29,7 +33,7 @@ describe("accountProvisioner", () => {
},
authenticationProvider: {
name: "google",
providerId: "example.com",
providerId: "example-company.com",
},
authentication: {
providerId: "123456789",
@@ -43,7 +47,7 @@ describe("accountProvisioner", () => {
expect(auth.scopes.length).toEqual(1);
expect(auth.scopes[0]).toEqual("read");
expect(team.name).toEqual("New team");
expect(user.email).toEqual("jenny@example.com");
expect(user.email).toEqual("jenny@example-company.com");
expect(user.username).toEqual("jtester");
expect(isNewUser).toEqual(true);
expect(isNewTeam).toEqual(true);
@@ -64,7 +68,7 @@ describe("accountProvisioner", () => {
});
const authentications = await existing.$get("authentications");
const authentication = authentications[0];
const newEmail = "test@example.com";
const newEmail = "test@example-company.com";
const newUsername = "tname";
const { user, isNewUser, isNewTeam } = await accountProvisioner({
ip,
@@ -148,16 +152,66 @@ describe("accountProvisioner", () => {
expect(error).toBeTruthy();
});
it("should create a new user in an existing team", async () => {
it("should throw an error when the domain is not allowed", async () => {
const { admin, team: existingTeam } = await seed();
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
await TeamDomain.create({
teamId: existingTeam.id,
name: "other.com",
createdById: admin.id,
});
let error;
try {
await accountProvisioner({
ip,
user: {
name: "Jenny Tester",
email: "jenny@example-company.com",
avatarUrl: "https://example.com/avatar.png",
username: "jtester",
},
team: {
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain: "example",
},
authenticationProvider: {
name: authenticationProvider.name,
providerId: authenticationProvider.providerId,
},
authentication: {
providerId: "123456789",
accessToken: "123",
scopes: ["read"],
},
});
} catch (err) {
error = err;
}
expect(error).toBeTruthy();
});
it("should create a new user in an existing team when the domain is allowed", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const team = await buildTeam();
const { admin, team } = await seed();
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
await TeamDomain.create({
teamId: team.id,
name: "example-company.com",
createdById: admin.id,
});
const { user, isNewUser } = await accountProvisioner({
ip,
user: {
name: "Jenny Tester",
email: "jenny@example.com",
email: "jenny@example-company.com",
avatarUrl: "https://example.com/avatar.png",
username: "jtester",
},
@@ -181,7 +235,7 @@ describe("accountProvisioner", () => {
expect(auth.accessToken).toEqual("123");
expect(auth.scopes.length).toEqual(1);
expect(auth.scopes[0]).toEqual("read");
expect(user.email).toEqual("jenny@example.com");
expect(user.email).toEqual("jenny@example-company.com");
expect(user.username).toEqual("jtester");
expect(isNewUser).toEqual(true);
expect(spy).toHaveBeenCalled();
@@ -191,4 +245,125 @@ describe("accountProvisioner", () => {
spy.mockRestore();
});
it("should create a new user in an existing team", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const team = await buildTeam();
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const { user, isNewUser } = await accountProvisioner({
ip,
user: {
name: "Jenny Tester",
email: "jenny@example-company.com",
avatarUrl: "https://example.com/avatar.png",
username: "jtester",
},
team: {
name: team.name,
avatarUrl: team.avatarUrl,
subdomain: "example",
},
authenticationProvider: {
name: authenticationProvider.name,
providerId: authenticationProvider.providerId,
},
authentication: {
providerId: "123456789",
accessToken: "123",
scopes: ["read"],
},
});
const authentications = await user.$get("authentications");
const auth = authentications[0];
expect(auth.accessToken).toEqual("123");
expect(auth.scopes.length).toEqual(1);
expect(auth.scopes[0]).toEqual("read");
expect(user.email).toEqual("jenny@example-company.com");
expect(user.username).toEqual("jtester");
expect(isNewUser).toEqual(true);
expect(spy).toHaveBeenCalled();
// should provision welcome collection
const collectionCount = await Collection.count();
expect(collectionCount).toEqual(1);
spy.mockRestore();
});
describe("self hosted", () => {
it("should fail if existing team and domain not in allowed list", async () => {
env.DEPLOYMENT = undefined;
let error;
const team = await buildTeam();
try {
await accountProvisioner({
ip,
user: {
name: "Jenny Tester",
email: "jenny@example-company.com",
avatarUrl: "https://example.com/avatar.png",
username: "jtester",
},
team: {
name: team.name,
avatarUrl: team.avatarUrl,
subdomain: "example",
},
authenticationProvider: {
name: "google",
providerId: "example-company.com",
},
authentication: {
providerId: "123456789",
accessToken: "123",
scopes: ["read"],
},
});
} catch (err) {
error = err;
}
expect(error.message).toEqual(
"The maximum number of teams has been reached"
);
});
it("should always use existing team if self-hosted", async () => {
env.DEPLOYMENT = undefined;
const team = await buildTeam();
const { user, isNewUser } = await accountProvisioner({
ip,
user: {
name: "Jenny Tester",
email: "jenny@example-company.com",
avatarUrl: "https://example.com/avatar.png",
username: "jtester",
},
team: {
name: team.name,
avatarUrl: team.avatarUrl,
subdomain: "example",
domain: "allowed-domain.com",
},
authenticationProvider: {
name: "google",
providerId: "allowed-domain.com",
},
authentication: {
providerId: "123456789",
accessToken: "123",
scopes: ["read"],
},
});
expect(user.teamId).toEqual(team.id);
expect(user.username).toEqual("jtester");
expect(isNewUser).toEqual(true);
const providers = await team.$get("authenticationProviders");
expect(providers.length).toEqual(2);
});
});
});
+4
View File
@@ -34,6 +34,7 @@ type Props = {
scopes: string[];
accessToken?: string;
refreshToken?: string;
expiresIn?: number;
};
};
@@ -83,6 +84,9 @@ async function accountProvisioner({
ip,
authentication: {
...authenticationParams,
expiresAt: authenticationParams.expiresIn
? new Date(Date.now() + authenticationParams.expiresIn * 1000)
: undefined,
authenticationProviderId: authenticationProvider.id,
},
});
+40 -19
View File
@@ -1,5 +1,11 @@
import { Transaction } from "sequelize";
import { APM } from "@server/logging/tracing";
import { Collection, Event, Team, User, FileOperation } from "@server/models";
import {
FileOperationType,
FileOperationState,
FileOperationFormat,
} from "@server/models/FileOperation";
import { getAWSKeyForFileOp } from "@server/utils/s3";
async function collectionExporter({
@@ -7,34 +13,49 @@ async function collectionExporter({
team,
user,
ip,
transaction,
}: {
collection?: Collection;
team: Team;
user: User;
ip: string;
transaction: Transaction;
}) {
const collectionId = collection?.id;
const key = getAWSKeyForFileOp(user.teamId, collection?.name || team.name);
const fileOperation = await FileOperation.create({
type: "export",
state: "creating",
key,
url: null,
size: 0,
collectionId,
userId: user.id,
teamId: user.teamId,
});
const fileOperation = await FileOperation.create(
{
type: FileOperationType.Export,
state: FileOperationState.Creating,
format: FileOperationFormat.MarkdownZip,
key,
url: null,
size: 0,
collectionId,
userId: user.id,
teamId: user.teamId,
},
{
transaction,
}
);
// Event is consumed on worker in queues/processors/exports
await Event.create({
name: collection ? "collections.export" : "collections.export_all",
collectionId,
teamId: user.teamId,
actorId: user.id,
modelId: fileOperation.id,
ip,
});
await Event.create(
{
name: "fileOperations.create",
teamId: user.teamId,
actorId: user.id,
modelId: fileOperation.id,
collectionId,
ip,
data: {
type: FileOperationType.Import,
},
},
{
transaction,
}
);
fileOperation.user = user;
+55 -46
View File
@@ -3,6 +3,7 @@ import invariant from "invariant";
import { uniq } from "lodash";
import { Node } from "prosemirror-model";
import * as Y from "yjs";
import { sequelize } from "@server/database/sequelize";
import { schema, serializer } from "@server/editor";
import { Document, Event } from "@server/models";
@@ -15,56 +16,64 @@ export default async function documentCollaborativeUpdater({
ydoc: Y.Doc;
userId?: string;
}) {
const document = await Document.scope("withState").findByPk(documentId);
invariant(document, "document not found");
return sequelize.transaction(async (transaction) => {
const document = await Document.unscoped()
.scope("withState")
.findByPk(documentId, {
transaction,
lock: {
of: Document,
level: transaction.LOCK.UPDATE,
},
paranoid: false,
});
invariant(document, "document not found");
const state = Y.encodeStateAsUpdate(ydoc);
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
const text = serializer.serialize(node, undefined);
const isUnchanged = document.text === text;
const hasMultiplayerState = !!document.state;
const state = Y.encodeStateAsUpdate(ydoc);
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
const text = serializer.serialize(node, undefined);
const isUnchanged = document.text === text;
const hasMultiplayerState = !!document.state;
if (isUnchanged && hasMultiplayerState) {
return;
}
// extract collaborators from doc user data
const pud = new Y.PermanentUserData(ydoc);
const pudIds = Array.from(pud.clients.values());
const existingIds = document.collaboratorIds;
const collaboratorIds = uniq([...pudIds, ...existingIds]);
await Document.scope(["withDrafts", "withState"]).update(
{
text,
state: Buffer.from(state),
updatedAt: isUnchanged ? document.updatedAt : new Date(),
lastModifiedById:
isUnchanged || !userId ? document.lastModifiedById : userId,
collaboratorIds,
},
{
silent: true,
hooks: false,
where: {
id: documentId,
},
if (isUnchanged && hasMultiplayerState) {
return;
}
);
if (isUnchanged) {
return;
}
// extract collaborators from doc user data
const pud = new Y.PermanentUserData(ydoc);
const pudIds = Array.from(pud.clients.values());
const existingIds = document.collaboratorIds;
const collaboratorIds = uniq([...pudIds, ...existingIds]);
await Event.schedule({
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: userId,
data: {
multiplayer: true,
title: document.title,
},
await document.update(
{
text,
state: Buffer.from(state),
lastModifiedById:
isUnchanged || !userId ? document.lastModifiedById : userId,
collaboratorIds,
},
{
transaction,
silent: isUnchanged,
hooks: false,
}
);
if (isUnchanged) {
return;
}
await Event.schedule({
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: userId,
data: {
multiplayer: true,
title: document.title,
},
});
});
}
+8 -23
View File
@@ -1,11 +1,9 @@
import path from "path";
import emojiRegex from "emoji-regex";
import { strikethrough, tables } from "joplin-turndown-plugin-gfm";
import { truncate } from "lodash";
import mammoth from "mammoth";
import quotedPrintable from "quoted-printable";
import { Transaction } from "sequelize";
import TurndownService from "turndown";
import utf8 from "utf8";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import parseTitle from "@shared/utils/parseTitle";
@@ -13,28 +11,10 @@ import { APM } from "@server/logging/tracing";
import { User } from "@server/models";
import dataURItoBuffer from "@server/utils/dataURItoBuffer";
import parseImages from "@server/utils/parseImages";
import turndownService from "@server/utils/turndown";
import { FileImportError, InvalidRequestError } from "../errors";
import attachmentCreator from "./attachmentCreator";
// https://github.com/domchristie/turndown#options
const turndownService = new TurndownService({
hr: "---",
bulletListMarker: "-",
headingStyle: "atx",
}).remove(["script", "style", "title", "head"]);
// Use the GitHub-flavored markdown plugin to parse
// strikethoughs and tables
turndownService
.use(strikethrough)
.use(tables)
.addRule("breaks", {
filter: ["br"],
replacement: function () {
return "\n";
},
});
interface ImportableFile {
type: string;
getMarkdown: (content: Buffer | string) => Promise<string>;
@@ -200,7 +180,8 @@ async function documentImporter({
const regex = emojiRegex();
const matches = regex.exec(text);
const firstEmoji = matches ? matches[0] : undefined;
if (firstEmoji && text.startsWith(firstEmoji)) {
const textStartsWithEmoji = firstEmoji && text.startsWith(firstEmoji);
if (textStartsWithEmoji) {
text = text.replace(firstEmoji, "").trim();
}
@@ -213,10 +194,14 @@ async function documentImporter({
}
// If we parsed an emoji from _above_ the title then add it back at prefixing
if (firstEmoji) {
if (textStartsWithEmoji) {
title = `${firstEmoji} ${title}`;
}
// Replace any <br> generated by the turndown plugin with escaped newlines
// to match our hardbreak parser.
text = text.replace(/<br>/gi, "\\n");
// find data urls, convert to blobs, upload and write attachments
const images = parseImages(text);
const dataURIs = images.filter((href) => href.startsWith("data:"));

Some files were not shown because too many files have changed in this diff Show More