Compare commits

...

99 Commits

Author SHA1 Message Date
Tom Moor f277d08982 fix: Actor on users.delete 2022-10-22 11:17:31 -04:00
Tom Moor 49d53ccfc2 fix: Disallow adding self to collection (#4299)
* api

* ui

* update collection permissions
2022-10-22 11:14:18 -04:00
Tom Moor 98e44f528f 0.66.1 2022-09-21 09:05:39 -04:00
Tom Moor 0e79795856 fix: Cannot download export result, closes #4059 2022-09-21 09:05:26 -04:00
Tom Moor 09b2d0babe 0.66.0 2022-09-05 00:07:38 +02:00
Translate-O-Tron 18821fdee2 New Crowdin updates (#4004) 2022-09-04 03:56:48 -07:00
Tom Moor c8b12a59e2 fix: Post-signin redirect path is no longer saved (#4054)
closes #4045
2022-09-04 03:56:12 -07:00
Tom Moor c964163cc5 fix: Handle GitLab can be configured for tokens to not expire. (#4051)
closes #4040
2022-09-04 03:56:00 -07:00
Tom Moor c9156ae399 fix: Login screen not vertically centered on mobile (#4052) 2022-09-04 00:14:32 -07:00
Tom Moor e0e87ea6a2 fix: Allow backlinks to work with fully qualified urls and anchors (#4050)
closes #4048
2022-09-04 00:14:21 -07:00
Tom Moor e1b0e94fd5 Wrap code blocks when printing, closes #4001 2022-09-03 23:39:22 +02:00
Tom Moor 2fa5e5c796 fix: Incorrect validation 2022-09-02 20:56:13 +02:00
Tom Moor 0882a50cfd fix: Requests using GET that should be POST, related #4042 2022-09-02 10:45:20 +02:00
Tom Moor c85f3bd7b4 fix: Remove ability to use GET for RPC API requests by default (#4042)
* fix: Remove ability to use GET for RPC API requests by default

* tsc
2022-09-02 01:05:40 -07:00
Nicolas Caluori 2d29f0f042 Content is displayed wrongly when printing / Save as PDF (#4043) 2022-09-02 01:05:09 -07:00
dependabot[bot] 67d119f932 chore(deps): bump moment-timezone from 0.5.34 to 0.5.37 (#4037)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-31 11:48:30 +05:30
Tom Moor 32b76303e5 Add simple count of views to share links (#4036)
* Add simple count of views to share links

* Remove no longer applicable tests

* Avoid incrementing view count for known bots
2022-08-30 23:16:40 -07:00
Tom Moor 212985e18f feat: Allow viewers to be upgraded to editors on individual collections (#4023)
* Improve types

* More types, fix default permission for viewers added to collection

* fix change of default role for CollectionGroup

* Restore policy

* test

* tests
2022-08-30 23:12:27 -07:00
Tom Moor b8115ae3ce fix: Add url validation to team and user avatar fields 2022-08-30 23:05:57 +02:00
Tom Moor 264f19d255 fix: Suppress TooManyRequestsError to error tracker 2022-08-28 21:17:51 +02:00
Tom Moor 6fc1cbc0ce fix: Unneccessary requests made on share links 2022-08-27 20:45:07 +02:00
Tom Moor 3cc3cd8cf8 fix: Do not replace SSR title with 'Untitled', closes #3985 2022-08-27 20:20:59 +02:00
Tom Moor b9f1fde2e3 test 2022-08-27 13:39:11 +02:00
Tom Moor a1d4cca9d9 Add support for document subscriptions to websockets 2022-08-27 12:53:40 +02:00
Tom Moor 922bf53753 fix: Document subscriptions backfill not recursive 2022-08-27 11:58:21 +02:00
Tom Moor 1c8fadbe02 Merge branch 'tom/socket-refactor' 2022-08-27 11:51:38 +02:00
Apoorv Mishra 4dbad4e46c feat: Support embed configuration (#3980)
* wip

* stash

* fix: make authenticationId nullable fk

* fix: apply generics to resolve compile time type errors

* fix: loosen integration settings

* chore: refactor into functional component

* feat: pass integrations all the way to embeds

* perf: avoid re-fetching integrations

* fix: change attr name to avoid type overlap

* feat: use hostname from embed settings in matcher

* Revert "feat: use hostname from embed settings in matcher"

This reverts commit e7485d9cda.

* feat: refactor  into a class

* chore: refactor url regex formation as a util

* fix: escape regex special chars

* fix: remove in-house escapeRegExp in favor of lodash's

* fix: sanitize url

* perf: memoize embeds

* fix: rename hostname to url and allow spreading entire settings instead of just url

* fix: replace diagrams with drawio

* fix: rename

* fix: support self-hosted and saas both

* fix: assert on settings url

* fix: move embed integrations loading to hook

* fix: address review comments

* fix: use observer in favor of explicit state setters

* fix: refactor useEmbedIntegrations into useEmbeds

* fix: use translations for toasts

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-08-26 12:21:46 +05:30
CuriousCorrelation 24c71c38a5 feat: Document subscriptions (#3834)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-08-25 23:47:13 -07:00
Tom Moor 354a68a8b7 Remove long-deprecated documents.star/documents.unstar 2022-08-25 21:51:34 +02:00
Tom Moor bb12f1fabb SocketProvider -> WebsocketProvider 2022-08-25 21:34:54 +02:00
Tom Moor debadcb711 fix: Wrap websocket handlers in action
Separate documents.archive
2022-08-25 21:34:54 +02:00
Tom Moor d2aea687f3 Remove collection fetch on document delete 2022-08-25 21:34:54 +02:00
Tom Moor 60309975e0 Allow usePolicy to fetch missing policies 2022-08-25 21:34:54 +02:00
Tom Moor 983010b5d8 fix: collections.create event not propagated when initialized with private permissions 2022-08-25 21:34:54 +02:00
Tom Moor de5524d366 Add tracing around websocket processor 2022-08-25 21:34:54 +02:00
Tom Moor c62bfc4a60 Separate documents.update event 2022-08-25 21:34:54 +02:00
Tom Moor 7804f33e0d Separate teams.update event 2022-08-25 21:34:54 +02:00
Tom Moor d17e6f3432 Separate documents.delete event 2022-08-25 21:34:54 +02:00
Tom Moor 4f1277f912 Separate groups.delete event 2022-08-25 21:34:54 +02:00
Tom Moor b172da6fdf Separate collections.delete event 2022-08-25 21:34:54 +02:00
Tom Moor 138bc367dd types 2022-08-25 21:34:54 +02:00
Tom Moor c657134b46 types 2022-08-25 21:34:54 +02:00
Tom Moor 864f585e5b chore: Remove long deprecated database columns (#3821)
* chore: Remove long deprecated database columns

* test

* Update 20220720221531-remove-deprecated-columns.js

* fix rollback

* Add guard for upgrading past v0.54.0
2022-08-25 11:52:01 -07:00
Tom Moor a869ab7609 fix: Improve error messaging when file cannot be fetched for import
related #4006
2022-08-24 21:25:19 +02:00
Tom Moor a3d8e6c8fc chore: Add db:create command 2022-08-24 00:04:37 -07:00
Tom Moor 68f24fce21 fix: Add support for new clickup sharing links 2022-08-23 23:04:21 +02:00
Tom Moor f0cbbee4b8 Merge branch 'main' of github.com:outline/outline 2022-08-23 22:58:45 +02:00
Tom Moor 7345d0c256 Remove links to valid files 2022-08-23 10:21:35 -07:00
Tom Moor ee05a8a0ca Merge branch 'main' of github.com:outline/outline 2022-08-23 09:58:03 +02:00
Translate-O-Tron fd7e0ef41f New Crowdin updates (#3958) 2022-08-22 11:41:16 -07:00
Tom Moor 1e5cf2d960 chore: Update dd-trace 2022-08-22 14:47:19 +02:00
Tom Moor 421312b845 Possible fix for #3986 2022-08-22 09:47:47 +02:00
Tom Moor f1bd4a5b31 Merge branch '3991-add-explicit-timeouts-to-requests' 2022-08-22 09:21:22 +02:00
Tom Moor 72b0e78788 fix: Validate uuid on attachments.create endpoint 2022-08-20 23:46:01 +02:00
Tom Moor 8302840ab5 feat: Add timeout to incoming requests 2022-08-19 08:14:11 +02:00
Tom Moor f32f07cdcc chore: Refactor user activation to command 2022-08-18 11:24:27 +02:00
Tom Moor f620a9d34c fix: Cannot start without --services argument, regressed in 41d7cc26b5
closes #3984
2022-08-18 09:48:28 +02:00
Tom Moor 7113b5f604 fix: Restore user deletion through API, increase rate limit 2022-08-17 22:40:00 +02:00
Tom Moor 41d7cc26b5 chore: Adds name to Redis connections for debugging (#3982)
* chore: Adds name to Redis connections for debugging, minor associated refactoring

* Upgrade bull, ioredis

* Add pid to redis connection name in development
2022-08-17 12:55:57 -07:00
Tom Moor e57941732a fix: emoji column no longer filled in db, simplified state length validation 2022-08-16 22:05:10 +02:00
Tom Moor a738b51d87 chore: Add additional logging for unknown request errors 2022-08-16 19:49:15 +02:00
Tom Moor 85dab03820 docs 2022-08-16 19:43:50 +02:00
Tom Moor ed8176ca7d fix: Limit ws payload size 2022-08-16 10:27:55 +02:00
Tom Moor cfa7ecd7f8 fix: Add missing validation to document state 2022-08-16 09:35:31 +02:00
github-actions[bot] 44a4aee5cf chore: Auto Compress Images (#3977)
Co-authored-by: apoorv-mishra <apoorv-mishra@users.noreply.github.com>
2022-08-16 00:10:52 -07:00
Jonathan Harrrington 7ead17a8e0 Add support for Grist embeds. (#3914)
* Add support for Grist embeds.

* Change Grist integration to only support SaaS

* Update Regex

* Update shared/editor/embeds/index.tsx

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>

* Change Grist embed to use function based API

* Convert standard URL into embed url

* Update shared/editor/embeds/Grist.tsx

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>

* Update shared/editor/embeds/Grist.tsx

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>

* Update shared/editor/embeds/Grist.tsx

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>

* Lint and test updates

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-08-16 09:17:20 +05:30
Apoorv Mishra 7a758f84a0 chore: refactor server test setup (#3976)
* chore: refactor server test setup

* Close dangling redis connections instead of mocking rate limiter
  specific modules
* Segregate pre and post env test setup

* fix: remove mock file
2022-08-16 09:16:57 +05:30
Tom Moor 93bb9d067d fix: H1 and title should be different sizes, closes #3975 2022-08-15 23:02:35 +02:00
Tom Moor 9f3266abaf Remove headings 4 and below from TOC, see:
https://github.com/outline/outline/discussions/3973
2022-08-15 22:46:49 +02:00
Tom Moor 4d0473c22c Reference email image by cid for self hosted instances (#3957) 2022-08-14 08:50:49 -07:00
Tom Moor d8b4814aa9 perf: Suppress Mermaid diagram rendering when hidden (#3963) 2022-08-14 08:50:37 -07:00
Tom Moor a326e0ee88 chore: Rate limiter audit (#3965)
* chore: Rate limiter audit api/users

* Make requests required

* api/collections

* Remove checkRateLimit on FileOperation (now done at route level through rate limiter)

* auth rate limit

* Add metric logging when rate limit exceeded

* Refactor to shared configs

* test
2022-08-14 08:04:04 -07:00
Tom Moor 9338328a82 fix: Add expiry to socket<->user mapping in Redis 2022-08-13 22:26:13 +02:00
Tom Moor 31931fc80c test: Remove --detectLeaks as this expiremental flag is good – but flakey, tests fail in CI that do not locally 2022-08-12 15:37:08 +02:00
Tom Moor 7deda03000 test: Fix test memory leakage by mocking RateLimiter 2022-08-12 15:14:58 +02:00
Nan Yu 990de127e3 feat: add session switching to the root action menu (#3925)
* feat: add session switching to the root action menu

* minor fixes

* stylistic consistency

* capitalize account section

* minor fix
2022-08-12 05:11:22 -07:00
Apoorv Mishra 0c51bfb899 perf: reduce memory usage upon running server tests (#3949)
* perf: reduce memory usage upon running server tests

* perf: plug leaks in server/routes

* perf: plug leaks in server/scripts

* perf: plug leaks in server/policies

* perf: plug leaks in server/models

* perf: plug leaks in server/middlewares

* perf: plug leaks in server/commands

* fix: missing await on db.flush

* perf: plug leaks in server/queues

* chore: remove unused legacy funcs

* fix: await on db.flush

* perf: await on GC to run in between tests

* fix: remove db refs

* fix: revert embeds

* perf: plug leaks in shared/i18n
2022-08-11 21:39:17 +05:30
akp 8e1f42a9cb Add optional export notifications (#3935)
* Add `emails.export_completed` notification to settings menu

Signed-off-by: AKP <tom@tdpain.net>

* Don't send email when export_completed notifications are disabled

Signed-off-by: AKP <tom@tdpain.net>

* Automatically subscribe new users to `export_completed` notifications

Signed-off-by: AKP <tom@tdpain.net>

* Alter secondary text on export page to mention optional notifications

Signed-off-by: AKP <tom@tdpain.net>

* Alter toast text on collection export for optional notifications

Signed-off-by: AKP <tom@tdpain.net>

* Only subscribe new admins to export notifs

Signed-off-by: AKP <tom@tdpain.net>

* Move `export_completed` notification decision into `beforeSend`

Signed-off-by: AKP <tom@tdpain.net>

* Update server/emails/templates/ExportFailureEmail.tsx

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

* Update server/emails/templates/ExportSuccessEmail.tsx

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

Signed-off-by: AKP <tom@tdpain.net>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-08-11 07:31:35 -07:00
Tom Moor 1adcce6b5d fix: Upgrade markdown-it to fix text collapse bug (#3953)
* fix: Upgrade markdown-it to fix text collapse bug

* tsc. Need to overwrite the types for now until all Prosemirror modules are updated, they have recently been converted to Typescript and the types conflict
2022-08-11 06:31:52 -07:00
Translate-O-Tron a5d611d544 New Crowdin updates (#3795) 2022-08-11 05:46:21 -07:00
Tom Moor 1d242d44b1 chore: Add eslint rule for object shorthand (#3955) 2022-08-11 05:18:14 -07:00
Apoorv Mishra 7eaa8eb961 feat: Put request rate limit at application server (#3857)
* feat: Put request rate limit at application server

This PR contains implementation for a blanket rate limiter at
application server level. Currently the allowed throughput is set high
only to be changed later as per the actual data gathered.

* Simplify implementation

1. Remove shutdown handler to purge rate limiter keys
2. Have separate keys for default and custom(route-based) rate limiters
3. Do not kill default rate limiter because it is not needed anymore due
   to (2) above

* Set 60s as default for rate limiting window

* Fix env types
2022-08-11 15:40:30 +05:30
Tom Moor 593cf73118 test: Update jest configuration (#3951)
* Split shared tests

* Centralize and parallelize jest config

* ci
2022-08-10 13:26:36 -07:00
Tom Moor e5c5e8907a fix: Disallow data: URI's for images 2022-08-09 16:31:09 +02:00
dependabot[bot] 5640ec30cc chore(deps): bump compressorjs from 1.0.7 to 1.1.1 (#3943)
Bumps [compressorjs](https://github.com/fengyuanchen/compressorjs) from 1.0.7 to 1.1.1.
- [Release notes](https://github.com/fengyuanchen/compressorjs/releases)
- [Changelog](https://github.com/fengyuanchen/compressorjs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fengyuanchen/compressorjs/compare/v1.0.7...v1.1.1)

---
updated-dependencies:
- dependency-name: compressorjs
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-08 09:13:03 -07:00
dependabot[bot] da67486f2f chore(deps): bump aws-sdk from 2.1044.0 to 2.1189.0 (#3942)
Bumps [aws-sdk](https://github.com/aws/aws-sdk-js) from 2.1044.0 to 2.1189.0.
- [Release notes](https://github.com/aws/aws-sdk-js/releases)
- [Changelog](https://github.com/aws/aws-sdk-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js/compare/v2.1044.0...v2.1189.0)

---
updated-dependencies:
- dependency-name: aws-sdk
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-08 09:11:55 -07:00
Tom Moor 8c39487c80 Move various document menu actions to action definitions 2022-08-08 17:31:53 +02:00
dependabot[bot] 3ab9d7492e chore(deps): bump react-merge-refs from 1.1.0 to 2.0.1 (#3903)
* chore(deps): bump react-merge-refs from 1.1.0 to 2.0.1

Bumps [react-merge-refs](https://github.com/gregberge/react-merge-refs) from 1.1.0 to 2.0.1.
- [Release notes](https://github.com/gregberge/react-merge-refs/releases)
- [Changelog](https://github.com/gregberge/react-merge-refs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gregberge/react-merge-refs/compare/v1.1.0...v2.0.1)

---
updated-dependencies:
- dependency-name: react-merge-refs
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* tsc

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-08-08 15:04:18 +01:00
dependabot[bot] 6a5d6ee3db chore(deps): bump oy-vey from 0.10.0 to 0.11.2 (#3902)
* chore(deps): bump oy-vey from 0.10.0 to 0.11.2

Bumps [oy-vey](https://github.com/oysterbooks/oy) from 0.10.0 to 0.11.2.
- [Release notes](https://github.com/oysterbooks/oy/releases)
- [Changelog](https://github.com/revivek/oy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/oysterbooks/oy/compare/0.10.0...0.11.2)

---
updated-dependencies:
- dependency-name: oy-vey
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* tsc

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-08-08 07:02:41 -07:00
Tom Moor 57f9871c22 Add NODE_ENV=production to env sample 2022-08-08 05:52:03 -07:00
Tom Moor dca491fc28 test: Fix frontend test failures from upgrading Jest to v28 2022-08-08 13:31:34 +02:00
Tom Moor e97cc61e2f test: Mock bull, fix setInterval capturing memory in tests
Towards #3939
2022-08-08 13:15:06 +02:00
Tom Moor ba385e1507 chore: Bump jest 2022-08-08 12:40:17 +02:00
Tom Moor 71c9fcf59b test: Avoid creation of new server/app instance for each route test 2022-08-08 12:06:54 +02:00
Tom Moor b45e6c504f fix: Prevent webhook delivery for deleted teams 2022-08-08 11:15:04 +02:00
Tom Moor 1b00d51c74 fix: Check WebhookSubscription is not disabled before delivery attempt 2022-08-08 11:10:10 +02:00
Tom Moor 7923a7e071 Enforce user invites/request on server 2022-08-08 11:02:37 +02:00
Tom Moor b37a848914 Add limit of 10 webhooks/team 2022-08-08 10:58:47 +02:00
github-actions[bot] dca9bc1598 chore: Compressed inefficient images automatically (#3933)
Co-authored-by: apoorv-mishra <apoorv-mishra@users.noreply.github.com>
2022-08-07 13:10:08 -07:00
307 changed files with 8929 additions and 4546 deletions
+14 -1
View File
@@ -70,6 +70,15 @@ jobs:
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
steps:
@@ -81,7 +90,7 @@ jobs:
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: yarn test:server
command: yarn test:server --forceExit
bundle-size:
<<: *defaults
steps:
@@ -140,6 +149,9 @@ workflows:
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
@@ -149,6 +161,7 @@ workflows:
- bundle-size:
requires:
- test-app
- test-shared
- test-server
build-docker:
+9
View File
@@ -1,5 +1,7 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
@@ -168,3 +170,10 @@ SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# Optionally enable rate limiter at application web server
RATE_LIMITER_ENABLED=true
# Configure default throttling parameters for rate limiter
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
+1
View File
@@ -25,6 +25,7 @@
"rules": {
"eqeqeq": 2,
"curly": 2,
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"es/no-regexp-lookbehind-assertions": "error",
+88
View File
@@ -0,0 +1,88 @@
{
"projects": [
{
"displayName": "server",
"verbose": false,
"roots": [
"<rootDir>/server"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/env.ts"
],
"setupFilesAfterEnv": [
"<rootDir>/server/test/setup.ts"
],
"testEnvironment": "node",
"runner": "@getoutline/jest-runner-serial"
},
{
"displayName": "app",
"verbose": false,
"roots": [
"<rootDir>/app"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"<rootDir>/app/test/setup.ts"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js"
],
"setupFilesAfterEnv": [
"<rootDir>/shared/test/setup.ts"
],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
-17
View File
@@ -1,17 +0,0 @@
export default class Queue {
name;
constructor(name) {
this.name = name;
}
process = (fn) => {
console.log(`Registered function ${this.name}`);
this.processFn = fn;
};
add = (data) => {
console.log(`Running ${this.name}`);
return this.processFn({ data });
};
}
-1
View File
@@ -1,2 +1 @@
// Mock for node-uuid
global.console.warn = () => {};
-27
View File
@@ -1,27 +0,0 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.ts"
],
"testEnvironment": "jsdom"
}
+203 -1
View File
@@ -11,9 +11,18 @@ import {
ImportIcon,
PinIcon,
SearchIcon,
UnsubscribeIcon,
SubscribeIcon,
MoveIcon,
TrashIcon,
CrossIcon,
ArchiveIcon,
} from "outline-icons";
import * as React from "react";
import { getEventFiles } from "@shared/utils/files";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
@@ -108,6 +117,68 @@ export const unstarDocument = createAction({
},
});
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
section: DocumentSection,
icon: <SubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
},
perform: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.subscribe();
stores.toasts.showToast(t("Subscribed to document notifications"), {
type: "success",
});
},
});
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
section: DocumentSection,
icon: <UnsubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
);
},
perform: ({ activeDocumentId, stores, currentUserId, t }) => {
if (!activeDocumentId || !currentUserId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.unsubscribe(currentUserId);
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
type: "success",
});
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
@@ -296,7 +367,7 @@ export const createTemplate = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate &&
@@ -329,15 +400,146 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Move {{ documentName }}", {
documentName: document.noun,
}),
content: (
<DocumentMove
document={document}
onRequestClose={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const archiveDocument = createAction({
name: ({ t }) => t("Archive"),
section: DocumentSection,
icon: <ArchiveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).archive;
},
perform: async ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.archive();
stores.toasts.showToast(t("Document archived"), {
type: "success",
});
}
},
});
export const deleteDocument = createAction({
name: ({ t }) => t("Delete"),
section: DocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).delete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
section: DocumentSection,
icon: <CrossIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).permanentDelete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Permanently delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentPermanentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createTemplate,
deleteDocument,
importDocument,
downloadDocument,
starDocument,
unstarDocument,
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
moveDocument,
permanentlyDeleteDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import styled from "styled-components";
import { createAction } from "~/actions";
import { loadSessionsFromCookie } from "~/hooks/useSessions";
export const changeTeam = createAction({
name: ({ t }) => t("Switch team"),
placeholder: ({ t }) => t("Select a team"),
keywords: "change workspace organization",
section: "Account",
visible: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.length > 0;
},
children: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.map((session) => ({
id: session.url,
name: session.name,
section: "Account",
icon: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
}));
},
});
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export const rootTeamActions = [changeTeam];
+1
View File
@@ -56,6 +56,7 @@ export function actionToMenuItem(
title,
icon,
visible,
dangerous: action.dangerous,
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
+2
View File
@@ -3,6 +3,7 @@ import { rootDeveloperActions } from "./definitions/developer";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
export default [
@@ -12,4 +13,5 @@ export default [
...rootNavigationActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
];
+1 -1
View File
@@ -25,7 +25,7 @@ function CollectionDescription({ collection }: Props) {
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection.id);
const can = usePolicy(collection);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
+4 -4
View File
@@ -49,8 +49,8 @@ function DocumentListItem(
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const user = useCurrentUser();
const team = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const {
@@ -70,7 +70,7 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = usePolicy(currentTeam.id);
const can = usePolicy(team);
const canCollection = usePolicy(document.collectionId);
return (
@@ -96,7 +96,7 @@ function DocumentListItem(
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy.id !== currentUser.id && (
{document.isBadgedNew && document.createdBy.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
+5 -3
View File
@@ -1,12 +1,12 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import mergeRefs from "react-merge-refs";
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 { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
@@ -18,6 +18,7 @@ import ErrorBoundary from "~/components/ErrorBoundary";
import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors";
@@ -58,6 +59,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { documents } = useStores();
const { showToast } = useToasts();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const [
activeLinkEvent,
setActiveLinkEvent,
@@ -310,4 +312,4 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
);
}
export default React.forwardRef(Editor);
export default observer(React.forwardRef(Editor));
+1 -1
View File
@@ -33,7 +33,7 @@ type Props = {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const location = useLocation();
const can = usePolicy(document.id);
const can = usePolicy(document);
const opts = {
userName: event.actor.name,
};
+3 -2
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import { CollectionPermission } from "@shared/types";
import InputSelect, { Props, Option } from "./InputSelect";
export default function InputSelectPermission(
@@ -31,11 +32,11 @@ export default function InputSelectPermission(
options={[
{
label: t("View and edit"),
value: "read_write",
value: CollectionPermission.ReadWrite,
},
{
label: t("View only"),
value: "read",
value: CollectionPermission.Read,
},
{
label: t("No access"),
+1 -1
View File
@@ -36,7 +36,7 @@ function AppSidebar() {
const { documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team.id);
const can = usePolicy(team);
React.useEffect(() => {
if (!user.isViewer) {
@@ -44,7 +44,7 @@ const CollectionLink: React.FC<Props> = ({
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const canUpdate = usePolicy(collection.id).update;
const canUpdate = usePolicy(collection).update;
const { t } = useTranslation();
const history = useHistory();
const inStarredSection = useStarredContext();
@@ -25,7 +25,7 @@ function CollectionLinkChildren({
expanded,
prefetchDocument,
}: Props) {
const can = usePolicy(collection.id);
const can = usePolicy(collection);
const { showToast } = useToasts();
const manualSort = collection.sort.field === "index";
const { documents } = useStores();
@@ -39,7 +39,7 @@ function DraggableCollectionLink({
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId && !locationStateStarred
);
const can = usePolicy(collection.id);
const can = usePolicy(collection);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Drop to reorder collection
-391
View File
@@ -1,391 +0,0 @@
import invariant from "invariant";
import { find } from "lodash";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { io, Socket } from "socket.io-client";
import RootStore from "~/stores/RootStore";
import withStores from "~/components/withStores";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
type SocketWithAuthentication = Socket & {
authenticated?: boolean;
};
export const SocketContext = React.createContext<SocketWithAuthentication | null>(
null
);
type Props = RootStore;
@observer
class SocketProvider extends React.Component<Props> {
@observable
socket: SocketWithAuthentication | null;
componentDidMount() {
this.createConnection();
document.addEventListener(getVisibilityListener(), this.checkConnection);
}
componentWillUnmount() {
if (this.socket) {
this.socket.authenticated = false;
this.socket.disconnect();
}
document.removeEventListener(getVisibilityListener(), this.checkConnection);
}
checkConnection = () => {
if (this.socket?.disconnected && getPageVisible()) {
// null-ifying this reference is important, do not remove. Without it
// references to old sockets are potentially held in context
this.socket.close();
this.socket = null;
this.createConnection();
}
};
createConnection = () => {
this.socket = io(window.location.origin, {
path: "/realtime",
transports: ["websocket"],
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
});
invariant(this.socket, "Socket should be defined");
this.socket.authenticated = false;
const {
auth,
toasts,
documents,
collections,
groups,
pins,
stars,
memberships,
policies,
presence,
views,
fileOperations,
} = this.props;
if (!auth.token) {
return;
}
this.socket.on("connect", () => {
// immediately send current users token to the websocket backend where it
// is verified, if all goes well an 'authenticated' message will be
// received in response
this.socket?.emit("authentication", {
token: auth.token,
});
});
this.socket.on("disconnect", () => {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.io.on("reconnect_attempt", () => {
if (this.socket) {
this.socket.io.opts.transports = auth?.team?.domain
? ["websocket"]
: ["websocket", "polling"];
}
});
this.socket.on("authenticated", () => {
if (this.socket) {
this.socket.authenticated = true;
}
});
this.socket.on("unauthorized", (err: Error) => {
if (this.socket) {
this.socket.authenticated = false;
}
toasts.showToast(err.message, {
type: "error",
});
throw err;
});
this.socket.on("entities", async (event: any) => {
if (event.documentIds) {
for (const documentDescriptor of event.documentIds) {
const documentId = documentDescriptor.id;
let document = documents.get(documentId) || {};
if (event.event === "documents.delete") {
const document = documents.get(documentId);
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
policies.remove(documentId);
continue;
}
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
const { title, updatedAt } = document;
if (updatedAt === documentDescriptor.updatedAt) {
continue;
}
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.remove(documentId);
return;
}
}
// if the title changed then we need to update the collection also
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
if (title !== document.title) {
if (!event.collectionIds) {
event.collectionIds = [];
}
const existing = find(event.collectionIds, {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
id: document.collectionId,
});
if (!existing) {
event.collectionIds.push({
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
id: document.collectionId,
});
}
}
}
}
if (event.collectionIds) {
for (const collectionDescriptor of event.collectionIds) {
const collectionId = collectionDescriptor.id;
const collection = collections.get(collectionId);
if (event.event === "collections.delete") {
if (collection) {
collection.deletedAt = collectionDescriptor.updatedAt;
}
const deletedDocuments = documents.inCollection(collectionId);
deletedDocuments.forEach((doc) => {
doc.deletedAt = collectionDescriptor.updatedAt;
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
continue;
}
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
try {
await collections.fetch(collectionId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
return;
}
}
}
}
if (event.groupIds) {
for (const groupDescriptor of event.groupIds) {
const groupId = groupDescriptor.id;
const group = groups.get(groupId) || {};
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type '{}'.
const { updatedAt } = group;
if (updatedAt === groupDescriptor.updatedAt) {
continue;
}
try {
await groups.fetch(groupId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
groups.remove(groupId);
}
}
}
}
if (event.teamIds) {
await auth.fetch();
}
});
this.socket.on("pins.create", (event: any) => {
pins.add(event);
});
this.socket.on("pins.update", (event: any) => {
pins.add(event);
});
this.socket.on("pins.delete", (event: any) => {
pins.remove(event.modelId);
});
this.socket.on("stars.create", (event: any) => {
stars.add(event);
});
this.socket.on("stars.update", (event: any) => {
stars.add(event);
});
this.socket.on("stars.delete", (event: any) => {
stars.remove(event.modelId);
});
this.socket.on("documents.permanent_delete", (event: any) => {
documents.remove(event.documentId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on("collections.add_user", (event: any) => {
if (auth.user && event.userId === auth.user.id) {
collections.fetch(event.collectionId, {
force: true,
});
}
// Document policies might need updating as the permission changes
documents.inCollection(event.collectionId).forEach((document) => {
policies.remove(document.id);
});
});
// received when a user is removed from having access to a collection
// to keep state in sync we must update our UI if the user is us,
// or otherwise just remove any membership state we have for that user.
this.socket.on("collections.remove_user", (event: any) => {
if (auth.user && event.userId === auth.user.id) {
collections.remove(event.collectionId);
memberships.removeCollectionMemberships(event.collectionId);
documents.removeCollectionDocuments(event.collectionId);
} else {
memberships.remove(`${event.userId}-${event.collectionId}`);
}
});
this.socket.on("collections.update_index", (event: any) => {
const collection = collections.get(event.collectionId);
if (collection) {
collection.updateIndex(event.index);
}
});
this.socket.on("fileOperations.create", async (event: any) => {
const user = auth.user;
if (user) {
fileOperations.add({ ...event, user });
}
});
this.socket.on("fileOperations.update", async (event: any) => {
const user = auth.user;
if (user) {
fileOperations.add({ ...event, user });
}
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event: any) => {
this.socket?.emit("join", event);
});
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
this.socket.on("leave", (event: any) => {
this.socket?.emit("leave", event);
});
// received whenever we join a document room, the payload includes
// userIds that are present/viewing and those that are editing.
this.socket.on("document.presence", (event: any) => {
presence.init(event.documentId, event.userIds, event.editingIds);
});
// received whenever a new user joins a document room, aka they
// navigate to / start viewing a document
this.socket.on("user.join", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
views.touch(event.documentId, event.userId);
});
// received whenever a new user leaves a document room, aka they
// navigate away / stop viewing a document
this.socket.on("user.leave", (event: any) => {
presence.leave(event.documentId, event.userId);
views.touch(event.documentId, event.userId);
});
// received when another client in a document room wants to change
// or update it's presence. Currently the only property is whether
// the client is in editing state or not.
this.socket.on("user.presence", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
};
render() {
return (
<SocketContext.Provider value={this.socket}>
{this.props.children}
</SocketContext.Provider>
);
}
}
export default withStores(SocketProvider);
+448
View File
@@ -0,0 +1,448 @@
import invariant from "invariant";
import { find } from "lodash";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { io, Socket } from "socket.io-client";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
import Pin from "~/models/Pin";
import Star from "~/models/Star";
import Subscription from "~/models/Subscription";
import Team from "~/models/Team";
import withStores from "~/components/withStores";
import {
PartialWithId,
WebsocketCollectionUpdateIndexEvent,
WebsocketCollectionUserEvent,
WebsocketEntitiesEvent,
WebsocketEntityDeletedEvent,
} from "~/types";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
type SocketWithAuthentication = Socket & {
authenticated?: boolean;
};
export const WebsocketContext = React.createContext<SocketWithAuthentication | null>(
null
);
type Props = RootStore;
@observer
class WebsocketProvider extends React.Component<Props> {
@observable
socket: SocketWithAuthentication | null;
componentDidMount() {
this.createConnection();
document.addEventListener(getVisibilityListener(), this.checkConnection);
}
componentWillUnmount() {
if (this.socket) {
this.socket.authenticated = false;
this.socket.disconnect();
}
document.removeEventListener(getVisibilityListener(), this.checkConnection);
}
checkConnection = () => {
if (this.socket?.disconnected && getPageVisible()) {
// null-ifying this reference is important, do not remove. Without it
// references to old sockets are potentially held in context
this.socket.close();
this.socket = null;
this.createConnection();
}
};
createConnection = () => {
this.socket = io(window.location.origin, {
path: "/realtime",
transports: ["websocket"],
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
});
invariant(this.socket, "Socket should be defined");
this.socket.authenticated = false;
const {
auth,
toasts,
documents,
collections,
groups,
pins,
stars,
memberships,
policies,
presence,
views,
subscriptions,
fileOperations,
} = this.props;
if (!auth.token) {
return;
}
this.socket.on("connect", () => {
// immediately send current users token to the websocket backend where it
// is verified, if all goes well an 'authenticated' message will be
// received in response
this.socket?.emit("authentication", {
token: auth.token,
});
});
this.socket.on("disconnect", () => {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.io.on("reconnect_attempt", () => {
if (this.socket) {
this.socket.io.opts.transports = auth?.team?.domain
? ["websocket"]
: ["websocket", "polling"];
}
});
this.socket.on("authenticated", () => {
if (this.socket) {
this.socket.authenticated = true;
}
});
this.socket.on("unauthorized", (err: Error) => {
if (this.socket) {
this.socket.authenticated = false;
}
toasts.showToast(err.message, {
type: "error",
});
throw err;
});
this.socket.on(
"entities",
action(async (event: WebsocketEntitiesEvent) => {
if (event.documentIds) {
for (const documentDescriptor of event.documentIds) {
const documentId = documentDescriptor.id;
let document = documents.get(documentId);
const previousTitle = document?.title;
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (document?.updatedAt === documentDescriptor.updatedAt) {
continue;
}
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.remove(documentId);
return;
}
}
// if the title changed then we need to update the collection also
if (document && previousTitle !== document.title) {
if (!event.collectionIds) {
event.collectionIds = [];
}
const existing = find(event.collectionIds, {
id: document.collectionId,
});
if (!existing) {
event.collectionIds.push({
id: document.collectionId,
});
}
}
}
}
if (event.collectionIds) {
for (const collectionDescriptor of event.collectionIds) {
const collectionId = collectionDescriptor.id;
const collection = collections.get(collectionId);
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
try {
await collections.fetch(collectionId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
return;
}
}
}
}
})
);
this.socket.on(
"documents.update",
action(
(event: PartialWithId<Document> & { title: string; url: string }) => {
documents.add(event);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.updateDocument(event);
}
}
)
);
this.socket.on(
"documents.archive",
action((event: PartialWithId<Document>) => {
documents.add(event);
policies.remove(event.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.removeDocument(event.id);
}
})
);
this.socket.on(
"documents.delete",
action((event: PartialWithId<Document>) => {
documents.add(event);
policies.remove(event.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.removeDocument(event.id);
}
})
);
this.socket.on(
"documents.permanent_delete",
(event: WebsocketEntityDeletedEvent) => {
documents.remove(event.modelId);
}
);
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
groups.add(event);
});
this.socket.on("groups.update", (event: PartialWithId<Group>) => {
groups.add(event);
});
this.socket.on("groups.delete", (event: WebsocketEntityDeletedEvent) => {
groups.remove(event.modelId);
});
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
collections.add(event);
});
this.socket.on(
"collections.delete",
action((event: WebsocketEntityDeletedEvent) => {
const collectionId = event.modelId;
const deletedAt = new Date().toISOString();
const deletedDocuments = documents.inCollection(collectionId);
deletedDocuments.forEach((doc) => {
doc.deletedAt = deletedAt;
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
})
);
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
auth.updateTeam(event);
});
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
pins.add(event);
});
this.socket.on("pins.update", (event: PartialWithId<Pin>) => {
pins.add(event);
});
this.socket.on("pins.delete", (event: WebsocketEntityDeletedEvent) => {
pins.remove(event.modelId);
});
this.socket.on("stars.create", (event: PartialWithId<Star>) => {
stars.add(event);
});
this.socket.on("stars.update", (event: PartialWithId<Star>) => {
stars.add(event);
});
this.socket.on("stars.delete", (event: WebsocketEntityDeletedEvent) => {
stars.remove(event.modelId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on(
"collections.add_user",
action((event: WebsocketCollectionUserEvent) => {
if (auth.user && event.userId === auth.user.id) {
collections.fetch(event.collectionId, {
force: true,
});
}
// Document policies might need updating as the permission changes
documents.inCollection(event.collectionId).forEach((document) => {
policies.remove(document.id);
});
})
);
// received when a user is removed from having access to a collection
// to keep state in sync we must update our UI if the user is us,
// or otherwise just remove any membership state we have for that user.
this.socket.on(
"collections.remove_user",
action((event: WebsocketCollectionUserEvent) => {
if (auth.user && event.userId === auth.user.id) {
collections.remove(event.collectionId);
memberships.removeCollectionMemberships(event.collectionId);
documents.removeCollectionDocuments(event.collectionId);
} else {
memberships.remove(`${event.userId}-${event.collectionId}`);
}
})
);
this.socket.on(
"collections.update_index",
action((event: WebsocketCollectionUpdateIndexEvent) => {
const collection = collections.get(event.collectionId);
if (collection) {
collection.updateIndex(event.index);
}
})
);
this.socket.on(
"fileOperations.create",
(event: PartialWithId<FileOperation>) => {
fileOperations.add(event);
}
);
this.socket.on(
"fileOperations.update",
(event: PartialWithId<FileOperation>) => {
fileOperations.add(event);
}
);
this.socket.on(
"subscriptions.create",
(event: PartialWithId<Subscription>) => {
subscriptions.add(event);
}
);
this.socket.on(
"subscriptions.delete",
(event: WebsocketEntityDeletedEvent) => {
subscriptions.remove(event.modelId);
}
);
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event: any) => {
this.socket?.emit("join", event);
});
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
this.socket.on("leave", (event: any) => {
this.socket?.emit("leave", event);
});
// received whenever we join a document room, the payload includes
// userIds that are present/viewing and those that are editing.
this.socket.on("document.presence", (event: any) => {
presence.init(event.documentId, event.userIds, event.editingIds);
});
// received whenever a new user joins a document room, aka they
// navigate to / start viewing a document
this.socket.on("user.join", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
views.touch(event.documentId, event.userId);
});
// received whenever a new user leaves a document room, aka they
// navigate away / stop viewing a document
this.socket.on("user.leave", (event: any) => {
presence.leave(event.documentId, event.userId);
views.touch(event.documentId, event.userId);
});
// received when another client in a document room wants to change
// or update it's presence. Currently the only property is whether
// the client is in editing state or not.
this.socket.on("user.presence", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
};
render() {
return (
<WebsocketContext.Provider value={this.socket}>
{this.props.children}
</WebsocketContext.Provider>
);
}
}
export default withStores(WebsocketProvider);
+8 -5
View File
@@ -7,9 +7,10 @@ import { Portal } from "react-portal";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
import { MenuItem } from "@shared/editor/types";
import { depths } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
@@ -427,10 +428,12 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
for (const embed of embeds) {
if (embed.title) {
embedItems.push({
...embed,
name: "embed",
});
embedItems.push(
new EmbedDescriptor({
...embed,
name: "embed",
})
);
}
}
+3 -3
View File
@@ -11,7 +11,7 @@ import { setTextSelection } from "prosemirror-utils";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import { isInternalUrl, sanitizeHref } from "@shared/utils/urls";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import { ToastOptions } from "~/types";
@@ -70,7 +70,7 @@ class LinkEditor extends React.Component<Props, State> {
};
get href(): string {
return sanitizeHref(this.props.mark?.attrs.href) ?? "";
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
}
get suggestedLinkTitle(): string {
@@ -113,7 +113,7 @@ class LinkEditor extends React.Component<Props, State> {
this.discardInputValue = true;
const { from, to } = this.props;
href = sanitizeHref(href) ?? "";
href = sanitizeUrl(href) ?? "";
this.props.onSelectLink({ href, title, from, to });
};
+15
View File
@@ -63,6 +63,15 @@ const EditorStyles = styled.div<{
margin-bottom: 0.25em;
}
// all of heading sizes are stepped down one from global styles, except h1
// which is between h1 and h2
h1 { font-size: 1.75em; }
h2 { font-size: 1.25em; }
h3 { font-size: 1em; }
h4 { font-size: 0.875em; }
h5 { font-size: 0.75em; }
h6 { font-size: 0.75em; }
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
@@ -1266,6 +1275,7 @@ const EditorStyles = styled.div<{
.placeholder:before,
.block-menu-trigger,
.heading-actions,
button.show-source-button,
h1:not(.placeholder):before,
h2:not(.placeholder):before,
h3:not(.placeholder):before,
@@ -1279,6 +1289,11 @@ const EditorStyles = styled.div<{
opacity: 0;
}
pre {
overflow-x: hidden;
white-space: pre-wrap;
}
em,
blockquote {
font-family: "SF Pro Text", ${(props) => props.theme.fontFamily};
+7 -2
View File
@@ -16,6 +16,7 @@ import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
import { Decoration, EditorView } from "prosemirror-view";
import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components";
import { EmbedDescriptor } from "@shared/editor/embeds";
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import getHeadings from "@shared/editor/lib/getHeadings";
@@ -25,8 +26,10 @@ import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import fullExtensionsPackage from "@shared/editor/packages/full";
import { EmbedDescriptor, EventType } from "@shared/editor/types";
import { EventType } from "@shared/editor/types";
import { IntegrationType } from "@shared/types";
import EventEmitter from "@shared/utils/events";
import Integration from "~/models/Integration";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
@@ -110,6 +113,8 @@ export type Props = {
onShowToast: (message: string) => void;
className?: string;
style?: React.CSSProperties;
embedIntegrations?: Integration<IntegrationType.Embed>[];
};
type State = {
@@ -430,7 +435,7 @@ export class Editor extends React.PureComponent<
state: this.createState(this.props.value),
editable: () => !this.props.readOnly,
nodeViews: this.nodeViews,
dispatchTransaction: function (transaction) {
dispatchTransaction(transaction) {
// callback is bound to have the view instance as its this binding
const { state, transactions } = this.state.applyTransaction(
transaction
+11 -1
View File
@@ -9,12 +9,14 @@ import {
LinkIcon,
TeamIcon,
BeakerIcon,
BuildingBlocksIcon,
DownloadIcon,
WebhooksIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import Details from "~/scenes/Settings/Details";
import Drawio from "~/scenes/Settings/Drawio";
import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features";
import Groups from "~/scenes/Settings/Groups";
@@ -67,7 +69,7 @@ type ConfigType = {
const useAuthorizedSettingsConfig = () => {
const team = useCurrentTeam();
const can = usePolicy(team.id);
const can = usePolicy(team);
const { t } = useTranslation();
const config: ConfigType = React.useMemo(
@@ -170,6 +172,14 @@ const useAuthorizedSettingsConfig = () => {
group: t("Integrations"),
icon: WebhooksIcon,
},
Drawio: {
name: t("Draw.io"),
path: "/settings/integrations/drawio",
component: Drawio,
enabled: can.update,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
Slack: {
name: "Slack",
path: "/settings/integrations/slack",
+48
View File
@@ -0,0 +1,48 @@
import { find } from "lodash";
import * as React from "react";
import embeds, { EmbedDescriptor } from "@shared/editor/embeds";
import { IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Logger from "~/utils/Logger";
import useStores from "./useStores";
/**
* Hook to get all embed configuration for the current team
*
* @param loadIfMissing Should we load integration settings if they are not
* locally available
* @returns A list of embed descriptors
*/
export default function useEmbeds(loadIfMissing = false) {
const { integrations } = useStores();
React.useEffect(() => {
async function fetchEmbedIntegrations() {
try {
await integrations.fetchPage({
limit: 100,
type: IntegrationType.Embed,
});
} catch (err) {
Logger.error("Failed to fetch embed integrations", err);
}
}
!integrations.isLoaded && loadIfMissing && fetchEmbedIntegrations();
}, [integrations, loadIfMissing]);
return React.useMemo(
() =>
embeds.map((e) => {
const em: Integration<IntegrationType.Embed> | undefined = find(
integrations.orderedData,
(i) => i.service === e.component.name.toLowerCase()
);
return new EmbedDescriptor({
...e,
settings: em?.settings,
});
}),
[integrations.orderedData]
);
}
+26 -4
View File
@@ -1,12 +1,34 @@
import * as React from "react";
import BaseModel from "~/models/BaseModel";
import useStores from "./useStores";
/**
* Quick access to retrieve the abilities of a policy for a given entity
* Retrieve the abilities of a policy for a given entity, if the policy is not
* located in the store, it will be fetched from the server.
*
* @param entityId The entity id
* @returns The available abilities
* @param entity The model or model id
* @returns The policy for the model
*/
export default function usePolicy(entityId: string) {
export default function usePolicy(entity: string | BaseModel | undefined) {
const { policies } = useStores();
const triggered = React.useRef(false);
const entityId = entity
? typeof entity === "string"
? entity
: entity.id
: "";
React.useEffect(() => {
if (entity && typeof entity !== "string") {
// The policy for this model is missing and we haven't tried to fetch it
// yet, go ahead and do that now. The force flag is needed otherwise the
// network request will be skipped due to the model existing in the store
if (!policies.get(entity.id) && !triggered.current) {
triggered.current = true;
void entity.store.fetch(entity.id, { force: true });
}
}
}, [policies, entity]);
return policies.abilities(entityId);
}
+1 -1
View File
@@ -8,7 +8,7 @@ type Session = {
teamId: string;
};
function loadSessionsFromCookie(): Session[] {
export function loadSessionsFromCookie(): Session[] {
const sessions = JSON.parse(getCookie("sessions") || "{}");
return Object.keys(sessions).map((teamId) => ({
teamId,
+2 -2
View File
@@ -187,8 +187,8 @@ function CollectionMenu({
);
const alphabeticalSort = collection.sort.field === "title";
const can = usePolicy(collection.id);
const canUserInTeam = usePolicy(team.id);
const can = usePolicy(collection);
const canUserInTeam = usePolicy(team);
const items: MenuItem[] = React.useMemo(
() => [
{
+30 -192
View File
@@ -1,20 +1,11 @@
import { observer } from "mobx-react";
import {
EditIcon,
StarredIcon,
UnstarredIcon,
DuplicateIcon,
ArchiveIcon,
TrashIcon,
MoveIcon,
HistoryIcon,
UnpublishIcon,
PrintIcon,
ImportIcon,
NewDocumentIcon,
DownloadIcon,
RestoreIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -25,19 +16,29 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import CollectionIcon from "~/components/CollectionIcon";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import Template from "~/components/ContextMenu/Template";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Switch from "~/components/Switch";
import { actionToMenuItem } from "~/actions";
import { pinDocument, createTemplate } from "~/actions/definitions/documents";
import {
pinDocument,
createTemplate,
subscribeDocument,
unsubscribeDocument,
moveDocument,
deleteDocument,
permanentlyDeleteDocument,
downloadDocument,
importDocument,
starDocument,
unstarDocument,
duplicateDocument,
archiveDocument,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
@@ -94,39 +95,8 @@ function DocumentMenu({
});
const { t } = useTranslation();
const isMobile = useMobile();
const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [
showPermanentDeleteModal,
setShowPermanentDeleteModal,
] = React.useState(false);
const [showMoveModal, setShowMoveModal] = React.useState(false);
const file = React.useRef<HTMLInputElement>(null);
const handleOpen = React.useCallback(() => {
setRenderModals(true);
if (onOpen) {
onOpen();
}
}, [onOpen]);
const handleDuplicate = React.useCallback(async () => {
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
showToast(t("Document duplicated"), {
type: "success",
});
}, [t, history, showToast, document]);
const handleArchive = React.useCallback(async () => {
await document.archive();
showToast(t("Document archived"), {
type: "success",
});
}, [showToast, t, document]);
const handleRestore = React.useCallback(
async (
ev: React.SyntheticEvent,
@@ -154,26 +124,8 @@ function DocumentMenu({
window.print();
}, [menu]);
const handleStar = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
document.star();
},
[document]
);
const handleUnstar = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
document.unstar();
},
[document]
);
const collection = collections.get(document.collectionId);
const can = usePolicy(document.id);
const can = usePolicy(document);
const canViewHistory = can.read && !can.restore;
const restoreItems = React.useMemo(
() => [
@@ -205,19 +157,6 @@ function DocumentMenu({
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
},
[file]
);
const handleFilePicked = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
@@ -280,7 +219,7 @@ function DocumentMenu({
<ContextMenu
{...menu}
aria-label={t("Document options")}
onOpen={handleOpen}
onOpen={onOpen}
onClose={onClose}
>
<Template
@@ -313,22 +252,10 @@ function DocumentMenu({
...restoreItems,
],
},
{
type: "button",
title: t("Unstar"),
onClick: handleUnstar,
visible: document.isStarred && !!can.unstar,
icon: <UnstarredIcon />,
},
{
type: "button",
title: t("Star"),
onClick: handleStar,
visible: !document.isStarred && !!can.star,
icon: <StarredIcon />,
},
// Pin document
actionToMenuItem(pinDocument, context),
actionToMenuItem(starDocument, context),
actionToMenuItem(unstarDocument, context),
actionToMenuItem(subscribeDocument, context),
actionToMenuItem(unsubscribeDocument, context),
{
type: "separator",
},
@@ -348,22 +275,9 @@ function DocumentMenu({
visible: !!can.createChildDocument,
icon: <NewDocumentIcon />,
},
{
type: "button",
title: t("Import document"),
visible: can.createChildDocument,
onClick: handleImportDocument,
icon: <ImportIcon />,
},
// Templatize document
actionToMenuItem(importDocument, context),
actionToMenuItem(createTemplate, context),
{
type: "button",
title: t("Duplicate"),
onClick: handleDuplicate,
visible: !!can.update,
icon: <DuplicateIcon />,
},
actionToMenuItem(duplicateDocument, context),
{
type: "button",
title: t("Unpublish"),
@@ -371,36 +285,14 @@ function DocumentMenu({
visible: !!can.unpublish,
icon: <UnpublishIcon />,
},
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(pinDocument, context),
{
type: "button",
title: t("Archive"),
onClick: handleArchive,
visible: !!can.archive,
icon: <ArchiveIcon />,
},
{
type: "button",
title: `${t("Move")}`,
onClick: () => setShowMoveModal(true),
visible: !!can.move,
icon: <MoveIcon />,
},
{
type: "button",
title: `${t("Delete")}`,
dangerous: true,
onClick: () => setShowDeleteModal(true),
visible: !!can.delete,
icon: <TrashIcon />,
},
{
type: "button",
title: `${t("Permanently delete")}`,
dangerous: true,
onClick: () => setShowPermanentDeleteModal(true),
visible: can.permanentDelete,
icon: <CrossIcon />,
type: "separator",
},
actionToMenuItem(deleteDocument, context),
actionToMenuItem(permanentlyDeleteDocument, context),
{
type: "separator",
},
@@ -413,13 +305,7 @@ function DocumentMenu({
visible: canViewHistory,
icon: <HistoryIcon />,
},
{
type: "button",
title: t("Download"),
onClick: document.download,
visible: !!can.download,
icon: <DownloadIcon />,
},
actionToMenuItem(downloadDocument, context),
{
type: "button",
title: t("Print"),
@@ -464,54 +350,6 @@ function DocumentMenu({
</>
)}
</ContextMenu>
{renderModals && (
<>
{can.move && (
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowMoveModal(false)}
isOpen={showMoveModal}
>
<DocumentMove
document={document}
onRequestClose={() => setShowMoveModal(false)}
/>
</Modal>
)}
{can.delete && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
isCentered
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
)}
{can.permanentDelete && (
<Modal
title={t("Permanently delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowPermanentDeleteModal(false)}
isOpen={showPermanentDeleteModal}
isCentered
>
<DocumentPermanentDelete
document={document}
onSubmit={() => setShowPermanentDeleteModal(false)}
/>
</Modal>
)}
</>
)}
</>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ function GroupMenu({ group, onMembers }: Props) {
});
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const can = usePolicy(group.id);
const can = usePolicy(group);
return (
<>
+1 -1
View File
@@ -27,7 +27,7 @@ function NewDocumentMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team.id);
const can = usePolicy(team);
const items = React.useMemo(
() =>
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
+1 -1
View File
@@ -22,7 +22,7 @@ function NewTemplateMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team.id);
const can = usePolicy(team);
const items = React.useMemo(
() =>
+5 -33
View File
@@ -2,11 +2,10 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { createAction } from "~/actions";
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
import { changeTeam } from "~/actions/definitions/teams";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePrevious from "~/hooks/usePrevious";
import useSessions from "~/hooks/useSessions";
@@ -32,32 +31,11 @@ const OrganizationMenu: React.FC = ({ children }) => {
}
}, [menu, theme, previousTheme]);
// NOTE: it's useful to memoize on the team id and session because the action
// menu is not cached at all.
const actions = React.useMemo(() => {
const otherSessions = sessions.filter(
(session) => session.teamId !== team.id && session.url !== team.url
);
return [
navigateToSettings,
separator(),
...(otherSessions.length
? [
createAction({
name: t("Switch team"),
section: "account",
children: otherSessions.map((session) => ({
id: session.url,
name: session.name,
section: "account",
icon: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
})),
}),
]
: []),
logout,
];
}, [team.id, team.url, sessions, t]);
return [navigateToSettings, separator(), changeTeam, logout];
}, [team.id, sessions]);
return (
<>
@@ -69,10 +47,4 @@ const OrganizationMenu: React.FC = ({ children }) => {
);
};
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export default observer(OrganizationMenu);
+31 -3
View File
@@ -1,5 +1,6 @@
import { trim } from "lodash";
import { action, computed, observable } from "mobx";
import { CollectionPermission } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import CollectionsStore from "~/stores/CollectionsStore";
import Document from "~/models/Document";
@@ -39,7 +40,7 @@ export default class Collection extends ParanoidModel {
@Field
@observable
permission: "read" | "read_write" | void;
permission: CollectionPermission | void;
@Field
@observable
@@ -101,8 +102,14 @@ export default class Collection extends ParanoidModel {
return sortNavigationNodes(this.documents, this.sort);
}
/**
* Updates the document identified by the given id in the collection in memory.
* Does not update the document in the database.
*
* @param document The document properties stored in the collection
*/
@action
updateDocument(document: Document) {
updateDocument(document: Pick<Document, "id" | "title" | "url">) {
const travelNodes = (nodes: NavigationNode[]) =>
nodes.forEach((node) => {
if (node.id === document.id) {
@@ -116,6 +123,27 @@ export default class Collection extends ParanoidModel {
travelNodes(this.documents);
}
/**
* Removes the document identified by the given id from the collection in
* memory. Does not remove the document from the database.
*
* @param documentId The id of the document to remove.
*/
@action
removeDocument(documentId: string) {
this.documents = this.documents.filter(function f(node): boolean {
if (node.id === documentId) {
return false;
}
if (node.children) {
node.children = node.children.filter(f);
}
return true;
});
}
@action
updateIndex(index: string) {
this.index = index;
@@ -188,7 +216,7 @@ export default class Collection extends ParanoidModel {
};
export = () => {
return client.get("/collections.export", {
return client.post("/collections.export", {
id: this.id,
});
};
+3 -7
View File
@@ -1,4 +1,5 @@
import { computed } from "mobx";
import { CollectionPermission } from "@shared/types";
import BaseModel from "./BaseModel";
class CollectionGroupMembership extends BaseModel {
@@ -8,16 +9,11 @@ class CollectionGroupMembership extends BaseModel {
collectionId: string;
permission: string;
permission: CollectionPermission;
@computed
get isEditor(): boolean {
return this.permission === "read_write";
}
@computed
get isMaintainer(): boolean {
return this.permission === "maintainer";
return this.permission === CollectionPermission.ReadWrite;
}
}
+40 -7
View File
@@ -155,6 +155,19 @@ export default class Document extends ParanoidModel {
);
}
/**
* Returns whether there is a subscription for this document in the store.
* Does not consider remote state.
*
* @returns True if there is a subscription, false otherwise.
*/
@computed
get isSubscribed(): boolean {
return !!this.store.rootStore.subscriptions.orderedData.find(
(subscription) => subscription.documentId === this.id
);
}
@computed
get isArchived(): boolean {
return !!this.archivedAt;
@@ -255,15 +268,15 @@ export default class Document extends ParanoidModel {
};
@action
pin = async (collectionId?: string) => {
await this.store.rootStore.pins.create({
pin = (collectionId?: string) => {
return this.store.rootStore.pins.create({
documentId: this.id,
...(collectionId ? { collectionId } : {}),
});
};
@action
unpin = async (collectionId?: string) => {
unpin = (collectionId?: string) => {
const pin = this.store.rootStore.pins.orderedData.find(
(pin) =>
pin.documentId === this.id &&
@@ -271,19 +284,39 @@ export default class Document extends ParanoidModel {
(!collectionId && !pin.collectionId))
);
await pin?.delete();
return pin?.delete();
};
@action
star = async () => {
star = () => {
return this.store.star(this);
};
@action
unstar = async () => {
unstar = () => {
return this.store.unstar(this);
};
/**
* Subscribes the current user to this document.
*
* @returns A promise that resolves when the subscription is created.
*/
@action
subscribe = () => {
return this.store.subscribe(this);
};
/**
* Unsubscribes the current user to this document.
*
* @returns A promise that resolves when the subscription is destroyed.
*/
@action
unsubscribe = (userId: string) => {
return this.store.unsubscribe(userId, this);
};
@action
view = () => {
// we don't record views for documents in the trash
@@ -304,7 +337,7 @@ export default class Document extends ParanoidModel {
};
@action
templatize = async () => {
templatize = () => {
return this.store.templatize(this.id);
};
+3 -8
View File
@@ -1,14 +1,9 @@
import { observable } from "mobx";
import type { IntegrationSettings } from "@shared/types";
import BaseModel from "~/models/BaseModel";
import Field from "./decorators/Field";
type Settings = {
url: string;
channel: string;
channelId: string;
};
class Integration extends BaseModel {
class Integration<T = unknown> extends BaseModel {
id: string;
type: string;
@@ -21,7 +16,7 @@ class Integration extends BaseModel {
@observable
events: string[];
settings: Settings;
settings: IntegrationSettings<T>;
}
export default Integration;
+3 -7
View File
@@ -1,4 +1,5 @@
import { computed } from "mobx";
import { CollectionPermission } from "@shared/types";
import BaseModel from "./BaseModel";
class Membership extends BaseModel {
@@ -8,16 +9,11 @@ class Membership extends BaseModel {
collectionId: string;
permission: string;
permission: CollectionPermission;
@computed
get isEditor(): boolean {
return this.permission === "read_write";
}
@computed
get isMaintainer(): boolean {
return this.permission === "maintainer";
return this.permission === CollectionPermission.ReadWrite;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
/**
* A subscription represents a request for a user to receive notifications for
* a document.
*/
class Subscription extends BaseModel {
@Field
@observable
id: string;
/** The user subscribing */
userId: string;
/** The document being subscribed to */
documentId: string;
/** The event being subscribed to */
@Field
@observable
event: string;
createdAt: string;
updatedAt: string;
}
export default Subscription;
+4 -4
View File
@@ -11,7 +11,7 @@ import Layout from "~/components/AuthenticatedLayout";
import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import Route from "~/components/ProfiledRoute";
import SocketProvider from "~/components/SocketProvider";
import WebsocketProvider from "~/components/WebsocketProvider";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
@@ -64,10 +64,10 @@ const RedirectDocument = ({
function AuthenticatedRoutes() {
const team = useCurrentTeam();
const can = usePolicy(team.id);
const can = usePolicy(team);
return (
<SocketProvider>
<WebsocketProvider>
<Layout>
<React.Suspense
fallback={
@@ -116,7 +116,7 @@ function AuthenticatedRoutes() {
</Switch>
</React.Suspense>
</Layout>
</SocketProvider>
</WebsocketProvider>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ type Props = {
function Actions({ collection }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection.id);
const can = usePolicy(collection);
return (
<>
+1 -1
View File
@@ -20,7 +20,7 @@ type Props = {
function EmptyCollection({ collection }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection.id);
const can = usePolicy(collection);
const collectionName = collection ? collection.name : "";
const [
+3 -1
View File
@@ -25,7 +25,9 @@ function CollectionExport({ collection, onSubmit }: Props) {
setIsLoading(false);
showToast(
t("Export started, you will receive an email when its complete.")
t(
"Export started. If you have notifications enabled, you will receive an email when it's complete."
)
);
onSubmit();
},
+4 -3
View File
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, WithTranslation } from "react-i18next";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
@@ -38,7 +39,7 @@ class CollectionNew extends React.Component<Props> {
sharing = true;
@observable
permission = "read_write";
permission = CollectionPermission.ReadWrite;
@observable
isSaving: boolean;
@@ -100,8 +101,8 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handlePermissionChange = (newPermission: string) => {
this.permission = newPermission;
handlePermissionChange = (permission: CollectionPermission) => {
this.permission = permission;
};
handleSharingChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
@@ -59,7 +59,6 @@ class AddGroupsToCollection extends React.Component<Props> {
this.props.collectionGroupMemberships.create({
collectionId: this.props.collection.id,
groupId: group.id,
permission: "read_write",
});
this.props.toasts.showToast(
t("{{ groupName }} was added to the collection", {
@@ -57,7 +57,6 @@ class AddPeopleToCollection extends React.Component<Props> {
this.props.memberships.create({
collectionId: this.props.collection.id,
userId: user.id,
permission: "read_write",
});
this.props.toasts.showToast(
t("{{ userName }} was added to the collection", {
@@ -110,7 +109,9 @@ class AddPeopleToCollection extends React.Component<Props> {
<Empty>{t("No people left to add")}</Empty>
)
}
items={users.notInCollection(collection.id, this.query)}
items={users
.notInCollection(collection.id, this.query)
.filter((member) => member.id !== user.id)}
fetch={this.query ? undefined : users.fetchPage}
renderItem={(item: User) => (
<MemberListItem
@@ -1,6 +1,7 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import Group from "~/models/Group";
import GroupListItem from "~/components/GroupListItem";
@@ -10,7 +11,7 @@ import CollectionGroupMemberMenu from "~/menus/CollectionGroupMemberMenu";
type Props = {
group: Group;
collectionGroupMembership: CollectionGroupMembership | null | undefined;
onUpdate: (permission: string) => void;
onUpdate: (permission: CollectionPermission) => void;
onRemove: () => void;
};
@@ -21,19 +22,6 @@ const CollectionGroupMemberListItem = ({
onRemove,
}: Props) => {
const { t } = useTranslation();
const PERMISSIONS = React.useMemo(
() => [
{
label: t("View only"),
value: "read",
},
{
label: t("View and edit"),
value: "read_write",
},
],
[t]
);
return (
<GroupListItem
@@ -43,7 +31,16 @@ const CollectionGroupMemberListItem = ({
<>
<Select
label={t("Permissions")}
options={PERMISSIONS}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
]}
value={
collectionGroupMembership
? collectionGroupMembership.permission
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import Membership from "~/models/Membership";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
@@ -19,7 +20,7 @@ type Props = {
canEdit: boolean;
onAdd?: () => void;
onRemove?: () => void;
onUpdate?: (permission: string) => void;
onUpdate?: (permission: CollectionPermission) => void;
};
const MemberListItem = ({
@@ -31,19 +32,6 @@ const MemberListItem = ({
canEdit,
}: Props) => {
const { t } = useTranslation();
const PERMISSIONS = React.useMemo(
() => [
{
label: t("View only"),
value: "read",
},
{
label: t("View and edit"),
value: "read_write",
},
],
[t]
);
return (
<ListItem
@@ -67,7 +55,16 @@ const MemberListItem = ({
{onUpdate && (
<Select
label={t("Permissions")}
options={PERMISSIONS}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
]}
value={membership ? membership.permission : undefined}
onChange={onUpdate}
disabled={!canEdit}
+7 -6
View File
@@ -3,6 +3,7 @@ import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import User from "~/models/User";
@@ -151,7 +152,7 @@ function CollectionPermissions({ collection }: Props) {
);
const handleChangePermission = React.useCallback(
async (permission: string) => {
async (permission: CollectionPermission) => {
try {
await collection.save({
permission,
@@ -218,9 +219,10 @@ function CollectionPermissions({ collection }: Props) {
}}
/>
)}
{collection.permission === "read" && (
{collection.permission === CollectionPermission.ReadWrite && (
<Trans
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
values={{
collectionName,
}}
@@ -229,10 +231,9 @@ function CollectionPermissions({ collection }: Props) {
}}
/>
)}
{collection.permission === "read_write" && (
{collection.permission === CollectionPermission.Read && (
<Trans
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
values={{
collectionName,
}}
+11 -9
View File
@@ -57,15 +57,17 @@ export default function Contents({ headings, isFullWidth }: Props) {
<Heading>{t("Contents")}</Heading>
{headings.length ? (
<List>
{headings.map((heading) => (
<ListItem
key={heading.id}
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
</ListItem>
))}
{headings
.filter((heading) => heading.level < 4)
.map((heading) => (
<ListItem
key={heading.id}
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
</ListItem>
))}
</List>
) : (
<Empty>
+19 -2
View File
@@ -8,6 +8,7 @@ import ErrorOffline from "~/scenes/ErrorOffline";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import Logger from "~/utils/Logger";
import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history";
import { matchDocumentEdit } from "~/utils/routeHelpers";
@@ -41,7 +42,7 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
};
function DataLoader({ match, children }: Props) {
const { ui, shares, documents, auth, revisions } = useStores();
const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
const { team } = auth;
const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params;
@@ -57,7 +58,7 @@ function DataLoader({ match, children }: Props) {
: undefined;
const isEditRoute = match.path === matchDocumentEdit;
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
const can = usePolicy(document ? document.id : "");
const can = usePolicy(document?.id);
const location = useLocation<LocationState>();
React.useEffect(() => {
@@ -86,6 +87,22 @@ function DataLoader({ match, children }: Props) {
fetchRevision();
}, [revisions, revisionId]);
React.useEffect(() => {
async function fetchSubscription() {
if (document?.id) {
try {
await subscriptions.fetchPage({
documentId: document.id,
event: "documents.update",
});
} catch (err) {
Logger.error("Failed to fetch subscriptions", err);
}
}
}
fetchSubscription();
}, [document?.id, subscriptions]);
const onCreateLink = React.useCallback(
async (title: string) => {
if (!document) {
+1 -1
View File
@@ -100,7 +100,7 @@ function DocumentHeader({
}, [onSave]);
const { isDeleted, isTemplate } = document;
const can = usePolicy(document.id);
const can = usePolicy(document?.id);
const canToggleEmbeds = team?.documentEmbeds;
const canEdit = can.update && !isEditing;
const toc = (
+1 -5
View File
@@ -1,6 +1,5 @@
import { Location } from "history";
import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "~/components/CenteredContent";
import PageTitle from "~/components/PageTitle";
import PlaceholderDocument from "~/components/PlaceholderDocument";
@@ -11,12 +10,9 @@ type Props = {
};
export default function Loading({ location }: Props) {
const { t } = useTranslation();
return (
<Container column auto>
<PageTitle
title={location.state ? location.state.title : t("Untitled")}
/>
{location.state?.title && <PageTitle title={location.state.title} />}
<CenteredContent>
<PlaceholderDocument />
</CenteredContent>
@@ -139,6 +139,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
});
if (debug) {
provider.on("close", (ev: MessageEvent) =>
Logger.debug("collaboration", "close", ev)
);
provider.on("message", (ev: MessageEvent) =>
Logger.debug("collaboration", "incoming", {
message: ev.message,
@@ -45,7 +45,7 @@ function SharePopover({
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const can = usePolicy(share ? share.id : "");
const documentAbilities = usePolicy(document.id);
const documentAbilities = usePolicy(document);
const canPublish =
can.update &&
!document.isTemplate &&
@@ -1,6 +1,6 @@
import * as React from "react";
import { USER_PRESENCE_INTERVAL } from "@shared/constants";
import { SocketContext } from "~/components/SocketProvider";
import { WebsocketContext } from "~/components/WebsocketProvider";
type Props = {
documentId: string;
@@ -8,9 +8,9 @@ type Props = {
};
export default class SocketPresence extends React.Component<Props> {
static contextType = SocketContext;
static contextType = WebsocketContext;
previousContext: typeof SocketContext;
previousContext: typeof WebsocketContext;
editingInterval: ReturnType<typeof setInterval>;
+3 -2
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { useState } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
@@ -38,8 +39,8 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
const { t } = useTranslation();
const prevCollection = collections.get(item.collectionId);
const accessMapping = {
read_write: t("view and edit access"),
read: t("view only access"),
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
null: t("no access"),
};
+1 -1
View File
@@ -25,7 +25,7 @@ function GroupEdit({ group, onSubmit }: Props) {
try {
await group.save({
name: name,
name,
});
onSubmit();
} catch (err) {
+1 -1
View File
@@ -26,7 +26,7 @@ function GroupMembers({ group }: Props) {
const { users, groupMemberships } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const can = usePolicy(group.id);
const can = usePolicy(group);
const handleAddModal = (state: boolean) => {
setAddModalOpen(state);
+1 -1
View File
@@ -29,7 +29,7 @@ function GroupNew({ onSubmit }: Props) {
const group = new Group(
{
name: name,
name,
},
groups
);
+1 -1
View File
@@ -30,7 +30,7 @@ function Home() {
pins.fetchPage();
}, [pins]);
const canManageTeam = usePolicy(team.id).manage;
const canManageTeam = usePolicy(team).manage;
return (
<Scene
+5 -6
View File
@@ -5,6 +5,7 @@ import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { Role } from "@shared/types";
import { UserValidation } from "@shared/validations";
import Button from "~/components/Button";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
@@ -19,8 +20,6 @@ import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
const MAX_INVITES = 20;
type Props = {
onSubmit: () => void;
};
@@ -57,7 +56,7 @@ function Invite({ onSubmit }: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const predictedDomain = user.email.split("@")[1];
const can = usePolicy(team.id);
const can = usePolicy(team);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
@@ -97,10 +96,10 @@ function Invite({ onSubmit }: Props) {
}, []);
const handleAdd = React.useCallback(() => {
if (invites.length >= MAX_INVITES) {
if (invites.length >= UserValidation.maxInvitesPerRequest) {
showToast(
t("Sorry, you can only send {{MAX_INVITES}} invites at a time", {
MAX_INVITES,
MAX_INVITES: UserValidation.maxInvitesPerRequest,
}),
{
type: "warning",
@@ -241,7 +240,7 @@ function Invite({ onSubmit }: Props) {
))}
<Flex justify="space-between">
{invites.length <= MAX_INVITES ? (
{invites.length <= UserValidation.maxInvitesPerRequest ? (
<Button type="button" onClick={handleAdd} neutral>
<Trans>Add another</Trans>
</Button>
+1 -1
View File
@@ -243,7 +243,7 @@ const CheckEmailIcon = styled(EmailIcon)`
const Background = styled(Fade)`
width: 100vw;
height: 100vh;
height: 100%;
background: ${(props) => props.theme.background};
display: flex;
`;
+101
View File
@@ -0,0 +1,101 @@
import { head } from "lodash";
import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import { ReactHookWrappedInput as Input } from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type FormData = {
url: string;
};
const SERVICE_NAME = "diagrams";
function Drawio() {
const { integrations } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
React.useEffect(() => {
integrations.fetchPage({
service: SERVICE_NAME,
type: IntegrationType.Embed,
});
}, [integrations]);
const integration = head(integrations.orderedData) as
| Integration<IntegrationType.Embed>
| undefined;
const { register, handleSubmit: formHandleSubmit, formState } = useForm<
FormData
>({
mode: "all",
defaultValues: {
url: integration?.settings.url,
},
});
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await integrations.save({
id: integration?.id,
type: IntegrationType.Embed,
service: SERVICE_NAME,
settings: {
url: data.url,
},
});
showToast(t("Settings saved"), {
type: "success",
});
} catch (err) {
showToast(err.message, {
type: "error",
});
}
},
[integrations, integration, t, showToast]
);
return (
<Scene title="Draw.io" icon={<BuildingBlocksIcon color="currentColor" />}>
<Heading>Draw.io</Heading>
<Text type="secondary">
<Trans>
Add your self-hosted draw.io installation url here to enable automatic
embedding of diagrams within documents.
</Trans>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<p>
<Input
label={t("Draw.io deployment")}
placeholder={"https://app.diagrams.net/"}
pattern="https?://.*"
{...register("url", {
required: true,
})}
/>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</p>
</form>
</Text>
</Scene>
);
}
export default observer(Drawio);
+1 -1
View File
@@ -56,7 +56,7 @@ function Export() {
<Heading>{t("Export")}</Heading>
<Text type="secondary">
<Trans
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started we will email a link to <em>{{ userEmail }}</em> when its complete."
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete."
values={{
userEmail: user.email,
}}
+1 -1
View File
@@ -24,7 +24,7 @@ function Groups() {
const { t } = useTranslation();
const { groups } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team.id);
const can = usePolicy(team);
const [
newGroupModalOpen,
handleNewGroupModalOpen,
+1 -1
View File
@@ -40,7 +40,7 @@ function Members() {
const [data, setData] = React.useState<User[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
const [userIds, setUserIds] = React.useState<string[]>([]);
const can = usePolicy(team.id);
const can = usePolicy(team);
const query = params.get("query") || "";
const filter = params.get("filter") || "";
const sort = params.get("sort") || "name";
+7
View File
@@ -51,6 +51,13 @@ function Notifications() {
"Receive a notification when someone you invited creates an account"
),
},
{
event: "emails.export_completed",
title: t("Export completed"),
description: t(
"Receive a notification when an export you requested has been completed"
),
},
{
visible: isCloudHosted,
event: "emails.onboarding",
+1 -1
View File
@@ -21,7 +21,7 @@ function Shares() {
const { t } = useTranslation();
const { shares, auth } = useStores();
const canShareDocuments = auth.team && auth.team.sharing;
const can = usePolicy(team.id);
const can = usePolicy(team);
const [isLoading, setIsLoading] = React.useState(false);
const [data, setData] = React.useState<Share[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
+4 -1
View File
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
@@ -124,7 +125,9 @@ function Slack() {
<SlackListItem
key={integration.id}
collection={collection}
integration={integration}
integration={
integration as Integration<IntegrationType.Post>
}
/>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ function Tokens() {
const { t } = useTranslation();
const { apiKeys } = useStores();
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
const can = usePolicy(team.id);
const can = usePolicy(team);
return (
<Scene
+1 -1
View File
@@ -23,7 +23,7 @@ function Webhooks() {
const { t } = useTranslation();
const { webhookSubscriptions } = useStores();
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
const can = usePolicy(team.id);
const can = usePolicy(team);
return (
<Scene
@@ -4,6 +4,7 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
@@ -17,7 +18,7 @@ import Text from "~/components/Text";
import useToasts from "~/hooks/useToasts";
type Props = {
integration: Integration;
integration: Integration<IntegrationType.Post>;
collection: Collection;
};
@@ -31,8 +31,6 @@ const WEBHOOK_EVENTS = {
"documents.archive",
"documents.unarchive",
"documents.restore",
"documents.star",
"documents.unstar",
"documents.move",
"documents.update",
"documents.update.delayed",
+1 -1
View File
@@ -21,7 +21,7 @@ function Templates(props: RouteComponentProps<{ sort: string }>) {
const team = useCurrentTeam();
const { fetchTemplates, templates, templatesAlphabetical } = documents;
const { sort } = props.match.params;
const can = usePolicy(team.id);
const can = usePolicy(team);
return (
<Scene
+9 -6
View File
@@ -260,12 +260,6 @@ export default class AuthStore {
@action
logout = async (savePath = false) => {
if (!this.token) {
return;
}
client.post(`/auth.delete`);
// if this logout was forced from an authenticated route then
// save the current path so we can go back there once signed in
if (savePath) {
@@ -276,10 +270,19 @@ export default class AuthStore {
}
}
// If there is no auth token stored there is nothing else to do
if (!this.token) {
return;
}
// invalidate authentication token on server
client.post(`/auth.delete`);
// remove authentication token itself
removeCookie("accessToken", {
path: "/",
});
// remove session record on apex cookie
const team = this.team;
+1 -3
View File
@@ -5,12 +5,10 @@ import { Class } from "utility-types";
import RootStore from "~/stores/RootStore";
import BaseModel from "~/models/BaseModel";
import Policy from "~/models/Policy";
import { PaginationParams } from "~/types";
import { PaginationParams, PartialWithId } from "~/types";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
type PartialWithId<T> = Partial<T> & { id: string };
export enum RPCAction {
Info = "info",
List = "list",
@@ -1,5 +1,6 @@
import invariant from "invariant";
import { action, runInAction } from "mobx";
import { CollectionPermission } from "@shared/types";
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
@@ -45,7 +46,7 @@ export default class CollectionGroupMembershipsStore extends BaseStore<
}: {
collectionId: string;
groupId: string;
permission: string;
permission?: CollectionPermission;
}) {
const res = await client.post("/collections.add_group", {
id: collectionId,
+5 -2
View File
@@ -1,6 +1,7 @@
import invariant from "invariant";
import { concat, find, last } from "lodash";
import { computed, action } from "mobx";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
@@ -171,8 +172,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
@computed
get publicCollections() {
return this.orderedData.filter((collection) =>
["read", "read_write"].includes(collection.permission || "")
return this.orderedData.filter(
(collection) =>
collection.permission &&
Object.values(CollectionPermission).includes(collection.permission)
);
}
+25 -8
View File
@@ -381,7 +381,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@action
searchTitles = async (query: string) => {
const res = await client.get("/documents.search_titles", {
const res = await client.post("/documents.search_titles", {
query,
});
invariant(res?.data, "Search response should be available");
@@ -397,7 +397,7 @@ export default class DocumentsStore extends BaseStore<Document> {
options: SearchParams
): Promise<SearchResult[]> => {
const compactedOptions = omitBy(options, (o) => !o);
const res = await client.get("/documents.search", {
const res = await client.post("/documents.search", {
...compactedOptions,
query,
});
@@ -532,7 +532,7 @@ export default class DocumentsStore extends BaseStore<Document> {
id: documentId,
collectionId,
parentDocumentId,
index: index,
index,
});
invariant(res?.data, "Data not available");
res.data.documents.forEach(this.add);
@@ -740,20 +740,37 @@ export default class DocumentsStore extends BaseStore<Document> {
}
};
star = async (document: Document) => {
await this.rootStore.stars.create({
star = (document: Document) => {
return this.rootStore.stars.create({
documentId: document.id,
});
};
unstar = async (document: Document) => {
unstar = (document: Document) => {
const star = this.rootStore.stars.orderedData.find(
(star) => star.documentId === document.id
);
await star?.delete();
return star?.delete();
};
getByUrl = (url = ""): Document | null | undefined => {
subscribe = (document: Document) => {
return this.rootStore.subscriptions.create({
documentId: document.id,
event: "documents.update",
});
};
unsubscribe = (userId: string, document: Document) => {
const subscription = this.rootStore.subscriptions.orderedData.find(
(subscription) =>
subscription.documentId === document.id &&
subscription.userId === userId
);
return subscription?.delete();
};
getByUrl = (url = ""): Document | undefined => {
return find(this.orderedData, (doc) => url.endsWith(doc.urlId));
};
+2 -1
View File
@@ -1,5 +1,6 @@
import invariant from "invariant";
import { action, runInAction } from "mobx";
import { CollectionPermission } from "@shared/types";
import Membership from "~/models/Membership";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
@@ -43,7 +44,7 @@ export default class MembershipsStore extends BaseStore<Membership> {
}: {
collectionId: string;
userId: string;
permission: string;
permission?: CollectionPermission;
}) {
const res = await client.post("/collections.add_user", {
id: collectionId,
+4
View File
@@ -18,6 +18,7 @@ import RevisionsStore from "./RevisionsStore";
import SearchesStore from "./SearchesStore";
import SharesStore from "./SharesStore";
import StarsStore from "./StarsStore";
import SubscriptionsStore from "./SubscriptionsStore";
import ToastsStore from "./ToastsStore";
import UiStore from "./UiStore";
import UsersStore from "./UsersStore";
@@ -45,6 +46,7 @@ export default class RootStore {
shares: SharesStore;
ui: UiStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
users: UsersStore;
views: ViewsStore;
toasts: ToastsStore;
@@ -72,6 +74,7 @@ export default class RootStore {
this.searches = new SearchesStore(this);
this.shares = new SharesStore(this);
this.stars = new StarsStore(this);
this.subscriptions = new SubscriptionsStore(this);
this.ui = new UiStore();
this.users = new UsersStore(this);
this.views = new ViewsStore(this);
@@ -99,6 +102,7 @@ export default class RootStore {
this.searches.clear();
this.shares.clear();
this.stars.clear();
this.subscriptions.clear();
this.fileOperations.clear();
// this.ui omitted to keep ui settings between sessions
this.users.clear();
+11
View File
@@ -0,0 +1,11 @@
import Subscription from "~/models/Subscription";
import BaseStore, { RPCAction } from "./BaseStore";
import RootStore from "./RootStore";
export default class SubscriptionsStore extends BaseStore<Subscription> {
actions = [RPCAction.List, RPCAction.Create, RPCAction.Delete];
constructor(rootStore: RootStore) {
super(rootStore, Subscription);
}
}
+10 -1
View File
@@ -12,9 +12,11 @@ export default createGlobalStyle`
html,
body {
width: 100%;
min-height: 100vh;
height: 100%;
margin: 0;
padding: 0;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
body,
@@ -37,6 +39,13 @@ export default createGlobalStyle`
text-rendering: optimizeLegibility;
}
@media (min-width: ${breakpoints.tablet}px) {
html,
body {
min-height: 100vh;
}
}
@media (min-width: ${breakpoints.tablet}px) and (display-mode: standalone) {
body:after {
content: "";
+38 -1
View File
@@ -1,7 +1,12 @@
import { Location, LocationDescriptor } from "history";
import { TFunction } from "react-i18next";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Document from "./models/Document";
import FileOperation from "./models/FileOperation";
import Pin from "./models/Pin";
import Star from "./models/Star";
export type PartialWithId<T> = Partial<T> & { id: string };
export type MenuItemButton = {
type: "button";
@@ -88,6 +93,7 @@ export type Action = {
section: ((context: ActionContext) => string) | string;
shortcut?: string[];
keywords?: string;
dangerous?: boolean;
iconInContextMenu?: boolean;
icon?: React.ReactElement | React.FC;
placeholder?: ((context: ActionContext) => string) | string;
@@ -177,3 +183,34 @@ export type ToastOptions = {
onClick: React.MouseEventHandler<HTMLSpanElement>;
};
};
export type WebsocketEntityDeletedEvent = {
modelId: string;
};
export type WebsocketEntitiesEvent = {
documentIds: { id: string; updatedAt?: string }[];
collectionIds: { id: string; updatedAt?: string }[];
groupIds: { id: string; updatedAt?: string }[];
teamIds: string[];
event: string;
};
export type WebsocketCollectionUserEvent = {
collectionId: string;
userId: string;
};
export type WebsocketCollectionUpdateIndexEvent = {
collectionId: string;
index: string;
};
export type WebsocketEvent =
| PartialWithId<Pin>
| PartialWithId<Star>
| PartialWithId<FileOperation>
| WebsocketCollectionUserEvent
| WebsocketCollectionUpdateIndexEvent
| WebsocketEntityDeletedEvent
| WebsocketEntitiesEvent;
+16 -1
View File
@@ -5,6 +5,7 @@ import queryString from "query-string";
import EDITOR_VERSION from "@shared/editor/version";
import stores from "~/stores";
import isCloudHosted from "~/utils/isCloudHosted";
import Logger from "./Logger";
import download from "./download";
import {
AuthorizationError,
@@ -12,6 +13,7 @@ import {
NetworkError,
NotFoundError,
OfflineError,
RateLimitExceededError,
RequestError,
ServiceUnavailableError,
UpdateRequiredError,
@@ -181,7 +183,20 @@ class ApiClient {
throw new ServiceUnavailableError(error.message);
}
throw new RequestError(`Error ${response.status}: ${error.message}`);
if (response.status === 429) {
throw new RateLimitExceededError(
`Too many requests, try again in a minute.`
);
}
const err = new RequestError(`Error ${response.status}`);
Logger.error("Request failed", err, {
...error,
url: urlToFetch,
});
// Still need to throw to trigger retry
throw err;
};
get = (
+1 -1
View File
@@ -20,7 +20,7 @@ let supportsPassive = false;
try {
const opts = Object.defineProperty({}, "passive", {
get: function () {
get() {
supportsPassive = true;
},
});
+2
View File
@@ -12,6 +12,8 @@ export class OfflineError extends ExtendableError {}
export class ServiceUnavailableError extends ExtendableError {}
export class RateLimitExceededError extends ExtendableError {}
export class RequestError extends ExtendableError {}
export class UpdateRequiredError extends ExtendableError {}
+1
View File
@@ -22,6 +22,7 @@ export function initSentry(history: History) {
"NetworkError",
"NotFoundError",
"OfflineError",
"RateLimitExceededError",
"ServiceUnavailableError",
"UpdateRequiredError",
"ChunkLoadError",
+29 -21
View File
@@ -20,14 +20,15 @@
"heroku-postbuild": "yarn build:webpack && yarn build:server && yarn copy:i18n && yarn db:migrate",
"sequelize:migrate": "sequelize db:migrate",
"db:create-migration": "sequelize migration:create",
"db:create": "sequelize db:create",
"db:migrate": "sequelize db:migrate",
"db:rollback": "sequelize db:migrate:undo",
"db:reset": "sequelize db:drop && sequelize db:create && sequelize db:migrate",
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
"test": "yarn test:app && yarn test:server",
"test:app": "jest --config=app/.jestconfig.json --runInBand --forceExit",
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
"test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll"
"test": "jest --config=.jestconfig.json --forceExit",
"test:app": "jest --config=.jestconfig.json --selectProjects app",
"test:shared": "jest --config=.jestconfig.json --selectProjects shared-node shared-jsdom",
"test:server": "jest --config=.jestconfig.json --selectProjects server"
},
"funding": {
"type": "GitHub Sponsors ❤",
@@ -50,8 +51,8 @@
"@babel/plugin-transform-regenerator": "^7.10.4",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.16.0",
"@bull-board/api": "^4.0.0",
"@bull-board/koa": "^4.0.0",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.2.2",
"@dnd-kit/core": "^4.0.3",
"@dnd-kit/modifiers": "^4.0.0",
"@dnd-kit/sortable": "^5.1.0",
@@ -66,21 +67,21 @@
"@sentry/node": "^6.3.1",
"@sentry/react": "^6.3.1",
"@sentry/tracing": "^6.3.1",
"@theo.gravity/datadog-apm": "2.1.0",
"@theo.gravity/datadog-apm": "2.2.0",
"@tippy.js/react": "^2.2.2",
"@tommoor/remove-markdown": "^0.3.2",
"@types/mermaid": "^8.2.9",
"autotrack": "^2.4.1",
"aws-sdk": "^2.1044.0",
"aws-sdk": "^2.1189.0",
"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",
"bull": "^4.8.5",
"cancan": "3.1.0",
"chalk": "^4.1.0",
"class-validator": "^0.13.2",
"compressorjs": "^1.0.7",
"compressorjs": "^1.1.1",
"copy-to-clipboard": "^3.3.1",
"core-js": "^3.10.2",
"crypto-js": "^4.1.1",
@@ -105,7 +106,7 @@
"i18next-http-backend": "^1.3.2",
"immutable": "^4.0.0",
"invariant": "^2.2.4",
"ioredis": "^4.28.0",
"ioredis": "^4.28.5",
"is-printable-key-event": "^1.0.0",
"json-loader": "0.5.4",
"jsonwebtoken": "^8.5.0",
@@ -123,9 +124,10 @@
"koa-send": "5.0.1",
"koa-sslify": "2.1.2",
"koa-static": "^4.0.1",
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mammoth": "^1.4.19",
"markdown-it": "^12.3.2",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
"mermaid": "9.1.3",
@@ -135,8 +137,8 @@
"natural-sort": "^1.0.0",
"node-fetch": "2.6.7",
"nodemailer": "^6.6.1",
"outline-icons": "^1.43.1",
"oy-vey": "^0.10.0",
"outline-icons": "^1.44.0",
"oy-vey": "^0.11.2",
"passport": "^0.6.0",
"passport-google-oauth2": "^0.2.0",
"passport-oauth2": "^1.6.1",
@@ -150,7 +152,7 @@
"prosemirror-history": "^1.2.0",
"prosemirror-inputrules": "^1.1.3",
"prosemirror-keymap": "^1.1.5",
"prosemirror-markdown": "^1.5.2",
"prosemirror-markdown": "^1.9.3",
"prosemirror-model": "1.16.1",
"prosemirror-schema-list": "1.1.4",
"prosemirror-state": "1.3.4",
@@ -161,6 +163,7 @@
"query-string": "^7.0.1",
"quoted-printable": "^1.0.1",
"randomstring": "1.1.5",
"rate-limiter-flexible": "^2.3.7",
"raw-loader": "^0.5.1",
"react": "^17.0.2",
"react-avatar-editor": "^11.1.0",
@@ -173,7 +176,7 @@
"react-hook-form": "^7.31.2",
"react-i18next": "^11.16.6",
"react-medium-image-zoom": "^3.1.3",
"react-merge-refs": "^1.1.0",
"react-merge-refs": "^2.0.1",
"react-portal": "^4.2.0",
"react-router-dom": "^5.2.0",
"react-table": "^7.7.0",
@@ -219,22 +222,24 @@
"devDependencies": {
"@babel/cli": "^7.10.5",
"@babel/preset-typescript": "^7.16.0",
"@getoutline/jest-runner-serial": "^2.0.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/bull": "^3.15.9",
"@types/crypto-js": "^4.1.0",
"@types/datadog-metrics": "^0.6.2",
"@types/emoji-regex": "^9.2.0",
"@types/enzyme": "^3.10.10",
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/express-useragent": "^1.0.2",
"@types/formidable": "^2.0.5",
"@types/fs-extra": "^9.0.13",
"@types/fuzzy-search": "^2.1.2",
"@types/google.analytics": "^0.0.42",
"@types/invariant": "^2.2.35",
"@types/ioredis": "^4.28.1",
"@types/jest": "^27.0.2",
"@types/jest": "^28.1.6",
"@types/jsonwebtoken": "^8.5.8",
"@types/koa": "^2.13.4",
"@types/koa-compress": "^4.0.3",
@@ -244,6 +249,7 @@
"@types/koa-router": "^7.4.4",
"@types/koa-sslify": "^2.1.0",
"@types/koa-static": "^4.0.2",
"@types/koa-useragent": "^2.1.2",
"@types/markdown-it": "^12.2.3",
"@types/markdown-it-container": "^2.0.4",
"@types/markdown-it-emoji": "^2.0.2",
@@ -277,6 +283,7 @@
"@types/react-table": "^7.7.9",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/redis-info": "^3.0.0",
"@types/refractor": "^3.0.2",
"@types/semver": "^7.3.10",
"@types/sequelize": "^4.28.10",
@@ -291,7 +298,7 @@
"@typescript-eslint/eslint-plugin": "^5.3.1",
"@typescript-eslint/parser": "^5.3.1",
"babel-eslint": "^10.1.0",
"babel-jest": "^27.4.5",
"babel-jest": "^28.1.3",
"babel-loader": "^8.1.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"babel-plugin-transform-typescript-metadata": "^0.3.2",
@@ -313,7 +320,8 @@
"html-webpack-plugin": "4.5.1",
"husky": "^7.0.4",
"i18next-parser": "^6.3.0",
"jest-cli": "^27.4.5",
"jest-cli": "^28.1.3",
"jest-environment-jsdom": "^28.1.3",
"jest-fetch-mock": "^3.0.3",
"koa-webpack-dev-middleware": "^1.4.5",
"koa-webpack-hot-middleware": "^1.0.3",
@@ -340,5 +348,5 @@
"js-yaml": "^3.14.1",
"jpeg-js": "0.4.4"
},
"version": "0.65.2"
"version": "0.66.1"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

-17
View File
@@ -1,17 +0,0 @@
{
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/server",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"./server/test/setup.ts"
],
"testEnvironment": "node"
}
+62
View File
@@ -0,0 +1,62 @@
import {
IRateLimiterStoreOptions,
RateLimiterRedis,
} from "rate-limiter-flexible";
import env from "@server/env";
import Redis from "@server/redis";
export default class RateLimiter {
constructor() {
throw Error(`Cannot instantiate class!`);
}
static readonly RATE_LIMITER_REDIS_KEY_PREFIX = "rl";
static readonly rateLimiterMap = new Map<string, RateLimiterRedis>();
static readonly defaultRateLimiter = new RateLimiterRedis({
storeClient: Redis.defaultClient,
points: env.RATE_LIMITER_REQUESTS,
duration: env.RATE_LIMITER_DURATION_WINDOW,
keyPrefix: this.RATE_LIMITER_REDIS_KEY_PREFIX,
});
static getRateLimiter(path: string): RateLimiterRedis {
return this.rateLimiterMap.get(path) || this.defaultRateLimiter;
}
static setRateLimiter(path: string, config: IRateLimiterStoreOptions): void {
const rateLimiter = new RateLimiterRedis(config);
this.rateLimiterMap.set(path, rateLimiter);
}
static hasRateLimiter(path: string): boolean {
return this.rateLimiterMap.has(path);
}
}
/**
* Re-useable configuration for rate limiter middleware.
*/
export const RateLimiterStrategy = {
/** Allows five requests per minute, per IP address */
FivePerMinute: {
duration: 60,
requests: 5,
},
/** Allows ten requests per minute, per IP address */
TenPerMinute: {
duration: 60,
requests: 10,
},
/** Allows ten requests per hour, per IP address */
TenPerHour: {
duration: 3600,
requests: 10,
},
/** Allows five requests per hour, per IP address */
FivePerHour: {
duration: 3600,
requests: 5,
},
};
+41
View File
@@ -0,0 +1,41 @@
export default class Queue {
done() {
//
}
on() {
//
}
count() {
return 0;
}
getDelayedCount() {
return 0;
}
add = function (data: any) {
const job = this.createJob(data);
if (!this.handler) {
return;
}
this.handler(job, this.done);
};
process = function (handler: any) {
if (this.handler) {
throw Error("Cannot define a handler more than once per Queue instance");
}
this.handler = handler;
};
createJob = function (data: any) {
return {
data,
};
};
}
+6 -4
View File
@@ -4,12 +4,14 @@ 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, seed } from "@server/test/support";
import { getTestDatabase, seed } from "@server/test/support";
import accountProvisioner from "./accountProvisioner";
beforeEach(() => {
return flushdb();
});
const db = getTestDatabase();
afterAll(db.disconnect);
beforeEach(db.flush);
describe("accountProvisioner", () => {
const ip = "127.0.0.1";
+7 -2
View File
@@ -2,11 +2,16 @@ import path from "path";
import fs from "fs-extra";
import Attachment from "@server/models/Attachment";
import { buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import { getTestDatabase } from "@server/test/support";
import documentImporter from "./documentImporter";
jest.mock("../utils/s3");
beforeEach(() => flushdb());
const db = getTestDatabase();
afterAll(db.disconnect);
beforeEach(db.flush);
describe("documentImporter", () => {
const ip = "127.0.0.1";

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