Compare commits

..

107 Commits

Author SHA1 Message Date
Tom Moor 4bca081faa chore: Add rolling window limits to import and export operations 2022-07-23 16:29:28 +01:00
Tom Moor 87b4c9fdba fix: Cannot create collection if all existing collections are deleted (#3836) 2022-07-22 10:44:11 -07:00
Tom Moor 6fe4b1cba1 fix: Code block previous language memory (#3830) 2022-07-22 01:59:48 -07:00
Tom Moor ef2abf824e fix: Correctly sanitize href in link editor 'open url' flow 2022-07-22 00:23:53 +01:00
Tom Moor 4b4b0f7037 Revert "feat: Port scenes/Search to functional component style (#3800)" (#3827)
This reverts commit 5758ff3459.
2022-07-21 03:37:27 -07:00
Tom Moor 80d50e3d88 fix: Diagrams.net proxy path considered as embeddable 2022-07-21 10:51:34 +01:00
Tom Moor ba264974cf fix: Improvement to accuracy of collaboration server metrics 2022-07-21 09:44:13 +01:00
CuriousCorrelation 71dd4f267b feat: Port UserListItem to functional style (#3802)
* feat: Port `UserListItem` to functional style

* Add translation for pending invite

* Add missing translations

* Update `translations.json`

* Revert "Update `translations.json`"

This reverts commit d8000a7510.

* fix: Incorrect translation strings

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-07-20 15:07:01 -07:00
CuriousCorrelation 5758ff3459 feat: Port scenes/Search to functional component style (#3800)
* feat: Refactor Search scene to functional style

* fix: Clicking on recent not updating search input

* Replace translations and root objs with stores

* Replace `props.location` with `useLocation`

* deconstruct `useLocation` for readability

* Replace match prop term with `useParams`

* [WIP] Replace props history with `useHistory`

* Replace `ReactComponentProps` with state style

* Remove `lastParam` check, use dependency array instead

* Add explict match on param change

This reverts commit bfcc4038ff.
2022-07-20 15:06:49 -07:00
dependabot[bot] 6568b16ef9 chore(deps): bump terser from 4.8.0 to 4.8.1 (#3817)
Bumps [terser](https://github.com/terser/terser) from 4.8.0 to 4.8.1.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-20 15:05:19 -07:00
Tom Moor ef0412c449 fix: Cannot create new team on self-hosted (#3819) 2022-07-20 13:18:21 -07:00
Tom Moor 031a7d396f Add message to login screen for shared links 2022-07-19 17:57:13 +01:00
Nan Yu c3f5563e7f feat: scope login attempts to specific subdomains if available - do not switch subdomains (#3741)
* make the user lookup in user creator sensitive to team
* add team specific logic to oidc strat
* factor out slugifyDomain
* change type of req during auth to Koa.Context
2022-07-19 06:50:55 -07:00
Tom Moor 4ee3929e9d 0.65.1 2022-07-19 09:59:02 +01:00
Tom Moor 9ab409a640 fix: Account for non-SSL database connection in pending migrations check (#3811)
* fix: Account for non-SSL database connection in pending migrations check

* Double exit
2022-07-19 01:58:48 -07:00
Tom Moor 9cafe75bf2 0.65.0 2022-07-18 23:30:51 +01:00
Tom Moor 630b4eff8a chore: Bump passport 2022-07-18 22:51:11 +01:00
Tom Moor bf8ca59442 chore: Bump moment 2022-07-18 22:40:34 +01:00
Tom Moor 9dd28def67 fix: Force download of public attachments 2022-07-18 21:49:48 +01:00
dependabot[bot] d785389fde chore(deps): bump prosemirror-gapcursor from 1.2.1 to 1.3.1 (#3808)
Bumps [prosemirror-gapcursor](https://github.com/prosemirror/prosemirror-gapcursor) from 1.2.1 to 1.3.1.
- [Release notes](https://github.com/prosemirror/prosemirror-gapcursor/releases)
- [Changelog](https://github.com/ProseMirror/prosemirror-gapcursor/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-gapcursor/compare/1.2.1...1.3.1)

---
updated-dependencies:
- dependency-name: prosemirror-gapcursor
  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-07-18 13:01:26 -07:00
Tom Moor 1ccd770bce Merge branch 'main' of github.com:outline/outline 2022-07-18 19:25:50 +01:00
dependabot[bot] 7719d378b0 chore(deps): bump semver and @types/semver (#3805)
Bumps [semver](https://github.com/npm/node-semver) and [@types/semver](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/semver). These dependencies needed to be updated together.

Updates `semver` from 7.3.5 to 7.3.7
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.3.5...v7.3.7)

Updates `@types/semver` from 7.3.9 to 7.3.10
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/semver)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/semver"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-18 11:25:09 -07:00
dependabot[bot] f26f8d4bb9 chore(deps-dev): bump @types/node from 15.12.2 to 18.0.6 (#3806)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 15.12.2 to 18.0.6.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-18 11:24:43 -07:00
dependabot[bot] 89d4aeac67 chore(deps): bump slug and @types/slug (#3804)
Bumps [slug](https://github.com/Trott/slug) and [@types/slug](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/slug). These dependencies needed to be updated together.

Updates `slug` from 4.0.4 to 5.3.0
- [Release notes](https://github.com/Trott/slug/releases)
- [Changelog](https://github.com/Trott/slug/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Trott/slug/compare/v4.0.4...v5.3.0)

Updates `@types/slug` from 5.0.2 to 5.0.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/slug)

---
updated-dependencies:
- dependency-name: slug
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: "@types/slug"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-18 11:24:29 -07:00
Tom Moor dc94a683e7 chore: Reduce timeout on webhook deliveries 2022-07-17 18:48:45 +01:00
Jamie Slome 04f5b08ba1 Update SECURITY.md (#3711)
* Update SECURITY.md

* Update SECURITY.md

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-07-17 07:29:10 -07:00
CuriousCorrelation 5924f4909f fix: Cursor disappearing behind emoji (#3786)
* fix: Cursor disappearing behind emoji

* Move emoji node styles to `Styles.ts`

* fix: grammar

* fix: Pasting emoji adds a new line

* fix: DOM element type
2022-07-17 06:49:39 -07:00
CuriousCorrelation c00bad38e2 feat(editor): Ability to select line in codeblocks (#3798)
* feat(editor): Ability to select line in codeblocks

* fix: Check to make sure sel is indeed in codeblock
2022-07-17 06:49:30 -07:00
Tom Moor 11e1ef455f chore: Improve UUID vaildation – prevent nonsense reaching db queries 2022-07-17 14:49:04 +01:00
Tom Moor 4af69b2758 fix: Moving an image to empty space results in endless upload (#3799)
* fix: Error dragging images below doc, types

* fix: Handle html/text content dropped into padding

* refactor, docs
2022-07-17 03:31:55 -07:00
Tom Moor dee87f15af fix: Members table does not correctly reset from filters 2022-07-16 18:47:36 +01:00
dependabot[bot] 67885e7339 chore(deps): bump react-dnd-html5-backend from 14.0.0 to 16.0.1 (#3769)
Bumps [react-dnd-html5-backend](https://github.com/react-dnd/react-dnd) from 14.0.0 to 16.0.1.
- [Release notes](https://github.com/react-dnd/react-dnd/releases)
- [Changelog](https://github.com/react-dnd/react-dnd/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react-dnd/react-dnd/commits)

---
updated-dependencies:
- dependency-name: react-dnd-html5-backend
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-16 10:15:11 -07:00
Tom Moor 0b0a1b0169 fix: Heading action depth conflict, closes #3558 2022-07-16 17:58:02 +01:00
Tom Moor de18196fd8 chore: Upgrade socket.io (#3697)
* Upgrade wip

* tsc

* tsc

* fix: Missing authenticated message
2022-07-16 06:02:03 -07:00
dependabot[bot] 96d1c4997b chore(deps): bump yjs from 13.5.34 to 13.5.39 (#3770)
Bumps [yjs](https://github.com/yjs/yjs) from 13.5.34 to 13.5.39.
- [Release notes](https://github.com/yjs/yjs/releases)
- [Commits](https://github.com/yjs/yjs/compare/v13.5.34...v13.5.39)

---
updated-dependencies:
- dependency-name: yjs
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-16 02:40:34 -07:00
Tom Moor 95f4fb2424 chore: Remove deprecated socket.io-auth (#3780) 2022-07-16 02:27:09 -07:00
Tom Moor 1247bb411e Merge branch 'paullessing-issue-3655-allowed-domains-save-no-change' 2022-07-16 00:38:28 +01:00
Tom Moor 7ffb182034 Merge branch 'issue-3655-allowed-domains-save-no-change' of github.com:paullessing/outline into paullessing-issue-3655-allowed-domains-save-no-change 2022-07-16 00:37:49 +01:00
Translate-O-Tron fc414e2dd4 New Crowdin updates (#3723) 2022-07-15 16:19:13 -07:00
Nan Yu c3ec7b0877 Feat: clarify security language and hide default settings when invites are required (#3751)
* clarify default role and allowed domains

* language tweaks

* Update app/scenes/Settings/Security.tsx

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

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-07-15 16:13:41 -07:00
Tom Moor e509719c77 Add ability to quickly create test users in development (#3764) 2022-07-15 16:11:30 -07:00
Tom Moor a16cf72b73 feat: Error state for paginated lists (#3766)
* Add error state for failed list loading

* Move sidebar collections to PaginatedList for improved error handling, loading, retrying etc
2022-07-15 16:11:04 -07:00
CuriousCorrelation acabc00643 fix: ToolbarMenu popup on inline code selection (#3775)
* fix: `ToolbarMenu` popup on inline code selection

* fix: Replace `isCode` checks with single `isInCode`

* feat: Only relevant options on `code_inline` selection

* Change special case with item visibility toggle

* fix: `formattingMenuItems` visibility in `code_inline`
2022-07-15 16:10:47 -07:00
Tom Moor e989999d6e fix: Upgrade prosemirror-view fixes duplicate lines, closes #3371
Note: That this bump of prosemirror-view also includes typescript types for the first time ever, these conflict with the @types packages and cause the need for extensive changes throughout the codebase. To prevent this becoming a massive PR with days of testing these new types are being removed for now. In the future we will bump all of the pm dependencies and restore the package types here
2022-07-15 10:34:03 +01:00
dependabot[bot] c3e149eb86 chore(deps): bump @babel/core from 7.17.10 to 7.18.6 (#3771)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.17.10 to 7.18.6.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.18.6/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  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-07-13 12:48:57 -07:00
dependabot[bot] 4c05fe422c chore(deps): bump http-errors from 1.4.0 to 2.0.0 (#3772)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-13 00:59:56 -07:00
Tom Moor 47e73cee4e feat: Cleanup api keys and webhooks for suspended users (#3756) 2022-07-13 00:59:31 -07:00
CuriousCorrelation d1b01d28e6 fix: svg+xml image type ext not assigned properly (#3774) 2022-07-13 00:59:17 -07:00
Tom Moor 973cfc3fa3 Do not show suspended users to non admins (#3776) 2022-07-13 00:59:06 -07:00
dependabot[bot] dd6084d044 chore(deps-dev): bump @types/formidable from 2.0.0 to 2.0.5 (#3773)
Bumps [@types/formidable](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/formidable) from 2.0.0 to 2.0.5.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/formidable)

---
updated-dependencies:
- dependency-name: "@types/formidable"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-12 11:07:17 -07:00
Tom Moor 206545f350 fix: Ensure signed attachment urls are always downloaded rather than loaded in browser 2022-07-12 17:14:22 +01:00
Tom Moor e92d68a0a3 Create dependabot.yml 2022-07-12 09:40:44 +02:00
CuriousCorrelation 66dbcde29b feat: Redirect on unpublished share access (#3760)
* feat(WIP): Redirect on unpublished shares

* feat[WIP]: add redirect with test notice

* Revert to `Login` display, no redirects
2022-07-10 23:59:45 -07:00
Tom Moor 465a8bd505 fix: Version tag should open new tab, related type improvements
closes #3737
2022-07-10 11:22:45 +02:00
Tom Moor aef62d1356 fix: Publish click from editing heading, closes #3759 2022-07-10 10:23:00 +02:00
Tom Moor 35e82beaf7 chore: Upgrade koa- dependencies (#3761) 2022-07-09 10:23:42 -07:00
Tom Moor 8bb88b8550 chore: Audit of all model column validations (#3757)
* chore: Updating all model validations before the white-hatters get to it ;)

* test

* Remove isUrl validation, thinking about it need to account for minio and other weird urls here
2022-07-09 08:04:40 -07:00
Tom Moor da4a10e877 chore: Remove shares.info apiVersion 1 (#3758)
* chore: Remove shares.info apiVersion 1

* fix: Sporadic test failure
2022-07-09 04:28:56 -07:00
Tom Moor caaf6dd76b fix: Enter at beginning of collapsed heading should create a new heading above (#3754) 2022-07-09 02:23:12 -07:00
Tom Moor 2893924e9a fix: Must check length before passing to timingSafeEqual 2022-07-09 11:19:40 +02:00
Tom Moor 32b7a7df00 fix: Handle sanitizeUrl can receive non-string value
closes #3746
2022-07-08 21:15:07 +02:00
Tom Moor 97f8c0813c fix: Use crypto.timingSafeEqual, closes #3740 2022-07-08 21:10:51 +02:00
CuriousCorrelation 746dc30aeb feat: Add pending migrations check during startup (#3744)
* feat: Add pending migrations check during startup

* fix: migration pending log message

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

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-07-08 11:30:16 -07:00
Tom Moor 4a46d19846 fix: Improved model validation for Collection (#3749)
* fix: Added improved model validation for Collection attributes

* sp

* fix: Enforce title length in UI
2022-07-08 11:10:22 -07:00
Tom Moor 98106e7f6f Update 20220702132722-add-webhooks-deleted-at.js 2022-07-08 02:22:48 -07:00
Nan Yu 1e808fc52c Feat: add auth provider to users on sign in (#3739)
* feat: merge a new authentication method onto existing user records when emails match

* adds test for invite acceptance and auth provider creation

* addresses comments
- test existing user and invites in different test cases
- update lastActiveAt syncronously when an invite is accepted

* sort arrays in test to prevent nondeterministic test behaivior when doing array compare
2022-07-08 00:24:46 -07:00
Tom Moor ec8c0645ba fix: Correct annotation order 🙈 2022-07-07 12:23:27 +02:00
Tom Moor f90309e781 fix: Unneccessary restrictive avatarUrl length 2022-07-07 12:16:54 +02:00
Paul Lessing d8f125f413 Fix: Move logic inline 2022-07-07 11:01:32 +01:00
Tom Moor c36e7bfbb6 fix: Add 10 domain limit per team (#3733)
* fix: Validate team domains are FQDN's
Add 10 domain limit per team
fix: Deletion of domains not happening within request lifecycle

* tests

* docs
2022-07-05 12:27:02 -07:00
Tom Moor 831df67358 feat: Adds route-level role filtering. (#3734)
* feat: Adds route-level role filtering. Another layer in the onion of security and performance

* fix: Regression in authentication middleware
2022-07-05 12:26:49 -07:00
Tom Moor c6fdffba77 chore: Internal request filtering 2022-07-05 11:06:47 +02:00
Tom Moor 4e189b8970 Improved sanitization of href's in editor 2022-07-05 10:14:16 +02:00
Tom Moor 2f3dcb2520 fix: Do not show 'Full width' toggle to viewers
closes #3728
2022-07-04 15:20:01 +02:00
Nan Yu f36f5f13f4 Fix: clear localstore after logout (#3731)
* fix: remove user, team, and policies from auth store and localstorage on logout

* true up the reset everywhere
2022-07-04 01:47:44 -07:00
Tom Moor 5d498632c6 fix: Models are not all removed from local store upon access change (#3729)
* fix: Clean data from stores correctly on 401/403 response

* Convert DataLoader from class component, remove observables and caching

* types
2022-07-03 13:48:50 -07:00
Tom Moor 9cd26168e1 Separates policy for file operations 2022-07-03 18:19:56 +02:00
Tom Moor ee10e1407a fix: Typo of fileOperation -> fileOperations 2022-07-03 16:27:03 +02:00
Tom Moor c9af7ff889 fix: Suppress db validation errors in error reporting 2022-07-03 16:03:53 +02:00
Tom Moor 27978b8fc4 fix: Remove teams.create from audit events 2022-07-03 14:16:49 +02:00
Tom Moor 62d9bf7105 chore: Move initial avatar upload to background worker (#3727)
* chore: Async user avatar upload processor

* chore: Async team avatar upload

* Refactor to task for retries

* Docs
Include avatarUrl in task props to prevent race condition
Remove transaction around upload fetch request
2022-07-03 02:36:15 -07:00
Tom Moor 1f3a1d4b86 fix: Improved websockets error handling (#3726)
* fix: Add websocket client error capturing
fix: Incorrect parsing of documentName will never be empty

* fix: Non-present documentId in collaboration route should trigger an error response

* fix: Close unhandled websocket requests
2022-07-03 00:00:59 -07:00
Tom Moor 8ebe4b27b1 fix: Add additional model validation (#3725) 2022-07-02 14:29:01 -07:00
Tom Moor 0c30d2bb34 fix: share.document can be null when document is deleted
closes #3724
2022-07-02 19:56:15 +02:00
Tom Moor f744d488f6 chore: Soft delete webhooks (#3722) 2022-07-02 10:41:28 -07:00
Tom Moor 8ebf6e884f fix: Startup warning caused by unnecessary compilation of tests and mocks in non-test environments 2022-07-02 15:57:35 +02:00
Tom Moor 4438c80ea1 fix: users.promote + users.demote not available for individual subscription in webhook form 2022-07-02 14:55:07 +02:00
Tom Moor 863f22750f feat: Add optional notification email when invite is accepted (#3718)
* feat: Add optional notification email when invite is accepted

* Refactor to use beforeSend
2022-07-02 05:40:40 -07:00
Tom Moor ee22a127f6 feat: Add email when webhook is disabled (#3721)
fix: Webhook not disabled under some error conditions
2022-07-02 05:36:40 -07:00
Tom Moor c9cd424a8d chore: Remove over-usage of invariant (#3719) 2022-07-02 05:29:39 -07:00
Tom Moor 108b5b934a fix: users.promote & users.demote not handled by DeliverWebhookTask 2022-07-02 14:24:49 +02:00
Tom Moor 94824af6e7 fix: Allow soft-deleted records to be queried from RevisionProcessor
closes #3706
2022-07-02 11:58:22 +02:00
Tom Moor 1c6eef3509 Don't show share link when team sharing disabled (#3714)
fix: Docs appear to be publicly shared when sharing previously enabled
2022-07-02 01:37:10 -07:00
Translate-O-Tron 4e09356982 New Crowdin updates (#3681) 2022-07-01 13:22:01 -07:00
Nan Yu 4b166432e6 fix: show a distinct error message when a user tries to create an account using a personal gmail (#3710)
* fix: show a different error message when a user tries to create an account using a personal gmail

* throw only after attempting to find the team
2022-07-01 13:21:23 -07:00
CuriousCorrelation adb55fa965 feat: Custom Length decorator for UTF-8 chars len (#3709)
* feat: Custom Length decorator for UTF-8 chars len

* fix: Length decorator function return type
2022-07-01 13:21:09 -07:00
Tom Moor 7ce57c9c83 fix: attachments events not recognised by DeliverWebhookTask 2022-07-01 18:40:32 +02:00
Tom Moor b44dc726f3 test: fix fetch related tests 2022-06-30 10:37:06 +02:00
Paul Lessing 117421b4cb Feat: Only show save domains button if changes were made
The logic for this is that we show the button if either:
a) one or more new non-empty domains have been added, or
b) an existing domain was modified, even if the modification was then undone.

The reasoning for b) is as follows:
If a user adds a new domain row, makes changes, then removes the domain row, it is clear to the user that no changes have been made, and therefore the "save" button should not be visible.
However, as soon as the user makes any changes to an existing domain, they want to feel confident that they can hit save and ensure that whatever change they made is persisted; even if the change is identical to the current state, because they may not be able to recall accurately what the current state was. In those situations a user gets more confidence out of being able to hit save, than they would from being told by the system "you haven't made any changes".
2022-06-29 08:33:07 +01:00
Tom Moor 930bfd5391 fix: Must import fetch, log errors, use git short sha for version 2022-06-29 08:28:44 +02:00
Tom Moor 10f86ed218 feat: Webhooks (#3691)
* Webhooks (#3607)

* Get the migration and the model setup. Also make the sample env file a bit easier to use. Now just requires setting a SECRET_KEY and besides that will boot up from the sample

* WIP: Start getting a Webhook page created. Just the skeleton state right now

* WIP: Getting a form created to create webhooks, need to bring in react-hook-forms now

* WIP: Get library installed and make TS happy

* Get a few checkboxes ready to go

* Get creating and destroying working with a decent start to a frontend

* Didn't mean to enable this

* Remove eslint and fix other random typescript issue

* Rename some events to be more realistic

* Revert these changes

* PR review comments around policies. Also make sure this inherits from IdModel so it actually gets an id

* Allow any admin on the team to edit webhooks

* Start sending some webhooks for some User events

* Make sure the URL is valid

* Start recording webhook deliveries

* Make sure to verify if the subscription is for the type of event we are looking at

* Refactor sending Webhooks and follow better webhook schema

This creates a presenter to unify the format of webhooks. We also
extract the sending of webhooks and recording their deliveries to a
method than can be used by each of the different event type methods

We also add a status to WebhookDelivery since we need to save the record
before we make the HTTP request to get its id. Then once we make the
request and get a response we can update the delivery with the HTTP info

* Turn off a subscription that has failed for the last 25 deliveries

* Get a first spec passing. Found a bug in my returning of promises so good to patch that up now

* This looks nicer

* Get some tests added for the processor

* Add cron task to delete older webhooks

* Add Document Events to the Processor

* Revisions, FileOperations and Collections

* Get all the server side events added to the processor and make Typescript make sure they are all accounted for

* Get all the events added to the Frontend and work on styling them a bit, still needs some love though

* Get UI styled up a bit

* Get events wired up for webhook subscriptions

* Get delete events working and test at least one variant of them

* Get deletes working and actually make sure to send the model id in the webhook

* Remove webhook secrets from this slice

* Add disabled label for subscriptions that are disabled

* Make sure to cascade the delete

* Reorg this file a bit

* Fix association

* I removed secret for the moment

* Apply Copy changes from PR Review

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

* Actually apply the copy changes

TIL that if you Resolve a conversation it _also_ removes the 'staged suggestion' from your list on Github

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

* Update app/scenes/Settings/Webhooks.tsx

Missed this copy change before

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

* Add disabled as yellow badge

* Resolve frontend comments

* Fixup Schema a bit and remove the dependency on the subscription

* Add test to make sure we don't disable until there are enough failures, and fix code to actually do that. Also some test fixes from the json response shape changes

* Fix WebhookDeliveries to store the responses as Text instead of blobs

* Switch to text better for response bodies, this is using the helpers better and makes the code read better

* Move the logic to a task but run in through the processor cause the tests expect that right now, moving the tests over next

* Split up the tests and actually enqueue the events from the WebhookProcessor instead of doing them inline

* Allow any team admin to see any webhook subscription for the team

* Add the indexes based on our lookup patterns

* Run eslint --fix to fix auto correct issues from when I tried to use Github to merge copy changes

* Allow subscriptions to be edited after creation

* Types caught that I didn't add the new event to the webhook processor, also added it to the frontend here

* I think this will get these into the translations file

* Catch a few more translations, use styled components better and remove usage of webhook subscription in the copy

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

* fix: tsc
fix: Document model payload empty

* fix: Revision webhook payload
Add custom UA for hooks

* Add webhooks icon, move under Integrations settings
Some spacing fixes

* Add actorId to webhook payloads

* Add View and ApiKey event types

* Spacing tweaks, fix team payload

* fix: Webhook not disabled after 25 failures

* fix: Enable webhook when editing if previously disabled

* fix: Correctly store response headers

* fix: Error in json/parsing/presentation results in hanging 'pending' webhook delivery

* fix: Awkward payload for users.invite webhook

* Add BaseEvent, ShareEvent

* fix: Add share events to form

* fix: Move webhook delivery cleanup to single DB call
Remove some unused abstraction

* Add user, collection, group context to membership webhook events
Some associated refactoring

Co-authored-by: Corey Alexander <coreyja@gmail.com>
2022-06-28 22:44:50 -07:00
Tom Moor 9a6e09bafa feat: Add mermaidjs integration (#3679)
* feat: Add mermaidjs integration (#3523)

* Add mermaidjs to dependencies and CodeFenceNode

* Fix diagram id for mermaidjs diagrams

* Fix typescript compiler errors on mermaid integration

* Fix id generation for mermaid diagrams

* Refactor mermaidjs integration into prosemirror plugin

* Remove unnecessary class attribute in mermaidjs integration

* Change mermaidjs label to singular

* Change decorator.inline to decorator.node for mermaid diagram id

* Fix diagram toggle state

* Add border and background to mermaid diagrams

* Stop mermaidjs from overwriting fontFamily inside diagrams

* Add stable diagramId to mermaid diagrams

* Separate text for hide/show diagram
Use uuid as diagramId, avoid storing in state
Fix cursor on diagrams

* fix: Base diagram visibility off presence of source

* fix: More cases where our font-family is ignored

* Disable HTML labels

* fix: Button styling – not technically required but now we have a third button this felt all the more needed

closes #3116

* named chunks

* Upgrade mermaid 9.1.3

Co-authored-by: Jan Niklas Richter <5812215+ArcticXWolf@users.noreply.github.com>
2022-06-28 22:44:36 -07:00
Paul Lessing c65a88fc9f Fix: Changing security settings should not implicitly save allowedDomains 2022-06-28 19:40:25 +01:00
Tom Moor e24a5adbd5 deps: upgrade nodemon, jpeg-js 2022-06-27 16:45:54 +02:00
Tom Moor cddb6b2c32 deps: upgrade bull-board 2022-06-27 16:42:45 +02:00
Tom Moor ac467b2936 fix: Return direct url to public attachments, closes #3686 2022-06-24 11:24:11 +02:00
Tom Moor 68ce304b48 fix: Language in document notification email, missing collection name 2022-06-24 10:01:54 +02:00
263 changed files with 8541 additions and 3859 deletions
+8
View File
@@ -35,6 +35,14 @@
"displayName": false
}
]
],
"ignore": [
"**/*.test.ts"
]
},
"development": {
"ignore": [
"**/*.test.ts"
]
}
}
-2
View File
@@ -12,7 +12,6 @@
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended"
],
"plugins": [
@@ -21,7 +20,6 @@
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-react-hooks",
"import"
],
"rules": {
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+1
View File
@@ -3,6 +3,7 @@ build
node_modules/*
.env
.log
.vscode/*
npm-debug.log
stats.json
.DS_Store
+5 -1
View File
@@ -1,6 +1,10 @@
{
"extends": [
"../.eslintrc"
"../.eslintrc",
"plugin:react-hooks/recommended",
],
"plugins": [
"eslint-plugin-react-hooks",
],
"env": {
"jest": true,
-32
View File
@@ -1,32 +0,0 @@
import { ToolsIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { createAction } from "~/actions";
import { DebugSection } from "~/actions/sections";
import env from "~/env";
import { deleteAllDatabases } from "~/utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DebugSection,
perform: async ({ t }) => {
await deleteAllDatabases();
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const development = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DebugSection,
visible: ({ event }) =>
env.ENVIRONMENT === "development" ||
(event instanceof KeyboardEvent && event.altKey),
children: [clearIndexedDB],
});
export const rootDebugActions = [development];
+50
View File
@@ -0,0 +1,50 @@
import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import { deleteAllDatabases } from "~/utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DeveloperSection,
perform: async ({ t }) => {
await deleteAllDatabases();
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const createTestUsers = createAction({
name: "Create test users",
icon: <UserIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: async () => {
const count = 10;
try {
await client.post("/developer.create_test_users", { count });
stores.toasts.showToast(`${count} test users created`);
} catch (err) {
stores.toasts.showToast(err.message, { type: "error" });
}
},
});
export const developer = createAction({
name: ({ t }) => t("Developer"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DeveloperSection,
visible: ({ event }) =>
env.ENVIRONMENT === "development" ||
(event instanceof KeyboardEvent && event.altKey),
children: [clearIndexedDB, createTestUsers],
});
export const rootDeveloperActions = [developer];
+3 -3
View File
@@ -13,7 +13,7 @@ import {
SearchIcon,
} from "outline-icons";
import * as React from "react";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { getEventFiles } from "@shared/utils/files";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
@@ -260,8 +260,8 @@ export const importDocument = createAction({
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev: Event) => {
const files = getDataTransferFiles(ev);
input.onchange = async (ev) => {
const files = getEventFiles(ev);
try {
const file = files[0];
+2 -2
View File
@@ -1,5 +1,5 @@
import { rootCollectionActions } from "./definitions/collections";
import { rootDebugActions } from "./definitions/debug";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
@@ -11,5 +11,5 @@ export default [
...rootUserActions,
...rootNavigationActions,
...rootSettingsActions,
...rootDebugActions,
...rootDeveloperActions,
];
+1 -1
View File
@@ -2,7 +2,7 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const DebugSection = ({ t }: ActionContext) => t("Debug");
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
+1 -1
View File
@@ -37,7 +37,7 @@ function Breadcrumb({
return (
<Flex justify="flex-start" align="center">
{topLevelItems.map((item, index) => (
<React.Fragment key={item.to || index}>
<React.Fragment key={String(item.to) || index}>
{item.icon}
{item.to ? (
<Item
+2 -1
View File
@@ -1,3 +1,4 @@
import { LocationDescriptor } from "history";
import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import * as React from "react";
@@ -155,7 +156,7 @@ export type Props<T> = {
primary?: boolean;
fullwidth?: boolean;
as?: T;
to?: string;
to?: LocationDescriptor;
borderOnHover?: boolean;
href?: string;
"data-on"?: string;
+1 -12
View File
@@ -1,17 +1,6 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
const ButtonLink: React.FC<Props> = React.forwardRef(
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
return <Button {...props} ref={ref} />;
}
);
const Button = styled.button`
const ButtonLink = styled.button`
margin: 0;
padding: 0;
border: 0;
+2 -1
View File
@@ -1,3 +1,4 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
@@ -10,7 +11,7 @@ type Props = {
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: string;
to?: LocationDescriptor;
href?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
+2 -1
View File
@@ -1,3 +1,4 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -35,7 +36,7 @@ type Props = {
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
to?: string;
to?: LocationDescriptor;
};
const DocumentMeta: React.FC<Props> = ({
+2 -1
View File
@@ -1,3 +1,4 @@
import { LocationDescriptor } from "history";
import { observer, useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -12,7 +13,7 @@ import useStores from "~/hooks/useStores";
type Props = {
document: Document;
isDraft: boolean;
to?: string;
to?: LocationDescriptor;
rtl?: boolean;
};
+30 -7
View File
@@ -1,5 +1,6 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import mergeRefs from "react-merge-refs";
@@ -7,8 +8,10 @@ import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds";
import { Heading } from "@shared/editor/lib/getHeadings";
import { supportedImageMimeTypes } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import {
getDataTransferFiles,
supportedImageMimeTypes,
} from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import Document from "~/models/Document";
@@ -177,21 +180,41 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
event.preventDefault();
event.stopPropagation();
const files = getDataTransferFiles(event);
const view = ref?.current?.view;
if (!view) {
return;
}
// Find a valid position at the end of the document to insert our content
const pos = TextSelection.near(
view.state.doc.resolve(view.state.doc.nodeSize - 2)
).from;
// If there are no files in the drop event attempt to parse the html
// as a fragment and insert it at the end of the document
if (files.length === 0) {
const text =
event.dataTransfer.getData("text/html") ||
event.dataTransfer.getData("text/plain");
const dom = new DOMParser().parseFromString(text, "text/html");
view.dispatch(
view.state.tr.insert(
pos,
ProsemirrorDOMParser.fromSchema(view.state.schema).parse(dom)
)
);
return;
}
// Insert all files as attachments if any of the files are not images.
const isAttachment = files.some(
(file) => !supportedImageMimeTypes.includes(file.type)
);
// Find a valid position at the end of the document
const pos = TextSelection.near(
view.state.doc.resolve(view.state.doc.nodeSize - 2)
).from;
insertFiles(view, event, pos, files, {
uploadFile: onUploadFile,
onFileUploadStart: props.onFileUploadStart,
+1
View File
@@ -2,6 +2,7 @@ import styled from "styled-components";
const Empty = styled.p`
color: ${(props) => props.theme.textTertiary};
user-select: none;
`;
export default Empty;
+29 -30
View File
@@ -104,31 +104,17 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
export type Props = React.InputHTMLAttributes<
HTMLInputElement | HTMLTextAreaElement
> & {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
value?: string;
label?: string;
className?: string;
labelHidden?: boolean;
label?: string;
flex?: boolean;
short?: boolean;
margin?: string | number;
icon?: React.ReactNode;
name?: string;
pattern?: string;
minLength?: number;
maxLength?: number;
autoFocus?: boolean;
autoComplete?: boolean | string;
readOnly?: boolean;
required?: boolean;
disabled?: boolean;
placeholder?: string;
onChange?: (
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
innerRef?: React.Ref<any>;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
};
@@ -171,8 +157,6 @@ class Input extends React.Component<Props> {
...rest
} = this.props;
const InputComponent: React.ComponentType =
type === "textarea" ? RealTextarea : RealInput;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
@@ -186,15 +170,24 @@ class Input extends React.Component<Props> {
))}
<Outline focused={this.focused} margin={margin}>
{icon && <IconWrapper>{icon}</IconWrapper>}
<InputComponent
// @ts-expect-error no idea why this is not working
ref={this.input}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type === "textarea" ? undefined : type}
{...rest}
/>
{type === "textarea" ? (
<RealTextarea
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
{...rest}
/>
) : (
<RealInput
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type}
{...rest}
/>
)}
</Outline>
</label>
</Wrapper>
@@ -202,4 +195,10 @@ class Input extends React.Component<Props> {
}
}
export const ReactHookWrappedInput = React.forwardRef(
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
return <Input {...{ ...props, innerRef: ref }} />;
}
);
export default Input;
+53
View File
@@ -0,0 +1,53 @@
import { DisconnectedIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Empty from "~/components/Empty";
import useEventListener from "~/hooks/useEventListener";
import { OfflineError } from "~/utils/errors";
import ButtonLink from "../ButtonLink";
import Flex from "../Flex";
type Props = {
error: Error;
retry: () => void;
};
export default function LoadingError({ error, retry, ...rest }: Props) {
const { t } = useTranslation();
useEventListener("online", retry);
const message =
error instanceof OfflineError ? (
<>
<DisconnectedIcon color="currentColor" /> {t("Youre offline.")}
</>
) : (
<>
<WarningIcon color="currentColor" /> {t("Sorry, an error occurred.")}
</>
);
return (
<Content {...rest}>
<Flex align="center" gap={4}>
{message}{" "}
<ButtonLink onClick={() => retry()}>{t("Click to retry")}</ButtonLink>
</Flex>
</Content>
);
}
const Content = styled(Empty)`
padding: 8px 0;
white-space: nowrap;
${ButtonLink} {
color: ${(props) => props.theme.textTertiary};
&:hover {
color: ${(props) => props.theme.textSecondary};
text-decoration: underline;
}
}
`;
+5 -1
View File
@@ -69,7 +69,11 @@ const ListItem = (
);
};
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
const Wrapper = styled.a<{
$small?: boolean;
$border?: boolean;
to?: string;
}>`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) =>
+2
View File
@@ -2,6 +2,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
type Props = {
@@ -40,6 +41,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
heading={heading}
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index, compositeProps) => (
<DocumentListItem
key={item.id}
+34 -16
View File
@@ -34,12 +34,19 @@ type Props<T> = WithTranslation &
index: number,
compositeProps: CompositeStateReturn
) => React.ReactNode;
renderError?: (options: {
error: Error;
retry: () => void;
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
@observer
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
@observable
error?: Error;
@observable
isFetchingMore = false;
@@ -80,6 +87,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.isFetchingMore = false;
};
@action
fetchResults = async () => {
if (!this.props.fetch) {
return;
@@ -87,25 +95,30 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = DEFAULT_PAGINATION_LIMIT;
this.error = undefined;
const results = await this.props.fetch({
limit,
offset: this.offset,
...this.props.options,
});
try {
const results = await this.props.fetch({
limit,
offset: this.offset,
...this.props.options,
});
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
this.renderCount += limit;
} catch (err) {
this.error = err;
} finally {
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
}
};
@@ -138,6 +151,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
auth,
empty = null,
renderHeading,
renderError,
onEscape,
} = this.props;
@@ -157,6 +171,10 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
if (items?.length === 0) {
if (this.error && renderError) {
return renderError({ error: this.error, retry: this.fetchResults });
}
return empty;
}
+7 -3
View File
@@ -10,6 +10,7 @@ import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
@@ -34,12 +35,15 @@ function AppSidebar() {
const { t } = useTranslation();
const { documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team.id);
React.useEffect(() => {
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
if (!user.isViewer) {
documents.fetchDrafts();
documents.fetchTemplates();
}
}, [documents, user.isViewer]);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
@@ -3,12 +3,13 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
import { createCollection } from "~/actions/definitions/collections";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import DraggableCollectionLink from "./DraggableCollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
@@ -18,39 +19,10 @@ import SidebarAction from "./SidebarAction";
import { DragObject } from "./SidebarLink";
function Collections() {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { documents, collections } = useStores();
const { showToast } = useToasts();
const isPreloaded = !!collections.orderedData.length;
const { t } = useTranslation();
const orderedCollections = collections.orderedData;
React.useEffect(() => {
async function load() {
if (!collections.isLoaded && !isFetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
}
);
setFetchError(error);
} finally {
setFetching(false);
}
}
}
load();
}, [collections, isFetching, showToast, fetchError, t]);
const [
{ isCollectionDropping, isDraggingAnyCollection },
dropToReorderCollection,
@@ -71,45 +43,46 @@ function Collections() {
}),
});
const content = (
<>
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
)}
{orderedCollections.map((collection: Collection, index: number) => (
<DraggableCollectionLink
key={collection.id}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
))}
<SidebarAction action={createCollection} depth={0} />
</>
);
if (!collections.isLoaded || fetchError) {
return (
<Flex column>
<Header id="collections" title={t("Collections")}>
<PlaceholderCollections />
</Header>
</Flex>
);
}
return (
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</Relative>
<Relative>
<PaginatedList
aria-label={t("Collections")}
items={collections.orderedData}
fetch={collections.fetchPage}
options={{ limit: 100 }}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
)}
/>
<SidebarAction action={createCollection} depth={0} />
</Relative>
</Header>
</Flex>
);
}
const StyledError = styled(Error)`
font-size: 15px;
padding: 0 8px;
`;
export default observer(Collections);
@@ -2,7 +2,7 @@
// This file is pulled almost 100% from react-router with the addition of one
// thing, automatic scroll to the active link. It's worth the copy paste because
// it avoids recalculating the link match again.
import { Location, createLocation } from "history";
import { Location, createLocation, LocationDescriptor } from "history";
import * as React from "react";
import {
__RouterContext as RouterContext,
@@ -13,12 +13,12 @@ import { Link } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
const resolveToLocation = (
to: string | Record<string, any>,
to: LocationDescriptor | ((location: Location) => LocationDescriptor),
currentLocation: Location
) => (typeof to === "function" ? to(currentLocation) : to);
const normalizeToLocation = (
to: string | Record<string, any>,
to: LocationDescriptor,
currentLocation: Location
) => {
return typeof to === "string"
@@ -30,17 +30,15 @@ const joinClassnames = (...classnames: (string | undefined)[]) => {
return classnames.filter((i) => i).join(" ");
};
export type Props = React.HTMLAttributes<HTMLAnchorElement> & {
export type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
activeClassName?: string;
activeStyle?: React.CSSProperties;
className?: string;
scrollIntoViewIfNeeded?: boolean;
exact?: boolean;
isActive?: (match: match | null, location: Location) => boolean;
location?: Location;
strict?: boolean;
style?: React.CSSProperties;
to: string | Record<string, any>;
to: LocationDescriptor;
};
/**
@@ -1,3 +1,4 @@
import { LocationDescriptor } from "history";
import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -14,8 +15,7 @@ export type DragObject = NavigationNode & {
};
type Props = Omit<NavLinkProps, "to"> & {
to?: string | Record<string, any>;
href?: string | Record<string, any>;
to?: LocationDescriptor;
innerRef?: (ref: HTMLElement | null | undefined) => void;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
@@ -32,6 +32,7 @@ export default function Version() {
return (
<SidebarLink
target="_blank"
href="https://github.com/outline/outline/releases"
label={
<>
+17 -13
View File
@@ -3,22 +3,17 @@ import { find } from "lodash";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import io from "socket.io-client";
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 = {
type SocketWithAuthentication = Socket & {
authenticated?: boolean;
disconnected: boolean;
disconnect: () => void;
close: () => void;
on: (event: string, callback: (data: any) => void) => void;
emit: (event: string, data: any) => void;
io: any;
};
export const SocketContext: any = React.createContext<SocketWithAuthentication | null>(
export const SocketContext = React.createContext<SocketWithAuthentication | null>(
null
);
@@ -98,7 +93,7 @@ class SocketProvider extends React.Component<Props> {
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.on("reconnect_attempt", () => {
this.socket.io.on("reconnect_attempt", () => {
if (this.socket) {
this.socket.io.opts.transports = auth?.team?.domain
? ["websocket"]
@@ -154,7 +149,10 @@ class SocketProvider extends React.Component<Props> {
force: true,
});
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.remove(documentId);
return;
}
@@ -216,7 +214,10 @@ class SocketProvider extends React.Component<Props> {
force: true,
});
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
@@ -245,7 +246,10 @@ class SocketProvider extends React.Component<Props> {
force: true,
});
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
groups.remove(groupId);
}
}
+3 -6
View File
@@ -1,17 +1,14 @@
import { m } from "framer-motion";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import NavLinkWithChildrenFunc from "~/components/NavLink";
import NavLink from "~/components/NavLink";
type Props = Omit<
React.ComponentProps<typeof NavLinkWithChildrenFunc>,
"children"
> & {
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
to: string;
exact?: boolean;
};
const TabLink = styled(NavLinkWithChildrenFunc)`
const TabLink = styled(NavLink)`
position: relative;
display: inline-flex;
align-items: center;
+2 -1
View File
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
@@ -72,4 +73,4 @@ const TableFromParams = (
);
};
export default TableFromParams;
export default observer(TableFromParams);
+3 -5
View File
@@ -6,15 +6,13 @@ import useStores from "~/hooks/useStores";
type StoreProps = keyof RootStore;
function withStores<
P extends React.ComponentType<React.ComponentProps<P> & RootStore>,
P extends React.ComponentType<ResolvedProps & RootStore>,
ResolvedProps = JSX.LibraryManagedAttributes<
P,
Omit<React.ComponentProps<P>, StoreProps>
>
>(WrappedComponent: P): React.FC<Omit<ResolvedProps, StoreProps>> {
const ComponentWithStore = (
props: Omit<React.ComponentProps<P>, StoreProps>
) => {
>(WrappedComponent: P): React.FC<ResolvedProps> {
const ComponentWithStore = (props: ResolvedProps) => {
const stores = useStores();
return <WrappedComponent {...(props as any)} {...stores} />;
};
+3 -4
View File
@@ -11,8 +11,7 @@ import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
import { depths } from "@shared/styles";
import { supportedImageMimeTypes } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { supportedImageMimeTypes, getEventFiles } from "@shared/utils/files";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
@@ -275,7 +274,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
};
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(event);
const files = getEventFiles(event);
const {
view,
@@ -424,7 +423,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) {
if (embed.title && embed.icon) {
if (embed.title) {
embedItems.push({
...embed,
name: "embed",
+10 -16
View File
@@ -11,8 +11,7 @@ import { setTextSelection } from "prosemirror-utils";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import isUrl from "@shared/editor/lib/isUrl";
import { isInternalUrl } from "@shared/utils/urls";
import { isInternalUrl, sanitizeHref } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import { ToastOptions } from "~/types";
@@ -45,7 +44,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast: (message: string, options: ToastOptions) => void;
onShowToast: (message: string, options?: ToastOptions) => void;
view: EditorView;
};
@@ -71,7 +70,7 @@ class LinkEditor extends React.Component<Props, State> {
};
get href(): string {
return this.props.mark ? this.props.mark.attrs.href : "";
return sanitizeHref(this.props.mark?.attrs.href) ?? "";
}
get suggestedLinkTitle(): string {
@@ -114,17 +113,7 @@ class LinkEditor extends React.Component<Props, State> {
this.discardInputValue = true;
const { from, to } = this.props;
// Make sure a protocol is added to the beginning of the input if it's
// likely an absolute URL that was entered without one.
if (
!isUrl(href) &&
!href.startsWith("/") &&
!href.startsWith("#") &&
!href.startsWith("mailto:")
) {
href = `https://${href}`;
}
href = sanitizeHref(href) ?? "";
this.props.onSelectLink({ href, title, from, to });
};
@@ -240,7 +229,12 @@ class LinkEditor extends React.Component<Props, State> {
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
this.props.onClickLink(this.href, event);
try {
this.props.onClickLink(this.href, event);
} catch (err) {
this.props.onShowToast(this.props.dictionary.openLinkError);
}
};
handleCreateLink = async (value: string) => {
+81 -10
View File
@@ -1,6 +1,7 @@
/* eslint-disable no-irregular-whitespace */
import { lighten, transparentize } from "polished";
import { darken, lighten, transparentize } from "polished";
import styled from "styled-components";
import { depths } from "@shared/styles";
const EditorStyles = styled.div<{
rtl: boolean;
@@ -359,6 +360,7 @@ const EditorStyles = styled.div<{
.heading-actions {
opacity: 0;
z-index: ${depths.editorHeadingActions};
background: ${(props) => props.theme.background};
margin-${(props) => (props.rtl ? "right" : "left")}: -26px;
flex-direction: ${(props) => (props.rtl ? "row-reverse" : "row")};
@@ -405,6 +407,7 @@ const EditorStyles = styled.div<{
&.collapsed {
svg {
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
pointer-events: none;
}
transition-delay: 0.1s;
opacity: 1;
@@ -773,20 +776,45 @@ const EditorStyles = styled.div<{
select,
button {
background: ${(props) => props.theme.background};
color: ${(props) => props.theme.text};
border-width: 1px;
font-size: 13px;
display: none;
margin: 0;
padding: 0;
border: 0;
background: ${(props) => props.theme.buttonNeutralBackground};
color: ${(props) => props.theme.buttonNeutralText};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${(props) =>
props.theme.buttonNeutralBorder} 0 0 0 1px inset;
border-radius: 4px;
padding: 2px 4px;
height: 18px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
flex-shrink: 0;
cursor: pointer;
user-select: none;
appearance: none !important;
padding: 6px 8px;
display: none;
&::-moz-focus-inner {
padding: 0;
border: 0;
}
&:hover:not(:disabled) {
background-color: ${(props) =>
darken(0.05, props.theme.buttonNeutralBackground)};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${(props) =>
props.theme.buttonNeutralBorder} 0 0 0 1px inset;
}
}
button {
padding: 2px 4px;
select {
background-image: url('data:image/svg+xml;utf8,<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" d="M9.03087 9C8.20119 9 7.73238 9.95209 8.23824 10.6097L11.2074 14.4696C11.6077 14.99 12.3923 14.99 12.7926 14.4696L15.7618 10.6097C16.2676 9.95209 15.7988 9 14.9691 9L9.03087 9Z" fill="currentColor"/> </svg>');
background-repeat: no-repeat;
background-position: center right;
padding-right: 20px;
}
&:focus-within,
&:hover {
select {
display: ${(props) => (props.readOnly ? "none" : "inline")};
@@ -803,6 +831,49 @@ const EditorStyles = styled.div<{
button:active {
display: inline;
}
button.show-source-button {
display: none;
}
button.show-diagram-button {
display: inline;
}
&.code-hidden {
button,
select,
button.show-diagram-button {
display: none;
}
button.show-source-button {
display: inline;
}
pre {
display: none;
}
}
}
.mermaid-diagram-wrapper {
display: flex;
align-items: center;
justify-content: center;
background: ${(props) => props.theme.codeBackground};
border-radius: 6px;
border: 1px solid ${(props) => props.theme.codeBorder};
padding: 8px;
user-select: none;
cursor: default;
* {
font-family: ${(props) => props.theme.fontFamily};
}
&.diagram-hidden {
display: none;
}
}
pre {
+15 -9
View File
@@ -14,6 +14,7 @@ import {
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import isInCode from "@shared/editor/queries/isInCode";
import isInList from "@shared/editor/queries/isInList";
import isMarkActive from "@shared/editor/queries/isMarkActive";
import isNodeActive from "@shared/editor/queries/isNodeActive";
@@ -28,6 +29,7 @@ export default function formattingMenuItems(
const { schema } = state;
const isTable = isInTable(state);
const isList = isInList(state);
const isCode = isInCode(state);
const allowBlocks = !isTable && !isList;
return [
@@ -47,19 +49,21 @@ export default function formattingMenuItems(
tooltip: dictionary.strong,
icon: BoldIcon,
active: isMarkActive(schema.marks.strong),
visible: !isCode,
},
{
name: "strikethrough",
tooltip: dictionary.strikethrough,
icon: StrikethroughIcon,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCode,
},
{
name: "highlight",
tooltip: dictionary.mark,
icon: HighlightIcon,
active: isMarkActive(schema.marks.highlight),
visible: !isTemplate,
visible: !isTemplate && !isCode,
},
{
name: "code_inline",
@@ -69,7 +73,7 @@ export default function formattingMenuItems(
},
{
name: "separator",
visible: allowBlocks,
visible: allowBlocks && !isCode,
},
{
name: "heading",
@@ -77,7 +81,7 @@ export default function formattingMenuItems(
icon: Heading1Icon,
active: isNodeActive(schema.nodes.heading, { level: 1 }),
attrs: { level: 1 },
visible: allowBlocks,
visible: allowBlocks && !isCode,
},
{
name: "heading",
@@ -85,7 +89,7 @@ export default function formattingMenuItems(
icon: Heading2Icon,
active: isNodeActive(schema.nodes.heading, { level: 2 }),
attrs: { level: 2 },
visible: allowBlocks,
visible: allowBlocks && !isCode,
},
{
name: "blockquote",
@@ -93,11 +97,11 @@ export default function formattingMenuItems(
icon: BlockQuoteIcon,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
visible: allowBlocks,
visible: allowBlocks && !isCode,
},
{
name: "separator",
visible: allowBlocks || isList,
visible: (allowBlocks || isList) && !isCode,
},
{
name: "checkbox_list",
@@ -105,24 +109,25 @@ export default function formattingMenuItems(
icon: TodoListIcon,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: allowBlocks || isList,
visible: (allowBlocks || isList) && !isCode,
},
{
name: "bullet_list",
tooltip: dictionary.bulletList,
icon: BulletedListIcon,
active: isNodeActive(schema.nodes.bullet_list),
visible: allowBlocks || isList,
visible: (allowBlocks || isList) && !isCode,
},
{
name: "ordered_list",
tooltip: dictionary.orderedList,
icon: OrderedListIcon,
active: isNodeActive(schema.nodes.ordered_list),
visible: allowBlocks || isList,
visible: (allowBlocks || isList) && !isCode,
},
{
name: "separator",
visible: !isCode,
},
{
name: "link",
@@ -130,6 +135,7 @@ export default function formattingMenuItems(
icon: LinkIcon,
active: isMarkActive(schema.marks.link),
attrs: { href: "" },
visible: !isCode,
},
];
}
+22 -4
View File
@@ -10,6 +10,7 @@ import {
TeamIcon,
BeakerIcon,
DownloadIcon,
WebhooksIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -25,6 +26,7 @@ import Security from "~/scenes/Settings/Security";
import Shares from "~/scenes/Settings/Shares";
import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens";
import Webhooks from "~/scenes/Settings/Webhooks";
import Zapier from "~/scenes/Settings/Zapier";
import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
@@ -46,6 +48,7 @@ type SettingsPage =
| "Shares"
| "Import"
| "Export"
| "Webhooks"
| "Slack"
| "Zapier";
@@ -146,7 +149,7 @@ const useAuthorizedSettingsConfig = () => {
name: t("Import"),
path: "/settings/import",
component: Import,
enabled: can.manage,
enabled: can.createImport,
group: t("Team"),
icon: NewDocumentIcon,
},
@@ -154,11 +157,19 @@ const useAuthorizedSettingsConfig = () => {
name: t("Export"),
path: "/settings/export",
component: Export,
enabled: can.export,
enabled: can.createExport,
group: t("Team"),
icon: DownloadIcon,
},
// Intergrations
// Integrations
Webhooks: {
name: t("Webhooks"),
path: "/settings/webhooks",
component: Webhooks,
enabled: can.createWebhookSubscription,
group: t("Integrations"),
icon: WebhooksIcon,
},
Slack: {
name: "Slack",
path: "/settings/integrations/slack",
@@ -176,7 +187,14 @@ const useAuthorizedSettingsConfig = () => {
icon: ZapierIcon,
},
}),
[can.createApiKey, can.export, can.manage, can.update, t]
[
can.createApiKey,
can.createWebhookSubscription,
can.createExport,
can.createImport,
can.update,
t,
]
);
const enabledConfigs = React.useMemo(
+4
View File
@@ -18,6 +18,7 @@ export default function useDictionary() {
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
copy: t("Copy"),
createLink: t("Create link"),
createLinkError: t("Sorry, an error occurred creating the link"),
createNewDoc: t("Create a new doc"),
@@ -52,6 +53,7 @@ export default function useDictionary() {
noResults: t("No results"),
openLink: t("Open link"),
goToLink: t("Go to link"),
openLinkError: t("Sorry, that type of link is not supported"),
orderedList: t("Ordered list"),
pageBreak: t("Page break"),
pasteLink: `${t("Paste a link")}`,
@@ -69,6 +71,8 @@ export default function useDictionary() {
table: t("Table"),
tip: t("Tip"),
tipNotice: t("Tip notice"),
showDiagram: t("Show diagram"),
showSource: t("Show source"),
warning: t("Warning"),
warningNotice: t("Warning notice"),
insertDate: t("Current date"),
+3 -3
View File
@@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionExport from "~/scenes/CollectionExport";
@@ -117,8 +117,8 @@ function CollectionMenu({
);
const handleFilePicked = React.useCallback(
async (ev: React.FormEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(ev);
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
+4 -4
View File
@@ -23,7 +23,7 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
@@ -219,8 +219,8 @@ function DocumentMenu({
);
const handleFilePicked = React.useCallback(
async (ev: React.FormEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(ev);
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
@@ -447,7 +447,7 @@ function DocumentMenu({
/>
</Style>
)}
{showDisplayOptions && !isMobile && (
{showDisplayOptions && !isMobile && can.update && (
<Style>
<ToggleMenuItem
width={26}
+27
View File
@@ -0,0 +1,27 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class WebhookSubscription extends BaseModel {
@Field
@observable
id: string;
@Field
@observable
name: string;
@Field
@observable
url: string;
@Field
@observable
enabled: boolean;
@Field
@observable
events: string[];
}
export default WebhookSubscription;
+24 -6
View File
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
import Archive from "~/scenes/Archive";
@@ -11,6 +12,8 @@ import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import Route from "~/components/ProfiledRoute";
import SocketProvider from "~/components/SocketProvider";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
const SettingsRoutes = React.lazy(
@@ -59,7 +62,10 @@ const RedirectDocument = ({
/>
);
export default function AuthenticatedRoutes() {
function AuthenticatedRoutes() {
const team = useCurrentTeam();
const can = usePolicy(team.id);
return (
<SocketProvider>
<Layout>
@@ -71,14 +77,24 @@ export default function AuthenticatedRoutes() {
}
>
<Switch>
{can.createDocument && (
<Route exact path="/templates" component={Templates} />
)}
{can.createDocument && (
<Route exact path="/templates/:sort" component={Templates} />
)}
{can.createDocument && (
<Route exact path="/drafts" component={Drafts} />
)}
{can.createDocument && (
<Route exact path="/archive" component={Archive} />
)}
{can.createDocument && (
<Route exact path="/trash" component={Trash} />
)}
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Route exact path="/templates" component={Templates} />
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Redirect exact from="/starred" to="/home" />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
@@ -103,3 +119,5 @@ export default function AuthenticatedRoutes() {
</SocketProvider>
);
}
export default observer(AuthenticatedRoutes);
+2
View File
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
import { useState } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker";
@@ -93,6 +94,7 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
type="text"
label={t("Name")}
onChange={handleNameChange}
maxLength={MAX_TITLE_LENGTH}
value={name}
required
autoFocus
+2
View File
@@ -3,6 +3,7 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, WithTranslation } from "react-i18next";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
@@ -127,6 +128,7 @@ class CollectionNew extends React.Component<Props> {
type="text"
label={t("Name")}
onChange={this.handleNameChange}
maxLength={MAX_TITLE_LENGTH}
value={this.name}
required
autoFocus
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -103,4 +104,4 @@ const Select = styled(InputSelect)`
}
` as React.ComponentType<SelectProps>;
export default MemberListItem;
export default observer(MemberListItem);
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -45,4 +46,4 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
);
};
export default UserListItem;
export default observer(UserListItem);
+36 -3
View File
@@ -2,16 +2,21 @@ import { Location } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { RouteComponentProps, useLocation } from "react-router-dom";
import { useTheme } from "styled-components";
import styled, { useTheme } from "styled-components";
import { setCookie } from "tiny-cookie";
import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { OfflineError } from "~/utils/errors";
import { AuthorizationError, OfflineError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import Login from "../Login";
import Document from "./components/Document";
import Loading from "./components/Loading";
@@ -73,6 +78,7 @@ function SharedDocumentScene(props: Props) {
const { ui } = useStores();
const theme = useTheme();
const location = useLocation();
const { t } = useTranslation();
const [response, setResponse] = React.useState<Response>();
const [error, setError] = React.useState<Error | null | undefined>();
const { documents } = useStores();
@@ -105,7 +111,29 @@ function SharedDocumentScene(props: Props) {
}, [documents, documentSlug, shareId, ui]);
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
if (error instanceof OfflineError) {
return <ErrorOffline />;
} else if (error instanceof AuthorizationError) {
setCookie("postLoginRedirectPath", props.location.pathname);
return (
<Login>
{(config) =>
config?.name && isCloudHosted ? (
<GetStarted>
{t(
"{{ teamName }} is using Outline to share documents, please login to continue.",
{
teamName: config.name,
}
)}
</GetStarted>
) : null
}
</Login>
);
} else {
return <Error404 />;
}
}
if (!response) {
@@ -137,4 +165,9 @@ function SharedDocumentScene(props: Props) {
);
}
const GetStarted = styled(Text)`
text-align: center;
margin-top: -8px;
`;
export default observer(SharedDocumentScene);
+117 -187
View File
@@ -1,14 +1,12 @@
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { RouteComponentProps, StaticContext } from "react-router";
import RootStore from "~/stores/RootStore";
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import withStores from "~/components/withStores";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history";
@@ -16,146 +14,99 @@ import { matchDocumentEdit } from "~/utils/routeHelpers";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
type Props = RootStore &
RouteComponentProps<
{
documentSlug: string;
revisionId?: string;
shareId?: string;
title?: string;
},
StaticContext,
{
title?: string;
}
> & {
children: (arg0: any) => React.ReactNode;
};
type Params = {
documentSlug: string;
revisionId?: string;
shareId?: string;
};
@observer
class DataLoader extends React.Component<Props> {
sharedTree: NavigationNode | null | undefined;
type LocationState = {
title?: string;
restore?: boolean;
revisionId?: string;
};
@observable
document: Document | null | undefined;
type Children = (options: {
document: Document;
revision: Revision | undefined;
abilities: Record<string, boolean>;
isEditing: boolean;
readOnly: boolean;
onCreateLink: (title: string) => Promise<string>;
sharedTree: NavigationNode | undefined;
}) => React.ReactNode;
@observable
revision: Revision | null | undefined;
type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
children: Children;
};
@observable
shapshot: Blob | null | undefined;
function DataLoader({ match, children }: Props) {
const { ui, shares, documents, auth, revisions } = useStores();
const { team } = auth;
const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params;
const document = documents.getByUrl(match.params.documentSlug);
const revision = revisionId ? revisions.get(revisionId) : undefined;
const sharedTree = document
? documents.getSharedTree(document.id)
: undefined;
const isEditRoute = match.path === matchDocumentEdit;
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
const can = usePolicy(document ? document.id : "");
const location = useLocation<LocationState>();
@observable
error: Error | null | undefined;
componentDidMount() {
const { documents, match } = this.props;
this.document = documents.getByUrl(match.params.documentSlug);
this.sharedTree = this.document
? documents.getSharedTree(this.document.id)
: undefined;
this.loadDocument();
}
componentDidUpdate(prevProps: Props) {
// If we have the document in the store, but not it's policy then we need to
// reload from the server otherwise the UI will not know which authorizations
// the user has
if (this.document) {
const document = this.document;
const policy = this.props.policies.get(document.id);
if (
!policy &&
!this.error &&
this.props.auth.user &&
this.props.auth.user.id
) {
this.loadDocument();
}
}
// Also need to load the revision if it changes
const { revisionId } = this.props.match.params;
if (
prevProps.match.params.revisionId !== revisionId &&
revisionId &&
revisionId !== "latest"
) {
this.loadRevision();
}
}
get isEditRoute() {
return this.props.match.path === matchDocumentEdit;
}
get isEditing() {
return this.isEditRoute || this.props.auth?.team?.collaborativeEditing;
}
onCreateLink = async (title: string) => {
const document = this.document;
invariant(document, "document must be loaded to create link");
const newDocument = await this.props.documents.create({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
title,
text: "",
});
return newDocument.url;
};
loadRevision = async () => {
const { revisionId } = this.props.match.params;
if (revisionId) {
this.revision = await this.props.revisions.fetch(revisionId);
}
};
loadDocument = async () => {
const { shareId, documentSlug, revisionId } = this.props.match.params;
// sets the document as active in the sidebar if we already have it loaded
if (this.document) {
this.props.ui.setActiveDocument(this.document);
}
try {
const response = await this.props.documents.fetchWithSharedTree(
documentSlug,
{
React.useEffect(() => {
async function fetchDocument() {
try {
await documents.fetchWithSharedTree(documentSlug, {
shareId,
}
);
this.sharedTree = response.sharedTree;
this.document = response.document;
if (revisionId && revisionId !== "latest") {
await this.loadRevision();
} else {
this.revision = undefined;
});
} catch (err) {
setError(err);
}
} catch (err) {
this.error = err;
return;
}
fetchDocument();
}, [ui, documents, document, shareId, documentSlug]);
const document = this.document;
React.useEffect(() => {
async function fetchRevision() {
if (revisionId && revisionId !== "latest") {
try {
await revisions.fetch(revisionId);
} catch (err) {
setError(err);
}
}
}
fetchRevision();
}, [revisions, revisionId]);
const onCreateLink = React.useCallback(
async (title: string) => {
if (!document) {
throw new Error("Document not loaded yet");
}
const newDocument = await documents.create({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
title,
text: "",
});
return newDocument.url;
},
[document, documents]
);
React.useEffect(() => {
if (document) {
const can = this.props.policies.abilities(document.id);
// sets the document as active in the sidebar, ideally in the future this
// will be route driven.
this.props.ui.setActiveDocument(document);
// sets the current document as active in the sidebar
ui.setActiveDocument(document);
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!can.update && this.isEditRoute) {
if (!can.update && isEditRoute) {
history.push(document.url);
return;
}
@@ -163,72 +114,51 @@ class DataLoader extends React.Component<Props> {
// Prevents unauthorized request to load share information for the document
// when viewing a public share link
if (can.read) {
this.props.shares.fetch(document.id).catch((err) => {
shares.fetch(document.id).catch((err) => {
if (!(err instanceof NotFoundError)) {
throw err;
}
});
}
}
};
}, [can.read, can.update, document, isEditRoute, shares, ui]);
render() {
const { location, policies, auth, match, ui } = this.props;
const { revisionId } = match.params;
if (this.error) {
return this.error instanceof OfflineError ? (
<ErrorOffline />
) : (
<Error404 />
);
}
const team = auth.team;
const document = this.document;
const revision = this.revision;
if (!document || !team || (revisionId && !revision)) {
return (
<>
<Loading location={location} />
{this.isEditing && !team?.collaborativeEditing && (
<HideSidebar ui={ui} />
)}
</>
);
}
const abilities = policies.abilities(document.id);
// We do not want to remount the document when changing from view->edit
// on the multiplayer flag as the doc is guaranteed to be upto date.
const key = team.collaborativeEditing
? ""
: this.isEditing
? "editing"
: "read-only";
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
if (!document || !team || (revisionId && !revision)) {
return (
<React.Fragment key={key}>
{this.isEditing && !team.collaborativeEditing && (
<HideSidebar ui={ui} />
)}
{this.props.children({
document,
revision,
abilities,
isEditing: this.isEditing,
readOnly:
!this.isEditing ||
!abilities.update ||
document.isArchived ||
!!revisionId,
onCreateLink: this.onCreateLink,
sharedTree: this.sharedTree,
})}
</React.Fragment>
<>
<Loading location={location} />
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
</>
);
}
// We do not want to remount the document when changing from view->edit
// on the multiplayer flag as the doc is guaranteed to be upto date.
const key = team.collaborativeEditing
? ""
: isEditing
? "editing"
: "read-only";
return (
<React.Fragment key={key}>
{isEditing && !team.collaborativeEditing && <HideSidebar ui={ui} />}
{children({
document,
revision,
abilities: can,
isEditing,
readOnly:
!isEditing || !can.update || document.isArchived || !!revisionId,
onCreateLink,
sharedTree,
})}
</React.Fragment>
);
}
export default withStores(DataLoader);
export default observer(DataLoader);
+13 -5
View File
@@ -55,13 +55,21 @@ import References from "./References";
const AUTOSAVE_DELAY = 3000;
type Params = {
documentSlug: string;
revisionId?: string;
shareId?: string;
};
type LocationState = {
title?: string;
restore?: boolean;
revisionId?: string;
};
type Props = WithTranslation &
RootStore &
RouteComponentProps<
Record<string, string>,
StaticContext,
{ restore?: boolean; revisionId?: string }
> & {
RouteComponentProps<Params, StaticContext, LocationState> & {
sharedTree?: NavigationNode;
abilities: Record<string, any>;
document: Document;
+3 -1
View File
@@ -57,8 +57,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
}
}, [ref]);
// Save document when blurring title, but delay so that if clicking on a
// button this is allowed to execute first.
const handleBlur = React.useCallback(() => {
props.onSave({ autosave: true });
setTimeout(() => props.onSave({ autosave: true }), 250);
}, [props]);
const handleGoToNextInput = React.useCallback(
@@ -7,6 +7,7 @@ import Document from "~/models/Document";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import Tooltip from "~/components/Tooltip";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import SharePopover from "./SharePopover";
@@ -17,10 +18,12 @@ type Props = {
function ShareButton({ document }: Props) {
const { t } = useTranslation();
const { shares } = useStores();
const team = useCurrentTeam();
const share = shares.getByDocumentId(document.id);
const sharedParent = shares.getByDocumentParents(document.id);
const isPubliclyShared =
share?.published || (sharedParent?.published && !document.isDraft);
team.sharing &&
(share?.published || (sharedParent?.published && !document.isDraft));
const popover = usePopoverState({
gutter: 0,
@@ -14,6 +14,7 @@ import Input from "~/components/Input";
import Notice from "~/components/Notice";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -36,8 +37,9 @@ function SharePopover({
onRequestClose,
visible,
}: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const { shares, auth } = useStores();
const { shares } = useStores();
const { showToast } = useToasts();
const [isCopied, setIsCopied] = React.useState(false);
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
@@ -47,22 +49,23 @@ function SharePopover({
const canPublish =
can.update &&
!document.isTemplate &&
auth.team?.sharing &&
team.sharing &&
documentAbilities.share;
const isPubliclyShared =
(share && share.published) ||
(sharedParent && sharedParent.published && !document.isDraft);
team.sharing &&
((share && share.published) ||
(sharedParent && sharedParent.published && !document.isDraft));
useKeyDown("Escape", onRequestClose);
React.useEffect(() => {
if (visible) {
if (visible && team.sharing) {
document.share();
buttonRef.current?.focus();
}
return () => (timeout.current ? clearTimeout(timeout.current) : undefined);
}, [document, visible]);
}, [document, visible, team.sharing]);
const handlePublishedChange = React.useCallback(
async (event) => {
@@ -113,6 +116,9 @@ function SharePopover({
const userLocale = useUserLocale();
const locale = userLocale ? dateLocale(userLocale) : undefined;
const shareUrl = team.sharing
? share?.url ?? ""
: `${team.url}${document.url}`;
return (
<>
@@ -199,14 +205,14 @@ function SharePopover({
type="text"
label={t("Link")}
placeholder={`${t("Loading")}`}
value={share ? share.url : ""}
value={shareUrl}
labelHidden
readOnly
/>
<CopyToClipboard text={share ? share.url : ""} onCopy={handleCopied}>
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
<Button
type="submit"
disabled={isCopied || !share}
disabled={isCopied || (!share && team.sharing)}
ref={buttonRef}
primary
>
+17 -9
View File
@@ -7,13 +7,21 @@ import DataLoader from "./components/DataLoader";
import Document from "./components/Document";
import SocketPresence from "./components/SocketPresence";
export default function DocumentScene(
props: RouteComponentProps<
{ documentSlug: string; revisionId: string },
StaticContext,
{ title?: string }
>
) {
type Params = {
documentSlug: string;
revisionId?: string;
shareId?: string;
};
type LocationState = {
title?: string;
restore?: boolean;
revisionId?: string;
};
type Props = RouteComponentProps<Params, StaticContext, LocationState>;
export default function DocumentScene(props: Props) {
const { ui } = useStores();
const team = useCurrentTeam();
const { documentSlug, revisionId } = props.match.params;
@@ -47,12 +55,12 @@ export default function DocumentScene(
if (isActive && !isMultiplayer) {
return (
<SocketPresence documentId={document.id} isEditing={isEditing}>
<Document document={document} match={props.match} {...rest} />
<Document document={document} {...rest} />
</SocketPresence>
);
}
return <Document document={document} match={props.match} {...rest} />;
return <Document document={document} {...rest} />;
}}
</DataLoader>
);
@@ -1,4 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -15,20 +17,22 @@ type Props = {
};
const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={user.name}
subtitle={
<>
{user.lastActiveAt ? (
<>
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</>
</Trans>
) : (
"Never signed in"
t("Never signed in")
)}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
image={<Avatar src={user.avatarUrl} size={32} />}
@@ -37,7 +41,7 @@ const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
{onAdd && (
<Button onClick={onAdd} neutral>
Add
{t("Add")}
</Button>
)}
</Flex>
@@ -46,4 +50,4 @@ const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
);
};
export default GroupMemberListItem;
export default observer(GroupMemberListItem);
@@ -1,5 +1,7 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -14,6 +16,8 @@ type Props = {
};
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={user.name}
@@ -21,20 +25,20 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
subtitle={
<>
{user.lastActiveAt ? (
<>
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</>
</Trans>
) : (
"Never signed in"
t("Never signed in")
)}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
actions={
canEdit ? (
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
Add
{t("Add")}
</Button>
) : undefined
}
@@ -42,4 +46,4 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
);
};
export default UserListItem;
export default observer(UserListItem);
+16
View File
@@ -18,6 +18,13 @@ export default function Notices() {
invite email.
</NoticeAlert>
)}
{notice === "gmail-account-creation" && (
<NoticeAlert>
Sorry, a new account cannot be created with a personal Gmail address.
<hr />
Please use a Google Workspaces account instead.
</NoticeAlert>
)}
{notice === "maximum-teams" && (
<NoticeAlert>
The team you authenticated with is not authorized on this
@@ -50,6 +57,15 @@ export default function Notices() {
Please try again.
</NoticeAlert>
))}
{notice === "invalid-authentication" &&
(description ? (
<NoticeAlert>{description}</NoticeAlert>
) : (
<NoticeAlert>
Authentication failed you do not have permission to access this
team.
</NoticeAlert>
))}
{notice === "expired-token" && (
<NoticeAlert>
Sorry, it looks like that sign-in link is no longer valid, please try
+13 -6
View File
@@ -49,7 +49,11 @@ function Header({ config }: { config?: Config | undefined }) {
);
}
function Login() {
type Props = {
children?: (config?: Config) => React.ReactNode;
};
function Login({ children }: Props) {
const location = useLocation();
const query = useQuery();
const { t, i18n } = useTranslation();
@@ -174,11 +178,14 @@ function Login() {
</GetStarted>
</>
) : (
<StyledHeading centered>
{t("Login to {{ authProviderName }}", {
authProviderName: config.name || "Outline",
})}
</StyledHeading>
<>
<StyledHeading centered>
{t("Login to {{ authProviderName }}", {
authProviderName: config.name || "Outline",
})}
</StyledHeading>
{children?.(config)}
</>
)}
<Notices />
{defaultProvider && (
+7
View File
@@ -44,6 +44,13 @@ function Notifications() {
"Receive a notification whenever a new collection is created"
),
},
{
event: "emails.invite_accepted",
title: t("Invite accepted"),
description: t(
"Receive a notification when someone you invited creates an account"
),
},
{
visible: isCloudHosted,
event: "emails.onboarding",
+125 -104
View File
@@ -36,9 +36,17 @@ function Security() {
defaultUserRole: team.defaultUserRole,
memberCollectionCreate: team.memberCollectionCreate,
inviteRequired: team.inviteRequired,
allowedDomains: team.allowedDomains,
});
const [allowedDomains, setAllowedDomains] = useState([
...(team.allowedDomains ?? []),
]);
const [lastKnownDomainCount, updateLastKnownDomainCount] = useState(
allowedDomains.length
);
const [existingDomainsTouched, setExistingDomainsTouched] = useState(false);
const authenticationMethods = team.signinMethods;
const showSuccessMessage = React.useMemo(
@@ -51,17 +59,13 @@ function Security() {
[showToast, t]
);
const [domainsChanged, setDomainsChanged] = useState(false);
const saveData = React.useCallback(
async (newData) => {
try {
setData(newData);
await auth.updateTeam(newData);
showSuccessMessage();
setDomainsChanged(false);
} catch (err) {
setDomainsChanged(true);
showToast(err.message, {
type: "error",
});
@@ -77,6 +81,21 @@ function Security() {
[data, saveData]
);
const handleSaveDomains = React.useCallback(async () => {
try {
await auth.updateTeam({
allowedDomains,
});
showSuccessMessage();
setExistingDomainsTouched(false);
updateLastKnownDomainCount(allowedDomains.length);
} catch (err) {
showToast(err.message, {
type: "error",
});
}
}, [auth, allowedDomains, showSuccessMessage, showToast]);
const handleDefaultRoleChange = React.useCallback(
async (newDefaultRole: string) => {
await saveData({ ...data, defaultUserRole: newDefaultRole });
@@ -84,26 +103,26 @@ function Security() {
[data, saveData]
);
const handleAllowSignupsChange = React.useCallback(
const handleInviteRequiredChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const inviteRequired = !ev.target.checked;
const inviteRequired = ev.target.checked;
const newData = { ...data, inviteRequired };
if (inviteRequired) {
dialogs.openModal({
isCentered: true,
title: t("Are you sure you want to disable authorized signups?"),
title: t("Are you sure you want to require invites?"),
content: (
<ConfirmationDialog
onSubmit={async () => {
await saveData(newData);
}}
submitText={t("Im sure — Disable")}
savingText={`${t("Disabling")}`}
submitText={t("Im sure")}
savingText={`${t("Saving")}`}
danger
>
<Trans
defaults="New account creation using <em>{{ authenticationMethods }}</em> will be disabled. New users will need to be invited."
defaults="New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply."
values={{
authenticationMethods,
}}
@@ -123,34 +142,41 @@ function Security() {
);
const handleRemoveDomain = async (index: number) => {
const newData = {
...data,
};
newData.allowedDomains && newData.allowedDomains.splice(index, 1);
const newDomains = allowedDomains.filter((_, i) => index !== i);
setData(newData);
setDomainsChanged(true);
setAllowedDomains(newDomains);
const touchedExistingDomain = index < lastKnownDomainCount;
if (touchedExistingDomain) {
setExistingDomainsTouched(true);
}
};
const handleAddDomain = () => {
const newData = {
...data,
allowedDomains: [...(data.allowedDomains || []), ""],
};
const newDomains = [...allowedDomains, ""];
setData(newData);
setAllowedDomains(newDomains);
};
const createOnDomainChangedHandler = (index: number) => (
ev: React.ChangeEvent<HTMLInputElement>
) => {
const newData = { ...data };
const newDomains = allowedDomains.slice();
newData.allowedDomains![index] = ev.currentTarget.value;
setData(newData);
setDomainsChanged(true);
newDomains[index] = ev.currentTarget.value;
setAllowedDomains(newDomains);
const touchedExistingDomain = index < lastKnownDomainCount;
if (touchedExistingDomain) {
setExistingDomainsTouched(true);
}
};
const showSaveChanges =
existingDomainsTouched ||
allowedDomains.filter((value: string) => value !== "").length > // New domains were added
lastKnownDomainCount;
return (
<Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}>
<Heading>{t("Security")}</Heading>
@@ -214,63 +240,57 @@ function Security() {
</SettingRow>
{isCloudHosted && (
<SettingRow
label={t("Allow authorized signups")}
name="allowSignups"
description={
<Trans
defaults="Allow authorized <em>{{ authenticationMethods }}</em> users to create new accounts without first receiving an invite"
values={{
authenticationMethods,
}}
components={{
em: <strong />,
}}
/>
}
label={t("Require invites")}
name="inviteRequired"
description={t(
"Require members to be invited to the team before they can create an account using SSO."
)}
>
<Switch
id="allowSignups"
checked={!data.inviteRequired}
onChange={handleAllowSignupsChange}
id="inviteRequired"
checked={data.inviteRequired}
onChange={handleInviteRequiredChange}
/>
</SettingRow>
)}
<SettingRow
label={t("Default role")}
name="defaultUserRole"
description={t(
"The default user role for new accounts. Changing this setting does not affect existing user accounts."
)}
>
<InputSelect
id="defaultUserRole"
value={data.defaultUserRole}
options={[
{
label: t("Member"),
value: "member",
},
{
label: t("Viewer"),
value: "viewer",
},
]}
onChange={handleDefaultRoleChange}
ariaLabel={t("Default role")}
short
/>
</SettingRow>
{!data.inviteRequired && (
<SettingRow
label={t("Default role")}
name="defaultUserRole"
description={t(
"The default user role for new accounts. Changing this setting does not affect existing user accounts."
)}
>
<InputSelect
id="defaultUserRole"
value={data.defaultUserRole}
options={[
{
label: t("Member"),
value: "member",
},
{
label: t("Viewer"),
value: "viewer",
},
]}
onChange={handleDefaultRoleChange}
ariaLabel={t("Default role")}
short
/>
</SettingRow>
)}
<SettingRow
label={t("Allowed Domains")}
name="allowedDomains"
description={t(
"The domains which should be allowed to create accounts. This applies to both SSO and Email logins. Changing this setting does not affect existing user accounts."
)}
>
{data.allowedDomains &&
data.allowedDomains.map((domain, index) => (
{!data.inviteRequired && (
<SettingRow
label={t("Allowed domains")}
name="allowedDomains"
description={t(
"The domains which should be allowed to create new accounts using SSO. Changing this setting does not affect existing user accounts."
)}
>
{allowedDomains.map((domain, index) => (
<Flex key={index} gap={4}>
<Input
key={index}
@@ -292,35 +312,36 @@ function Security() {
</Flex>
))}
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
{!data.allowedDomains?.length ||
data.allowedDomains[data.allowedDomains.length - 1] !== "" ? (
<Fade>
<Button type="button" onClick={handleAddDomain} neutral>
{data.allowedDomains?.length ? (
<Trans>Add another</Trans>
) : (
<Trans>Add a domain</Trans>
)}
</Button>
</Fade>
) : (
<span />
)}
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
{!allowedDomains.length ||
allowedDomains[allowedDomains.length - 1] !== "" ? (
<Fade>
<Button type="button" onClick={handleAddDomain} neutral>
{allowedDomains.length ? (
<Trans>Add another</Trans>
) : (
<Trans>Add a domain</Trans>
)}
</Button>
</Fade>
) : (
<span />
)}
{domainsChanged && (
<Fade>
<Button
type="button"
onClick={handleChange}
disabled={auth.isSaving}
>
<Trans>Save changes</Trans>
</Button>
</Fade>
)}
</Flex>
</SettingRow>
{showSaveChanges && (
<Fade>
<Button
type="button"
onClick={handleSaveDomains}
disabled={auth.isSaving}
>
<Trans>Save changes</Trans>
</Button>
</Fade>
)}
</Flex>
</SettingRow>
)}
</Scene>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { observer } from "mobx-react";
import { WebhooksIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import WebhookSubscription from "~/models/WebhookSubscription";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Modal from "~/components/Modal";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import WebhookSubscriptionListItem from "./components/WebhookSubscriptionListItem";
import WebhookSubscriptionNew from "./components/WebhookSubscriptionNew";
function Webhooks() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { webhookSubscriptions } = useStores();
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
const can = usePolicy(team.id);
return (
<Scene
title={t("Webhooks")}
icon={<WebhooksIcon color="currentColor" />}
actions={
<>
{can.createWebhookSubscription && (
<Action>
<Button
type="submit"
value={`${t("New webhook")}`}
onClick={handleNewModalOpen}
/>
</Action>
)}
</>
}
>
<Heading>{t("Webhooks")}</Heading>
<Text type="secondary">
<Trans defaults="Webhooks can be used to notify your application when events happen in Outline. Events are sent as a https request with a JSON payload in near real-time." />
</Text>
<PaginatedList
fetch={webhookSubscriptions.fetchPage}
items={webhookSubscriptions.orderedData}
heading={<Subheading sticky>{t("Webhooks")}</Subheading>}
renderItem={(webhook: WebhookSubscription) => (
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
)}
/>
<Modal
title={t("Create a webhook")}
onRequestClose={handleNewModalClose}
isOpen={newModalOpen}
>
<WebhookSubscriptionNew onSubmit={handleNewModalClose} />
</Modal>
</Scene>
);
}
export default observer(Webhooks);
+26 -28
View File
@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
@@ -13,34 +14,31 @@ type Props = {
showMenu: boolean;
};
@observer
class UserListItem extends React.Component<Props> {
render() {
const { user, showMenu } = this.props;
const UserListItem = ({ user, showMenu }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={<Title>{user.name}</Title>}
image={<Avatar src={user.avatarUrl} size={32} />}
subtitle={
<>
{user.email ? `${user.email} · ` : undefined}
{user.lastActiveAt ? (
<>
Active <Time dateTime={user.lastActiveAt} /> ago
</>
) : (
"Invited"
)}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
{user.isSuspended && <Badge>Suspended</Badge>}
</>
}
actions={showMenu ? <UserMenu user={user} /> : undefined}
/>
);
}
}
return (
<ListItem
title={<Title>{user.name}</Title>}
image={<Avatar src={user.avatarUrl} size={32} />}
subtitle={
<>
{user.email ? `${user.email} · ` : undefined}
{user.lastActiveAt ? (
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Invited")
)}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</>
}
actions={showMenu ? <UserMenu user={user} /> : undefined}
/>
);
};
const Title = styled.span`
&:hover {
@@ -49,4 +47,4 @@ const Title = styled.span`
}
`;
export default UserListItem;
export default observer(UserListItem);
@@ -1,6 +1,9 @@
import { compact } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "~/components/FilterOptions";
import useCurrentUser from "~/hooks/useCurrentUser";
type Props = {
activeKey: string;
@@ -9,34 +12,41 @@ type Props = {
const UserStatusFilter = ({ activeKey, onSelect, ...rest }: Props) => {
const { t } = useTranslation();
const user = useCurrentUser();
const options = React.useMemo(
() => [
{
key: "",
label: t("Active"),
},
{
key: "all",
label: t("Everyone"),
},
{
key: "admins",
label: t("Admins"),
},
{
key: "suspended",
label: t("Suspended"),
},
{
key: "invited",
label: t("Invited"),
},
{
key: "viewers",
label: t("Viewers"),
},
],
[t]
() =>
compact([
{
key: "",
label: t("Active"),
},
{
key: "all",
label: t("Everyone"),
},
{
key: "admins",
label: t("Admins"),
},
...(user.isAdmin
? [
{
key: "suspended",
label: t("Suspended"),
},
]
: []),
{
key: "invited",
label: t("Invited"),
},
{
key: "viewers",
label: t("Viewers"),
},
]),
[t, user.isAdmin]
);
return (
@@ -50,4 +60,4 @@ const UserStatusFilter = ({ activeKey, onSelect, ...rest }: Props) => {
);
};
export default UserStatusFilter;
export default observer(UserStatusFilter);
@@ -0,0 +1,34 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import WebhookSubscription from "~/models/WebhookSubscription";
import ConfirmationDialog from "~/components/ConfirmationDialog";
type Props = {
webhook: WebhookSubscription;
onSubmit: () => void;
};
export default function WebhookSubscriptionRevokeDialog({
webhook,
onSubmit,
}: Props) {
const { t } = useTranslation();
const handleSubmit = async () => {
await webhook.delete();
onSubmit();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Delete")}
savingText={`${t("Deleting")}`}
danger
>
{t("Are you sure you want to delete the {{ name }} webhook?", {
name: webhook.name,
})}
</ConfirmationDialog>
);
}
@@ -0,0 +1,57 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import WebhookSubscription from "~/models/WebhookSubscription";
import useToasts from "~/hooks/useToasts";
import WebhookSubscriptionForm from "./WebhookSubscriptionForm";
type Props = {
onSubmit: () => void;
webhookSubscription: WebhookSubscription;
};
interface FormData {
name: string;
url: string;
events: string[];
}
function WebhookSubscriptionEdit({ onSubmit, webhookSubscription }: Props) {
const { showToast } = useToasts();
const { t } = useTranslation();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const events = Array.isArray(data.events) ? data.events : [data.events];
const toSend = {
...data,
events,
};
await webhookSubscription.save(toSend);
showToast(
t("Webhook updated", {
type: "success",
})
);
onSubmit();
} catch (err) {
showToast(err.message, {
type: "error",
});
}
},
[t, showToast, onSubmit, webhookSubscription]
);
return (
<WebhookSubscriptionForm
handleSubmit={handleSubmit}
webhookSubscription={webhookSubscription}
/>
);
}
export default WebhookSubscriptionEdit;
@@ -0,0 +1,286 @@
import { isEqual, filter, includes } from "lodash";
import * as React from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import WebhookSubscription from "~/models/WebhookSubscription";
import Button from "~/components/Button";
import { ReactHookWrappedInput } from "~/components/Input";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
const WEBHOOK_EVENTS = {
user: [
"users.create",
"users.signin",
"users.update",
"users.suspend",
"users.activate",
"users.delete",
"users.invite",
"users.promote",
"users.demote",
],
document: [
"documents.create",
"documents.publish",
"documents.unpublish",
"documents.delete",
"documents.permanent_delete",
"documents.archive",
"documents.unarchive",
"documents.restore",
"documents.star",
"documents.unstar",
"documents.move",
"documents.update",
"documents.update.delayed",
"documents.update.debounced",
"documents.title_change",
],
revision: ["revisions.create"],
fileOperation: [
"file_operations.create",
"file_operations.update",
"file_operations.delete",
],
collection: [
"collections.create",
"collections.update",
"collections.delete",
"collections.add_user",
"collections.remove_user",
"collections.add_group",
"collections.remove_group",
"collections.move",
"collections.permission_changed",
],
group: [
"groups.create",
"groups.update",
"groups.delete",
"groups.add_user",
"groups.remove_user",
],
integration: ["integrations.create", "integrations.update"],
share: ["shares.create", "shares.update", "shares.revoke"],
team: ["teams.update"],
pin: ["pins.create", "pins.update", "pins.delete"],
webhookSubscription: [
"webhook_subscriptions.create",
"webhook_subscriptions.delete",
"webhook_subscriptions.update",
],
view: ["views.create"],
};
const EventCheckboxLabel = styled.label`
display: flex;
align-items: center;
font-size: 15px;
padding: 0.2em 0;
`;
const GroupEventCheckboxLabel = styled(EventCheckboxLabel)`
font-weight: 500;
font-size: 1.2em;
`;
const AllEventCheckboxLabel = styled(GroupEventCheckboxLabel)`
font-size: 1.4em;
`;
const EventCheckboxText = styled.span`
margin-left: 0.5rem;
`;
interface FieldProps {
disabled?: boolean;
}
const FieldSet = styled.fieldset<FieldProps>`
padding: 0;
margin: 0;
border: none;
${({ disabled }) =>
disabled &&
`
opacity: 0.5;
`}
`;
interface MobileProps {
isMobile?: boolean;
}
const GroupGrid = styled.div<MobileProps>`
display: grid;
grid-template-columns: 1fr 1fr;
${({ isMobile }) =>
isMobile &&
`
grid-template-columns: 1fr;
`}
`;
const GroupWrapper = styled.div<MobileProps>`
padding-bottom: 2rem;
${({ isMobile }) =>
isMobile &&
`
padding-bottom: 1rem;
`}
`;
const TextFields = styled.div`
display: flex;
flex-direction: column;
margin-bottom: 1em;
`;
type Props = {
handleSubmit: (data: FormData) => void;
webhookSubscription?: WebhookSubscription;
};
interface FormData {
name: string;
url: string;
events: string[];
}
function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) {
const { t } = useTranslation();
const {
register,
handleSubmit: formHandleSubmit,
formState,
watch,
setValue,
} = useForm<FormData>({
mode: "all",
defaultValues: {
events: webhookSubscription ? [...webhookSubscription.events] : [],
name: webhookSubscription?.name,
url: webhookSubscription?.url,
},
});
const events = watch("events");
const selectedGroups = filter(events, (e) => !e.includes("."));
const isAllEventSelected = includes(events, "*");
const filteredEvents = filter(events, (e) => {
const [beforePeriod] = e.split(".");
return (
selectedGroups.length === 0 ||
e === beforePeriod ||
!selectedGroups.includes(beforePeriod)
);
});
const isMobile = useMobile();
useEffect(() => {
if (isAllEventSelected) {
setValue("events", ["*"]);
}
}, [isAllEventSelected, setValue]);
useEffect(() => {
if (!isEqual(events, filteredEvents)) {
setValue("events", filteredEvents);
}
}, [events, filteredEvents, setValue]);
const verb = webhookSubscription ? t("Update") : t("Create");
const inProgressVerb = webhookSubscription ? t("Updating") : t("Creating");
function EventCheckbox({ label, value }: { label: string; value: string }) {
const LabelComponent =
value === "*"
? AllEventCheckboxLabel
: Object.keys(WEBHOOK_EVENTS).includes(value)
? GroupEventCheckboxLabel
: EventCheckboxLabel;
return (
<LabelComponent>
<input
type="checkbox"
defaultValue={value}
{...register("events", {})}
/>
<EventCheckboxText>{label}</EventCheckboxText>
</LabelComponent>
);
}
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text type="secondary">
<Trans>
Provide a descriptive name for this webhook and the URL we should send
a POST request to when matching events are created.
</Trans>
</Text>
<Text type="secondary">
<Trans>
Subscribe to all events, groups, or individual events. We recommend
only subscribing to the minimum amount of events that your application
needs to function.
</Trans>
</Text>
<TextFields>
<ReactHookWrappedInput
required
autoFocus
flex
label={t("Name")}
{...register("name", {
required: true,
})}
/>
<ReactHookWrappedInput
required
autoFocus
flex
pattern="https://.*"
placeholder="https://…"
label={t("URL")}
{...register("url", { required: true })}
/>
</TextFields>
<EventCheckbox label={t("All events")} value="*" />
<FieldSet disabled={isAllEventSelected}>
<GroupGrid isMobile={isMobile}>
{Object.entries(WEBHOOK_EVENTS).map(([group, events], i) => (
<GroupWrapper key={i} isMobile={isMobile}>
<EventCheckbox
label={t(`All {{ groupName }} events`, { groupName: group })}
value={group}
/>
<FieldSet disabled={selectedGroups.includes(group)}>
{events.map((event) => (
<EventCheckbox label={event} value={event} key={event} />
))}
</FieldSet>
</GroupWrapper>
))}
</GroupGrid>
</FieldSet>
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{formState.isSubmitting ? `${inProgressVerb}` : verb}
</Button>
</form>
);
}
export default WebhookSubscriptionForm;
@@ -0,0 +1,89 @@
import { EditIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import WebhookSubscription from "~/models/WebhookSubscription";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import WebhookSubscriptionRevokeDialog from "./WebhookSubscriptionDeleteDialog";
import WebhookSubscriptionEdit from "./WebhookSubscriptionEdit";
type Props = {
webhook: WebhookSubscription;
};
const WebhookSubscriptionListItem = ({ webhook }: Props) => {
const { t } = useTranslation();
const { dialogs } = useStores();
const [
editModalOpen,
handleEditModalOpen,
handleEditModalClose,
] = useBoolean();
const showDeletionConfirmation = React.useCallback(() => {
dialogs.openModal({
title: t("Delete webhook"),
isCentered: true,
content: (
<WebhookSubscriptionRevokeDialog
onSubmit={dialogs.closeAllModals}
webhook={webhook}
/>
),
});
}, [t, dialogs, webhook]);
return (
<ListItem
key={webhook.id}
title={
<>
{webhook.name}
{!webhook.enabled && (
<StyledBadge yellow={true}>{t("Disabled")}</StyledBadge>
)}
</>
}
subtitle={
<>
{t("Subscribed events")}: <code>{webhook.events.join(", ")}</code>
</>
}
actions={
<>
<Button
onClick={showDeletionConfirmation}
icon={<TrashIcon />}
neutral
>
{t("Delete")}
</Button>
<Button icon={<EditIcon />} onClick={handleEditModalOpen} neutral>
{t("Edit")}
</Button>
<Modal
title={t("Edit webhook")}
onRequestClose={handleEditModalClose}
isOpen={editModalOpen}
>
<WebhookSubscriptionEdit
onSubmit={handleEditModalClose}
webhookSubscription={webhook}
/>
</Modal>
</>
}
/>
);
};
const StyledBadge = styled(Badge)`
position: absolute;
`;
export default WebhookSubscriptionListItem;
@@ -0,0 +1,51 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import WebhookSubscriptionForm from "./WebhookSubscriptionForm";
type Props = {
onSubmit: () => void;
};
interface FormData {
name: string;
url: string;
events: string[];
}
function WebhookSubscriptionNew({ onSubmit }: Props) {
const { webhookSubscriptions } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const events = Array.isArray(data.events) ? data.events : [data.events];
const toSend = {
...data,
events,
};
await webhookSubscriptions.create(toSend);
showToast(
t("Webhook created", {
type: "success",
})
);
onSubmit();
} catch (err) {
showToast(err.message, {
type: "error",
});
}
},
[t, showToast, onSubmit, webhookSubscriptions]
);
return <WebhookSubscriptionForm handleSubmit={handleSubmit} />;
}
export default WebhookSubscriptionNew;
+8 -9
View File
@@ -204,6 +204,7 @@ export default class AuthStore {
runInAction("AuthStore#updateUser", () => {
this.user = null;
this.team = null;
this.policies = [];
this.token = null;
});
};
@@ -236,6 +237,7 @@ export default class AuthStore {
collaborativeEditing?: boolean;
defaultCollectionId?: string | null;
subdomain?: string | null | undefined;
allowedDomains?: string[] | null | undefined;
}) => {
this.isSaving = true;
@@ -259,14 +261,6 @@ export default class AuthStore {
client.post(`/auth.delete`);
// remove user and team from localStorage
Storage.set(AUTH_STORE, {
user: null,
team: null,
policies: [],
});
this.token = null;
// 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) {
@@ -290,7 +284,12 @@ export default class AuthStore {
setCookie("sessions", JSON.stringify(sessions), {
domain: getCookieDomain(window.location.hostname),
});
this.team = null;
}
// clear all credentials from cache (and local storage via autorun)
this.user = null;
this.team = null;
this.policies = [];
this.token = null;
};
}
+2 -1
View File
@@ -7,6 +7,7 @@ import BaseModel from "~/models/BaseModel";
import Policy from "~/models/Policy";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
type PartialWithId<T> = Partial<T> & { id: string };
@@ -209,7 +210,7 @@ export default abstract class BaseStore<T extends BaseModel> {
this.addPolicies(res.policies);
return this.add(res.data);
} catch (err) {
if (err.statusCode === 403) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
this.remove(id);
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { computed, action } from "mobx";
import Collection from "~/models/Collection";
import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
@@ -158,7 +159,7 @@ export default class CollectionsStore extends BaseStore<Collection> {
this.addPolicies(res.policies);
return this.add(res.data);
} catch (err) {
if (err.statusCode === 403) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
this.remove(id);
}
+4
View File
@@ -22,6 +22,7 @@ import ToastsStore from "./ToastsStore";
import UiStore from "./UiStore";
import UsersStore from "./UsersStore";
import ViewsStore from "./ViewsStore";
import WebhookSubscriptionsStore from "./WebhookSubscriptionStore";
export default class RootStore {
apiKeys: ApiKeysStore;
@@ -48,6 +49,7 @@ export default class RootStore {
views: ViewsStore;
toasts: ToastsStore;
fileOperations: FileOperationsStore;
webhookSubscriptions: WebhookSubscriptionsStore;
constructor() {
// PoliciesStore must be initialized before AuthStore
@@ -75,6 +77,7 @@ export default class RootStore {
this.views = new ViewsStore(this);
this.fileOperations = new FileOperationsStore(this);
this.toasts = new ToastsStore();
this.webhookSubscriptions = new WebhookSubscriptionsStore(this);
}
logout() {
@@ -100,5 +103,6 @@ export default class RootStore {
// this.ui omitted to keep ui settings between sessions
this.users.clear();
this.views.clear();
this.webhookSubscriptions.clear();
}
}
-1
View File
@@ -59,7 +59,6 @@ export default class SharesStore extends BaseStore<Share> {
try {
const res = await client.post(`/${this.modelName}s.info`, {
documentId,
apiVersion: 2,
});
if (isUndefined(res)) {
+18
View File
@@ -0,0 +1,18 @@
import WebhookSubscription from "~/models/WebhookSubscription";
import BaseStore, { RPCAction } from "./BaseStore";
import RootStore from "./RootStore";
export default class WebhookSubscriptionsStore extends BaseStore<
WebhookSubscription
> {
actions = [
RPCAction.List,
RPCAction.Create,
RPCAction.Delete,
RPCAction.Update,
];
constructor(rootStore: RootStore) {
super(rootStore, WebhookSubscription);
}
}
+1 -1
View File
@@ -13,4 +13,4 @@ Enzyme.configure({
global.localStorage = localStorage;
require("jest-fetch-mock").enableMocks();
require("jest-fetch-mock").enableMocks();
+2 -2
View File
@@ -1,4 +1,4 @@
import { Location } from "history";
import { Location, LocationDescriptor } from "history";
import { TFunction } from "react-i18next";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
@@ -40,7 +40,7 @@ export type MenuHeading = {
export type MenuInternalLink = {
type: "route";
title: React.ReactNode;
to: string;
to: LocationDescriptor;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
+1 -6
View File
@@ -141,16 +141,11 @@ class ApiClient {
// Handle failed responses
const error: {
statusCode?: number;
response?: Response;
message?: string;
error?: string;
data?: Record<string, any>;
} = {};
error.statusCode = response.status;
error.response = response;
try {
const parsed = await response.json();
error.message = parsed.message || "";
@@ -186,7 +181,7 @@ class ApiClient {
throw new ServiceUnavailableError(error.message);
}
throw new RequestError(`Error ${error.statusCode}: ${error.message}`);
throw new RequestError(`Error ${response.status}: ${error.message}`);
};
get = (
+4 -1
View File
@@ -8,7 +8,10 @@ export async function loadPolyfills() {
if (!supportsResizeObserver()) {
polyfills.push(
import("@juggle/resize-observer").then((module) => {
import(
/* webpackChunkName: "resize-observer" */
"@juggle/resize-observer"
).then((module) => {
window.ResizeObserver = module.ResizeObserver;
})
);
+1 -1
View File
@@ -4,7 +4,7 @@
The Outline team takes security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
To report a security issue, email [hello@getoutline.com](mailto:hello@getoutline.com) and include the word "SECURITY" in the subject line.
If you discover a security vulnerability in outline, please disclose it via [our huntr page](https://huntr.dev/repos/${owner}/${repo}/). Bounty eligibility, CVE assignment, response times and past reports are all there.
The Outline team will send a response indicating the next steps in handling your report. After the initial reply to your report you will be kept informed of the progress towards a fix and full announcement.
+40 -36
View File
@@ -16,6 +16,7 @@
"lint": "eslint app server shared",
"deploy": "git push heroku master",
"prepare": "husky install",
"postinstall": "rimraf node_modules/prosemirror-view/dist/index.d.ts",
"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",
@@ -42,14 +43,14 @@
"> 0.25%, not dead"
],
"dependencies": {
"@babel/core": "^7.16.0",
"@babel/core": "^7.18.6",
"@babel/plugin-proposal-decorators": "^7.10.5",
"@babel/plugin-transform-destructuring": "^7.10.4",
"@babel/plugin-transform-regenerator": "^7.10.4",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.16.0",
"@bull-board/api": "^3.11.1",
"@bull-board/koa": "^3.11.1",
"@bull-board/api": "^4.0.0",
"@bull-board/koa": "^4.0.0",
"@dnd-kit/core": "^4.0.3",
"@dnd-kit/modifiers": "^4.0.0",
"@dnd-kit/sortable": "^5.1.0",
@@ -58,7 +59,7 @@
"@hocuspocus/server": "^1.0.0-alpha.102",
"@joplin/turndown-plugin-gfm": "^1.0.44",
"@juggle/resize-observer": "^3.3.1",
"@outlinewiki/koa-passport": "^4.1.4",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@renderlesskit/react": "^0.6.0",
"@sentry/node": "^6.3.1",
@@ -67,6 +68,7 @@
"@theo.gravity/datadog-apm": "2.1.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",
"babel-plugin-lodash": "^3.3.4",
@@ -97,7 +99,7 @@
"fs-extra": "^4.0.2",
"fuzzy-search": "^3.2.1",
"gemoji": "6.x",
"http-errors": "1.4.0",
"http-errors": "2.0.0",
"i18next": "^20.6.1",
"i18next-http-backend": "^1.3.2",
"immutable": "^4.0.0",
@@ -108,16 +110,15 @@
"jsonwebtoken": "^8.5.0",
"jszip": "^3.7.1",
"kbar": "0.1.0-beta.28",
"koa": "^2.10.0",
"koa": "^2.13.4",
"koa-body": "^4.2.0",
"koa-compress": "2.0.0",
"koa-convert": "1.2.0",
"koa-helmet": "5.2.0",
"koa-jwt": "^3.6.0",
"koa-compress": "^5.1.0",
"koa-convert": "^2.0.0",
"koa-helmet": "^6.1.0",
"koa-logger": "^3.2.1",
"koa-mount": "^3.0.0",
"koa-onerror": "^4.1.0",
"koa-router": "7.0.1",
"koa-onerror": "^4.2.0",
"koa-router": "7.4.0",
"koa-send": "5.0.1",
"koa-sslify": "2.1.2",
"koa-static": "^4.0.1",
@@ -126,24 +127,25 @@
"markdown-it": "^12.3.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
"mermaid": "9.1.3",
"mime-types": "^2.1.35",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
"natural-sort": "^1.0.0",
"node-htmldiff": "^0.9.3",
"node-fetch": "2.6.7",
"nodemailer": "^6.6.1",
"outline-icons": "^1.42.0",
"outline-icons": "^1.43.1",
"oy-vey": "^0.10.0",
"passport": "^0.4.1",
"passport": "^0.6.0",
"passport-google-oauth2": "^0.2.0",
"passport-oauth2": "^1.6.1",
"passport-slack-oauth2": "^1.1.0",
"passport-slack-oauth2": "^1.1.1",
"pg": "^8.5.1",
"pg-hstore": "^2.3.4",
"polished": "^3.7.2",
"prosemirror-commands": "^1.2.1",
"prosemirror-dropcursor": "^1.4.0",
"prosemirror-gapcursor": "^1.2.1",
"prosemirror-gapcursor": "^1.3.1",
"prosemirror-history": "^1.2.0",
"prosemirror-inputrules": "^1.1.3",
"prosemirror-keymap": "^1.1.5",
@@ -154,7 +156,7 @@
"prosemirror-tables": "^1.1.1",
"prosemirror-transform": "1.2.5",
"prosemirror-utils": "^0.9.6",
"prosemirror-view": "1.23.6",
"prosemirror-view": "1.26.5",
"query-string": "^7.0.1",
"quoted-printable": "^1.0.1",
"randomstring": "1.1.5",
@@ -163,10 +165,11 @@
"react-avatar-editor": "^11.1.0",
"react-color": "^2.17.3",
"react-dnd": "^14.0.1",
"react-dnd-html5-backend": "^14.0.0",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^17.0.2",
"react-dropzone": "^11.3.2",
"react-helmet": "^6.1.0",
"react-hook-form": "^7.31.2",
"react-i18next": "^11.16.6",
"react-medium-image-zoom": "^3.1.3",
"react-merge-refs": "^1.1.0",
@@ -180,19 +183,20 @@
"reflect-metadata": "^0.1.13",
"refractor": "^3.5.0",
"regenerator-runtime": "^0.13.7",
"semver": "^7.3.2",
"request-filtering-agent": "^1.1.2",
"semver": "^7.3.7",
"sequelize": "^6.20.1",
"sequelize-cli": "^6.4.1",
"sequelize-encrypted": "^1.0.0",
"sequelize-typescript": "^2.1.3",
"slate": "0.45.0",
"slate-md-serializer": "5.5.4",
"slug": "^4.0.4",
"slug": "^5.3.0",
"slugify": "^1.6.5",
"smooth-scroll-into-view-if-needed": "^1.1.32",
"socket.io": "^2.4.0",
"socket.io-redis": "^5.4.0",
"socketio-auth": "^0.1.1",
"socket.io": "^3.1.2",
"socket.io-client": "^3.1.3",
"socket.io-redis": "^6.1.0",
"stoppable": "^1.1.0",
"string-replace-to-array": "^1.0.3",
"styled-components": "^5.2.3",
@@ -209,7 +213,7 @@
"winston": "^3.3.3",
"ws": "^7.5.3",
"y-indexeddb": "^9.0.6",
"yjs": "^13.5.34"
"yjs": "^13.5.39"
},
"devDependencies": {
"@babel/cli": "^7.10.5",
@@ -223,7 +227,7 @@
"@types/emoji-regex": "^9.2.0",
"@types/enzyme": "^3.10.10",
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/formidable": "^2.0.0",
"@types/formidable": "^2.0.5",
"@types/fs-extra": "^9.0.13",
"@types/fuzzy-search": "^2.1.2",
"@types/google.analytics": "^0.0.42",
@@ -237,14 +241,15 @@
"@types/koa-logger": "^3.1.2",
"@types/koa-mount": "^4.0.1",
"@types/koa-router": "^7.4.4",
"@types/koa-sslify": "^4.0.2",
"@types/koa-sslify": "^2.1.0",
"@types/koa-static": "^4.0.2",
"@types/markdown-it": "^12.2.3",
"@types/markdown-it-container": "^2.0.4",
"@types/markdown-it-emoji": "^2.0.2",
"@types/mime-types": "^2.1.1",
"@types/natural-sort": "^0.0.21",
"@types/node": "15.12.2",
"@types/node": "18.0.6",
"@types/node-fetch": "^2.6.2",
"@types/nodemailer": "^6.4.4",
"@types/passport-oauth2": "^1.4.11",
"@types/prosemirror-commands": "^1.0.1",
@@ -272,11 +277,9 @@
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/refractor": "^3.0.2",
"@types/semver": "^7.3.9",
"@types/semver": "^7.3.10",
"@types/sequelize": "^4.28.10",
"@types/slug": "^5.0.2",
"@types/socket.io": "2.1.13",
"@types/socket.io-parser": "^2.2.1",
"@types/slug": "^5.0.3",
"@types/stoppable": "^1.1.1",
"@types/styled-components": "^5.1.15",
"@types/throng": "^5.0.3",
@@ -314,7 +317,7 @@
"koa-webpack-dev-middleware": "^1.4.5",
"koa-webpack-hot-middleware": "^1.0.3",
"lint-staged": "^12.3.8",
"nodemon": "^2.0.15",
"nodemon": "^2.0.18",
"prettier": "^2.0.5",
"react-refresh": "^0.9.0",
"rimraf": "^2.5.4",
@@ -329,11 +332,12 @@
"yarn-deduplicate": "^3.1.0"
},
"resolutions": {
"d3": "^7.0.0",
"node-fetch": "^2.6.7",
"socket.io-parser": "^3.4.0",
"prosemirror-transform": "1.2.5",
"dot-prop": "^5.2.0",
"js-yaml": "^3.14.1"
"js-yaml": "^3.14.1",
"jpeg-js": "0.4.4"
},
"version": "0.64.3"
"version": "0.65.1"
}
+15 -1
View File
@@ -24,5 +24,19 @@
}
],
"tsconfig-paths-module-resolver"
]
],
"env": {
"production": {
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"development": {
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();
// changes default behavior of fetchMock to use the real 'fetch' implementation.
// Mocks can now be enabled in each individual test with fetchMock.doMock()
fetchMock.dontMock();
export default fetch;
+7 -3
View File
@@ -30,7 +30,11 @@ export default class MetricsExtension implements Extension {
});
Metrics.gaugePerInstance(
"collaboration.connections_count",
instance.getConnectionsCount()
instance.getConnectionsCount() + 1
);
Metrics.gaugePerInstance(
"collaboration.documents_count",
instance.getDocumentsCount()
);
}
@@ -43,8 +47,8 @@ export default class MetricsExtension implements Extension {
instance.getConnectionsCount()
);
Metrics.gaugePerInstance(
"collaboration.documents_count", // -1 adjustment because hook is called before document is removed
instance.getDocumentsCount() - 1
"collaboration.documents_count",
instance.getDocumentsCount()
);
}
+1 -2
View File
@@ -3,7 +3,6 @@ import {
onLoadDocumentPayload,
Extension,
} from "@hocuspocus/server";
import invariant from "invariant";
import * as Y from "yjs";
import { sequelize } from "@server/database/sequelize";
import Logger from "@server/logging/Logger";
@@ -30,11 +29,11 @@ export default class PersistenceExtension implements Extension {
const document = await Document.scope("withState").findOne({
transaction,
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
where: {
id: documentId,
},
});
invariant(document, "Document not found");
if (document.state) {
const ydoc = new Y.Doc();
+5 -5
View File
@@ -3,6 +3,7 @@ import { UniqueConstraintError } from "sequelize";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import {
AuthenticationError,
InvalidAuthenticationError,
EmailAuthenticationRequiredError,
AuthenticationProviderDisabledError,
} from "@server/errors";
@@ -20,6 +21,7 @@ type Props = {
username?: string;
};
team: {
id?: string;
name: string;
domain?: string;
subdomain: string;
@@ -56,14 +58,12 @@ async function accountProvisioner({
try {
result = await teamCreator({
name: teamParams.name,
domain: teamParams.domain,
subdomain: teamParams.subdomain,
avatarUrl: teamParams.avatarUrl,
...teamParams,
authenticationProvider: authenticationProviderParams,
ip,
});
} catch (err) {
throw AuthenticationError(err.message);
throw InvalidAuthenticationError(err.message);
}
invariant(result, "Team creator result must exist");
@@ -1,5 +1,4 @@
import { yDocToProsemirrorJSON } from "@getoutline/y-prosemirror";
import invariant from "invariant";
import { uniq } from "lodash";
import { Node } from "prosemirror-model";
import * as Y from "yjs";
@@ -25,9 +24,9 @@ export default async function documentCollaborativeUpdater({
of: Document,
level: transaction.LOCK.UPDATE,
},
rejectOnEmpty: true,
paranoid: false,
});
invariant(document, "document not found");
const state = Y.encodeStateAsUpdate(ydoc);
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
+2 -5
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { Document, Event, User } from "@server/models";
@@ -105,14 +104,12 @@ export default async function documentCreator({
// reload to get all of the data needed to present (user, collection etc)
// we need to specify publishedAt to bypass default scope that only returns
// published documents
const doc = await Document.findOne({
return await Document.findOne({
where: {
id: document.id,
publishedAt: document.publishedAt,
},
rejectOnEmpty: true,
transaction,
});
invariant(doc, "Document must exist");
return doc;
}
+10 -9
View File
@@ -64,7 +64,7 @@ export default async function loadDocument({
],
});
if (!share || share.document.archivedAt) {
if (!share || share.document?.archivedAt) {
throw InvalidRequestError("Document could not be found for shareId");
}
@@ -133,24 +133,25 @@ export default async function loadDocument({
// If we're attempting to load a document that isn't the document originally
// shared then includeChildDocuments must be enabled and the document must
// still be active and nested within the shared document
if (share.document.id !== document.id) {
if (share.documentId !== document.id) {
if (!share.includeChildDocuments) {
throw AuthorizationError();
}
const childDocumentIds = await share.document.getChildDocumentIds({
archivedAt: {
[Op.is]: null,
},
});
const childDocumentIds =
(await share.document?.getChildDocumentIds({
archivedAt: {
[Op.is]: null,
},
})) ?? [];
if (!childDocumentIds.includes(document.id)) {
throw AuthorizationError();
}
}
// It is possible to disable sharing at the team level so we must check
const team = await Team.findByPk(document.teamId);
invariant(team, "team not found");
const team = await Team.findByPk(document.teamId, { rejectOnEmpty: true });
if (!team.sharing) {
throw AuthorizationError();
+26
View File
@@ -7,6 +7,8 @@ import teamCreator from "./teamCreator";
beforeEach(() => flushdb());
describe("teamCreator", () => {
const ip = "127.0.0.1";
it("should create team and authentication provider", async () => {
env.DEPLOYMENT = "hosted";
const result = await teamCreator({
@@ -17,6 +19,7 @@ describe("teamCreator", () => {
name: "google",
providerId: "example.com",
},
ip,
});
const { team, authenticationProvider, isNewTeam } = result;
expect(authenticationProvider.name).toEqual("google");
@@ -40,6 +43,7 @@ describe("teamCreator", () => {
name: "google",
providerId: "example.com",
},
ip,
});
expect(result.team.subdomain).toEqual("myteam1");
@@ -62,12 +66,30 @@ describe("teamCreator", () => {
name: "google",
providerId: "example.com",
},
ip,
});
expect(result.team.subdomain).toEqual("myteam2");
});
describe("self hosted", () => {
it("should allow creating first team", async () => {
env.DEPLOYMENT = undefined;
const { team, isNewTeam } = await teamCreator({
name: "Test team",
subdomain: "example",
avatarUrl: "http://example.com/logo.png",
authenticationProvider: {
name: "google",
providerId: "example.com",
},
ip,
});
expect(isNewTeam).toBeTruthy();
expect(team.name).toEqual("Test team");
});
it("should not allow creating multiple teams in installation", async () => {
env.DEPLOYMENT = undefined;
await buildTeam();
@@ -82,6 +104,7 @@ describe("teamCreator", () => {
name: "google",
providerId: "example.com",
},
ip,
});
} catch (err) {
error = err;
@@ -109,6 +132,7 @@ describe("teamCreator", () => {
name: "google",
providerId: "allowed-domain.com",
},
ip,
});
const { team, authenticationProvider, isNewTeam } = result;
expect(team.id).toEqual(existing.id);
@@ -142,6 +166,7 @@ describe("teamCreator", () => {
name: "google",
providerId: "other-domain.com",
},
ip,
});
} catch (err) {
error = err;
@@ -164,6 +189,7 @@ describe("teamCreator", () => {
name: "Updated name",
subdomain: "example",
authenticationProvider,
ip,
});
const { team, isNewTeam } = result;
expect(team.id).toEqual(existing.id);
+35 -5
View File
@@ -1,9 +1,13 @@
import { sequelize } from "@server/database/sequelize";
import env from "@server/env";
import { DomainNotAllowedError, MaximumTeamsError } from "@server/errors";
import {
InvalidAuthenticationError,
DomainNotAllowedError,
MaximumTeamsError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
import { APM } from "@server/logging/tracing";
import { Team, AuthenticationProvider } from "@server/models";
import { Team, AuthenticationProvider, Event } from "@server/models";
import { generateAvatarUrl } from "@server/utils/avatars";
type TeamCreatorResult = {
@@ -13,6 +17,7 @@ type TeamCreatorResult = {
};
type Props = {
id?: string;
name: string;
domain?: string;
subdomain: string;
@@ -21,17 +26,22 @@ type Props = {
name: string;
providerId: string;
};
ip: string;
};
async function teamCreator({
id,
name,
domain,
subdomain,
avatarUrl,
authenticationProvider,
ip,
}: Props): Promise<TeamCreatorResult> {
let authP = await AuthenticationProvider.findOne({
where: authenticationProvider,
where: id
? { ...authenticationProvider, teamId: id }
: authenticationProvider,
include: [
{
model: Team,
@@ -50,6 +60,11 @@ async function teamCreator({
isNewTeam: false,
};
}
// A team id was provided but no auth provider was found matching those credentials
// The user is attempting to log into a team with an incorrect SSO - fail the login
else if (id) {
throw InvalidAuthenticationError("incorrect authentication credentials");
}
// This team has never been seen before, if self hosted the logic is different
// to the multi-tenant version, we want to restrict to a single team that MAY
@@ -76,7 +91,9 @@ async function teamCreator({
}
}
throw MaximumTeamsError();
if (team) {
throw MaximumTeamsError();
}
}
// If the service did not provide a logo/avatar then we attempt to generate
@@ -90,7 +107,7 @@ async function teamCreator({
}
const team = await sequelize.transaction(async (transaction) => {
return Team.create(
const team = await Team.create(
{
name,
avatarUrl,
@@ -101,6 +118,19 @@ async function teamCreator({
transaction,
}
);
await Event.create(
{
name: "teams.create",
teamId: team.id,
ip,
},
{
transaction,
}
);
return team;
});
// Note provisioning the subdomain is done outside of the transaction as
+1 -3
View File
@@ -97,9 +97,7 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
const deletedDomains = existingAllowedDomains.filter(
(x) => !allowedDomains.includes(x.name)
);
for (const deletedDomain of deletedDomains) {
deletedDomain.destroy({ transaction });
}
await Promise.all(deletedDomains.map((x) => x.destroy({ transaction })));
team.allowedDomains = newAllowedDomains;
}
+72
View File
@@ -1,3 +1,4 @@
import { v4 as uuidv4 } from "uuid";
import { TeamDomain } from "@server/models";
import { buildUser, buildTeam, buildInvite } from "@server/test/factories";
import { flushdb, seed } from "@server/test/support";
@@ -37,6 +38,77 @@ describe("userCreator", () => {
expect(isNewUser).toEqual(false);
});
it("should add authentication provider to existing users", async () => {
const team = await buildTeam({ inviteRequired: true });
const teamAuthProviders = await team.$get("authenticationProviders");
const authenticationProvider = teamAuthProviders[0];
const email = "mynam@email.com";
const existing = await buildUser({
email,
teamId: team.id,
authentications: [],
});
const result = await userCreator({
name: existing.name,
email,
username: "new-username",
avatarUrl: existing.avatarUrl,
teamId: existing.teamId,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: uuidv4(),
accessToken: "123",
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
const authentications = await user.$get("authentications");
expect(authentications.length).toEqual(1);
expect(isNewUser).toEqual(false);
});
it("should add authentication provider to invited users", async () => {
const team = await buildTeam({ inviteRequired: true });
const teamAuthProviders = await team.$get("authenticationProviders");
const authenticationProvider = teamAuthProviders[0];
const email = "mynam@email.com";
const existing = await buildInvite({
email,
teamId: team.id,
});
const result = await userCreator({
name: existing.name,
email,
username: "new-username",
avatarUrl: existing.avatarUrl,
teamId: existing.teamId,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: uuidv4(),
accessToken: "123",
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
const authentications = await user.$get("authentications");
expect(authentications.length).toEqual(1);
expect(isNewUser).toEqual(true);
});
it("should create user with deleted user matching providerId", async () => {
const existing = await buildUser();
const authentications = await existing.$get("authentications");
+58 -27
View File
@@ -1,4 +1,5 @@
import { Op } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import { DomainNotAllowedError, InviteRequiredError } from "@server/errors";
import { Event, Team, User, UserAuthentication } from "@server/models";
@@ -46,6 +47,8 @@ export default async function userCreator({
{
model: User,
as: "user",
where: { teamId },
required: true,
},
],
});
@@ -87,58 +90,86 @@ export default async function userCreator({
// A `user` record might exist in the form of an invite even if there is no
// existing authentication record that matches. In Outline an invite is a
// shell user record.
const invite = await User.findOne({
const existingUser = await User.scope([
"withAuthentications",
"withTeam",
]).findOne({
where: {
// Email from auth providers may be capitalized and we should respect that
// however any existing invites will always be lowercased.
email: email.toLowerCase(),
teamId,
lastActiveAt: {
[Op.is]: null,
},
},
include: [
{
model: UserAuthentication,
as: "authentications",
required: false,
},
],
});
// We have an existing invite for his user, so we need to update it with our
// new details and link up the authentication method
if (invite && !invite.authentications.length) {
const transaction = await User.sequelize!.transaction();
let auth;
// new details, link up the authentication method, and count this as a new
// user creation.
if (existingUser) {
// A `user` record might exist in the form of an invite.
// In Outline an invite is a shell user record with no authentication method
// that's never been active before.
const isInvite = existingUser.isInvited;
try {
await invite.update(
const auth = await sequelize.transaction(async (transaction) => {
if (isInvite) {
await Event.create(
{
name: "users.create",
actorId: existingUser.id,
userId: existingUser.id,
teamId: existingUser.teamId,
data: {
name,
},
ip,
},
{
transaction,
}
);
}
// Regardless, create a new authentication record
// against the existing user (user can auth with multiple SSO providers)
// Update user's name and avatar based on the most recently added provider
await existingUser.update(
{
name,
avatarUrl,
lastActiveAt: new Date(),
lastActiveIp: ip,
},
{
transaction,
}
);
auth = await invite.$create<UserAuthentication>(
return await existingUser.$create<UserAuthentication>(
"authentication",
authentication,
{
transaction,
}
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
});
if (isInvite) {
const inviter = await existingUser.$get("invitedBy");
if (inviter) {
await InviteAcceptedEmail.schedule({
to: inviter.email,
inviterId: inviter.id,
invitedName: existingUser.name,
teamUrl: existingUser.team.url,
});
}
}
return {
user: invite,
user: existingUser,
authentication: auth,
isNewUser: true,
isNewUser: isInvite,
};
}
@@ -151,9 +182,9 @@ export default async function userCreator({
transaction,
});
// If the team settings are set to require invites, and the user is not already invited,
// If the team settings are set to require invites, and there's no existing user record,
// throw an error and fail user creation.
if (team?.inviteRequired && !invite) {
if (team?.inviteRequired) {
throw InviteRequiredError();
}
+36
View File
@@ -0,0 +1,36 @@
import { sequelize } from "@server/database/sequelize";
import { ValidationError } from "@server/errors";
import { Event, User } from "@server/models";
import type { UserRole } from "@server/models/User";
import CleanupDemotedUserTask from "@server/queues/tasks/CleanupDemotedUserTask";
type Props = {
user: User;
actorId: string;
to: UserRole;
ip: string;
};
export default async function userDemoter({ user, actorId, to, ip }: Props) {
if (user.id === actorId) {
throw ValidationError("Unable to demote the current user");
}
return sequelize.transaction(async (transaction) => {
await user.demote(to, { transaction });
await Event.create(
{
name: "users.demote",
actorId,
userId: user.id,
teamId: user.teamId,
data: {
name: user.name,
},
ip,
},
{ transaction }
);
await CleanupDemotedUserTask.schedule({ userId: user.id });
});
}
+3 -4
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import { uniqBy } from "lodash";
import { Role } from "@shared/types";
import InviteEmail from "@server/emails/templates/InviteEmail";
@@ -7,7 +6,7 @@ import Logger from "@server/logging/Logger";
import { User, Event, Team } from "@server/models";
import { UserFlag } from "@server/models/User";
type Invite = {
export type Invite = {
name: string;
email: string;
role: Role;
@@ -25,8 +24,7 @@ export default async function userInviter({
sent: Invite[];
users: User[];
}> {
const team = await Team.findByPk(user.teamId);
invariant(team, "team not found");
const team = await Team.findByPk(user.teamId, { rejectOnEmpty: true });
// filter out empties and obvious non-emails
const compactedInvites = invites.filter(
@@ -73,6 +71,7 @@ export default async function userInviter({
name: "users.invite",
actorId: user.id,
teamId: user.teamId,
userId: newUser.id,
data: {
email: invite.email,
name: invite.name,
+3
View File
@@ -1,6 +1,7 @@
import { Transaction } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import { User, Event, GroupUser } from "@server/models";
import CleanupDemotedUserTask from "@server/queues/tasks/CleanupDemotedUserTask";
import { ValidationError } from "../errors";
type Props = {
@@ -49,5 +50,7 @@ export default async function userSuspender({
transaction,
}
);
await CleanupDemotedUserTask.schedule({ userId: user.id });
});
}
+16 -15
View File
@@ -1,4 +1,5 @@
import mailer from "@server/emails/mailer";
import Logger from "@server/logging/Logger";
import Metrics from "@server/logging/metrics";
import { taskQueue } from "@server/queues";
import { TaskPriority } from "@server/queues/tasks/BaseTask";
@@ -54,11 +55,19 @@ export default abstract class BaseEmail<T extends EmailProps, S = any> {
* @returns A promise that resolves once the email has been successfully sent.
*/
public async send() {
const bsResponse = this.beforeSend
? await this.beforeSend(this.props)
: ({} as S);
const data = { ...this.props, ...bsResponse };
const templateName = this.constructor.name;
const bsResponse = await this.beforeSend?.(this.props);
if (bsResponse === false) {
Logger.info(
"email",
`Email ${templateName} not sent due to beforeSend hook`,
this.props
);
return;
}
const data = { ...this.props, ...(bsResponse ?? ({} as S)) };
try {
await mailer.sendMail({
@@ -67,7 +76,6 @@ export default abstract class BaseEmail<T extends EmailProps, S = any> {
previewText: this.preview(data),
component: this.render(data),
text: this.renderAsText(data),
headCSS: this.headCSS ? this.headCSS(data) : undefined,
});
Metrics.increment("email.sent", {
templateName,
@@ -115,20 +123,13 @@ export default abstract class BaseEmail<T extends EmailProps, S = any> {
*/
protected abstract render(props: S & T): JSX.Element;
/**
* Allows injecting additional CSS into the head of the email.
*
* @param props Props in email constructor
* @returns A string of CSS
*/
protected headCSS?(props: T): string;
/**
* beforeSend hook allows async loading additional data that was not passed
* through the serialized worker props.
* through the serialized worker props. If false is returned then the email
* send is aborted.
*
* @param props Props in email constructor
* @returns A promise resolving to additional data
*/
protected beforeSend?(props: T): Promise<S>;
protected beforeSend?(props: T): Promise<S | false>;
}
@@ -1,4 +1,3 @@
import invariant from "invariant";
import * as React from "react";
import env from "@server/env";
import { Collection } from "@server/models";
@@ -37,7 +36,10 @@ export default class CollectionNotificationEmail extends BaseEmail<
const collection = await Collection.scope("withUser").findByPk(
collectionId
);
invariant(collection, "Collection not found");
if (!collection) {
return false;
}
return { collection };
}
@@ -1,16 +1,13 @@
import invariant from "invariant";
import * as React from "react";
import { Document } from "@server/models";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
import { css } from "./components/css";
type InputProps = {
to: string;
@@ -20,7 +17,6 @@ type InputProps = {
eventName: string;
teamUrl: string;
unsubscribeUrl: string;
content: string;
};
type BeforeSend = {
@@ -39,7 +35,10 @@ export default class DocumentNotificationEmail extends BaseEmail<
> {
protected async beforeSend({ documentId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
invariant(document, "Document not found");
if (!document) {
return false;
}
return { document };
}
@@ -51,10 +50,6 @@ export default class DocumentNotificationEmail extends BaseEmail<
return `${actorName} ${eventName} a document`;
}
protected headCSS(): string {
return css;
}
protected renderAsText({
actorName,
teamUrl,
@@ -78,10 +73,7 @@ Open Document: ${teamUrl}${document.url}
eventName = "published",
teamUrl,
unsubscribeUrl,
content,
}: Props) {
const link = `${teamUrl}${document.url}?ref=notification-email`;
return (
<EmailTemplate>
<Header />
@@ -94,17 +86,12 @@ Open Document: ${teamUrl}${document.url}
{actorName} {eventName} the document "{document.title}", in the{" "}
{collectionName} collection.
</p>
{content && (
<>
<EmptySpace height={20} />
<Diff href={link}>
<div dangerouslySetInnerHTML={{ __html: content }} />
</Diff>
<EmptySpace height={20} />
</>
)}
<hr />
<EmptySpace height={10} />
<p>{document.getSummary()}</p>
<EmptySpace height={10} />
<p>
<Button href={link}>Open Document</Button>
<Button href={`${teamUrl}${document.url}`}>Open Document</Button>
</p>
</Body>
@@ -0,0 +1,82 @@
import * as React from "react";
import { NotificationSetting } from "@server/models";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
inviterId: string;
invitedName: string;
teamUrl: string;
};
type BeforeSendProps = {
unsubscribeUrl: string;
};
/**
* Email sent to a user when someone they invited successfully signs up.
*/
export default class InviteAcceptedEmail extends BaseEmail<Props> {
protected async beforeSend({ inviterId }: Props) {
const notificationSetting = await NotificationSetting.findOne({
where: {
userId: inviterId,
event: "emails.invite_accepted",
},
});
if (!notificationSetting) {
return false;
}
return { unsubscribeUrl: notificationSetting.unsubscribeUrl };
}
protected subject({ invitedName }: Props) {
return `${invitedName} has joined your Outline team`;
}
protected preview({ invitedName }: Props) {
return `Great news, ${invitedName}, accepted your invitation`;
}
protected renderAsText({ invitedName, teamUrl }: Props): string {
return `
Great news, ${invitedName} just accepted your invitation and has created an account. You can now start collaborating on documents.
Open Outline: ${teamUrl}
`;
}
protected render({
invitedName,
teamUrl,
unsubscribeUrl,
}: Props & BeforeSendProps) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>{invitedName} has joined your team</Heading>
<p>
Great news, {invitedName} just accepted your invitation and has
created an account. You can now start collaborating on documents.
</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Open Outline</Button>
</p>
</Body>
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ Join now: ${teamUrl}
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
<Button href={teamUrl}>Join now</Button>
</p>
</Body>

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