Compare commits

...

66 Commits

Author SHA1 Message Date
codegen-sh[bot] b378af2294 Fix linting issues in OIDCStrategy 2025-08-19 00:15:29 +00:00
codegen-sh[bot] 5fec2681da Fix TypeScript errors in OIDCStrategy 2025-08-19 00:11:01 +00:00
codegen-sh[bot] 64f51ee40d Fix linting issues: replace any with unknown types and add missing React keys 2025-08-19 00:09:05 +00:00
codegen-sh[bot] fec0f193a8 Fix linting issues 2025-08-19 00:02:14 +00:00
codegen-sh[bot] b689ebd8ca fix: Update GitLabIssueStatusIcon to match style of other icon components 2025-08-18 23:49:32 +00:00
codegen-sh[bot] 8e74bb7d01 fix: add type guard for draft property in GitLabIssueStatusIcon 2025-08-18 23:20:56 +00:00
codegen-sh[bot] c02bc22cce Fix code formatting issues to pass CI checks 2025-08-18 23:16:18 +00:00
codegen-sh[bot] 7ea308a52c Fix linting issues in React Hook dependencies 2025-08-18 23:15:39 +00:00
codegen-sh[bot] e4d1a38367 fix: Fix TypeScript errors in OIDCStrategy
- Change Record<string, unknown> to any to match the Strategy interface
- Add null check for options parameter
2025-08-18 23:15:22 +00:00
codegen-sh[bot] 98d54da0de fix: Remove trailing whitespace in HoverPreviewIssue.tsx 2025-08-18 23:03:21 +00:00
codegen-sh[bot] 4b638ae346 fix: fix remaining linting errors 2025-08-18 22:38:22 +00:00
codegen-sh[bot] abb849e1f6 fix: replace any with unknown in types and add proper type definitions 2025-08-18 22:30:48 +00:00
codegen-sh[bot] 2ec65e3dfc fix: replace any with unknown in MutexLock 2025-08-18 22:27:16 +00:00
codegen-sh[bot] 4e493972e5 fix: fix remaining linting errors 2025-08-18 22:24:07 +00:00
codegen-sh[bot] 4a8b8d5fa7 fix: replace any types with unknown to fix linting errors 2025-08-18 22:19:11 +00:00
codegen-sh[bot] 391fc5fdee fix: replace any types in MultiplayerEditor and SlackUtils 2025-08-18 22:15:50 +00:00
codegen-sh[bot] cbcf7d6a8e fix: replace more any types with more specific types to fix linting errors 2025-08-18 22:15:05 +00:00
codegen-sh[bot] 94eb1aa07d fix: replace any types with more specific types to fix linting errors 2025-08-18 22:13:36 +00:00
codegen-sh[bot] ca66a6b2fa fix: Fix linting issues in GitLab integration and OIDC strategy
- Remove unused IntegrationService import in UploadGitLabProjectAvatarTask
- Replace 'any' type with proper types in gitlab.ts and OIDCStrategy.ts
2025-08-18 21:59:03 +00:00
codegen-sh[bot] 404a5991b3 feat: Add GitLab integration matching Linear integration patterns 2025-08-18 21:47:16 +00:00
Hemachandar bd5de2e185 Display correct child document structure & auto open share section (#9854)
* Show correct child structure for shared documents

* useMemo dep

* auto open tree, show child docs in references

* document.children
2025-08-17 23:09:49 -04:00
codegen-sh[bot] b8eefe4b78 Add functionality to split code blocks with triple backticks (#9959)
* Add functionality to split code blocks with triple backticks

This change allows users to split a code block into two by typing three backticks within it.
When a user types three backticks inside a code block, the block will be split at that point,
creating a new code block below with the same language.

Fixes #9958

* testing

* refactor

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-17 22:08:20 -04:00
Tom Moor cc8a3d8b5e chore: Still seeing redis connection failures in CI (#9957) 2025-08-17 18:42:20 -04:00
Tom Moor dd061790a8 fix: Frontend requests do not send Content-Type header in request (#9956)
* Revert "Revert "fix: Frontend requests do not send Content-Type in request (#…"

This reverts commit 7fddd99c28.

* Update authentication.ts
2025-08-17 21:47:06 +00:00
Tom Moor 7fddd99c28 Revert "fix: Frontend requests do not send Content-Type in request (#9954)" (#9955)
This reverts commit 7ab7e6efb7.
2025-08-17 21:42:28 +00:00
Tom Moor 7ab7e6efb7 fix: Frontend requests do not send Content-Type in request (#9954) 2025-08-17 16:02:56 -04:00
Tom Moor 4464d3c8b4 chore: More CSP hardening (#9951) 2025-08-16 22:10:28 +00:00
Tom Moor e0f40f9bc1 chore: Make no-unused-vars rule strict (#9950) 2025-08-16 11:29:11 -04:00
Tom Moor c83a6b4f41 fix: Usage of ctx.attachment overrides explicit Content-Type (#9949) 2025-08-16 11:29:00 -04:00
Tom Moor 82994c7b7b fix: Scroll to anchor reliability (#9945)
* fix: Scroll to anchor reliability

* feedback
2025-08-16 08:18:35 -04:00
Tom Moor e891de7f49 fix: Document move/copy/publish dialog behavior (#9947)
* fix: Document move/copy/publish dialog behavior

* More fixes
2025-08-16 08:18:25 -04:00
dependabot[bot] fc2648becf chore(deps): bump mammoth from 1.9.1 to 1.10.0 (#9908)
Bumps [mammoth](https://github.com/mwilliamson/mammoth.js) from 1.9.1 to 1.10.0.
- [Release notes](https://github.com/mwilliamson/mammoth.js/releases)
- [Changelog](https://github.com/mwilliamson/mammoth.js/blob/master/NEWS)
- [Commits](https://github.com/mwilliamson/mammoth.js/compare/1.9.1...1.10.0)

---
updated-dependencies:
- dependency-name: mammoth
  dependency-version: 1.10.0
  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>
2025-08-15 20:41:40 -04:00
Tom Moor ff548eae5c chore: Remove usage of vite-static-copy plugin (#9916) 2025-08-15 20:41:27 -04:00
Tom Moor 866a7f264b Upgrade vite-plugin-pwa (#9943) 2025-08-15 20:33:13 -04:00
Tom Moor 8fcb629bdf fix: Standardize request filtering between cloud / self-hosted (#9914)
* fix: Add request-filtering-agent to self-hosted environment

* refactor

* Debug logging

* self-review

* Remove unused AbortController

* test

* test

* Address feedback
2025-08-15 07:16:29 -04:00
Tom Moor 99655c65d4 fix: Drafts without a collection should be publishable by all members with update rights (#9941) 2025-08-15 07:16:09 -04:00
Tom Moor 5e176415ab fix: Line-height too compact on editor headings (#9942)
closes #9932
2025-08-15 07:16:00 -04:00
Tom Moor 34555bce86 fix: Properly truncate multiline labels in sidebar (#9940) 2025-08-15 00:10:44 -04:00
Tom Moor dcd7a050bd chore: Formatting (#9939) 2025-08-14 22:49:07 -04:00
Tom Moor dc0df7c7e9 fix: Prevent both img parseDOM rules matching (#9938)
closes #9930
2025-08-14 22:09:24 -04:00
codegen-sh[bot] e2c8ee7b54 chore: Migrate from dotenv to dotenvx with minimal changes (#9921)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-08-14 19:43:42 -04:00
Tom Moor d2a0ddab12 fix: Increase timeout on remote file storage operations, make configurable for edge cases (#9936) 2025-08-14 19:39:36 -04:00
Tom Moor 31b254ff09 chore: Upgrade request-filtering-agent (#9937) 2025-08-14 19:39:14 -04:00
Volodymyr Koval 025af4f9fd docs: Update architecture docs to reflect Oxlint usage (#9935)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-14 17:44:51 -04:00
Tom Moor 221169db51 fix: Remove mime-types usage from the browser – fixes dev/vite warnings (#9926) 2025-08-14 08:02:13 -04:00
Tom Moor d2a50256b0 fix: Remove attachments.redirect sw caching (#9927) 2025-08-14 08:02:00 -04:00
Tom Moor 6bc80720c9 fix: Allow user account lookup with mismatching email capitalization (#9929) 2025-08-14 08:01:51 -04:00
Tom Moor 23106bfce8 fix: Use safeEqual in VerificationCode verify method (#9915) 2025-08-13 22:45:11 -04:00
Tom Moor e8046f0d2f fix: Tighten rate limits on email.callback endpoint from defaults (#9917) 2025-08-13 22:45:03 -04:00
Tom Moor ba8ade0244 chore: Add some additional debugging around auth failures (#9924) 2025-08-13 22:44:53 -04:00
Translate-O-Tron 119eb92f27 New Crowdin updates (#9805)
* fix: New Romanian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2025-08-11 22:11:12 -04:00
dependabot[bot] c26a75af27 chore(deps-dev): bump rollup-plugin-webpack-stats from 2.1.0 to 2.1.3 (#9907)
Bumps [rollup-plugin-webpack-stats](https://github.com/relative-ci/rollup-plugin-webpack-stats) from 2.1.0 to 2.1.3.
- [Release notes](https://github.com/relative-ci/rollup-plugin-webpack-stats/releases)
- [Commits](https://github.com/relative-ci/rollup-plugin-webpack-stats/compare/v2.1.0...v2.1.3)

---
updated-dependencies:
- dependency-name: rollup-plugin-webpack-stats
  dependency-version: 2.1.3
  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>
2025-08-11 20:45:05 -04:00
dependabot[bot] 10f508a8dd chore(deps-dev): bump typescript from 5.8.3 to 5.9.2 (#9906)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.8.3 to 5.9.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 5.9.2
  dependency-type: direct:development
  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>
2025-08-11 19:59:02 -04:00
dependabot[bot] d872293551 chore(deps): bump validator and @types/validator (#9905)
Bumps [validator](https://github.com/validatorjs/validator.js) and [@types/validator](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/validator). These dependencies needed to be updated together.

Updates `validator` from 13.15.0 to 13.15.15
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.15.0...13.15.15)

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

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/validator"
  dependency-version: 13.15.2
  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>
2025-08-11 19:35:00 -04:00
dependabot[bot] e419aa6c3a chore(deps): bump nodemailer from 6.10.0 to 6.10.1 (#9904)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 6.10.0 to 6.10.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.10.0...v6.10.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 6.10.1
  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>
2025-08-11 19:22:49 -04:00
dependabot[bot] ba60d4bc0a chore(deps): bump @octokit/webhooks from 13.8.0 to 13.9.1 (#9903)
Bumps [@octokit/webhooks](https://github.com/octokit/webhooks.js) from 13.8.0 to 13.9.1.
- [Release notes](https://github.com/octokit/webhooks.js/releases)
- [Commits](https://github.com/octokit/webhooks.js/compare/v13.8.0...v13.9.1)

---
updated-dependencies:
- dependency-name: "@octokit/webhooks"
  dependency-version: 13.9.1
  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>
2025-08-11 19:22:28 -04:00
codegen-sh[bot] b1dffc3486 Convert insights from sidebar to modal dialog (#9892)
* Convert insights from sidebar to modal dialog

- Remove insights routing and sidebar layout
- Update DocumentMeta to use modal action instead of navigation
- Convert Insights component to modal-ready format
- Clean up route helpers and authenticated layout
- Remove insights route from authenticated routes

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

* Applied automatic fixes

* refactor

* singular

* refactor

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-11 18:57:27 -04:00
codegen-sh[bot] 4fc6ac1f15 Add in-app reaction notifications (#9893)
* Add ReactionsCreate notification event type

- Add ReactionsCreate to NotificationEventType enum and defaults
- Add notification settings UI with SmileyIcon and proper labels
- Create ReactionsCreateNotificationsTask to handle comment reactions
- Update NotificationsProcessor to handle comments.add_reaction events
- Add eventText and path handling in client Notification model
- Notifications are enabled by default but never send emails

* Applied automatic fixes

* Show the actual emoji in the notification

* Cleanup notifications if reaction is removed

* PR feedback

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-11 18:54:43 -04:00
dependabot[bot] 289302fd2e chore(deps): bump fs-extra from 11.3.0 to 11.3.1 (#9899)
Bumps [fs-extra](https://github.com/jprichardson/node-fs-extra) from 11.3.0 to 11.3.1.
- [Changelog](https://github.com/jprichardson/node-fs-extra/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jprichardson/node-fs-extra/compare/11.3.0...11.3.1)

---
updated-dependencies:
- dependency-name: fs-extra
  dependency-version: 11.3.1
  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>
2025-08-11 18:26:53 -04:00
dependabot[bot] 00db4010d0 chore(deps): bump the aws group with 5 updates (#9900)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.859.0` | `3.864.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.859.0` | `3.864.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.859.0` | `3.864.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.859.0` | `3.864.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.858.0` | `3.864.0` |


Updates `@aws-sdk/client-s3` from 3.859.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.859.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.859.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.859.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.858.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 18:26:45 -04:00
dependabot[bot] bc14699994 chore(deps): bump dd-trace from 5.41.1 to 5.62.0 (#9901)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.41.1 to 5.62.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.41.1...v5.62.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.62.0
  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>
2025-08-11 18:26:37 -04:00
Tom Moor 013a7a6d39 Improved error boundary with option to clear cache on repeated errors… (#9891)
* Improved error boundary with option to clear cache on repeated errors and more detail for reporting

* fix

* db
2025-08-10 15:24:12 -04:00
codegen-sh[bot] c3f93a3e9d Add relationships API endpoints (#9402)
* Migrate Backlink model to generic Relationship model

- Create new Relationship model with type field to support different relationship types
- Add database migration to create relationships table and migrate existing backlinks
- Update Backlink model to delegate to Relationship model for backward compatibility
- Update BacklinksProcessor to use Relationship model with backlink type
- Update API routes to use new Relationship model
- Update test files to use Relationship model
- Maintain backward compatibility through database view and model delegation

Fixes #9366

* Update migration to rename table instead of creating new one

- Rename existing backlinks table to relationships instead of creating new table
- Add type column with default value to existing table
- Update existing rows to have type='backlink'
- Avoid expensive data migration by keeping existing data in place
- Maintain backward compatibility with database view
- Update rollback to reverse table rename and column addition

This approach is much more efficient for large datasets as it avoids copying millions of rows.

* Remove unnecessary UPDATE statement from migration

The UPDATE statement is not needed since defaultValue automatically
applies to existing rows when adding a column with a default value.

Thanks @tommoor for catching this!

* Wrap up migration in transaction

- Wrap all migration operations in a transaction for atomicity
- Add transaction parameter to all queryInterface calls
- Follow the same pattern as other migrations in the codebase
- Ensures all operations succeed or fail together

* Remove Backlink class entirely and use Relationship everywhere

- Delete server/models/Backlink.ts
- Remove Backlink export from server/models/index.ts
- Remove Backlink import and association from Document model
- All functionality now uses Relationship model with RelationshipType.Backlink
- Maintains same API through Relationship model methods
- Cleaner architecture with single relationship model

* Update documents.test.ts to use RelationshipType enum instead of string

- Import RelationshipType from Relationship model
- Replace type: "backlink" with type: RelationshipType.Backlink
- Improves type safety and consistency with enum usage

* Address code review feedback

- Add transaction wrapper to migration down method for safer rollback
- Remove unused findByTypeForUser method from Relationship model
- Method wasn't used and won't work for all relationship types (e.g., user mentions)
- Clean up code structure and improve safety

* Restore imports

* Add relationships API endpoints

- Create relationships API following stars pattern
- Add CRUD operations: create, list, delete
- Include proper validation, authentication, and authorization
- Support filtering by relationship type and document IDs
- Add relationship presenter and policies
- Register routes in main API router

* Remove relationships.create and relationships.delete endpoints

- Keep only relationships.list endpoint as requested
- Remove create and delete schemas from validation
- Update policies to only allow read operations
- Relationships will be managed internally, not via external API

* Add relationships.info endpoint

- Use Document.findByPk for authorization as requested
- Find relationship by ID and verify user has access to related document
- Return relationship details with accessible documents
- Include proper validation schema for UUID parameter

* Update 20250601223331-migrate-backlink-to-relationship.js

* Update Relationship.ts

* wip

* test

* Final tweaks

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-10 15:24:01 -04:00
Tom Moor c5cd4d9335 fix: Remove nullable on teams.name column (#9890)
* fix: Remove nullable on teams.name column

* Add test
2025-08-10 14:02:01 -04:00
Hemachandar c2f84466df chore: Move TableOfContentsMenu & BreadcrumbMenu to Radix (#9882)
* mobile toc menu

* indent heading levels, filter > H3

* breadcrumb menu

* tiny
2025-08-09 21:59:46 -04:00
Tom Moor 00d7239601 v0.86.1 (#9885) 2025-08-09 18:00:47 -04:00
192 changed files with 6420 additions and 3370 deletions
+4
View File
@@ -211,6 +211,10 @@ GITHUB_APP_PRIVATE_KEY=
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# The GitLab integration allows previewing issue and merge request links as rich mentions
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
+8 -9
View File
@@ -25,15 +25,14 @@ jobs:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
lint:
needs: build
-1
View File
@@ -12,7 +12,6 @@
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalSetup": "<rootDir>/server/test/globalSetup.js",
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
},
+1 -1
View File
@@ -87,7 +87,7 @@
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"no-unused-vars": [
"warn",
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
+1 -1
View File
@@ -1,4 +1,4 @@
require("dotenv").config({
require("@dotenvx/dotenvx").config({
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
});
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.86.0
Licensed Work: Outline 0.86.1
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-08-06
Change Date: 2029-08-09
Change License: Apache License, Version 2.0
+6
View File
@@ -8,6 +8,12 @@
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
+13
View File
@@ -1,3 +1,4 @@
import Storage from "@shared/utils/Storage";
import copy from "copy-to-clipboard";
import {
BeakerIcon,
@@ -127,6 +128,17 @@ export const clearIndexedDB = createAction({
},
});
export const clearStorage = createAction({
name: ({ t }) => t("Clear local storage"),
icon: <TrashIcon />,
keywords: "cache clear localstorage",
section: DeveloperSection,
perform: ({ t }) => {
Storage.clear();
toast.success(t("Local storage cleared"));
},
});
export const createTestUsers = createAction({
name: "Create 10 test users",
icon: <UserIcon />,
@@ -201,6 +213,7 @@ export const developer = createAction({
createToast,
createTestUsers,
clearIndexedDB,
clearStorage,
startTyping,
],
});
+6 -41
View File
@@ -26,7 +26,6 @@ import {
PublishIcon,
CommentIcon,
CopyIcon,
EyeIcon,
PadlockIcon,
GlobeIcon,
LogoutIcon,
@@ -70,7 +69,6 @@ import env from "~/env";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import {
documentInsightsPath,
documentHistoryPath,
homePath,
newDocumentPath,
@@ -84,6 +82,7 @@ import {
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import Insights from "~/scenes/Document/components/Insights";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -1329,7 +1328,7 @@ export const openDocumentHistory = createInternalLinkActionV2({
},
});
export const openDocumentInsights = createInternalLinkActionV2({
export const openDocumentInsights = createActionV2({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
@@ -1347,51 +1346,17 @@ export const openDocumentInsights = createInternalLinkActionV2({
!document?.isDeleted
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const [pathname, search] = documentInsightsPath(document).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const toggleViewerInsights = createActionV2({
name: ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return document?.insightsEnabled
? t("Disable viewer insights")
: t("Enable viewer insights");
},
analyticsName: "Toggle viewer insights",
section: ActiveDocumentSection,
icon: <EyeIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return can.updateInsights;
},
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.save({
insightsEnabled: !document.insightsEnabled,
stores.dialogs.openModal({
title: t("Insights"),
content: <Insights document={document} />,
});
},
});
+1 -1
View File
@@ -268,7 +268,7 @@ export function actionV2ToMenuItem(
switch (action.type) {
case "action": {
const title = resolve<string>(action.name, context);
const visible = resolve<boolean>(action.visible, context);
const visible = resolve<boolean>(action.visible, context) ?? true;
const disabled = resolve<boolean>(action.disabled, context);
const icon =
!!action.icon && action.iconInContextMenu !== false
+2 -11
View File
@@ -27,7 +27,6 @@ import {
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
@@ -39,9 +38,7 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const DocumentInsights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
type Props = {
@@ -98,12 +95,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showInsights =
!!matchPath(location.pathname, {
path: matchDocumentInsights,
}) && can.listViews;
const showComments =
!showInsights &&
!showHistory &&
can.comment &&
ui.activeDocumentId &&
@@ -115,12 +107,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showInsights || showComments) && (
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
+65 -31
View File
@@ -6,55 +6,89 @@ import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { MenuInternalLink } from "~/types";
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useComputed } from "~/hooks/useComputed";
type TopLevelAction =
| InternalLinkActionV2
| { type: "menu"; actions: InternalLinkActionV2[] };
type Props = React.PropsWithChildren<{
items: MenuInternalLink[];
actions: InternalLinkActionV2[];
max?: number;
highlightFirstItem?: boolean;
}>;
function Breadcrumb(
{ items, highlightFirstItem, children, max = 2 }: Props,
{ actions, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
const actionContext = useActionContext({ isContextMenu: true });
const visibleActions = useComputed(
() =>
actions.filter((action) =>
typeof action.visible === "function"
? action.visible(actionContext)
: (action.visible ?? true)
),
[actions, actionContext]
);
const totalVisibleActions = visibleActions.length;
const topLevelActions: TopLevelAction[] = [...visibleActions];
// chop middle breadcrumbs and present a "..." menu instead
if (totalItems > max) {
if (totalVisibleActions > max) {
const halfMax = Math.floor(max / 2);
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
const menuActions = topLevelActions.splice(
halfMax,
totalVisibleActions - max
) as InternalLinkActionV2[];
topLevelItems.splice(halfMax, 0, {
to: "",
type: "route",
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
topLevelActions.splice(halfMax, 0, {
type: "menu",
actions: menuActions,
});
}
const toBreadcrumb = React.useCallback(
(action: TopLevelAction, index: number) => {
if (action.type === "menu") {
return <BreadcrumbMenu key="menu" actions={action.actions} />;
}
const item = actionV2ToMenuItem(
action,
actionContext
) as MenuInternalLink;
return (
<>
{item.icon}
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
{item.title}
</Item>
</>
);
},
[actionContext, highlightFirstItem]
);
return (
<Flex justify="flex-start" align="center" ref={ref}>
{topLevelItems.map((item, index) => (
<React.Fragment
key={
(typeof item.to === "string" ? item.to : item.to.pathname) || index
}
>
{item.icon}
{item.to ? (
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
{item.title}
</Item>
) : (
item.title
)}
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
{topLevelActions.map((action, index) => (
<React.Fragment key={action.type === "menu" ? "menu" : `item-${index}`}>
{toBreadcrumb(action, index)}
{index !== topLevelActions.length - 1 || !!children ? (
<Slash />
) : null}
</React.Fragment>
))}
{children}
+5 -1
View File
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
{...rest}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
@@ -148,6 +148,10 @@ function Collaborators(props: Props) {
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
);
if (!document.insightsEnabled) {
return null;
}
return (
<Popover>
<PopoverTrigger>
+21 -28
View File
@@ -3,9 +3,10 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { MenuInternalLink } from "~/types";
import { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveCollectionSection } from "~/actions/sections";
type Props = {
collection: Collection;
@@ -14,32 +15,24 @@ type Props = {
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const { t } = useTranslation();
const items = React.useMemo(() => {
const collectionNode: MenuInternalLink = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
};
const actions = React.useMemo(
() => [
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: collection.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
}),
],
[collection, t]
);
const category: MenuInternalLink | undefined = collection.isArchived
? {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
}
: undefined;
const output = [];
if (category) {
output.push(category);
}
output.push(collectionNode);
return output;
}, [collection, t]);
return <Breadcrumb items={items} highlightFirstItem />;
return <Breadcrumb actions={actions} highlightFirstItem />;
};
+79 -90
View File
@@ -11,8 +11,9 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
type Props = {
children?: React.ReactNode;
@@ -27,46 +28,12 @@ type Props = {
maxDepth?: number;
};
function useCategory(document: Document): MenuInternalLink | null {
const { t } = useTranslation();
if (document.isDeleted) {
return {
type: "route",
icon: <TrashIcon />,
title: t("Trash"),
to: trashPath(),
};
}
if (document.isArchived) {
return {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
};
}
if (document.template) {
return {
type: "route",
icon: <ShapesIcon />,
title: t("Templates"),
to: settingsPath("templates"),
};
}
return null;
}
function DocumentBreadcrumb(
{ document, children, onlyText, reverse = false, maxDepth }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
const sidebarContext = useLocationSidebarContext();
const collection = document.collectionId
? collections.get(document.collectionId)
@@ -78,69 +45,91 @@ function DocumentBreadcrumb(
void document.loadRelations({ withoutPolicies: true });
}, [document]);
let collectionNode: MenuInternalLink | undefined;
if (collection && can.readDocument) {
collectionNode = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: {
pathname: collection.path,
state: { sidebarContext },
},
};
} else if (document.isCollectionDeleted) {
collectionNode = {
type: "route",
title: t("Deleted Collection"),
icon: undefined,
to: "",
};
}
const path = document.pathTo.slice(0, -1);
const items = React.useMemo(() => {
const output: MenuInternalLink[] = [];
const actions = React.useMemo(() => {
if (depth === 0) {
return output;
return [];
}
if (category) {
output.push(category);
}
if (collectionNode) {
output.push(collectionNode);
}
path.forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
output.push({
type: "route",
title: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
title
),
to: {
pathname: node.url,
state: { sidebarContext },
},
});
});
const outputActions = [
createInternalLinkActionV2({
name: t("Trash"),
section: ActiveDocumentSection,
icon: <TrashIcon />,
visible: document.isDeleted,
to: trashPath(),
}),
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkActionV2({
name: collection?.name,
section: ActiveDocumentSection,
icon: collection ? (
<CollectionIcon collection={collection} expanded />
) : undefined,
visible: !!(collection && can.readDocument),
to: collection
? {
pathname: collection.path,
state: { sidebarContext },
}
: "",
}),
createInternalLinkActionV2({
name: t("Deleted Collection"),
section: ActiveDocumentSection,
visible: document.isCollectionDeleted,
to: "",
}),
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkActionV2({
name: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
title
),
section: ActiveDocumentSection,
to: {
pathname: node.url,
state: { sidebarContext },
},
});
}),
];
return reverse
? depth !== undefined
? output.slice(-depth)
: output
? outputActions.slice(-depth)
: outputActions
: depth !== undefined
? output.slice(0, depth)
: output;
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
? outputActions.slice(0, depth)
: outputActions;
}, [
t,
document,
collection,
can.readDocument,
sidebarContext,
path,
reverse,
depth,
]);
if (!collections.isLoaded) {
return null;
@@ -176,7 +165,7 @@ function DocumentBreadcrumb(
}
return (
<Breadcrumb items={items} ref={ref} highlightFirstItem>
<Breadcrumb actions={actions} ref={ref} highlightFirstItem>
{children}
</Breadcrumb>
);
+32 -34
View File
@@ -1,4 +1,3 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -11,7 +10,6 @@ import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
import Switch from "./Switch";
import Text from "./Text";
@@ -32,7 +30,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
);
const items = React.useMemo(() => {
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
const nodes = collectionTrees.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
@@ -78,34 +76,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<OptionsContainer>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</>
)}
</OptionsContainer>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
@@ -127,9 +123,11 @@ function DocumentCopy({ document, onSubmit }: Props) {
}
const OptionsContainer = styled.div`
margin: 16px 0 8px 0;
padding-left: 24px;
padding-right: 24px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding: 16px 24px 0;
margin-bottom: -1px;
background: ${(props) => props.theme.modalBackground};
z-index: 1;
`;
export default observer(DocumentCopy);
+7 -9
View File
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
@@ -26,7 +26,8 @@ import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { ancestors, descendants } from "~/utils/tree";
import { ancestors, descendants, flattenTree } from "~/utils/tree";
import flatten from "lodash/flatten";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
@@ -80,7 +81,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const searchIndex = React.useMemo(
() =>
new FuzzySearch(items, ["title"], {
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
caseSensitive: false,
}),
[items]
@@ -125,11 +126,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.flatMap(includeDescendants);
: items.flatMap(includeDescendants);
}
const nodes = getNodes();
@@ -137,6 +134,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -310,7 +308,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={(node.depth ?? 0) - baseDepth}
depth={(node.depth ?? 0) - normalizedBaseDepth}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
+89 -15
View File
@@ -13,6 +13,9 @@ import Text from "~/components/Text";
import env from "~/env";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
import Storage from "@shared/utils/Storage";
import { deleteAllDatabases } from "~/utils/developer";
import Flex from "./Flex";
type Props = WithTranslation & {
/** Whether to reload the page if a chunk fails to load. */
@@ -23,6 +26,9 @@ type Props = WithTranslation & {
component?: React.ComponentType | string;
};
const ERROR_TRACKING_KEY = "error-boundary-tracking";
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
@observer
class ErrorBoundary extends React.Component<Props> {
@observable
@@ -31,6 +37,13 @@ class ErrorBoundary extends React.Component<Props> {
@observable
showDetails = false;
@observable
isRepeatedError = false;
componentDidMount() {
this.checkForPreviousErrors();
}
componentDidCatch(error: Error) {
this.error = error;
@@ -46,9 +59,47 @@ class ErrorBoundary extends React.Component<Props> {
return;
}
this.trackError();
Logger.error("ErrorBoundary", error);
}
private checkForPreviousErrors = () => {
try {
const stored = Storage.get(ERROR_TRACKING_KEY);
if (!stored) {
return;
}
const errors: number[] = JSON.parse(stored);
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
const recentErrors = errors.filter((timestamp) => timestamp > cutoff);
this.isRepeatedError = recentErrors.length > 0;
} catch (err) {
Logger.warn("Failed to parse stored errors for error boundary", { err });
}
};
private trackError = () => {
try {
const stored = Storage.get(ERROR_TRACKING_KEY);
const errors: number[] = stored ? JSON.parse(stored) : [];
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
// Filter out old errors and add current one
const updatedErrors = [
...errors.filter((timestamp) => timestamp > cutoff),
Date.now(),
];
Storage.set(ERROR_TRACKING_KEY, JSON.stringify(updatedErrors));
this.isRepeatedError = updatedErrors.length > 1;
} catch (err) {
Logger.warn("Failed to track error in error boundary", { err });
}
};
handleReload = () => {
window.location.reload();
};
@@ -61,6 +112,12 @@ class ErrorBoundary extends React.Component<Props> {
window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github);
};
handleClearCache = async () => {
await deleteAllDatabases();
Storage.clear();
window.location.reload();
};
render() {
const { t, component: Component = CenteredContent, showTitle } = this.props;
@@ -107,29 +164,46 @@ class ErrorBoundary extends React.Component<Props> {
</Heading>
</>
)}
<Text as="p" type="secondary">
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
</Text>
{this.showDetails && <Pre>{error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
{this.isRepeatedError ? (
<Text as="p" type="secondary">
<Trans>
An error has occurred multiple times recently. If it continues
please try clearing the cache or using a different browser.
</Trans>
</Text>
) : (
<Text as="p" type="secondary">
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
</Text>
)}
{this.showDetails && <Pre>{error.stack}</Pre>}
<Flex gap={8} wrap>
{this.isRepeatedError && (
<Button onClick={this.handleClearCache}>
<Trans>Clear cache + reload</Trans>
</Button>
)}
<Button onClick={this.handleReload} neutral={this.isRepeatedError}>
{t("Reload")}
</Button>
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
<Trans>Report a bug</Trans>
<Trans>Report a bug</Trans>
</Button>
) : (
<Button onClick={this.handleShowDetails} neutral>
<Trans>Show detail</Trans>
</Button>
)}
</p>
</Flex>
</Component>
);
}
@@ -30,10 +30,15 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
) {
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
let service;
if (urlObj.hostname === "github.com") {
service = IntegrationService.GitHub;
} else if (urlObj.hostname === "gitlab.com") {
service = IntegrationService.GitLab;
} else {
service = IntegrationService.Linear;
}
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<any>> {
export interface LazyComponent<T extends React.ComponentType<unknown>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
export function createLazyComponent<T extends React.ComponentType<unknown>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
@@ -35,7 +35,7 @@ function Notifications(
const context = useActionContext();
const { notifications } = useStores();
const { t } = useTranslation();
const isEmpty = notifications.orderedData.length === 0;
const isEmpty = notifications.active.length === 0;
// Update the notification count in the dock icon, if possible.
React.useEffect(() => {
@@ -80,7 +80,7 @@ function Notifications(
<PaginatedList<Notification>
fetch={notifications.fetchPage}
options={{ archived: false }}
items={notifications.orderedData}
items={notifications.active}
renderItem={(item) => (
<NotificationListItem
key={item.id}
+1 -1
View File
@@ -14,7 +14,7 @@ describe("PaginatedList", () => {
i18n,
tReady: true,
t: ((key: string) => key) as TFunction,
} as any;
} as unknown;
it("with no items renders nothing", () => {
const result = render(
+6 -4
View File
@@ -34,11 +34,11 @@ interface Props<T extends PaginatedItem>
* @param options Pagination and other query options
*/
fetch?: (
options: Record<string, any> | undefined
options: Record<string, unknown> | undefined
) => Promise<unknown[] | undefined> | undefined;
/** Additional options to pass to the fetch function */
options?: Record<string, any>;
options?: Record<string, unknown>;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@@ -77,7 +77,9 @@ interface Props<T extends PaginatedItem>
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
renderHeading?: (
name: React.ReactElement<unknown> | string
) => React.ReactNode;
/**
* Handler for escape key press
@@ -206,7 +208,7 @@ const PaginatedList = <T extends PaginatedItem>({
if (fetch) {
void fetchResults();
}
}, [fetch]);
}, [fetch, fetchResults]);
// Handle updates to fetch or options
React.useEffect(() => {
+1 -1
View File
@@ -25,7 +25,7 @@ import HistoryNavigation from "./components/HistoryNavigation";
import Section from "./components/Section";
import SharedWithMe from "./components/SharedWithMe";
import SidebarAction from "./components/SidebarAction";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import SidebarButton from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import ToggleButton from "./components/ToggleButton";
@@ -33,10 +33,13 @@ import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import UserMembership from "~/models/UserMembership";
import GroupMembership from "~/models/GroupMembership";
type Props = {
node: NavigationNode;
collection?: Collection;
membership?: UserMembership | GroupMembership;
activeDocument: Document | null | undefined;
prefetchDocument?: (documentId: string) => Promise<Document | void>;
isDraft?: boolean;
@@ -49,6 +52,7 @@ function InnerDocumentLink(
{
node,
collection,
membership,
activeDocument,
prefetchDocument,
isDraft,
@@ -87,20 +91,27 @@ function InnerDocumentLink(
isActiveDocument,
]);
const showChildren = React.useMemo(
() =>
!!(
hasChildDocuments &&
activeDocument &&
collection &&
(collection
.pathToDocument(activeDocument.id)
.map((entry) => entry.id)
.includes(node.id) ||
isActiveDocument)
),
[hasChildDocuments, activeDocument, isActiveDocument, node, collection]
);
const showChildren = React.useMemo(() => {
if (!hasChildDocuments || !activeDocument) {
return false;
}
const pathToDocument =
collection?.pathToDocument(activeDocument.id) ??
membership?.pathToDocument(activeDocument.id);
return !!(
pathToDocument?.map((entry) => entry.id).includes(node.id) ||
isActiveDocument
);
}, [
hasChildDocuments,
activeDocument,
isActiveDocument,
node,
collection,
membership,
]);
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
@@ -404,6 +415,7 @@ function InnerDocumentLink(
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
@@ -18,13 +18,17 @@ import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext from "./SidebarContext";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useHistory } from "react-router-dom";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
function SharedWithMe() {
const { userMemberships, groupMemberships } = useStores();
const { ui, userMemberships, groupMemberships } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const history = useHistory();
const locationSidebarContext = useLocationSidebarContext();
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
@@ -44,6 +48,54 @@ function SharedWithMe() {
}
}, [error, t]);
useEffect(() => {
const isContextInSharedSection =
locationSidebarContext === "shared" ||
locationSidebarContext?.startsWith("group");
if (!ui.activeDocumentId || isContextInSharedSection) {
return;
}
const isActiveDocSharedDirectly = user.documentMemberships.find(
(m) => m.pathToDocument(ui.activeDocumentId!).length > 0
);
if (isActiveDocSharedDirectly) {
history.push({
...history.location,
state: {
...(history.location.state as Record<string, unknown>),
sidebarContext: "shared",
},
});
return;
}
const groupWithActiveDocument = user.groupsWithDocumentMemberships.find(
(group) =>
group.documentMemberships.some(
(m) => m.pathToDocument(ui.activeDocumentId!).length > 0
)
);
if (groupWithActiveDocument) {
history.push({
...history.location,
state: {
...(history.location.state as Record<string, unknown>),
sidebarContext: groupSidebarContext(groupWithActiveDocument.id),
},
});
}
}, [
ui.activeDocumentId,
locationSidebarContext,
user.documentMemberships,
user.groupsWithDocumentMemberships,
]);
if (
!user.documentMemberships.length &&
!user.groupsWithDocumentMemberships.length
@@ -40,21 +40,20 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
const sidebarContext = useSidebarContext();
const document = documentId ? documents.get(documentId) : undefined;
const isActiveDocumentInPath = ui.activeDocumentId
? membership.pathToDocument(ui.activeDocumentId).length > 0
: false;
const [expanded, setExpanded, setCollapsed] = useBoolean(
membership.documentId === ui.activeDocumentId &&
locationSidebarContext === sidebarContext
isActiveDocumentInPath && locationSidebarContext === sidebarContext
);
React.useEffect(() => {
if (
membership.documentId === ui.activeDocumentId &&
locationSidebarContext === sidebarContext
) {
if (isActiveDocumentInPath && locationSidebarContext === sidebarContext) {
setExpanded();
}
}, [
membership.documentId,
ui.activeDocumentId,
isActiveDocumentInPath,
sidebarContext,
locationSidebarContext,
setExpanded,
@@ -63,6 +62,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
React.useEffect(() => {
if (documentId) {
void documents.fetch(documentId);
void membership.fetchDocuments();
}
}, [documentId, documents]);
@@ -118,9 +118,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
? collections.get(document.collectionId)
: undefined;
const node = document.asNavigationNode;
const childDocuments = node.children;
const hasChildDocuments = childDocuments.length > 0;
const childDocuments = membership.documents ?? [];
return (
<>
@@ -139,7 +137,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
state: { sidebarContext },
}}
expanded={
hasChildDocuments && !isDragging ? expanded : undefined
childDocuments.length > 0 && !isDragging
? expanded
: undefined
}
onDisclosureClick={handleDisclosureClick}
icon={icon}
@@ -180,8 +180,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={node.isDraft}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
@@ -3,7 +3,7 @@ import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { s, truncateMultiline } from "@shared/styles";
import { isMobile } from "@shared/utils/browser";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
@@ -272,8 +272,8 @@ const Link = styled(NavLink)<{
const Label = styled.div`
position: relative;
width: 100%;
max-height: 4.8em;
line-height: 24px;
${truncateMultiline(3)}
* {
unicode-bidi: plaintext;
+1 -1
View File
@@ -9,7 +9,7 @@ function Toasts() {
return (
<StyledToaster
theme={ui.resolvedTheme as any}
theme={ui.resolvedTheme as unknown}
closeButton
toastOptions={{
duration: 5000,
+24 -18
View File
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
}, [query, documents.add])
);
useEffect(() => {
@@ -79,6 +79,22 @@ const LinkEditor: React.FC<Props> = ({
}
}, [trimmedQuery, request]);
const save = React.useCallback(
(href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
},
[onSelectLink, from, to]
);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
@@ -107,20 +123,7 @@ const LinkEditor: React.FC<Props> = ({
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
};
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
@@ -195,7 +198,7 @@ const LinkEditor: React.FC<Props> = ({
}
};
const handleRemoveLink = () => {
const handleRemoveLink = React.useCallback(() => {
discardRef.current = true;
const { state, dispatch } = view;
@@ -203,9 +206,12 @@ const LinkEditor: React.FC<Props> = ({
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
if (onRemoveLink) {
onRemoveLink();
}
view.focus();
};
}, [view, mark, from, to, onRemoveLink]);
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
+10 -1
View File
@@ -184,7 +184,16 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
collections,
]);
const handleSelect = useCallback(
async (item: MentionItem) => {
+1 -1
View File
@@ -87,7 +87,7 @@ function useIsActive(state: EditorState) {
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
const nodes = (fragment as unknown).content;
return some(nodes, (n) => n.content.size);
}
+13 -1
View File
@@ -504,12 +504,24 @@ export class Editor extends React.PureComponent<
return;
}
function isVisible(element: HTMLElement | null) {
for (let e = element; e; e = e.parentElement) {
const s = getComputedStyle(e);
if (s.display === "none" || s.opacity === "0") {
return false;
}
}
return true;
}
try {
this.mutationObserver?.disconnect();
this.mutationObserver = observe(
hash,
(element) => {
element.scrollIntoView();
if (isVisible(element)) {
element.scrollIntoView();
}
},
this.elementRef.current || undefined
);
+1 -1
View File
@@ -73,7 +73,7 @@ export default function useCollectionTrees(): NavigationNode[] {
parent: null,
};
return addParent(addCollectionId(addDepth(addType(collectionNode))));
return addParent(addCollectionId(addDepth(addType(collectionNode), 1)));
};
const key = collections.orderedData.map((o) => o.documents?.length).join("-");
+16
View File
@@ -0,0 +1,16 @@
import { formatNumber } from "~/utils/language";
import useUserLocale from "./useUserLocale";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
/**
* Hook that returns a function to format numbers based on the user's locale.
*
* @returns A function that formats numbers
*/
export function useFormatNumber() {
const language = useUserLocale();
return (input: number) =>
language
? formatNumber(input, unicodeCLDRtoBCP47(language))
: input.toString();
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const useLocaleTime = ({
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = useState(0);
const [, setMinutesMounted] = useState(0);
const callback = useRef<() => void>();
useEffect(() => {
+11 -17
View File
@@ -1,27 +1,21 @@
import { useTranslation } from "react-i18next";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { MenuInternalLink } from "~/types";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useMenuAction } from "~/hooks/useMenuAction";
import { InternalLinkActionV2 } from "~/types";
type Props = {
items: MenuInternalLink[];
actions: InternalLinkActionV2[];
};
export default function BreadcrumbMenu({ items }: Props) {
export default function BreadcrumbMenu({ actions }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom",
});
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
<ContextMenu {...menu} aria-label={t("Path to document")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Show path to document")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
+20
View File
@@ -148,6 +148,13 @@ function DocumentMenu({
[user, document]
);
const handleInsightsToggle = React.useCallback(
(checked: boolean) => {
void document.save({ insightsEnabled: checked });
},
[document]
);
const templateMenuActions = useTemplateMenuActions({
document,
onSelectTemplate,
@@ -231,6 +238,18 @@ function DocumentMenu({
<>
<MenuSeparator />
<DisplayOptions>
{can.updateInsights && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Enable viewer insights")}
labelPosition="left"
checked={document.insightsEnabled}
onChange={handleInsightsToggle}
/>
</Style>
)}
{showToggleEmbeds && (
<Style>
<ToggleMenuItem
@@ -263,6 +282,7 @@ function DocumentMenu({
can.update,
document.embedsDisabled,
document.fullWidth,
document.insightsEnabled,
isMobile,
showDisplayOptions,
showToggleEmbeds,
-36
View File
@@ -1,36 +0,0 @@
import { t } from "i18next";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import NudeButton from "~/components/NudeButton";
import { toggleViewerInsights } from "~/actions/definitions/documents";
import { useMemo } from "react";
import { useMenuAction } from "~/hooks/useMenuAction";
const InsightsMenu: React.FC = () => {
const actions = useMemo(() => [toggleViewerInsights], []);
const rootAction = useMenuAction(actions);
return (
<DropdownMenu action={rootAction} align="end" ariaLabel={t("Insights")}>
<Button>
<MoreIcon />
</Button>
</DropdownMenu>
);
};
const Button = styled(NudeButton)`
color: ${s("textSecondary")};
&:${hover},
&:active,
&[data-state="open"] {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
export default InsightsMenu;
+57 -58
View File
@@ -2,87 +2,86 @@ import { observer } from "mobx-react";
import { TableOfContentsIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { createActionV2, createActionV2Group } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { useDocumentContext } from "~/components/DocumentContext";
import { useMenuState } from "~/hooks/useMenuState";
import { MenuItem } from "~/types";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { useMenuAction } from "~/hooks/useMenuAction";
function TableOfContentsMenu() {
const { headings } = useDocumentContext();
const menu = useMenuState({
modal: true,
unstable_preventOverflow: true,
unstable_fixed: true,
unstable_flip: true,
});
const { t } = useTranslation();
const minHeading = headings.reduce(
(memo, heading) => (heading.level < memo ? heading.level : memo),
Infinity
);
const items: MenuItem[] = useMemo(() => {
const i = [
{
type: "heading",
title: t("Contents"),
},
...headings.map((heading) => ({
type: "button",
onClick: () =>
requestAnimationFrame(() =>
requestAnimationFrame(
() => (window.location.hash = `#${heading.id}`)
)
),
title: <HeadingWrapper>{t(heading.title)}</HeadingWrapper>,
level: heading.level - minHeading,
})),
] as MenuItem[];
if (i.length === 1) {
i.push({
type: "link",
href: "#",
title: (
<HeadingWrapper>
{t("Headings you add to the document will appear here")}
</HeadingWrapper>
const headingActions = useMemo(
() =>
headings
.filter((heading) => heading.level < 4)
.map((heading) =>
createActionV2({
name: (
<HeadingWrapper $level={heading.level - minHeading}>
{t(heading.title)}
</HeadingWrapper>
),
section: ActiveDocumentSection,
perform: () =>
requestAnimationFrame(() =>
requestAnimationFrame(
() => (window.location.hash = `#${heading.id}`)
)
),
})
),
disabled: true,
});
[t, headings, minHeading]
);
const actions = useMemo(() => {
let childActions = headingActions;
if (!childActions.length) {
childActions = [
createActionV2({
name: (
<HeadingWrapper>
{t("Headings you add to the document will appear here")}
</HeadingWrapper>
),
section: ActiveDocumentSection,
disabled: true,
perform: () => {},
}),
];
}
return i;
}, [t, headings, minHeading]);
return [
createActionV2Group({
name: t("Contents"),
actions: childActions,
}),
];
}, [t, headingActions]);
const rootAction = useMenuAction(actions);
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button
{...props}
icon={<TableOfContentsIcon />}
borderOnHover
neutral
/>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Table of contents")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Table of contents")}>
<Button icon={<TableOfContentsIcon />} borderOnHover neutral />
</DropdownMenu>
);
}
const HeadingWrapper = styled.div`
const HeadingWrapper = styled.div<{ $level?: number }>`
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
margin-left: ${({ $level }) => `${12 * ($level ?? 0)}px`};
`;
export default observer(TableOfContentsMenu);
+22
View File
@@ -665,6 +665,28 @@ export default class Document extends ArchivableModel implements Searchable {
};
}
/**
* Returns all children of the document.
* This is determined by the collection structure, or the user/group memberships in case it's a shared document.
*
* @returns An array of NavigationNode objects.
*/
@computed
get children(): NavigationNode[] {
const { userMemberships, groupMemberships } = this.store.rootStore;
const collection = this.collection;
const membership =
userMemberships.getByDocumentId(this.id) ??
groupMemberships.getByDocumentId(this.id);
return (
collection?.getChildrenForDocument(this.id) ??
membership?.getChildrenForDocument(this.id) ??
[]
);
}
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
+19 -2
View File
@@ -3,14 +3,14 @@ import { CollectionPermission, DocumentPermission } from "@shared/types";
import Collection from "./Collection";
import Document from "./Document";
import Group from "./Group";
import Model from "./base/Model";
import { AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
import NavigableModel from "./base/NavigableModel";
/**
* Represents a groups's membership to a collection or document.
*/
class GroupMembership extends Model {
class GroupMembership extends NavigableModel {
static modelName = "GroupMembership";
/** The group ID that this membership is granted to. */
@@ -45,6 +45,23 @@ class GroupMembership extends Model {
@observable
permission: CollectionPermission | DocumentPermission;
// methods
/**
* Fetches the child documents structure from the server.
*/
async fetchDocuments(options: { force?: boolean } = {}) {
if (!this.documentId) {
return;
}
await super.fetchDocuments({
path: "/documents.documents",
params: { id: this.documentId },
...options,
});
}
// hooks
@AfterRemove
+12 -2
View File
@@ -1,6 +1,6 @@
import { TFunction } from "i18next";
import { action, computed, observable } from "mobx";
import { NotificationEventType } from "@shared/types";
import { NotificationData, NotificationEventType } from "@shared/types";
import {
collectionPath,
commentPath,
@@ -73,6 +73,11 @@ class Notification extends Model {
*/
event: NotificationEventType;
/**
* Additional data associated with the notification.
*/
data: NotificationData;
/**
* Mark the notification as read or unread
*
@@ -121,6 +126,10 @@ class Notification extends Model {
return t("left a comment on");
case NotificationEventType.ResolveComment:
return t("resolved a comment on");
case NotificationEventType.ReactionsCreate:
return t("reacted {{ emoji }} to your comment on", {
emoji: this.data.emoji,
});
case NotificationEventType.AddUserToDocument:
return t("shared");
case NotificationEventType.AddUserToCollection:
@@ -173,7 +182,8 @@ class Notification extends Model {
}
case NotificationEventType.MentionedInComment:
case NotificationEventType.ResolveComment:
case NotificationEventType.CreateComment: {
case NotificationEventType.CreateComment:
case NotificationEventType.ReactionsCreate: {
return this.document && this.comment
? commentPath(this.document, this.comment)
: this.document?.path;
+19 -2
View File
@@ -3,12 +3,12 @@ import { DocumentPermission } from "@shared/types";
import type UserMembershipsStore from "~/stores/UserMembershipsStore";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
import NavigableModel from "./base/NavigableModel";
class UserMembership extends Model {
class UserMembership extends NavigableModel {
static modelName = "UserMembership";
/** The sort order of the membership (In users sidebar) */
@@ -50,6 +50,23 @@ class UserMembership extends Model {
store: UserMembershipsStore;
// methods
/**
* Fetches the child documents structure from the server.
*/
async fetchDocuments(options: { force?: boolean } = {}) {
if (!this.documentId) {
return;
}
await super.fetchDocuments({
path: "/documents.documents",
params: { id: this.documentId },
...options,
});
}
/**
* Returns the next membership for the same user in the list, or undefined if this is the last.
*/
+112
View File
@@ -0,0 +1,112 @@
import { computed, observable, runInAction } from "mobx";
import { JSONObject, type NavigationNode } from "@shared/types";
import { client } from "~/utils/ApiClient";
import ParanoidModel from "./ParanoidModel";
export default abstract class NavigableModel extends ParanoidModel {
private isFetching = false;
@observable
node?: NavigationNode;
/**
* Fetches the child documents structure from the server.
*/
async fetchDocuments(options: {
path: string;
params: JSONObject;
force?: boolean;
}) {
if (this.isFetching) {
return;
}
if (this.documents && options.force !== true) {
return;
}
try {
this.isFetching = true;
const res = await client.post(options.path, options.params);
runInAction(`${NavigableModel.modelName}#fetchDocuments`, () => {
this.node = res.data;
});
} finally {
this.isFetching = false;
}
}
/**
* Child documents structure of the document shared with this membership.
*/
@computed
get documents(): NavigationNode[] | undefined {
return this.node?.children;
}
/**
* Returns the document path from the original document shared with this membership.
*/
pathToDocument(documentId: string) {
let path: NavigationNode[] | undefined = [];
const document = this.store.rootStore.documents.get(documentId);
if (!document) {
return path;
}
const travelNodes = (
nodes: NavigationNode[],
previousPath: NavigationNode[]
) => {
nodes.forEach((node) => {
const newPath = [...previousPath, node];
if (node.id === documentId) {
path = newPath;
return;
}
if (
document.parentDocumentId &&
node.id === document.parentDocumentId
) {
path = [...newPath, document.asNavigationNode];
return;
}
return travelNodes(node.children, newPath);
});
};
if (this.node) {
travelNodes([this.node], path);
}
return path;
}
/**
* Returns the child documents structure for the document.
*/
getChildrenForDocument(documentId: string) {
let result: NavigationNode[] = [];
const travelNodes = (nodes: NavigationNode[]) => {
nodes.forEach((node) => {
if (node.id === documentId) {
result = node.children;
return;
}
return travelNodes(node.children);
});
};
if (this.node) {
travelNodes([this.node]);
}
return result;
}
}
+1 -5
View File
@@ -93,11 +93,7 @@ function AuthenticatedRoutes() {
path={`/doc/${slug}/history/:revisionId?`}
component={Document}
/>
<Route
exact
path={`/doc/${slug}/insights`}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route
+1 -1
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { MoreIcon, PlusIcon } from "outline-icons";
import { PlusIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import Collection from "~/models/Collection";
+24 -13
View File
@@ -3,18 +3,20 @@ import { observer, useObserver } from "mobx-react";
import { CommentIcon } from "outline-icons";
import { useRef, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { Link, useRouteMatch } from "react-router-dom";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { openDocumentInsights } from "~/actions/definitions/documents";
import DocumentMeta from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
import { documentPath } from "~/utils/routeHelpers";
type Props = {
/* The document to display meta data for */
@@ -27,7 +29,6 @@ type Props = {
function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const { views, comments, ui } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const sidebarContext = useLocationSidebarContext();
const team = useCurrentTeam();
const documentViews = useObserver(() => views.inDocument(document.id));
@@ -35,10 +36,12 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const onlyYou = totalViewers === 1 && documentViews[0].userId;
const viewsLoadedOnMount = useRef(totalViewers > 0);
const can = usePolicy(document);
const actionContext = useActionContext({
activeDocumentId: document.id,
});
const Wrapper = viewsLoadedOnMount.current ? Fragment : Fade;
const insightsPath = documentInsightsPath(document);
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
const commentingEnabled = !!team.getPreference(TeamPreference.Commenting);
@@ -67,14 +70,8 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<Link
to={{
pathname:
match.url === insightsPath
? documentPath(document)
: insightsPath,
state: { sidebarContext },
}}
<InsightsButton
onClick={() => openDocumentInsights.perform(actionContext)}
>
{t("Viewed by")}{" "}
{onlyYou
@@ -82,7 +79,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</Link>
</InsightsButton>
</Wrapper>
) : null}
</Meta>
@@ -94,6 +91,20 @@ const CommentLink = styled(Link)`
align-items: center;
`;
const InsightsButton = styled.button`
background: none;
border: none;
padding: 0;
color: inherit;
font: inherit;
text-decoration: none;
cursor: var(--pointer);
&:hover {
text-decoration: underline;
}
`;
export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;
+1 -1
View File
@@ -55,7 +55,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
* The main document editor includes an editable title with metadata below it,
* and support for commenting.
*/
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
const titleRef = React.useRef<RefHandle>(null);
const { t } = useTranslation();
const match = useRouteMatch();
+1 -1
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { TableOfContentsIcon, EditIcon, MoreIcon } from "outline-icons";
import { TableOfContentsIcon, EditIcon } from "outline-icons";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
+59 -111
View File
@@ -1,55 +1,33 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import DocumentViews from "~/components/DocumentViews";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useTextSelection from "~/hooks/useTextSelection";
import { useTextStats } from "~/hooks/useTextStats";
import InsightsMenu from "~/menus/InsightsMenu";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
import type Document from "~/models/Document";
import { useFormatNumber } from "~/hooks/useFormatNumber";
function Insights() {
const { views, documents } = useStores();
type Props = {
document: Document;
};
function Insights({ document }: Props) {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const selectedText = useTextSelection();
const document = documents.getByUrl(match.params.documentSlug);
const { editor } = useDocumentContext();
const text = editor?.getPlainText();
const text = document.toPlainText();
const stats = useTextStats(text ?? "", selectedText);
const can = usePolicy(document);
const documentViews = document ? views.inDocument(document.id) : [];
const onCloseInsights = () => {
if (document) {
history.push({
pathname: documentPath(document),
state: { sidebarContext },
});
}
};
useKeyDown("Escape", onCloseInsights);
const formatNumber = useFormatNumber();
return (
<Sidebar title={t("Insights")} onClose={onCloseInsights}>
<div>
{document ? (
<Flex
column
@@ -58,69 +36,86 @@ function Insights() {
justify="space-between"
>
<div>
<Content column>
{document.sourceMetadata && (
<>
<Heading>{t("Source")}</Heading>
{
<Text as="p" type="secondary" size="small">
<Flex column>
<Text as="h2" size="large">
{t("Source")}
</Text>
<Text as="p" type="secondary" size="small">
<List>
<li>
{t("Created")}{" "}
<Time dateTime={document.createdAt} addSuffix />
</li>
<li>
{t(`Last updated`)}{" "}
<Time dateTime={document.updatedAt} addSuffix />
</li>
{document.sourceMetadata && (
<li>
{t("Imported from {{ source }}", {
source:
document.sourceName ??
`${document.sourceMetadata.fileName}`,
})}
</Text>
}
</>
)}
<Heading>{t("Stats")}</Heading>
</li>
)}
</List>
</Text>
<Text as="h2" size="large">
{t("Stats")}
</Text>
<Text as="p" type="secondary" size="small">
<List>
{stats.total.words > 0 && (
<li>
{t(`{{ count }} minute read`, {
count: stats.total.readingTime,
{t(`{{ number }} minute read`, {
number: formatNumber(stats.total.readingTime),
})}
</li>
)}
<li>
{t(`{{ count }} words`, { count: stats.total.words })}
</li>
<li>
{t(`{{ count }} characters`, {
count: stats.total.characters,
{t(`{{ number }} words`, {
count: stats.total.words,
number: formatNumber(stats.total.words),
})}
</li>
<li>
{t(`{{ number }} emoji`, { number: stats.total.emoji })}
{t(`{{ number }} characters`, {
count: stats.total.characters,
number: formatNumber(stats.total.characters),
})}
</li>
<li>
{t(`{{ number }} emoji`, {
number: formatNumber(stats.total.emoji),
})}
</li>
{stats.selected.characters === 0 ? (
<li>{t("No text selected")}</li>
) : (
<>
<li>
{t(`{{ count }} words selected`, {
{t(`{{ number }} words selected`, {
count: stats.selected.words,
number: formatNumber(stats.selected.words),
})}
</li>
<li>
{t(`{{ count }} characters selected`, {
{t(`{{ number }} characters selected`, {
count: stats.selected.characters,
number: formatNumber(stats.selected.characters),
})}
</li>
</>
)}
</List>
</Text>
</Content>
</Flex>
<Content column>
<Heading>{t("Contributors")}</Heading>
<Text as="p" type="secondary" size="small">
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
<br />
{t(`Last updated`)}{" "}
<Time dateTime={document.updatedAt} addSuffix />.
<Flex column>
<Text as="h2" size="large">
{t("Contributors")}
</Text>
<ListSpacing>
{document.sourceMetadata?.createdByName && (
@@ -166,49 +161,11 @@ function Insights() {
)}
/>
</ListSpacing>
</Content>
{(document.insightsEnabled || can.updateInsights) && (
<Content column>
<Heading>
<Flex justify="space-between">
{t("Viewed by")}
{can.updateInsights && <InsightsMenu />}
</Flex>
</Heading>
{document.insightsEnabled ? (
<>
<Text as="p" type="secondary" size="small">
{documentViews.length <= 1
? t("No one else has viewed yet")
: t(
`Viewed {{ count }} times by {{ teamMembers }} people`,
{
count: documentViews.reduce(
(memo, view) => memo + view.count,
0
),
teamMembers: documentViews.length,
}
)}
.
</Text>
{documentViews.length > 1 && (
<ListSpacing>
<DocumentViews document={document} />
</ListSpacing>
)}
</>
) : (
<Text as="p" type="secondary" size="small">
{t("Viewer insights are disabled.")}
</Text>
)}
</Content>
)}
</Flex>
</div>
</Flex>
) : null}
</Sidebar>
</div>
);
}
@@ -218,7 +175,7 @@ const ListSpacing = styled("div")`
`;
const List = styled("ul")`
margin: 0;
margin: 0 0 1em;
padding: 0;
list-style: none;
@@ -231,13 +188,4 @@ const List = styled("ul")`
}
`;
const Content = styled(Flex)`
padding: 0 16px;
user-select: none;
`;
const Heading = styled("h3")`
font-size: 15px;
`;
export default observer(Insights);
@@ -51,7 +51,10 @@ type MessageEvent = {
};
};
function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
function MultiplayerEditor(
{ onSynced, ...props }: Props,
ref: React.Ref<unknown>
) {
const documentId = props.id;
const history = useHistory();
const { t } = useTranslation();
@@ -2,8 +2,9 @@ import * as React from "react";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Breadcrumb from "~/components/Breadcrumb";
import { MenuInternalLink } from "~/types";
import { sharedModelPath } from "~/utils/routeHelpers";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
type Props = {
children?: React.ReactNode;
@@ -47,23 +48,24 @@ const PublicBreadcrumb: React.FC<Props> = ({
sharedTree,
children,
}: Props) => {
const items: MenuInternalLink[] = React.useMemo(
const actions = React.useMemo(
() =>
pathToDocument(sharedTree, documentId)
.slice(1, -1)
.map((item) => ({
...item,
icon: item.icon ? (
<Icon value={item.icon} color={item.color} />
) : undefined,
title: item.title,
type: "route",
to: sharedModelPath(shareId, item.url),
})),
.map((item) =>
createInternalLinkActionV2({
name: item.title,
section: ActiveDocumentSection,
icon: item.icon ? (
<Icon value={item.icon} color={item.color} />
) : undefined,
to: sharedModelPath(shareId, item.url),
})
),
[sharedTree, shareId, documentId]
);
return <Breadcrumb items={items}>{children}</Breadcrumb>;
return <Breadcrumb actions={actions}>{children}</Breadcrumb>;
};
export default PublicBreadcrumb;
@@ -28,10 +28,7 @@ function References({ document }: Props) {
}, [documents, document.id]);
const backlinks = document.backlinks;
const collection = document.collection;
const children = collection
? collection.getChildrenForDocument(document.id)
: [];
const children = document.children;
const showBacklinks = !!backlinks.length;
const showChildDocuments = !!children.length;
const shouldFade = useRef(!showBacklinks && !showChildDocuments);
+2 -9
View File
@@ -1,4 +1,3 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
@@ -13,7 +12,6 @@ import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
type Props = {
document: Document;
@@ -36,12 +34,7 @@ function DocumentMove({ document }: Props) {
.map(filterSourceDocument),
});
// Filter out the document itself and its existing parent doc, if any.
const nodes = flatten(collectionTrees.map(flattenTree))
.filter(
(node) =>
node.id !== document.id && node.id !== document.parentDocumentId
)
const nodes = collectionTrees
.map(filterSourceDocument)
// Filter out collections that we don't have permission to create documents in.
.filter((node) =>
@@ -100,7 +93,7 @@ function DocumentMove({ document }: Props) {
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
location: selectedPath.title || t("Untitled"),
}}
components={{
em: <strong />,
+1 -3
View File
@@ -1,4 +1,3 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
@@ -13,7 +12,6 @@ import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
type Props = {
/** Document to publish */
@@ -27,7 +25,7 @@ function DocumentPublish({ document }: Props) {
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const publishOptions = useMemo(
() =>
flatten(collectionTrees.map(flattenTree)).filter((node) =>
collectionTrees.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
+3 -2
View File
@@ -1,3 +1,4 @@
import * as React from "react";
import { useState, useRef, useEffect } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -132,10 +133,10 @@ function Authorize() {
{t("Required OAuth parameters are missing")}
<Pre>
{missingParams.map((param) => (
<>
<React.Fragment key={param}>
{param}
<br />
</>
</React.Fragment>
))}
</Pre>
</Text>
+16 -13
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { CopyIcon, InternetIcon, ReplaceIcon } from "outline-icons";
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
@@ -29,6 +29,8 @@ import { ActionRow } from "./components/ActionRow";
import { CopyButton } from "./components/CopyButton";
import ImageInput from "./components/ImageInput";
import SettingRow from "./components/SettingRow";
import { createInternalLinkActionV2 } from "~/actions";
import { NavigationSection } from "~/actions/sections";
type Props = {
oauthClient: OAuthClient;
@@ -77,6 +79,18 @@ const Application = observer(function Application({ oauthClient }: Props) {
},
});
const breadcrumbActions = useMemo(
() => [
createInternalLinkActionV2({
name: t("Applications"),
section: NavigationSection,
icon: <InternetIcon />,
to: settingsPath("applications"),
}),
],
[t]
);
const handleSubmit = useCallback(
async (data: FormData) => {
try {
@@ -118,18 +132,7 @@ const Application = observer(function Application({ oauthClient }: Props) {
return (
<Scene
title={oauthClient.name}
left={
<Breadcrumb
items={[
{
type: "route",
title: t("Applications"),
to: settingsPath("applications"),
icon: <InternetIcon />,
},
]}
/>
}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={<OAuthClientMenu oauthClient={oauthClient} showEdit={false} />}
>
<form onSubmit={formHandleSubmit(handleSubmit)}>
+9
View File
@@ -10,6 +10,7 @@ import {
EditIcon,
EmailIcon,
PublishIcon,
SmileyIcon,
StarredIcon,
UserIcon,
} from "outline-icons";
@@ -77,6 +78,14 @@ function Notifications() {
"Receive a notification when a comment thread you were involved in is resolved"
),
},
{
event: NotificationEventType.ReactionsCreate,
icon: <SmileyIcon />,
title: t("Reaction added"),
description: t(
"Receive a notification when someone reacts to your comment"
),
},
{
event: NotificationEventType.CreateCollection,
icon: <CollectionIcon />,
@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import { CopyIcon } from "outline-icons";
import * as React from "react";
import { useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -50,10 +51,10 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
{apiKey.scope && (
<Tooltip
content={apiKey.scope.map((s) => (
<>
<React.Fragment key={s}>
{s}
<br />
</>
</React.Fragment>
))}
>
<Text type="tertiary">{t("Restricted scope")}</Text>
@@ -1,6 +1,8 @@
import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { createInternalLinkActionV2 } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import Breadcrumb from "~/components/Breadcrumb";
import Scene from "~/components/Scene";
import { settingsPath } from "~/utils/routeHelpers";
@@ -11,22 +13,20 @@ export function IntegrationScene({
}: React.ComponentProps<typeof Scene>) {
const { t } = useTranslation();
const breadcrumbActions = React.useMemo(
() => [
createInternalLinkActionV2({
name: t("Integrations"),
section: NavigationSection,
icon: <SettingsIcon />,
to: settingsPath("integrations"),
}),
],
[t]
);
return (
<Scene
left={
<Breadcrumb
items={[
{
type: "route",
title: t("Integrations"),
icon: <SettingsIcon />,
to: settingsPath("integrations"),
},
]}
/>
}
{...rest}
>
<Scene left={<Breadcrumb actions={breadcrumbActions} />} {...rest}>
{children}
</Scene>
);
+4 -12
View File
@@ -1,7 +1,6 @@
import compact from "lodash/compact";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -13,9 +12,8 @@ import {
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useUserLocale from "~/hooks/useUserLocale";
import ShareMenu from "~/menus/ShareMenu";
import { formatNumber } from "~/utils/language";
import { useFormatNumber } from "~/hooks/useFormatNumber";
const ROW_HEIGHT = 50;
@@ -25,7 +23,7 @@ type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
export function SharesTable({ data, canManage, ...rest }: Props) {
const { t } = useTranslation();
const language = useUserLocale();
const formatNumber = useFormatNumber();
const hasDomain = data.some((share) => share.domain);
const columns = useMemo<TableColumn<Share>[]>(
@@ -101,13 +99,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
id: "views",
header: t("Views"),
accessor: (share) => share.views,
component: (share) => (
<>
{language
? formatNumber(share.views, unicodeCLDRtoBCP47(language))
: share.views}
</>
),
component: (share) => formatNumber(share.views),
width: "150px",
},
canManage
@@ -123,7 +115,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
}
: undefined,
]),
[t, language, hasDomain, canManage]
[t, hasDomain, canManage]
);
return (
-2
View File
@@ -8,8 +8,6 @@ import { observable, action, computed, runInAction } from "mobx";
import {
SubscriptionType,
type DateFilter,
type NavigationNode,
type PublicTeam,
type StatusFilter,
} from "@shared/types";
import { subtractDate } from "@shared/utils/date";
+16
View File
@@ -140,4 +140,20 @@ export default class GroupMembershipsStore extends Store<GroupMembership> {
*/
inDocument = (documentId: string) =>
this.orderedData.filter((cgm) => cgm.documentId === documentId);
/**
* Returns the group membership associated with the document.
*/
getByDocumentId = (documentId: string): GroupMembership | undefined => {
const membership = this.find({ documentId });
if (membership) {
return membership;
}
const document = this.rootStore.documents.get(documentId);
return document?.parentDocumentId
? this.getByDocumentId(document.parentDocumentId)
: undefined;
};
}
+7
View File
@@ -34,6 +34,13 @@ class IntegrationsStore extends Store<Integration> {
(integration) => integration.service === IntegrationService.Linear
);
}
@computed
get gitlab(): Integration<IntegrationType.Embed>[] {
return this.orderedData.filter(
(integration) => integration.service === IntegrationService.GitLab
);
}
}
export default IntegrationsStore;
+9 -2
View File
@@ -73,8 +73,7 @@ export default class NotificationsStore extends Store<Notification> {
*/
@computed
get approximateUnreadCount(): number {
return this.orderedData.filter((notification) => !notification.viewedAt)
.length;
return this.active.filter((notification) => !notification.viewedAt).length;
}
/**
@@ -87,4 +86,12 @@ export default class NotificationsStore extends Store<Notification> {
(item) => (item.viewedAt ? 1 : -1)
);
}
/**
* Returns only the active (non-archived) notifications.
*/
@computed
get active(): Notification[] {
return this.orderedData.filter((n) => !n.archivedAt);
}
}
+16
View File
@@ -101,4 +101,20 @@ export default class UserMembershipsStore extends Store<UserMembership> {
return a.index < b.index ? -1 : 1;
});
}
/**
* Returns the user membership associated with the document.
*/
getByDocumentId = (documentId: string): UserMembership | undefined => {
const membership = this.find({ documentId });
if (membership) {
return membership;
}
const document = this.rootStore.documents.get(documentId);
return document?.parentDocumentId
? this.getByDocumentId(document.parentDocumentId)
: undefined;
};
}
+2 -2
View File
@@ -125,7 +125,7 @@ export type Action = {
* Perform the action note this should generally not be called directly, use `performAction`
* instead. Errors will be caught and displayed to the user as a toast message.
*/
perform?: (context: ActionContext) => any;
perform?: (context: ActionContext) => unknown;
to?: string | { url: string; target?: string };
children?: ((context: ActionContext) => Action[]) | Action[];
};
@@ -154,7 +154,7 @@ export type ActionV2 = BaseActionV2 & {
tooltip?:
| ((context: ActionContext) => React.ReactChild | undefined)
| React.ReactChild;
perform: (context: ActionContext) => any;
perform: (context: ActionContext) => unknown;
};
export type InternalLinkActionV2 = BaseActionV2 & {
+15 -14
View File
@@ -47,7 +47,7 @@ class ApiClient {
this.shareId = shareId;
};
fetch = async <T = any>(
fetch = async <T = unknown>(
path: string,
method: string,
data: JSONObject | FormData | undefined,
@@ -75,17 +75,18 @@ class ApiClient {
} else if (method === "POST" || method === "PUT") {
if (data instanceof FormData || typeof data === "string") {
body = data;
}
// Only stringify data if its a normal object and
// not if it's [object FormData], in addition to
// toggling Content-Type to application/json
if (
typeof data === "object" &&
(data || "").toString() === "[object Object]"
) {
} else {
isJson = true;
body = JSON.stringify(data);
// Only stringify data if its a normal object and
// not if it's [object FormData], in addition to
// toggling Content-Type to application/json
if (
typeof data === "object" &&
(data || "").toString() === "[object Object]"
) {
body = JSON.stringify(data);
}
}
}
@@ -179,7 +180,7 @@ class ApiClient {
const error: {
message?: string;
error?: string;
data?: Record<string, any>;
data?: Record<string, unknown>;
} = {};
try {
@@ -243,13 +244,13 @@ class ApiClient {
throw err;
};
get = <T = any>(
get = <T = unknown>(
path: string,
data: JSONObject | undefined,
options?: FetchOptions
) => this.fetch<T>(path, "GET", data, options);
post = <T = any>(
post = <T = unknown>(
path: string,
data?: JSONObject | FormData | undefined,
options?: FetchOptions
+1 -1
View File
@@ -13,7 +13,7 @@ type LogCategory =
| "plugins"
| "policies";
type Extra = Record<string, any>;
type Extra = Record<string, unknown>;
class Logger {
/**
+2 -1
View File
@@ -3,7 +3,8 @@ import { locales, unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Desktop from "./Desktop";
/**
* Formats a number using the user's locale where possible.
* Formats a number using the user's locale where possible. Use `useFormatNumber` hook
* instead of this function in React components, to automatically use the user's locale.
*
* @param number The number to format
* @param locale The locale to use for formatting (BCP47 format)
+3 -3
View File
@@ -1,6 +1,6 @@
import * as React from "react";
type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
type ComponentPromise<T extends React.ComponentType<unknown>> = Promise<{
default: T;
}>;
@@ -12,7 +12,7 @@ type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
* @param interval The interval between retries in milliseconds, defaults to 1000.
* @returns A lazy component.
*/
export default function lazyWithRetry<T extends React.ComponentType<any>>(
export default function lazyWithRetry<T extends React.ComponentType<unknown>>(
component: () => ComponentPromise<T>,
retries?: number,
interval?: number
@@ -20,7 +20,7 @@ export default function lazyWithRetry<T extends React.ComponentType<any>>(
return React.lazy(() => retry(component, retries, interval));
}
function retry<T extends React.ComponentType<any>>(
function retry<T extends React.ComponentType<unknown>>(
fn: () => ComponentPromise<T>,
retriesLeft = 3,
interval = 1000
+21
View File
@@ -37,6 +37,17 @@ export const isURLMentionable = ({
);
}
case IntegrationService.GitLab: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "gitlab.com" &&
settings.gitlab?.project.path_with_namespace ===
pathParts.slice(1, -2).join("/") // ensure installed project path matches with the provided url.
);
}
default:
return false;
}
@@ -67,6 +78,16 @@ export const determineMentionType = ({
return type === "issue" ? MentionType.Issue : undefined;
}
case IntegrationService.GitLab: {
const type = pathParts[pathParts.length - 2];
if (type === "issues") {
return MentionType.Issue;
} else if (type === "merge_requests") {
return MentionType.PullRequest;
}
return undefined;
}
default:
return;
}
-6
View File
@@ -63,10 +63,6 @@ export function documentEditPath(doc: Document): string {
return `${documentPath(doc)}/edit`;
}
export function documentInsightsPath(doc: Document): string {
return `${documentPath(doc)}/insights`;
}
export function documentHistoryPath(
doc: Document,
revisionId?: string
@@ -154,5 +150,3 @@ export const matchDocumentSlug =
export const matchDocumentEdit = `/doc/${matchDocumentSlug}/edit`;
export const matchDocumentHistory = `/doc/${matchDocumentSlug}/history/:revisionId?`;
export const matchDocumentInsights = `/doc/${matchDocumentSlug}/insights`;
+1 -1
View File
@@ -1,6 +1,6 @@
# Architecture
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in TypeScript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and types. Prettier formatting and ESLint are enforced by CI.
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in TypeScript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and types. Prettier formatting and Oxlint are enforced by CI.
## Frontend
+27 -22
View File
@@ -51,11 +51,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.859.0",
"@aws-sdk/lib-storage": "3.859.0",
"@aws-sdk/s3-presigned-post": "3.859.0",
"@aws-sdk/s3-request-presigner": "3.859.0",
"@aws-sdk/signature-v4-crt": "^3.858.0",
"@aws-sdk/client-s3": "3.864.0",
"@aws-sdk/lib-storage": "3.864.0",
"@aws-sdk/s3-presigned-post": "3.864.0",
"@aws-sdk/s3-request-presigner": "3.864.0",
"@aws-sdk/signature-v4-crt": "^3.864.0",
"@babel/core": "^7.27.7",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
@@ -70,6 +70,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dotenvx/dotenvx": "^1.48.4",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.5",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
@@ -87,7 +88,7 @@
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.4",
"@octokit/webhooks": "^13.8.0",
"@octokit/webhooks": "^13.9.1",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-collapsible": "^1.1.11",
@@ -119,26 +120,25 @@
"class-validator": "^0.14.2",
"command-score": "^0.1.2",
"compressorjs": "^1.2.1",
"content-disposition": "^0.5.4",
"cookie": "^0.7.0",
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.37.0",
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.40.0",
"dd-trace": "^5.62.0",
"diff": "^5.2.0",
"dotenv": "^16.5.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
"es6-error": "^4.1.1",
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^5.0.6",
"fetch-with-proxy": "^3.0.1",
"form-data": "^4.0.4",
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"fs-extra": "^11.2.0",
"fs-extra": "^11.3.1",
"fuzzy-search": "^3.2.1",
"glob": "^8.1.0",
"http-errors": "2.0.0",
@@ -166,18 +166,18 @@
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mailparser": "^3.7.4",
"mammoth": "^1.9.1",
"mammoth": "^1.10.0",
"markdown-it": "^14.1.0",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
"mermaid": "11.9.0",
"mime-types": "^2.1.35",
"mime-types": "^3.0.1",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
"mobx-utils": "^4.0.1",
"natural-sort": "^1.0.0",
"node-fetch": "2.7.0",
"nodemailer": "^6.10.0",
"nodemailer": "^6.10.1",
"octokit": "^3.2.2",
"outline-icons": "^3.12.1",
"oy-vey": "^0.12.1",
@@ -205,6 +205,7 @@
"prosemirror-tables": "^1.7.1",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.40.1",
"proxy-from-env": "^1.1.0",
"query-string": "^7.1.3",
"rate-limiter-flexible": "^2.4.2",
"react": "^17.0.2",
@@ -230,7 +231,7 @@
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
"request-filtering-agent": "^2.0.1",
"resolve-path": "^1.4.0",
"rfc6902": "^5.1.2",
"sanitize-filename": "^1.6.3",
@@ -255,15 +256,16 @@
"throng": "^5.0.0",
"tiny-cookie": "^2.5.1",
"tmp": "^0.2.4",
"tunnel-agent": "^0.6.0",
"turndown": "^7.2.0",
"ukkonen": "^2.1.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.15.0",
"validator": "13.15.15",
"vaul": "^1.1.2",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-pwa": "^0.21.2",
"vite-plugin-pwa": "^1.0.2",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -293,6 +295,7 @@
"@types/glob": "^8.0.1",
"@types/google.analytics": "^0.0.46",
"@types/invariant": "^2.2.37",
"@types/ioredis-mock": "^8.2.6",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^8.5.9",
"@types/katex": "^0.16.7",
@@ -308,7 +311,7 @@
"@types/markdown-it": "^14.1.2",
"@types/markdown-it-container": "^2.0.9",
"@types/markdown-it-emoji": "^3.0.1",
"@types/mime-types": "^2.1.4",
"@types/mime-types": "^3.0.1",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.17.30",
"@types/node-fetch": "^2.6.9",
@@ -316,6 +319,7 @@
"@types/passport-oauth2": "^1.4.17",
"@types/pluralize": "^0.0.33",
"@types/png-chunks-extract": "^1.0.2",
"@types/proxy-from-env": "^1.0.4",
"@types/quoted-printable": "^1.0.2",
"@types/react": "^17.0.34",
"@types/react-avatar-editor": "^13.0.4",
@@ -339,7 +343,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.15.0",
"@types/validator": "^13.15.2",
"@types/yauzl": "^2.10.3",
"babel-jest": "^29.7.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
@@ -348,8 +352,10 @@
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.119",
"eslint": "^9.33.0",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"ioredis-mock": "^8.9.0",
"jest-cli": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
@@ -360,10 +366,9 @@
"prettier": "^3.6.2",
"react-refresh": "^0.17.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.1.0",
"rollup-plugin-webpack-stats": "^2.1.3",
"terser": "^5.43.1",
"typescript": "^5.8.3",
"vite-plugin-static-copy": "^0.17.0",
"typescript": "^5.9.2",
"yarn-deduplicate": "^6.0.2"
},
"resolutions": {
@@ -377,6 +382,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "0.86.0",
"version": "0.86.1",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+12 -2
View File
@@ -177,7 +177,17 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
client,
});
};
router.get("email.callback", validate(T.EmailCallbackSchema), emailCallback);
router.post("email.callback", validate(T.EmailCallbackSchema), emailCallback);
router.get(
"email.callback",
rateLimiter(RateLimiterStrategy.TenPerHour),
validate(T.EmailCallbackSchema),
emailCallback
);
router.post(
"email.callback",
rateLimiter(RateLimiterStrategy.TenPerHour),
validate(T.EmailCallbackSchema),
emailCallback
);
export default router;
+1 -1
View File
@@ -22,7 +22,7 @@ export class GitHubUtils {
*/
public static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
baseUrl: env.URL,
params: undefined,
}
) {
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L17.7 7.8L19.4 13.4L12 20.8Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L12 20.8Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.3"
/>
<path
d="M4.6 13.4L2.5 7.8L6.3 7.8L4.6 13.4Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.5"
/>
<path
d="M19.4 13.4L21.5 7.8L17.7 7.8L19.4 13.4Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.5"
/>
<path
d="M6.3 7.8L8.7 2.2L15.3 2.2L17.7 7.8L6.3 7.8Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.7"
/>
</svg>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import GitLabIcon from "./Icon";
import { GitLabConnectButton } from "./components/GitLabButton";
function GitLab() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
React.useEffect(() => {
void integrations.fetchAll({
service: IntegrationService.GitLab,
withRelations: true,
});
}, [integrations]);
return (
<Scene title="GitLab" icon={<GitLabIcon />}>
<Heading>GitLab</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in GitLab to connect{" "}
{{ appName }} to your project. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{env.GITLAB_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Enable previews of GitLab issues and merge requests in documents
by connecting a GitLab project to {appName}.
</Trans>
</Text>
{integrations.gitlab.length ? (
<>
<Heading as="h2">
<Flex justify="space-between" auto>
{t("Connected")}
<GitLabConnectButton icon={<PlusIcon />} />
</Flex>
</Heading>
<List>
{integrations.gitlab.map((integration) => {
const gitlabProject = integration.settings?.gitlab?.project;
const integrationCreatedBy = integration.user
? integration.user.name
: undefined;
return (
<ListItem
key={gitlabProject?.id}
small
title={gitlabProject?.name}
subtitle={
integrationCreatedBy ? (
<>
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
&middot;{" "}
<Time
dateTime={integration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
) : (
<PlaceholderText />
)
}
image={
<TeamLogo
src={gitlabProject?.avatar_url}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={integration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?"
)}
/>
}
/>
);
})}
</List>
</>
) : (
<p>
<GitLabConnectButton icon={<GitLabIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The GitLab integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
export default observer(GitLab);
@@ -0,0 +1,23 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { GitLabUtils } from "../../shared/GitLabUtils";
export function GitLabConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(GitLabUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
+7
View File
@@ -0,0 +1,7 @@
{
"id": "gitlab",
"name": "GitLab",
"priority": 16,
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
"after": "linear"
}
+99
View File
@@ -0,0 +1,99 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import Logger from "@server/logging/Logger";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration } from "@server/models";
import { APIContext } from "@server/types";
import { GitLab } from "../gitlab";
import UploadGitLabProjectAvatarTask from "../tasks/UploadGitLabProjectAvatarTask";
import * as T from "./schema";
import { GitLabUtils } from "plugins/gitlab/shared/GitLabUtils";
const router = new Router();
router.get(
"gitlab.callback",
auth({
optional: true,
}),
validate(T.GitLabCallbackSchema),
apexAuthRedirect<T.GitLabCallbackReq>({
getTeamId: (ctx) => GitLabUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
GitLabUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => GitLabUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.GitLabCallbackReq>) => {
const { code, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(GitLabUtils.errorUrl(error));
return;
}
try {
// validation middleware ensures that code is non-null at this point.
const oauth = await GitLab.oauthAccess(code!);
const project = await GitLab.getInstalledProject(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.GitLab,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
scopes: oauth.scope.split(" "),
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.Embed>
>(
{
service: IntegrationService.GitLab,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
gitlab: {
project: {
id: project.id,
name: project.name,
path_with_namespace: project.path_with_namespace,
avatar_url: project.avatar_url,
},
},
},
},
{ transaction }
);
transaction.afterCommit(async () => {
if (project.avatar_url) {
await new UploadGitLabProjectAvatarTask().schedule({
integrationId: integration.id,
avatarUrl: project.avatar_url,
});
}
});
ctx.redirect(GitLabUtils.successUrl());
} catch (err) {
Logger.error("Encountered error during GitLab OAuth callback", err);
ctx.redirect(GitLabUtils.errorUrl("unknown"));
}
}
);
export default router;
+17
View File
@@ -0,0 +1,17 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const GitLabCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
}),
});
export type GitLabCallbackReq = z.infer<typeof GitLabCallbackSchema>;
+6
View File
@@ -0,0 +1,6 @@
import env from "@server/env";
export default {
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
};
+273
View File
@@ -0,0 +1,273 @@
import { z } from "zod";
import {
IntegrationService,
IntegrationType,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import User from "@server/models/User";
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
import { GitLabUtils } from "../shared/GitLabUtils";
import env from "./env";
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number(),
refresh_token: z.string(),
scope: z.string(),
created_at: z.number(),
});
const GitLabProjectSchema = z.object({
id: z.string(),
name: z.string(),
path_with_namespace: z.string(),
avatar_url: z.string().optional(),
});
const GitLabIssueSchema = z.object({
id: z.number(),
iid: z.number(),
title: z.string(),
description: z.string().nullable(),
state: z.string(),
created_at: z.string(),
author: z.object({
id: z.number(),
name: z.string(),
avatar_url: z.string().nullable(),
}),
labels: z.array(z.string()).optional(),
});
const GitLabMergeRequestSchema = z.object({
id: z.number(),
iid: z.number(),
title: z.string(),
description: z.string().nullable(),
state: z.string(),
created_at: z.string(),
author: z.object({
id: z.number(),
name: z.string(),
avatar_url: z.string().nullable(),
}),
labels: z.array(z.string()).optional(),
draft: z.boolean().optional(),
});
export class GitLab {
private static supportedUnfurls = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
];
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("code", code);
body.set("client_id", env.GITLAB_CLIENT_ID!);
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
body.set("redirect_uri", GitLabUtils.callbackUrl());
body.set("grant_type", "authorization_code");
const res = await fetch(GitLabUtils.tokenUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error while exchanging oauth code from GitLab; status: ${res.status}`
);
}
return AccessTokenResponseSchema.parse(await res.json());
}
static async revokeAccess(accessToken: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("client_id", env.GITLAB_CLIENT_ID!);
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
body.set("token", accessToken);
await fetch(GitLabUtils.revokeUrl, {
method: "POST",
headers,
body,
});
}
static async getInstalledProject(accessToken: string) {
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
};
// Get the first project the user has access to
// In a real implementation, we would want to let the user select which project to connect
const res = await fetch(
"https://gitlab.com/api/v4/projects?membership=true&per_page=1",
{
headers,
}
);
if (res.status !== 200) {
throw new Error(
`Error while fetching GitLab projects; status: ${res.status}`
);
}
const projects = await res.json();
if (!projects.length) {
throw new Error("No GitLab projects found");
}
return GitLabProjectSchema.parse(projects[0]);
}
/**
*
* @param url GitLab resource url
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a GitLab issue or merge request details
*/
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
const resource = GitLab.parseUrl(url);
if (!resource) {
return;
}
const integration = (await Integration.scope("withAuthentication").findOne({
where: {
service: IntegrationService.GitLab,
teamId: actor.teamId,
"settings.gitlab.project.path_with_namespace": resource.projectPath,
},
})) as Integration<IntegrationType.Embed>;
if (!integration) {
return;
}
try {
const headers = {
Authorization: `Bearer ${integration.authentication.token}`,
Accept: "application/json",
};
let apiUrl: string;
let resourceSchema: z.ZodObject<z.ZodRawShape>;
let resourceType: UnfurlResourceType;
if (resource.type === "issues") {
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/issues/${resource.id}`;
resourceSchema = GitLabIssueSchema;
resourceType = UnfurlResourceType.Issue;
} else if (resource.type === "merge_requests") {
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/merge_requests/${resource.id}`;
resourceSchema = GitLabMergeRequestSchema;
resourceType = UnfurlResourceType.PR;
} else {
return;
}
const res = await fetch(apiUrl, { headers });
if (res.status !== 200) {
return { error: `Resource not found (${res.status})` };
}
const data = resourceSchema.parse(await res.json());
// Fetch labels if they exist
let labels = [];
if (data.labels && data.labels.length > 0) {
labels = data.labels.map((label) => ({
name: label,
color: "#428BCA", // Default GitLab blue
}));
}
return {
type: resourceType,
url,
id: `#${data.iid}`,
title: data.title,
description: data.description,
author: {
name: data.author.name,
avatarUrl: data.author.avatar_url || "",
},
labels,
state: {
name: data.state,
color: data.state === "opened" ? "#1aaa55" : "#db3b21", // Green for open, red for closed
draft:
resourceType === UnfurlResourceType.PR ? data.draft : undefined,
},
createdAt: data.created_at,
} satisfies UnfurlIssueOrPR;
} catch (err) {
Logger.warn("Failed to fetch resource from GitLab", err);
return { error: err.message || "Unknown error" };
}
};
/**
* Parses a given URL and returns resource identifiers for GitLab specific URLs
*
* @param url URL to parse
* @returns {object} Containing resource identifiers - `projectPath`, `type`, and `id`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (hostname !== "gitlab.com") {
return;
}
const parts = pathname.split("/");
// Remove empty first element
parts.shift();
// GitLab URLs are in the format: /namespace/project/-/issues/1 or /namespace/project/-/merge_requests/1
// The namespace can have multiple levels (e.g., /group/subgroup/project/-/issues/1)
if (parts.length < 4) {
return;
}
// Find the index of "-" which separates project path from resource type
const separatorIndex = parts.indexOf("-");
if (separatorIndex === -1 || separatorIndex === parts.length - 1) {
return;
}
const projectPath = parts.slice(0, separatorIndex).join("/");
const type = parts[separatorIndex + 1];
const id = parts[separatorIndex + 2];
if (
!type ||
!id ||
!GitLab.supportedUnfurls.includes(type as UnfurlResourceType)
) {
return;
}
return { projectPath, type, id };
}
}
@@ -0,0 +1,56 @@
import { IntegrationType } from "@shared/types";
import BaseTask from "@server/queues/tasks/BaseTask";
import { Integration } from "@server/models";
import { FileOperation } from "@server/models";
import fetch from "node-fetch";
type Props = {
integrationId: string;
avatarUrl: string;
};
export default class UploadGitLabProjectAvatarTask extends BaseTask<Props> {
public async perform({ integrationId, avatarUrl }: Props) {
const integration = await Integration.findByPk(integrationId, {
rejectOnEmpty: true,
});
try {
const res = await fetch(avatarUrl);
const buffer = await res.buffer();
const name = avatarUrl.split("/").pop() || "avatar";
const contentType = res.headers.get("content-type") || "image/png";
const operation = await FileOperation.createFromBuffer({
buffer,
contentType,
name,
userId: integration.userId,
teamId: integration.teamId,
source: "gitlab",
});
await integration.update({
settings: {
...integration.settings,
gitlab: {
...(integration.settings as Integration<IntegrationType.Embed>)
.gitlab,
project: {
...(integration.settings as Integration<IntegrationType.Embed>)
.gitlab?.project,
avatar_url: operation.url,
},
},
},
});
} catch (err) {
// If the avatar upload fails, we don't need to fail the entire task
// as it's not critical to the integration's functionality.
// Just log the error and continue.
this.logger.error(
`Failed to upload GitLab project avatar: ${err.message}`
);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class GitLabUtils {
private static oauthScopes = "api read_api read_user read_repository";
public static tokenUrl = "https://gitlab.com/oauth/token";
public static revokeUrl = "https://gitlab.com/oauth/revoke";
private static authBaseUrl = "https://gitlab.com/oauth/authorize";
private static settingsUrl = integrationSettingsPath("gitlab");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl() {
return this.settingsUrl;
}
static errorUrl(error: string) {
return `${this.settingsUrl}?error=${error}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: env.URL,
params: undefined,
}
) {
return params
? `${baseUrl}/api/gitlab.callback?${params}`
: `${baseUrl}/api/gitlab.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.GITLAB_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
scope: this.oauthScopes,
response_type: "code",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ export class LinearUtils {
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
baseUrl: env.URL,
params: undefined,
}
) {
+9 -1
View File
@@ -55,7 +55,15 @@ export const Notion = observer(() => {
onClose: clearQueryParams,
});
}
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
}, [
t,
dialogs,
oauthSuccess,
service,
clearQueryParams,
handleSubmit,
integrationId,
]);
React.useEffect(() => {
if (!oauthError) {
@@ -52,7 +52,15 @@ export function ImportDialog({ integrationId, onSubmit }: Props) {
toast.error(err.message);
resetSubmitting();
}
}, [permission, onSubmit]);
}, [
permission,
onSubmit,
integrationId,
t,
imports,
resetSubmitting,
setSubmitting,
]);
return (
<Flex column gap={12}>
+1 -1
View File
@@ -36,7 +36,7 @@ export class NotionUtils {
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
baseUrl: env.URL,
params: undefined,
}
) {
+18 -9
View File
@@ -1,10 +1,17 @@
import { HttpsProxyAgent } from "https-proxy-agent";
import OAuth2Strategy, { Strategy } from "passport-oauth2";
import * as OAuth2StrategyModule from "passport-oauth2";
import { Request } from "express";
const { Strategy } = OAuth2StrategyModule;
type OIDCOptions = Record<string, unknown> & {
originalQuery?: Record<string, unknown>;
};
export class OIDCStrategy extends Strategy {
constructor(
options: OAuth2Strategy.StrategyOptionsWithRequest,
verify: OAuth2Strategy.VerifyFunctionWithRequest
options: OAuth2StrategyModule.StrategyOptionsWithRequest,
verify: OAuth2StrategyModule.VerifyFunctionWithRequest
) {
super(options, verify);
@@ -14,15 +21,17 @@ export class OIDCStrategy extends Strategy {
}
}
authenticate(req: any, options: any) {
options.originalQuery = req.query;
super.authenticate(req, options);
authenticate(req: Request, options?: unknown) {
const opts = (options || {}) as OIDCOptions;
opts.originalQuery = req.query as Record<string, unknown>;
super.authenticate(req, opts);
}
authorizationParams(options: any) {
authorizationParams(options: unknown) {
const opts = options as OIDCOptions;
return {
...(options.originalQuery || {}),
...(super.authorizationParams?.(options) || {}),
...(opts.originalQuery ?? {}),
...super.authorizationParams?.(options),
};
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ import env from "./env";
const SLACK_API_URL = "https://slack.com/api";
export async function post(endpoint: string, body: Record<string, any>) {
export async function post(endpoint: string, body: Record<string, unknown>) {
let data;
const token = body.token;
@@ -30,7 +30,7 @@ export async function post(endpoint: string, body: Record<string, any>) {
return data;
}
export async function request(endpoint: string, body: Record<string, any>) {
export async function request(endpoint: string, body: Record<string, unknown>) {
let data;
try {
+3 -3
View File
@@ -16,7 +16,7 @@ export class SlackUtils {
static createState(
teamId: string,
type: IntegrationType,
data?: Record<string, any>
data?: Record<string, unknown>
) {
return JSON.stringify({ type, teamId, ...data });
}
@@ -35,7 +35,7 @@ export class SlackUtils {
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
baseUrl: env.URL,
params: undefined,
}
) {
@@ -46,7 +46,7 @@ export class SlackUtils {
static connectUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
baseUrl: env.URL,
params: undefined,
}
) {
+10 -6
View File
@@ -1,6 +1,7 @@
import JWT from "jsonwebtoken";
import Router from "koa-router";
import mime from "mime-types";
import contentDisposition from "content-disposition";
import env from "@server/env";
import {
AuthenticationError,
@@ -27,7 +28,7 @@ const router = new Router();
router.post(
"files.create",
rateLimiter(RateLimiterStrategy.TenPerMinute),
auth(),
auth({ allowMultipart: true }),
validate(T.FilesCreateSchema),
multipart({
maximumFileSize: Math.max(
@@ -97,11 +98,14 @@ router.get(
ctx.set("Cache-Control", cacheHeader);
ctx.set("Content-Type", contentType);
ctx.set("Content-Security-Policy", "sandbox");
ctx.attachment(fileName, {
type: forceDownload
? "attachment"
: FileStorage.getContentDisposition(contentType),
});
ctx.set(
"Content-Disposition",
contentDisposition(fileName, {
type: forceDownload
? "attachment"
: FileStorage.getContentDisposition(contentType),
})
);
// Handle byte range requests
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
@@ -787,7 +787,8 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
const failedDeliveries = deliveriesInWindow.filter(
(delivery) => delivery.status === "failed"
);
const failureRate = (failedDeliveries.length / deliveriesInWindow.length) * 100;
const failureRate =
(failedDeliveries.length / deliveriesInWindow.length) * 100;
// Only log analysis if there are failures to report
if (failedDeliveries.length > 0) {
@@ -802,7 +803,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
}
// Check if failure rate exceeds threshold and we have enough data points
if (failureRate >= failureRateThreshold && deliveriesInWindow.length >= DeliverWebhookTask.MIN_DELIVERIES_FOR_ANALYSIS) {
if (
failureRate >= failureRateThreshold &&
deliveriesInWindow.length >=
DeliverWebhookTask.MIN_DELIVERIES_FOR_ANALYSIS
) {
Logger.warn("Disabling webhook due to high failure rate", {
subscriptionId: subscription.id,
failureRate: Math.round(failureRate * 100) / 100,
+52 -5
View File
@@ -16,7 +16,7 @@ describe("accountProvisioner", () => {
describe("hosted", () => {
it("should create a new user and team", async () => {
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const email = faker.internet.email().toLowerCase();
const email = faker.internet.email();
const { user, team, isNewTeam, isNewUser } = await accountProvisioner(
ctx,
{
@@ -71,7 +71,7 @@ describe("accountProvisioner", () => {
});
const authentications = await existing.$get("authentications");
const authentication = authentications[0];
const newEmail = faker.internet.email().toLowerCase();
const newEmail = faker.internet.email();
const { user, isNewUser, isNewTeam } = await accountProvisioner(ctx, {
user: {
name: existing.name,
@@ -113,7 +113,7 @@ describe("accountProvisioner", () => {
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
const email = faker.internet.email().toLowerCase();
const email = faker.internet.email();
const userWithoutAuth = await buildUser({
email,
teamId: existingTeam.id,
@@ -245,7 +245,7 @@ describe("accountProvisioner", () => {
const admin = await buildAdmin({ teamId: existingTeam.id });
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
const email = faker.internet.email().toLowerCase();
const email = faker.internet.email();
await TeamDomain.create({
teamId: existingTeam.id,
@@ -344,7 +344,7 @@ describe("accountProvisioner", () => {
"authenticationProviders"
);
const authenticationProvider = authenticationProviders[0];
const email = faker.internet.email().toLowerCase();
const email = faker.internet.email();
const { user, isNewUser } = await accountProvisioner(ctx, {
user: {
name: "Jenny Tester",
@@ -384,6 +384,53 @@ describe("accountProvisioner", () => {
spy.mockRestore();
});
it("should handle emails with capital letters correctly", async () => {
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const email = "Jenny.Tester@EXAMPLE.COM";
const params = {
user: {
name: "Jenny Tester",
email,
avatarUrl: faker.image.avatar(),
},
team: {
name: "New workspace",
avatarUrl: faker.image.avatar(),
subdomain: faker.internet.domainWord(),
},
authenticationProvider: {
name: "google",
providerId: faker.internet.domainName(),
},
authentication: {
providerId: uuidv4(),
accessToken: "123",
scopes: ["read"],
},
};
const { user, isNewTeam, isNewUser } = await accountProvisioner(
ctx,
params
);
expect(user.email).toEqual(email);
expect(isNewUser).toEqual(true);
expect(isNewTeam).toEqual(true);
expect(spy).toHaveBeenCalled();
// Test that we can find the user again
const existing = await accountProvisioner(ctx, params);
expect(user.email).toEqual(email);
expect(existing.isNewTeam).toEqual(false);
expect(existing.isNewUser).toEqual(false);
expect(existing.user.id).toEqual(user.id);
spy.mockRestore();
});
});
describe("self hosted", () => {
+7 -1
View File
@@ -6,6 +6,7 @@ import {
InvalidAuthenticationError,
TeamPendingDeletionError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
import { traceFunction } from "@server/logging/tracing";
import { Team, AuthenticationProvider } from "@server/models";
import { sequelize } from "@server/storage/database";
@@ -74,7 +75,12 @@ async function teamProvisioner(
} else if (teamId) {
// The user is attempting to log into a team with an unfamiliar SSO provider
if (env.isCloudHosted) {
throw InvalidAuthenticationError();
const err = InvalidAuthenticationError();
Logger.error("Authentication provider does not exist for team", err, {
authenticationProvider,
teamId,
});
throw err;
}
// This team + auth provider combination has not been seen before in self hosted

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