Compare commits

...

60 Commits

Author SHA1 Message Date
Tom Moor 51d39d915d chore: Missing react key in SuggestionMenu 2025-03-30 10:30:42 -04:00
Tom Moor ca0f0638c9 fix: Handle deleted user in NotificationHelper (#8835) 2025-03-29 19:11:04 -07:00
Tom Moor f13e6a3691 fix: Show @ symbol on mentions in email snippets (#8833) 2025-03-30 00:26:18 +00:00
Hemachandar dcb7b86df8 Store import error in DB (#8811) 2025-03-29 06:08:07 -07:00
Hemachandar 45c6e72c6d Manage collection subscriptions when user (or) group is removed from a collection (#8821)
* Manage collection subscriptions when user (or) group is removed from a collection

* rename collection task

* rename document task

* remove unnecessary actor filter
2025-03-29 06:07:57 -07:00
codegen-sh[bot] a51456deb3 Add missing JSDoc to shared components (#8829)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-28 14:27:30 -07:00
Aditya Sharma 3ffe7e7671 fix: conversion b/w checkbox & other list types (#8828) 2025-03-28 14:19:12 -07:00
Hemachandar a7fe6c9af3 Include non-deleted imports for cleanup (#8822) 2025-03-28 05:46:36 -07:00
codegen-sh[bot] 52c673261b Add JSDoc to hooks in app/hooks directory (#8819)
* Add JSDoc to hooks in app/hooks directory

* lint

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-28 02:37:32 +00:00
Tom Moor 60c0a53a1f chore: Change lint rule to trigger on actor rather than branch name (#8820) 2025-03-28 02:24:00 +00:00
Tom Moor 66fae19034 fix: Improve performance of notification queries (#8809)
* Remove onlySubscribers

* refactor

* perf
2025-03-27 19:10:32 -07:00
codegen-sh[bot] 37ea6bb92b Add JSDoc comments to AvatarWithPresence component (#8817)
* Add JSDoc comments to AvatarWithPresence component

* lint

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-03-27 19:07:24 -07:00
Tom Moor 762816adbc Update lint.yml (#8818) 2025-03-28 01:58:24 +00:00
Tom Moor d1b24b15d5 chore: Attempt auto-lint of Codegen PR's (#8816) 2025-03-28 01:42:28 +00:00
Hemachandar 877b7ad0df fix: Handle index collision when creating a collection (#8803)
* fix: Handle index collision when creating a collection

* move to sequelize hooks

* index maxLen parity between api and model

* remove beforeUpdate hook

* use common indexLen in model

* beforeUpdate hook..

* test
2025-03-27 02:50:40 -07:00
Tom Moor e98d931aaa Remove maintainers from probot behavior (#8808) 2025-03-26 23:37:59 +00:00
Tom Moor ba7d102a72 perf: Avoid querying all users in team for common notification types (#8806) 2025-03-26 16:19:45 -07:00
codegen-sh[bot] ab1f00e919 fix: handle missing user error during Notion import (#8801)
* fix: handle missing user error during Notion import

* lint

* typesafe check

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: hmacr <hmac.devo@gmail.com>
2025-03-26 07:46:53 -07:00
dependabot[bot] 34cb31ff43 chore(deps): bump ioredis from 5.4.1 to 5.6.0 (#8789)
Bumps [ioredis](https://github.com/luin/ioredis) from 5.4.1 to 5.6.0.
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.4.1...v5.6.0)

---
updated-dependencies:
- dependency-name: ioredis
  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-03-26 05:48:58 -07:00
codegen-sh[bot] aac95c2b2e Add SMTP_SERVICE environment variable for well-known services (#8781)
* Add SMTP_SERVICE environment variable for well-known services

* Fix PR #8777: Restore code in teams.ts and users.ts

* The rest of the work

* fix validation

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-26 05:48:47 -07:00
dependabot[bot] 0dd6ef5196 chore(deps): bump vite from 5.4.14 to 5.4.15 (#8798)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.14 to 5.4.15.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.15/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.15/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-26 01:35:02 +00:00
dependabot[bot] 5cd11002d1 chore(deps): bump the aws group with 5 updates (#8788)
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.772.0` | `3.774.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.772.0` | `3.774.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.772.0` | `3.774.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.772.0` | `3.774.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.758.0` | `3.774.0` |


Updates `@aws-sdk/client-s3` from 3.772.0 to 3.774.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.774.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.772.0 to 3.774.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.774.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.772.0 to 3.774.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.774.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.772.0 to 3.774.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.774.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.758.0 to 3.774.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.774.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-03-25 18:29:29 -07:00
dependabot[bot] 5334f7ae08 chore(deps-dev): bump @types/node from 20.17.16 to 20.17.27 (#8790)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.16 to 20.17.27.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 18:29:18 -07:00
dependabot[bot] df1de2b822 chore(deps): bump zod from 3.23.8 to 3.24.2 (#8791)
Bumps [zod](https://github.com/colinhacks/zod) from 3.23.8 to 3.24.2.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Changelog](https://github.com/colinhacks/zod/blob/main/CHANGELOG.md)
- [Commits](https://github.com/colinhacks/zod/compare/v3.23.8...v3.24.2)

---
updated-dependencies:
- dependency-name: zod
  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-03-25 18:29:08 -07:00
dependabot[bot] deb93ef767 chore(deps): bump pg from 8.12.0 to 8.14.1 (#8792)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.12.0 to 8.14.1.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.14.1/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  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-03-25 18:28:59 -07:00
Tom Moor 5bef4c4b55 fix: typeerror cannot read properties of undefined reading lang (#8794)
* fix: Access of undefined with invalid code lang
closes #8793

* test
2025-03-25 12:31:53 +00:00
Tom Moor 72bff1ec8a Revert "Change @aws-sdk dependency update frequency from weekly to monthly (#…" (#8787)
This reverts commit 323c5f5978.
2025-03-25 11:58:21 +00:00
Tom Moor c12b257098 fix: Use configured proxy for OIDC server-to-server requests (#8776) 2025-03-25 04:31:16 -07:00
Hemachandar f6da244c33 fix: Handle empty text blocks from Notion response (#8785) 2025-03-25 04:31:06 -07:00
Tom Moor ab55e0bed9 feat: Add XML as code formatting option (#8767)
Refactor to achieve this
2025-03-24 14:58:05 -07:00
dependabot[bot] 84ae9a2c31 chore(deps): bump datadog-metrics from 0.11.2 to 0.12.1 (#8773)
* chore(deps): bump datadog-metrics from 0.11.2 to 0.12.1

Bumps [datadog-metrics](https://github.com/dbader/node-datadog-metrics) from 0.11.2 to 0.12.1.
- [Release notes](https://github.com/dbader/node-datadog-metrics/releases)
- [Commits](https://github.com/dbader/node-datadog-metrics/compare/v0.11.2...v0.12.1)

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

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

* fix: flush now returns a promise

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-24 21:38:54 +00:00
Hemachandar 5c4eb32c26 fix: Release redis lock only when it hasn't expired (#8765)
* fix: Suppress redlock release errors

* release only when lock hasn't expired
2025-03-24 14:37:36 -07:00
dependabot[bot] 10b8f11e0b chore(deps): bump the babel group with 4 updates (#8769)
Bumps the babel group with 4 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core), [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator), [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) and [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript).


Updates `@babel/core` from 7.26.9 to 7.26.10
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-core)

Updates `@babel/plugin-transform-regenerator` from 7.25.9 to 7.27.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-plugin-transform-regenerator)

Updates `@babel/cli` from 7.26.4 to 7.27.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-cli)

Updates `@babel/preset-typescript` from 7.26.0 to 7.27.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 14:27:21 -07:00
dependabot[bot] 0a4c3bd633 chore(deps): bump the aws group with 4 updates (#8770)
Bumps the aws group with 4 updates: [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3), [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage), [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) and [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner).


Updates `@aws-sdk/client-s3` from 3.758.0 to 3.772.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.772.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.758.0 to 3.772.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.772.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.758.0 to 3.772.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.772.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.758.0 to 3.772.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.772.0/packages/s3-request-presigner)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  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-03-24 14:27:13 -07:00
dependabot[bot] 580cf52fd3 chore(deps-dev): bump rollup-plugin-webpack-stats from 2.0.1 to 2.0.3 (#8771)
Bumps [rollup-plugin-webpack-stats](https://github.com/relative-ci/rollup-plugin-webpack-stats) from 2.0.1 to 2.0.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.0.1...v2.0.3)

---
updated-dependencies:
- dependency-name: rollup-plugin-webpack-stats
  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-03-24 14:27:06 -07:00
dependabot[bot] ee1fd65a19 chore(deps): bump prosemirror-commands from 1.6.2 to 1.7.0 (#8772)
Bumps [prosemirror-commands](https://github.com/prosemirror/prosemirror-commands) from 1.6.2 to 1.7.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-commands/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-commands/compare/1.6.2...1.7.0)

---
updated-dependencies:
- dependency-name: prosemirror-commands
  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-03-24 14:26:59 -07:00
codegen-sh[bot] 323c5f5978 Change @aws-sdk dependency update frequency from weekly to monthly (#8774)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-24 14:24:18 -07:00
Tom Moor bdb34a202c fix: Disable Notion import when env is not available (#8761) 2025-03-24 02:52:54 +00:00
Tom Moor 40278b2d9a fix: notifications.pixel requests hang (#8760)
tests
2025-03-24 00:52:54 +00:00
Tom Moor a69ef1f3c9 quick: Remove expired temporary AWS keys from fixture data (#8755)
* fix: Remove temporary AWS keys causing false positive alerts

* Previously missed PR feedback

* snap
2025-03-23 19:31:38 +00:00
Hemachandar 6e98568e5b API importer for Notion (#8710) 2025-03-23 12:19:13 -07:00
Tom Moor 8b65ad3cfa chore: Move notification event writing to model layer (#8754)
* Move notification event writing to model layer
fix: Bulk notification action does not reflect on other clients

* Add missing locks

* fixes
2025-03-23 11:59:19 -07:00
Tom Moor 533a14369c fix: Do not wait for connections at end of test suites (#8752) 2025-03-22 20:46:36 -07:00
Tom Moor 0ec6440506 Prevent outdated clients from connecting to collaboration server (#8751)
* Move editor version check to collaboration server connection

* connected -> onConnect

* docs

* Remove hardcoded event codes
2025-03-22 14:35:45 -07:00
Tom Moor 6fde025ce4 Revert "Send editor version down websocket and force reload (#8582)" (#8750)
This reverts commit 13f45e1a1c.
2025-03-22 20:06:54 +00:00
Tom Moor 18bbe6ecf6 fix: Direct link to heading lost when pasting (#8749) 2025-03-22 10:02:09 -07:00
Tom Moor a48f6c7a85 fix: Unsubscribe link for collection subscriptions (#8734)
* fix: Cannot unsubscribe from collection subscriptions via email token

* tests

* Separate redirect for pass through

* Delete both subscriptions

* Test draft documents
2025-03-22 08:22:20 -07:00
Tom Moor ec9f45f310 fix: Allow team admin to manage permissions on any document they have access to (#8746) 2025-03-22 08:22:09 -07:00
Tom Moor dd053c4152 fix: Allow dash,emdash,underscore in mention search (#8747)
* fix: Allow dash,emdash,underscore in mention search

* fix: Highlight color on secondary accent menu items
2025-03-22 08:22:01 -07:00
Tom Moor 5565034486 Revert "Double test timeout (#8696)" (#8738)
This reverts commit 7c41c1360b.
2025-03-21 12:38:28 +00:00
Tom Moor 42cfac97aa fix: Add prevention of auto-following unsubscribe links in emails (#8735) 2025-03-21 04:57:26 -07:00
Tom Moor f369c2f8bf Refactor logic for validating authentication tokens (#8727)
* Remove use of Promise.any

* Restore retry on all invalid
2025-03-20 20:49:47 -07:00
Tom Moor 08f91aa60c fix: Error rendering read only editor with paragraph without text content (#8730) 2025-03-20 13:46:53 +00:00
Tom Moor 0fe50c179c fix: "unknown" users visible in users table (#8726)
* fix: 'Unknown' users appearing in members table

* snapshots

* refactor
2025-03-19 21:00:04 -07:00
Josiah "Rebase" Roberts ae249f720d Allow middle click (#8725) 2025-03-19 16:27:47 -07:00
Tom Moor 0a9e76f600 fix: Mentions of current user not highlighted (#8718) 2025-03-18 18:08:49 -07:00
Tom Moor 912b9159f0 fix: Add missing users.delete event handling (#8715) 2025-03-18 15:27:27 -07:00
Tom Moor 307f4a1351 fix: Write lastValidatedAt when accessToken changes (#8716) 2025-03-18 15:27:19 -07:00
Hemachandar 021a286d99 Migrate language, theme and user-role input-select to Radix (#8711)
* Migrate language, theme and user-role input-select to Radix

* use theme
2025-03-17 20:37:40 -07:00
Tom Moor 6869d4cb02 perf: Add fast read-only editor (#8704)
* stash

* tests

* sp
2025-03-16 07:12:16 -07:00
170 changed files with 16101 additions and 1216 deletions
+5 -5
View File
@@ -147,6 +147,10 @@ DISCORD_SERVER_ID=
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
@@ -201,14 +205,10 @@ SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
SMTP_SERVICE=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
+2
View File
@@ -15,6 +15,8 @@ requestInfoDefaultTitles:
requestInfoLabelToAdd: more information needed
requestInfoUserstoExclude:
- tommoor
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
+30
View File
@@ -0,0 +1,30 @@
name: Lint
on:
pull_request:
branches: [ main ]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: 'Applied automatic fixes'
+1 -2
View File
@@ -14,8 +14,7 @@
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalSetup": "<rootDir>/server/test/globalSetup.js",
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node",
"testTimeout": 10000
"testEnvironment": "node"
},
{
"displayName": "app",
+4
View File
@@ -171,6 +171,10 @@
"description": "smtp.example.com (optional)",
"required": false
},
"SMTP_SERVICE": {
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
"required": false
},
"SMTP_PORT": {
"description": "1234 (optional)",
"required": false
@@ -7,17 +7,43 @@ import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
/**
* Props for the AvatarWithPresence component
*/
type Props = {
/** The user to display the avatar for */
user: User;
/** Whether the user is currently present in the document */
isPresent: boolean;
/** Whether the user is currently editing the document */
isEditing: boolean;
/** Whether the user is currently observing the document */
isObserving: boolean;
/** Whether this avatar represents the current user */
isCurrentUser: boolean;
/** Optional click handler for the avatar */
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Size of the avatar, defaults to AvatarSize.Large */
size?: AvatarSize;
/** Optional inline styles to apply to the avatar wrapper */
style?: React.CSSProperties;
};
/**
* AvatarWithPresence component displays a user's avatar with visual indicators
* for their current status (present, editing, observing).
*
* The component shows different visual states:
* - Present users have full opacity
* - Non-present users have reduced opacity
* - Observing users have a colored border matching their user color
* - Hovering shows a colored border
*
* A tooltip displays the user's name and current status.
*
* @param props - Component properties
* @returns React component
*/
function AvatarWithPresence({
onClick,
user,
@@ -64,16 +90,33 @@ function AvatarWithPresence({
);
}
/**
* Centered container for tooltip content
*/
const Centered = styled.div`
text-align: center;
`;
/**
* Props for the AvatarPresence styled component
*/
type AvatarWrapperProps = {
/** Whether the user is currently present */
$isPresent: boolean;
/** Whether the user is currently observing */
$isObserving: boolean;
/** The user's color for border highlighting */
$color: string;
};
/**
* Styled component that wraps the Avatar and provides visual indicators
* for the user's presence status.
*
* - Adjusts opacity based on presence
* - Adds colored borders for observing users
* - Handles hover effects
*/
const AvatarPresence = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
+2 -2
View File
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
() =>
orderBy(
filter(
users.orderedData,
users.all,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
),
[document.collaboratorIds, users.orderedData, presentIds]
[document.collaboratorIds, users.all, presentIds]
);
// load any users we don't yet have in memory
+3
View File
@@ -11,6 +11,7 @@ import Collection from "~/models/Collection";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
@@ -24,6 +25,7 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
const handleSave = React.useMemo(
@@ -65,6 +67,7 @@ function CollectionDescription({ collection }: Props) {
maxLength={CollectionValidation.maxDescriptionLength}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
editorStyle={editorStyle}
embedsDisabled
/>
+10 -4
View File
@@ -4,6 +4,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -14,21 +20,21 @@ function ConnectionStatus() {
const { t } = useTranslation();
const codeToMessage = {
1009: {
[DocumentTooLarge.code]: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
4401: {
[AuthenticationFailed.code]: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
4403: {
[AuthorizationFailed.code]: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
4503: {
[TooManyConnections.code]: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
+4 -1
View File
@@ -23,7 +23,10 @@ function Dialogs() {
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => dialogs.closeModal(id)}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
>
+39 -13
View File
@@ -6,7 +6,9 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { getDataTransferFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
@@ -183,22 +185,46 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[updateComments]
);
const paragraphs = React.useMemo(() => {
if (props.readOnly && typeof props.value === "object") {
return ProsemirrorHelper.getPlainParagraphs(props.value);
}
return undefined;
}, [props.readOnly, props.value]);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
<p key={index} dir="auto">
{paragraph.content?.map((content) => content.text)}
</p>
))}
</div>
</EditorContainer>
) : (
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
)}
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
@@ -96,7 +96,7 @@ export const Suggestions = observer(
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.orderedData
: users.activeOrInvited
).filter((u) => !u.isSuspended && u.id !== user.id);
if (isEmail(query)) {
@@ -114,7 +114,7 @@ export const Suggestions = observer(
}, [
getSuggestionForEmail,
users,
users.orderedData,
users.activeOrInvited,
groups,
groups.orderedData,
document?.id,
+29 -18
View File
@@ -1,15 +1,16 @@
import invariant from "invariant";
import find from "lodash/find";
import isObject from "lodash/isObject";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import semver from "semver";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
import EDITOR_VERSION from "@shared/editor/version";
import { FileOperationState, FileOperationType } from "@shared/types";
import {
FileOperationState,
FileOperationType,
ImportState,
} from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
@@ -18,6 +19,7 @@ import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupUser from "~/models/GroupUser";
import Import from "~/models/Import";
import Membership from "~/models/Membership";
import Notification from "~/models/Notification";
import Pin from "~/models/Pin";
@@ -103,6 +105,7 @@ class WebsocketProvider extends React.Component<Props> {
subscriptions,
fileOperations,
notifications,
imports,
} = this.props;
const currentUserId = auth?.user?.id;
@@ -117,23 +120,10 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("authenticated", (data) => {
this.socket.on("authenticated", () => {
if (this.socket) {
this.socket.authenticated = true;
}
if (isObject(data) && "editorVersion" in data) {
const parsedClientVersion = semver.parse(EDITOR_VERSION);
const parsedCurrentVersion = semver.parse(String(data.editorVersion));
if (
parsedClientVersion &&
parsedCurrentVersion &&
(parsedClientVersion.major < parsedCurrentVersion.major ||
parsedClientVersion.minor < parsedCurrentVersion.minor)
) {
window.location.reload();
}
}
});
this.socket.on("unauthorized", (err: Error) => {
@@ -636,6 +626,23 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
imports.add(event);
});
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
imports.add(event);
if (
event.state === ImportState.Completed &&
event.createdBy?.id === auth.user?.id
) {
toast.success(event.name, {
description: this.props.t("Your import completed"),
});
}
});
this.socket.on(
"subscriptions.create",
(event: PartialExcept<Subscription, "id">) => {
@@ -661,6 +668,10 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => {
users.remove(event.modelId);
});
this.socket.on(
"userMemberships.update",
async (event: PartialExcept<UserMembership, "id">) => {
+2 -3
View File
@@ -645,12 +645,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<>
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
@@ -659,7 +658,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
onClick: () => handleClickItem(item),
})}
</ListItem>
</>
</React.Fragment>
);
previousHeading = currentHeading;
@@ -1,3 +1,4 @@
import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
@@ -68,12 +69,16 @@ function SuggestionsMenuItem({
const Subtitle = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
`;
const Shortcut = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
flex-grow: 1;
text-align: right;
`;
+6 -4
View File
@@ -10,8 +10,8 @@ import {
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { codeLanguages } from "@shared/editor/lib/code";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
@@ -122,6 +122,8 @@ export default class PasteHandler extends Extension {
}
// Is the link a link to a document? If so, we can grab the title and insert it.
const containsHash = text.includes("#");
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
@@ -133,7 +135,7 @@ export default class PasteHandler extends Extension {
return;
}
if (document) {
if (state.schema.nodes.mention) {
if (state.schema.nodes.mention && !containsHash) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
@@ -178,7 +180,7 @@ export default class PasteHandler extends Extension {
return;
}
if (collection) {
if (state.schema.nodes.mention) {
if (state.schema.nodes.mention && !containsHash) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
@@ -226,7 +228,7 @@ export default class PasteHandler extends Extension {
state.tr
.replaceSelectionWith(
state.schema.nodes.code_block.create({
language: Object.keys(LANGUAGES).includes(
language: Object.keys(codeLanguages).includes(
vscodeMeta.mode
)
? vscodeMeta.mode
+1 -1
View File
@@ -23,7 +23,7 @@ export default class Suggestion extends Extension {
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
}\\.\\-_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
);
}
+1 -1
View File
@@ -843,7 +843,7 @@ const EditorContainer = styled(Styles)<{
${(props) =>
props.userId &&
css`
.mention[data-id=${props.userId}] {
.mention[data-id="${props.userId}"] {
color: ${props.theme.textHighlightForeground};
background: ${props.theme.textHighlight};
+13 -12
View File
@@ -2,8 +2,11 @@ import { CopyIcon, ExpandedIcon } from "outline-icons";
import { Node as ProseMirrorNode } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
import {
getFrequentCodeLanguages,
codeLanguages,
getLabelForLanguage,
} from "@shared/editor/lib/code";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
@@ -14,20 +17,19 @@ export default function codeMenuItems(
): MenuItem[] {
const node = state.selection.$from.node();
const allLanguages = Object.entries(LANGUAGES) as [
keyof typeof LANGUAGES,
string
][];
const frequentLanguages = getFrequentCodeLanguages();
const frequentLangMenuItems = frequentLanguages.map((value) => {
const label = LANGUAGES[value];
const label = codeLanguages[value]?.label;
return langToMenuItem({ node, value, label });
});
const remainingLangMenuItems = allLanguages
.filter(([value]) => !frequentLanguages.includes(value))
.map(([value, label]) => langToMenuItem({ node, value, label }));
const remainingLangMenuItems = Object.entries(codeLanguages)
.filter(
([value]) =>
!frequentLanguages.includes(value as keyof typeof codeLanguages)
)
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
const languageMenuItems = frequentLangMenuItems.length
? [
@@ -52,8 +54,7 @@ export default function codeMenuItems(
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
// @ts-expect-error We have a fallback for incorrect mapping
label: LANGUAGES[node.attrs.language ?? "none"],
label: getLabelForLanguage(node.attrs.language ?? "none"),
children: languageMenuItems,
},
];
+5
View File
@@ -1,6 +1,11 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
/**
* Hook that provides a dictionary of translated UI strings.
*
* @returns An object containing all translated UI strings used throughout the application
*/
export default function useDictionary() {
const { t } = useTranslation();
+9
View File
@@ -1,6 +1,15 @@
import * as React from "react";
import useWindowSize from "./useWindowSize";
/**
* Hook to calculate the maximum height for an element based on its position and viewport size.
*
* @param options Configuration options
* @param options.elementRef A ref pointing to the element to calculate max height for
* @param options.maxViewportPercentage The maximum height of the element as a percentage of the viewport
* @param options.margin The margin to apply to the positioning
* @returns Object containing the calculated maxHeight and a function to recalculate it
*/
const useMaxHeight = ({
elementRef,
maxViewportPercentage = 90,
+6
View File
@@ -1,5 +1,11 @@
import { useState, useEffect } from "react";
/**
* Hook to check if a media query matches the current viewport.
*
* @param query The CSS media query to check against
* @returns boolean indicating whether the media query matches
*/
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(false);
+5
View File
@@ -1,6 +1,11 @@
import { breakpoints } from "@shared/styles";
import useMediaQuery from "~/hooks/useMediaQuery";
/**
* Hook to detect if the current viewport is mobile-sized.
*
* @returns boolean indicating whether the current viewport is mobile-sized
*/
export default function useMobile(): boolean {
return useMediaQuery(`(max-width: ${breakpoints.tablet - 1}px)`);
}
+3 -2
View File
@@ -18,6 +18,7 @@ export default function usePolicy(entity?: string | Model | null) {
? entity
: entity.id
: "";
const policy = policies.get(entityId);
React.useEffect(() => {
if (
@@ -28,11 +29,11 @@ export default function usePolicy(entity?: string | Model | null) {
) {
// The policy for this model is missing and we have an authenticated session, attempt to
// reload relationships for this model.
if (!policies.get(entity.id) && user) {
if (!policy && user) {
void entity.loadRelations();
}
}
}, [policies, user, entity]);
}, [policies, policy, user, entity]);
return policies.abilities(entityId);
}
+5
View File
@@ -1,6 +1,11 @@
import React from "react";
import { useLocation } from "react-router-dom";
/**
* Hook to access URL query parameters from the current location.
*
* @returns URLSearchParams object containing the current URL query parameters
*/
export default function useQuery() {
const location = useLocation();
+8
View File
@@ -24,6 +24,14 @@ export default function useQueryNotices() {
);
break;
}
case QueryNotices.UnsubscribeCollection: {
toast.success(
t("Unsubscribed from collection", {
type: "success",
})
);
break;
}
default:
}
}, [t, notice]);
+5
View File
@@ -2,6 +2,11 @@ import { MobXProviderContext } from "mobx-react";
import * as React from "react";
import RootStore from "~/stores";
/**
* Hook to access the MobX stores from the React context.
*
* @returns The root store containing all application stores
*/
export default function useStores() {
return React.useContext(MobXProviderContext) as typeof RootStore;
}
+5
View File
@@ -1,5 +1,10 @@
import * as React from "react";
/**
* Hook that executes a callback when the component unmounts.
*
* @param callback Function to be called on component unmount
*/
const useUnmount = (callback: (...args: Array<any>) => any) => {
const ref = React.useRef(callback);
ref.current = callback;
+6
View File
@@ -1,5 +1,11 @@
import { useLayoutEffect, useState } from "react";
/**
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
* Uses the VisualViewport API when available, falling back to window.innerHeight.
*
* @returns The current viewport height in pixels
*/
export default function useViewportHeight(): number | void {
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
// Note: No support in Firefox at time of writing, however this mainly exists
+7
View File
@@ -13,6 +13,13 @@ const defaultOptions = {
throttle: 100,
};
/**
* Hook to track the window's scroll position.
*
* @param options Configuration options
* @param options.throttle Time in milliseconds to throttle the scroll event
* @returns Object containing the current scroll position (x, y coordinates)
*/
export default function useWindowScrollPosition(options: {
throttle: number;
}): {
+62
View File
@@ -0,0 +1,62 @@
import { observer } from "mobx-react";
import { CrossIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import Import from "~/models/Import";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
import { MenuItem } from "~/types";
type Props = {
/** Import to which actions will be applied. */
importModel: Import;
/** Callback to handle import cancellation. */
onCancel: () => Promise<void>;
/** Callback to handle import deletion. */
onDelete: () => Promise<void>;
};
export const ImportMenu = observer(
({ importModel, onCancel, onDelete }: Props) => {
const { t } = useTranslation();
const can = usePolicy(importModel);
const menu = useMenuState({
modal: true,
});
const items = React.useMemo(
() =>
[
{
type: "button",
title: t("Cancel"),
visible: can.cancel,
icon: <CrossIcon />,
dangerous: true,
onClick: onCancel,
},
{
type: "button",
title: t("Delete"),
visible: can.delete,
icon: <TrashIcon />,
dangerous: true,
onClick: onDelete,
},
] satisfies MenuItem[],
[t, can.delete, can.cancel, onCancel, onDelete]
);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Import menu options")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
);
+1 -1
View File
@@ -600,7 +600,7 @@ export default class Document extends ArchivableModel implements Searchable {
*/
getSummary = (blocks = 4) => ({
...this.data,
content: this.data.content.slice(0, blocks),
content: this.data.content?.slice(0, blocks),
});
@computed
+57
View File
@@ -0,0 +1,57 @@
import { observable } from "mobx";
import { ImportableIntegrationService, ImportState } from "@shared/types";
import ImportsStore from "~/stores/ImportsStore";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterChange } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
class Import extends Model {
static modelName = "Import";
store: ImportsStore;
/** The name of the import. */
name: string;
/** Descriptive error message when the import errors out. */
error: string | null;
/** The current state of the import. */
@Field
@observable
state: ImportState;
/** The external service from which the import is created. */
service: ImportableIntegrationService;
/** The count of documents created in the import. */
@observable
documentCount: number;
/** The user who created the import. */
@Relation(() => User, {})
createdBy: User;
/** The ID of the user who created the import. */
createdById: string;
/**
* Cancel the import this will stop the import process and mark it as
* cancelled at the first opportunity.
*/
cancel = async () => this.store.cancel(this);
// hooks
@AfterChange
static removePolicies(model: Import, previousAttributes: Partial<Import>) {
if (previousAttributes.state && previousAttributes.state !== model.state) {
const { policies } = model.store.rootStore;
policies.remove(model.id);
}
}
}
export default Import;
+14 -14
View File
@@ -43,12 +43,6 @@ export default abstract class Model {
this: Model,
options: { withoutPolicies?: boolean } = {}
): Promise<any> {
const relations = getRelationsForModelClass(
this.constructor as typeof Model
);
if (!relations) {
return;
}
// this is to ensure that multiple loads dont happen in parallel
if (this.loadingRelations) {
return this.loadingRelations;
@@ -56,14 +50,20 @@ export default abstract class Model {
const promises = [];
for (const properties of relations.values()) {
const store = this.store.rootStore.getStoreForModelName(
properties.relationClassResolver().modelName
);
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id as string));
const relations = getRelationsForModelClass(
this.constructor as typeof Model
);
if (relations) {
for (const properties of relations.values()) {
const store = this.store.rootStore.getStoreForModelName(
properties.relationClassResolver().modelName
);
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id as string));
}
}
}
}
+6 -1
View File
@@ -1,7 +1,12 @@
import { observable } from "mobx";
import { computed, observable } from "mobx";
import Model from "./Model";
export default abstract class ParanoidModel extends Model {
@observable
deletedAt: string | undefined;
@computed
get isDeleted(): boolean {
return !!this.deletedAt;
}
}
@@ -6,6 +6,12 @@ import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
AuthenticationFailed,
DocumentTooLarge,
EditorUpdateError,
} from "@shared/collaboration/CloseEvents";
import EDITOR_VERSION from "@shared/editor/version";
import { supportsPassiveListener } from "@shared/utils/browser";
import Editor, { Props as EditorProps } from "~/components/Editor";
import MultiplayerExtension from "~/editor/extensions/Multiplayer";
@@ -65,6 +71,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const name = `document.${documentId}`;
const localProvider = new IndexeddbPersistence(name, ydoc);
const provider = new HocuspocusProvider({
parameters: {
editorVersion: EDITOR_VERSION,
},
url: `${env.COLLABORATION_URL}/collaboration`,
name,
document: ydoc,
@@ -140,8 +149,14 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
provider.on("close", (ev: MessageEvent) => {
if ("code" in ev.event) {
provider.shouldConnect =
ev.event.code !== 1009 && ev.event.code !== 4401;
ev.event.code !== DocumentTooLarge.code &&
ev.event.code !== AuthenticationFailed.code &&
ev.event.code !== EditorUpdateError.code;
ui.setMultiplayerStatus("disconnected", ev.event.code);
if (ev.event.code === EditorUpdateError.code) {
window.location.reload();
}
}
});
+154 -88
View File
@@ -1,10 +1,13 @@
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import { NewDocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Pagination } from "@shared/constants";
import { FileOperationType } from "@shared/types";
import { cdnPath } from "@shared/utils/urls";
import FileOperation from "~/models/FileOperation";
import ImportModel from "~/models/Import";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
@@ -15,16 +18,146 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { Hook, PluginManager } from "~/utils/PluginManager";
import FileOperationListItem from "./components/FileOperationListItem";
import ImportJSONDialog from "./components/ImportJSONDialog";
import { ImportListItem } from "./components/ImportListItem";
import ImportMarkdownDialog from "./components/ImportMarkdownDialog";
import ImportNotionDialog from "./components/ImportNotionDialog";
type Config = {
/** The title of the import. */
title: string;
/** The auxiliary descriptive text of the import. */
subtitle: string;
/** An icon to denote the kind of import. */
icon: React.ReactElement;
/** Trigger for the import. */
action: React.ReactElement;
};
function useImportsConfig() {
const { t } = useTranslation();
const { dialogs } = useStores();
const appName = env.APP_NAME;
return React.useMemo(() => {
const items: Config[] = [
{
title: t("Markdown"),
subtitle: t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
),
icon: <MarkdownIcon size={28} />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
{
title: "JSON",
subtitle: t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
),
icon: <OutlineIcon size={28} cover />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
];
PluginManager.getHooks(Hook.Imports).forEach((plugin) => {
items.push({ ...plugin.value });
});
items.push({
title: "Confluence",
subtitle: t("Import pages from a Confluence instance"),
icon: <img src={cdnPath("/images/confluence.png")} width={28} />,
action: (
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
),
});
return items;
}, [t, dialogs, appName]);
}
function Import() {
const { t } = useTranslation();
const { dialogs, fileOperations } = useStores();
const { fileOperations, imports } = useStores();
const configs = useImportsConfig();
const appName = env.APP_NAME;
const [, setForceRender] = React.useState(0);
const offset = React.useMemo(() => ({ imports: 0, fileOperations: 0 }), []);
const fetchImports = React.useCallback(async () => {
const [importsArr, fileOpsArr] = await Promise.all([
imports.fetchPage({
offset: offset.imports,
limit: Pagination.defaultLimit,
}),
fileOperations.fetchPage({
type: FileOperationType.Import,
offset: offset.fileOperations,
limit: Pagination.defaultLimit,
}),
]);
const pageImports = orderBy(
[...importsArr, ...fileOpsArr],
"createdAt",
"desc"
).slice(0, Pagination.defaultLimit);
const apiImportsCount = pageImports.filter(
(item) => item instanceof ImportModel
).length;
offset.imports += apiImportsCount;
offset.fileOperations += pageImports.length - apiImportsCount;
// needed to re-render after mobx store and offset is updated
setForceRender((s) => ++s);
return pageImports;
}, [imports, fileOperations, offset]);
const allImports = orderBy(
[
...imports.orderedData,
...fileOperations.filter({ type: FileOperationType.Import }),
],
"createdAt",
"desc"
).slice(0, offset.imports + offset.fileOperations);
return (
<Scene title={t("Import")} icon={<NewDocumentIcon />}>
<Heading>{t("Import")}</Heading>
@@ -38,100 +171,33 @@ function Import() {
</Text>
<div>
<Item
border={false}
image={<MarkdownIcon size={28} />}
title={t("Markdown")}
subtitle={t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<OutlineIcon size={28} cover />}
title="JSON"
subtitle={t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/notion.png")} width={28} />}
title="Notion"
subtitle={t("Import pages exported from Notion")}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportNotionDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
title="Confluence"
subtitle={t("Import pages from a Confluence instance")}
actions={
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
}
/>
{configs.map((config) => (
<Item
key={config.title}
title={config.title}
subtitle={config.subtitle}
image={config.icon}
actions={config.action}
border={false}
/>
))}
</div>
<br />
<PaginatedList
items={fileOperations.imports}
fetch={fileOperations.fetchPage}
options={{
type: FileOperationType.Import,
}}
items={allImports}
fetch={fetchImports}
heading={
<h2>
<Trans>Recent imports</Trans>
</h2>
}
renderItem={(item: FileOperation) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
renderItem={(item: ImportModel | FileOperation) =>
item instanceof ImportModel ? (
<ImportListItem key={item.id} importModel={item} />
) : (
<FileOperationListItem key={item.id} fileOperation={item} />
)
}
/>
</Scene>
);
+1 -1
View File
@@ -194,7 +194,7 @@ function getFilteredUsers({
switch (filter) {
case "all":
filteredUsers = users.orderedData;
filteredUsers = users.all;
break;
case "suspended":
filteredUsers = users.suspended;
+49 -19
View File
@@ -3,12 +3,12 @@ import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { languageOptions } from "@shared/i18n";
import { languageOptions as availableLanguages } from "@shared/i18n";
import { TeamPreference, UserPreference } from "@shared/types";
import { Theme } from "~/stores/UiStore";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import InputSelect from "~/components/InputSelect";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -26,6 +26,29 @@ function Preferences() {
const team = useCurrentTeam();
const can = usePolicy(user.id);
const languageOptions: Option[] = React.useMemo(
() =>
availableLanguages.map(
(lang) =>
({
type: "item",
label: lang.label,
value: lang.value,
} satisfies Option)
),
[]
);
const themeOptions: Option[] = React.useMemo(
() =>
[
{ type: "item", label: t("Light"), value: Theme.Light },
{ type: "item", label: t("Dark"), value: Theme.Dark },
{ type: "item", label: t("System"), value: Theme.System },
] satisfies Option[],
[t]
);
const handlePreferenceChange =
(inverted = false) =>
async (ev: React.ChangeEvent<HTMLInputElement>) => {
@@ -37,10 +60,21 @@ function Preferences() {
toast.success(t("Preferences saved"));
};
const handleLanguageChange = async (language: string) => {
await user.save({ language });
toast.success(t("Preferences saved"));
};
const handleLanguageChange = React.useCallback(
async (language: string) => {
await user.save({ language });
toast.success(t("Preferences saved"));
},
[t, user]
);
const handleThemeChange = React.useCallback(
(theme) => {
ui.setTheme(theme as Theme);
toast.success(t("Preferences saved"));
},
[t, ui]
);
const showDeleteAccount = () => {
dialogs.openModal({
@@ -77,12 +111,13 @@ function Preferences() {
</>
}
>
<InputSelect
id="language"
<InputSelectNew
options={languageOptions}
value={user.language}
onChange={handleLanguageChange}
ariaLabel={t("Language")}
label={t("Language")}
hideLabel
/>
</SettingRow>
<SettingRow
@@ -90,18 +125,13 @@ function Preferences() {
label={t("Appearance")}
description={t("Choose your preferred interface color scheme.")}
>
<InputSelect
<InputSelectNew
options={themeOptions}
value={ui.theme}
onChange={handleThemeChange}
ariaLabel={t("Appearance")}
options={[
{ label: t("Light"), value: Theme.Light },
{ label: t("Dark"), value: Theme.Dark },
{ label: t("System"), value: Theme.System },
]}
value={ui.resolvedTheme}
onChange={(theme) => {
ui.setTheme(theme as Theme);
toast.success(t("Preferences saved"));
}}
label={t("Appearance")}
hideLabel
/>
</SettingRow>
<SettingRow
+22 -13
View File
@@ -10,7 +10,7 @@ import { TeamPreference } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import InputSelect from "~/components/InputSelect";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import PluginIcon from "~/components/PluginIcon";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
@@ -44,6 +44,23 @@ function Security() {
request,
} = useRequest(authenticationProviders.fetchPage);
const userRoleOptions: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Editor"),
value: "member",
},
{
type: "item",
label: t("Viewer"),
value: "viewer",
},
] satisfies Option[],
[t]
);
React.useEffect(() => {
if (!providers && !loading) {
void request();
@@ -229,21 +246,13 @@ function Security() {
)}
border={false}
>
<InputSelect
id="defaultUserRole"
<InputSelectNew
value={data.defaultUserRole}
options={[
{
label: t("Editor"),
value: "member",
},
{
label: t("Viewer"),
value: "viewer",
},
]}
options={userRoleOptions}
onChange={handleDefaultRoleChange}
ariaLabel={t("Default role")}
label={t("Default role")}
hideLabel
short
/>
</SettingRow>
@@ -0,0 +1,159 @@
import capitalize from "lodash/capitalize";
import { observer } from "mobx-react";
import { CrossIcon, DoneIcon, WarningIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import { ImportState } from "@shared/types";
import Import from "~/models/Import";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { ImportMenu } from "~/menus/ImportMenu";
import isCloudHosted from "~/utils/isCloudHosted";
type Props = {
/** Import that's displayed as list item. */
importModel: Import;
};
export const ImportListItem = observer(({ importModel }: Props) => {
const { t } = useTranslation();
const { dialogs } = useStores();
const user = useCurrentUser();
const theme = useTheme();
const showProgress =
importModel.state !== ImportState.Canceled &&
importModel.state !== ImportState.Errored;
const showErrorInfo =
!isCloudHosted &&
importModel.state === ImportState.Errored &&
!!importModel.error;
const stateMap = React.useMemo(
() => ({
[ImportState.Created]: t("Processing"),
[ImportState.InProgress]: t("Processing"),
[ImportState.Processed]: t("Processing"),
[ImportState.Completed]: t("Completed"),
[ImportState.Errored]: t("Failed"),
[ImportState.Canceled]: t("Canceled"),
}),
[t]
);
const iconMap = React.useMemo(
() => ({
[ImportState.Created]: <Spinner />,
[ImportState.InProgress]: <Spinner />,
[ImportState.Processed]: <Spinner />,
[ImportState.Completed]: <DoneIcon color={theme.accent} />,
[ImportState.Errored]: <WarningIcon color={theme.danger} />,
[ImportState.Canceled]: <CrossIcon color={theme.textTertiary} />,
}),
[theme]
);
const handleCancel = React.useCallback(async () => {
const onCancel = async () => {
try {
await importModel.cancel();
toast.success(t("Import canceled"));
} catch (err) {
toast.error(err.message);
}
};
dialogs.openModal({
title: t("Are you sure you want to cancel this import?"),
content: (
<ConfirmationDialog
onSubmit={onCancel}
submitText={t("Cancel")}
savingText={`${t("Canceling")}`}
danger
>
{t(
"Canceling this import will discard any progress made. This cannot be undone."
)}
</ConfirmationDialog>
),
});
}, [t, dialogs, importModel]);
const handleDelete = React.useCallback(async () => {
const onDelete = async () => {
try {
await importModel.delete();
toast.success(t("Import deleted"));
} catch (err) {
toast.error(err.message);
}
};
dialogs.openModal({
title: t("Are you sure you want to delete this import?"),
content: (
<ConfirmationDialog
onSubmit={onDelete}
savingText={`${t("Deleting")}`}
danger
>
{t(
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone."
)}
</ConfirmationDialog>
),
});
}, [t, dialogs, importModel]);
return (
<ListItem
title={importModel.name}
image={iconMap[importModel.state]}
subtitle={
<>
{stateMap[importModel.state]}&nbsp;&nbsp;
{showErrorInfo && (
<>
{importModel.error}
{`. ${t("Check server logs for more details.")}`}&nbsp;&nbsp;
</>
)}
{t(`{{userName}} requested`, {
userName:
user.id === importModel.createdBy.id
? t("You")
: importModel.createdBy.name,
})}
&nbsp;
<Time dateTime={importModel.createdAt} addSuffix shorten />
&nbsp;&nbsp;
{capitalize(importModel.service)}
{showProgress && (
<>
&nbsp;&nbsp;
{t("{{ count }} document imported", {
count: importModel.documentCount,
})}
</>
)}
</>
}
actions={
<Action>
<ImportMenu
importModel={importModel}
onCancel={handleCancel}
onDelete={handleDelete}
/>
</Action>
}
/>
);
});
+4
View File
@@ -8,6 +8,7 @@ type DialogDefinition = {
isOpen: boolean;
fullscreen?: boolean;
style?: React.CSSProperties;
onClose?: () => void;
};
export default class DialogsStore {
@@ -50,6 +51,7 @@ export default class DialogsStore {
fullscreen,
replace,
style,
onClose,
}: {
id?: string;
title: string;
@@ -57,6 +59,7 @@ export default class DialogsStore {
content: React.ReactNode;
style?: React.CSSProperties;
replace?: boolean;
onClose?: () => void;
}) => {
setTimeout(
action(() => {
@@ -70,6 +73,7 @@ export default class DialogsStore {
fullscreen,
style,
isOpen: true,
onClose,
});
}),
0
+25
View File
@@ -0,0 +1,25 @@
import invariant from "invariant";
import { action, runInAction } from "mobx";
import Import from "~/models/Import";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
export default class ImportsStore extends Store<Import> {
constructor(rootStore: RootStore) {
super(rootStore, Import);
}
@action
cancel = async (importModel: Import) => {
const res = await client.post("/imports.cancel", {
id: importModel.id,
});
runInAction("Import#cancel", () => {
invariant(res?.data, "Data should be available");
importModel.updateData(res.data);
this.addPolicies(res.policies);
});
};
}
+3
View File
@@ -14,6 +14,7 @@ import FileOperationsStore from "./FileOperationsStore";
import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupUsersStore from "./GroupUsersStore";
import GroupsStore from "./GroupsStore";
import ImportsStore from "./ImportsStore";
import IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore";
import NotificationsStore from "./NotificationsStore";
@@ -43,6 +44,7 @@ export default class RootStore {
events: EventsStore;
groups: GroupsStore;
groupUsers: GroupUsersStore;
imports: ImportsStore;
integrations: IntegrationsStore;
memberships: MembershipsStore;
notifications: NotificationsStore;
@@ -72,6 +74,7 @@ export default class RootStore {
this.registerStore(EventsStore);
this.registerStore(GroupsStore);
this.registerStore(GroupUsersStore);
this.registerStore(ImportsStore);
this.registerStore(IntegrationsStore);
this.registerStore(MembershipsStore);
this.registerStore(NotificationsStore);
+8 -10
View File
@@ -27,46 +27,44 @@ export default class UsersStore extends Store<User> {
@computed
get active(): User[] {
return this.orderedData.filter(
(user) => !user.isSuspended && user.lastActiveAt
);
return this.all.filter((user) => !user.isSuspended && !user.isInvited);
}
@computed
get suspended(): User[] {
return this.orderedData.filter((user) => user.isSuspended);
return this.all.filter((user) => user.isSuspended);
}
@computed
get activeOrInvited(): User[] {
return this.orderedData.filter((user) => !user.isSuspended);
return this.all.filter((user) => !user.isSuspended);
}
@computed
get invited(): User[] {
return this.orderedData.filter((user) => user.isInvited);
return this.all.filter((user) => user.isInvited);
}
@computed
get admins(): User[] {
return this.orderedData.filter((user) => user.isAdmin);
return this.all.filter((user) => user.isAdmin && !user.isInvited);
}
@computed
get members(): User[] {
return this.orderedData.filter(
return this.all.filter(
(user) => !user.isViewer && !user.isAdmin && !user.isInvited
);
}
@computed
get viewers(): User[] {
return this.orderedData.filter((user) => user.isViewer);
return this.all.filter((user) => user.isViewer && !user.isInvited);
}
@computed
get all(): User[] {
return this.orderedData.filter((user) => user.lastActiveAt);
return this.orderedData.filter((user) => !user.isDeleted);
}
@computed
+5 -1
View File
@@ -1,6 +1,7 @@
import commandScore from "command-score";
import invariant from "invariant";
import { deburr, type ObjectIterateeCustom } from "lodash";
import type { ObjectIterateeCustom } from "lodash";
import deburr from "lodash/deburr";
import filter from "lodash/filter";
import find from "lodash/find";
import flatten from "lodash/flatten";
@@ -102,6 +103,9 @@ export default abstract class Store<T extends Model> {
return this.orderedData
.filter((item: T & Searchable) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("searchContent" in item) {
const seachables =
typeof item.searchContent === "string"
+5
View File
@@ -17,6 +17,7 @@ import {
RateLimitExceededError,
RequestError,
ServiceUnavailableError,
UnprocessableEntityError,
UpdateRequiredError,
} from "./errors";
@@ -214,6 +215,10 @@ class ApiClient {
throw new ServiceUnavailableError(error.message);
}
if (response.status === 422) {
throw new UnprocessableEntityError(error.message);
}
if (response.status === 429) {
throw new RateLimitExceededError(
`Too many requests, try again in a minute.`
+11
View File
@@ -12,6 +12,7 @@ import isCloudHosted from "./isCloudHosted";
*/
export enum Hook {
Settings = "settings",
Imports = "imports",
Icon = "icon",
}
@@ -31,6 +32,16 @@ type PluginValueMap = {
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
[Hook.Imports]: {
/** The title of the import. */
title: string;
/** The auxiliary descriptive text of the import. */
subtitle: string;
/** An icon to denote the kind of import. */
icon: React.ReactElement;
/** Trigger for the import. */
action: React.ReactElement;
};
[Hook.Icon]: React.ElementType;
};
+2
View File
@@ -16,6 +16,8 @@ export class ServiceUnavailableError extends ExtendableError {}
export class BadGatewayError extends ExtendableError {}
export class UnprocessableEntityError extends ExtendableError {}
export class RateLimitExceededError extends ExtendableError {}
export class RequestError extends ExtendableError {}
+21 -18
View File
@@ -48,16 +48,16 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.758.0",
"@aws-sdk/lib-storage": "3.758.0",
"@aws-sdk/s3-presigned-post": "3.758.0",
"@aws-sdk/s3-request-presigner": "3.758.0",
"@aws-sdk/signature-v4-crt": "^3.758.0",
"@babel/core": "^7.26.9",
"@aws-sdk/client-s3": "3.774.0",
"@aws-sdk/lib-storage": "3.774.0",
"@aws-sdk/s3-presigned-post": "3.774.0",
"@aws-sdk/s3-request-presigner": "3.774.0",
"@aws-sdk/signature-v4-crt": "^3.774.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-destructuring": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.27.0",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
@@ -79,6 +79,7 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@notionhq/client": "^2.2.16",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
@@ -95,6 +96,7 @@
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
"async-sema": "^3.1.1",
"autotrack": "^2.4.1",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-class-properties": "^6.24.1",
@@ -108,7 +110,7 @@
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.37.0",
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.11.2",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.40.0",
"diff": "^5.2.0",
@@ -127,11 +129,12 @@
"fuzzy-search": "^3.2.1",
"glob": "^8.1.0",
"http-errors": "2.0.0",
"https-proxy-agent": "^7.0.6",
"i18next": "^22.5.1",
"i18next-fs-backend": "^2.6.0",
"i18next-http-backend": "^2.7.3",
"invariant": "^2.2.4",
"ioredis": "^5.4.1",
"ioredis": "^5.6.0",
"is-printable-key-event": "^1.0.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
@@ -170,13 +173,13 @@
"passport-oauth2": "^1.8.0",
"passport-slack-oauth2": "^1.2.0",
"patch-package": "^7.0.2",
"pg": "^8.12.0",
"pg": "^8.14.1",
"pg-tsquery": "^8.4.2",
"pluralize": "^8.0.0",
"png-chunks-extract": "^1.0.0",
"polished": "^4.3.1",
"prosemirror-codemark": "^0.4.2",
"prosemirror-commands": "^1.6.2",
"prosemirror-commands": "^1.7.0",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
@@ -245,7 +248,7 @@
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^5.4.14",
"vite": "^5.4.15",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -254,11 +257,11 @@
"y-protocols": "^1.0.6",
"yauzl": "^2.10.0",
"yjs": "^13.6.1",
"zod": "^3.23.8"
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.26.4",
"@babel/preset-typescript": "^7.26.0",
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.14",
"@testing-library/react": "^12.0.0",
@@ -293,7 +296,7 @@
"@types/markdown-it-emoji": "^2.0.4",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.17.16",
"@types/node": "20.17.27",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -357,7 +360,7 @@
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.1",
"rollup-plugin-webpack-stats": "^2.0.3",
"terser": "^5.39.0",
"typescript": "^5.7.3",
"vite-plugin-static-copy": "^0.17.0",
@@ -375,4 +378,4 @@
"prismjs": "1.30.0"
},
"version": "0.82.0"
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./auth/email";
const enabled = !!env.SMTP_HOST || env.isDevelopment;
const enabled = !!(env.SMTP_HOST || env.SMTP_SERVICE) || env.isDevelopment;
if (enabled) {
PluginManager.add({
+93
View File
@@ -0,0 +1,93 @@
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import env from "@shared/env";
import { IntegrationService } from "@shared/types";
import Button from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { redirectTo } from "~/utils/urls";
import { NotionUtils } from "../shared/NotionUtils";
import { ImportDialog } from "./components/ImportDialog";
export const Notion = observer(() => {
const { t } = useTranslation();
const { dialogs } = useStores();
const team = useCurrentTeam();
const history = useHistory();
const location = useLocation();
const queryParams = useQuery();
const appName = env.APP_NAME;
const authUrl = NotionUtils.authUrl({ state: { teamId: team.id } });
const service = queryParams.get("service");
const oauthSuccess = queryParams.get("success") === "";
const oauthError = queryParams.get("error");
const integrationId = queryParams.get("integrationId");
const clearQueryParams = React.useCallback(() => {
history.replace({
pathname: location.pathname,
search: "",
});
}, [history, location]);
const handleSubmit = React.useCallback(() => {
dialogs.closeAllModals();
clearQueryParams();
}, [dialogs, clearQueryParams]);
React.useEffect(() => {
if (
integrationId &&
oauthSuccess &&
service === IntegrationService.Notion
) {
dialogs.openModal({
title: t("Import data"),
content: (
<ImportDialog integrationId={integrationId} onSubmit={handleSubmit} />
),
onClose: clearQueryParams,
});
}
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
React.useEffect(() => {
if (!oauthError) {
return;
}
if (oauthError === "access_denied") {
toast.error(
t(
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
{
appName,
}
)
);
} else {
toast.error(
t(
"Something went wrong while authenticating your request. Please try logging in again."
)
);
}
}, [t, appName, oauthError]);
return (
<Button
type="submit"
onClick={() => redirectTo(authUrl)}
disabled={!env.NOTION_CLIENT_ID}
neutral
>
{t("Import")}
</Button>
);
});
@@ -0,0 +1,78 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ImportInput } from "@shared/schema";
import { CollectionPermission, IntegrationService } from "@shared/types";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import InputSelectPermission from "~/components/InputSelectPermission";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
type Props = {
/** The integrationId associated with this import flow. */
integrationId: string;
/** Callback to handle import creation. */
onSubmit: () => void;
};
export function ImportDialog({ integrationId, onSubmit }: Props) {
const { t } = useTranslation();
const { imports } = useStores();
const [submitting, setSubmitting, resetSubmitting] = useBoolean();
const [permission, setPermission] = React.useState<CollectionPermission>();
const handlePermissionChange = React.useCallback(
(value: CollectionPermission | typeof EmptySelectValue) => {
setPermission(value === EmptySelectValue ? undefined : value);
},
[]
);
const handleStartImport = React.useCallback(async () => {
setSubmitting();
// TODO: This can send the page info + permission once we overcome the search timeout issues.
const input: ImportInput<IntegrationService.Notion> = [{ permission }];
try {
await imports.create(
{ service: IntegrationService.Notion },
{ integrationId, input }
);
toast.success(
t("Your import is being processed, you can safely leave this page")
);
onSubmit();
} catch (err) {
toast.error(err.message);
resetSubmitting();
}
}, [permission, onSubmit]);
return (
<Flex column gap={12}>
<div>
<InputSelectPermission
value={permission}
onChange={handlePermissionChange}
/>
<Text as="span" type="secondary">
{t(
"Set the default permission level for collections created from the import"
)}
.
</Text>
</div>
<Flex justify="flex-end">
<Button onClick={handleStartImport} disabled={submitting}>
{t("Start import")}
</Button>
</Flex>
</Flex>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { t } from "i18next";
import * as React from "react";
import { cdnPath } from "@shared/utils/urls";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import { Notion } from "./Imports";
PluginManager.add([
{
...config,
type: Hook.Imports,
value: {
title: "Notion",
subtitle: t("Import pages from Notion"),
icon: <img src={cdnPath("/images/notion.png")} width={28} />,
action: <Notion />,
},
},
]);
+5
View File
@@ -0,0 +1,5 @@
{
"id": "notion",
"name": "Notion",
"description": "Adds a Notion integration for importing data."
}
+105
View File
@@ -0,0 +1,105 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Integration, IntegrationAuthentication, Team } from "@server/models";
import { APIContext } from "@server/types";
import { NotionClient } from "../notion";
import * as T from "./schema";
import { NotionUtils } from "plugins/notion/shared/NotionUtils";
const router = new Router();
router.get(
"notion.callback",
auth({ optional: true }),
validate(T.NotionCallbackSchema),
transaction(),
async (ctx: APIContext<T.NotionCallbackReq>) => {
const { code, state, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
let parsedState;
try {
parsedState = NotionUtils.parseState(state);
} catch {
ctx.redirect(NotionUtils.errorUrl("invalid_state"));
return;
}
const { teamId } = parsedState;
// This code block accounts for the root domain being unable to access authentication for subdomains.
// We must forward to the appropriate subdomain to complete the oauth flow.
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
transaction,
});
return parseDomain(ctx.host).teamSubdomain === team.subdomain
? ctx.redirect("/")
: ctx.redirectOnClient(
NotionUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
}
}
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(NotionUtils.errorUrl(error));
return;
}
// validation middleware ensures that code is non-null at this point.
const data = await NotionClient.oauthAccess(code!);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Notion,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.Import>
>(
{
service: IntegrationService.Notion,
type: IntegrationType.Import,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
externalWorkspace: {
id: data.workspace_id,
name: data.workspace_name ?? "Notion import",
iconUrl: data.workspace_icon ?? undefined,
},
},
},
{ transaction }
);
ctx.redirect(NotionUtils.successUrl(integration.id));
}
);
export default router;
+25
View File
@@ -0,0 +1,25 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const NotionCallbackSchema = 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 NotionCallbackReq = z.infer<typeof NotionCallbackSchema>;
export const NotionSearchSchema = BaseSchema.extend({
body: z.object({
integrationId: z.string().uuid(),
}),
});
export type NotionSearchReq = z.infer<typeof NotionSearchSchema>;
+19
View File
@@ -0,0 +1,19 @@
import { IsOptional } from "class-validator";
import { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
class NotionPluginEnvironment extends Environment {
@Public
@IsOptional()
public NOTION_CLIENT_ID = this.toOptionalString(environment.NOTION_CLIENT_ID);
@IsOptional()
@CannotUseWithout("NOTION_CLIENT_ID")
public NOTION_CLIENT_SECRET = this.toOptionalString(
environment.NOTION_CLIENT_SECRET
);
}
export default new NotionPluginEnvironment();
+26
View File
@@ -0,0 +1,26 @@
import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./api/notion";
import env from "./env";
import { NotionImportsProcessor } from "./processors/NotionImportsProcessor";
import NotionAPIImportTask from "./tasks/NotionAPIImportTask";
const enabled = !!env.NOTION_CLIENT_ID && !!env.NOTION_CLIENT_SECRET;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
{
type: Hook.Processor,
value: NotionImportsProcessor,
},
{
type: Hook.Task,
value: NotionAPIImportTask,
},
]);
}
+297
View File
@@ -0,0 +1,297 @@
import {
APIErrorCode,
APIResponseError,
Client,
isFullPage,
isFullPageOrDatabase,
isFullUser,
} from "@notionhq/client";
import {
BlockObjectResponse,
DatabaseObjectResponse,
PageObjectResponse,
RichTextItemResponse,
} from "@notionhq/client/build/src/api-endpoints";
import { RateLimit } from "async-sema";
import compact from "lodash/compact";
import { z } from "zod";
import { Second } from "@shared/utils/time";
import { NotionUtils } from "../shared/NotionUtils";
import { Block, Page, PageType } from "../shared/types";
import env from "./env";
type PageInfo = {
title: string;
emoji?: string;
author?: string;
createdAt?: Date;
updatedAt?: Date;
};
const Credentials = Buffer.from(
`${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`
).toString("base64");
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
bot_id: z.string(),
workspace_id: z.string(),
workspace_name: z.string().nullish(),
workspace_icon: z.string().url().nullish(),
});
export class NotionClient {
private client: Client;
private limiter: ReturnType<typeof RateLimit>;
private pageSize = 25;
constructor(
accessToken: string,
rateLimit: { window: number; limit: number } = {
window: Second.ms,
limit: 3,
}
) {
this.client = new Client({
auth: accessToken,
});
this.limiter = RateLimit(rateLimit.limit, {
timeUnit: rateLimit.window,
uniformDistribution: true,
});
}
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Basic ${Credentials}`,
};
const body = {
grant_type: "authorization_code",
code,
redirect_uri: NotionUtils.callbackUrl(),
};
const res = await fetch(NotionUtils.tokenUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
});
return AccessTokenResponseSchema.parse(await res.json());
}
async fetchRootPages() {
const pages: Page[] = [];
let cursor: string | undefined;
let hasMore = true;
while (hasMore) {
await this.limiter();
const response = await this.client.search({
start_cursor: cursor,
page_size: this.pageSize,
});
response.results.forEach((item) => {
if (!isFullPageOrDatabase(item)) {
return;
}
if (item.parent.type === "workspace") {
pages.push({
type: item.object === "page" ? PageType.Page : PageType.Database,
id: item.id,
name: this.parseTitle(item),
emoji: this.parseEmoji(item),
});
}
});
hasMore = response.has_more;
cursor = response.next_cursor ?? undefined;
}
return pages;
}
async fetchPage(pageId: string) {
const pageInfo = await this.fetchPageInfo(pageId);
const blocks = await this.fetchBlockChildren(pageId);
return { ...pageInfo, blocks };
}
async fetchDatabase(databaseId: string) {
const databaseInfo = await this.fetchDatabaseInfo(databaseId);
const pages = await this.queryDatabase(databaseId);
return { ...databaseInfo, pages };
}
private async fetchBlockChildren(blockId: string) {
const blocks: Block[] = [];
let cursor: string | undefined;
let hasMore = true;
while (hasMore) {
await this.limiter();
const response = await this.client.blocks.children.list({
block_id: blockId,
start_cursor: cursor,
page_size: this.pageSize,
});
blocks.push(...(response.results as BlockObjectResponse[]));
hasMore = response.has_more;
cursor = response.next_cursor ?? undefined;
}
// Recursive fetch when direct children have their own children.
await Promise.all(
blocks.map(async (block) => {
if (
block.has_children &&
block.type !== "child_page" &&
block.type !== "child_database"
) {
block.children = await this.fetchBlockChildren(block.id);
}
})
);
return blocks;
}
private async queryDatabase(databaseId: string) {
const pages: Page[] = [];
let cursor: string | undefined;
let hasMore = true;
while (hasMore) {
await this.limiter();
const response = await this.client.databases.query({
database_id: databaseId,
filter_properties: ["title"],
start_cursor: cursor,
page_size: this.pageSize,
});
const pagesFromRes = compact(
response.results.map<Page | undefined>((item) => {
if (!isFullPage(item)) {
return;
}
return {
type: PageType.Page,
id: item.id,
name: this.parseTitle(item),
emoji: this.parseEmoji(item),
};
})
);
pages.push(...pagesFromRes);
hasMore = response.has_more;
cursor = response.next_cursor ?? undefined;
}
return pages;
}
private async fetchPageInfo(pageId: string): Promise<PageInfo> {
await this.limiter();
const page = (await this.client.pages.retrieve({
page_id: pageId,
})) as PageObjectResponse;
const author = await this.fetchUsername(page.created_by.id);
return {
title: this.parseTitle(page),
emoji: this.parseEmoji(page),
author: author ?? undefined,
createdAt: !page.created_time ? undefined : new Date(page.created_time),
updatedAt: !page.last_edited_time
? undefined
: new Date(page.last_edited_time),
};
}
private async fetchDatabaseInfo(databaseId: string): Promise<PageInfo> {
await this.limiter();
const database = (await this.client.databases.retrieve({
database_id: databaseId,
})) as DatabaseObjectResponse;
const author = await this.fetchUsername(database.created_by.id);
return {
title: this.parseTitle(database),
emoji: this.parseEmoji(database),
author: author ?? undefined,
createdAt: !database.created_time
? undefined
: new Date(database.created_time),
updatedAt: !database.last_edited_time
? undefined
: new Date(database.last_edited_time),
};
}
private async fetchUsername(userId: string) {
await this.limiter();
try {
const user = await this.client.users.retrieve({ user_id: userId });
if (user.type === "person" || !user.bot.owner) {
return user.name;
}
// bot belongs to a user, get the user's name.
if (user.bot.owner.type === "user" && isFullUser(user.bot.owner.user)) {
return user.bot.owner.user.name;
}
// bot belongs to a workspace, fallback to bot's name.
return user.name;
} catch (error) {
// Handle the case where a user can't be found
if (
error instanceof APIResponseError &&
error.code === APIErrorCode.ObjectNotFound
) {
return "Unknown";
}
throw error;
}
}
private parseTitle(item: PageObjectResponse | DatabaseObjectResponse) {
let richTexts: RichTextItemResponse[];
if (item.object === "page") {
const titleProp = Object.values(item.properties).find(
(property) => property.type === "title"
);
richTexts = titleProp?.title ?? [];
} else {
richTexts = item.title;
}
return richTexts.map((richText) => richText.plain_text).join("");
}
private parseEmoji(item: PageObjectResponse | DatabaseObjectResponse) {
// Other icon types return a url to download from, which we don't support.
return item.icon?.type === "emoji" ? item.icon.emoji : undefined;
}
}
@@ -0,0 +1,71 @@
import { Transaction } from "sequelize";
import { NotionImportInput, NotionImportTaskInput } from "@shared/schema";
import { IntegrationService } from "@shared/types";
import { Import, ImportTask, Integration } from "@server/models";
import ImportsProcessor from "@server/queues/processors/ImportsProcessor";
import { NotionClient } from "../notion";
import NotionAPIImportTask from "../tasks/NotionAPIImportTask";
export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.Notion> {
/**
* Determine whether this is a "Notion" import.
*
* @param importModel Import model associated with the import.
* @returns boolean.
*/
protected canProcess(
importModel: Import<IntegrationService.Notion>
): boolean {
return importModel.service === IntegrationService.Notion;
}
/**
* Build task inputs which will be used for `NotionAPIImportTask`s.
*
* @param importInput Array of root externalId and associated info which were used to create the import.
* @returns `NotionImportTaskInput`.
*/
protected async buildTasksInput(
importModel: Import<IntegrationService.Notion>,
transaction: Transaction
): Promise<NotionImportTaskInput> {
const integration = await Integration.scope("withAuthentication").findByPk(
importModel.integrationId,
{ rejectOnEmpty: true }
);
const notion = new NotionClient(integration.authentication.token);
const rootPages = await notion.fetchRootPages();
// App will send the default permission in an array with single item.
const defaultPermission = importModel.input[0].permission;
// TODO: This update can be deleted when we receive the page info + permission from app.
const importInput: NotionImportInput = rootPages.map((page) => ({
type: page.type,
externalId: page.id,
permission: defaultPermission,
}));
importModel.input = importInput;
await importModel.save({ transaction });
return rootPages.map((page) => ({
type: page.type,
externalId: page.id,
}));
}
/**
* Schedule the first `NotionAPIImportTask` for the import.
*
* @param importTask ImportTask model associated with the `NotionAPIImportTask`.
* @returns Promise that resolves when the task is scheduled.
*/
protected async scheduleTask(
importTask: ImportTask<IntegrationService.Notion>
): Promise<void> {
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
}
}
@@ -0,0 +1,144 @@
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { Integration } from "@server/models";
import ImportTask from "@server/models/ImportTask";
import APIImportTask, {
ProcessOutput,
} from "@server/queues/tasks/APIImportTask";
import { Block, PageType } from "../../shared/types";
import { NotionClient } from "../notion";
import { NotionConverter, NotionPage } from "../utils/NotionConverter";
type ChildPage = { type: PageType; externalId: string };
type ParsePageOutput = ImportTaskOutput[number] & {
collectionExternalId?: string;
children: ChildPage[];
};
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
/**
* Process the Notion import task.
* This fetches data from Notion and converts it to task output.
*
* @param importTask ImportTask model to process.
* @returns Promise with output that resolves once processing has completed.
*/
protected async process(
importTask: ImportTask<IntegrationService.Notion>
): Promise<ProcessOutput<IntegrationService.Notion>> {
const integration = await Integration.scope("withAuthentication").findByPk(
importTask.import.integrationId,
{ rejectOnEmpty: true }
);
const client = new NotionClient(integration.authentication.token);
const parsedPages = await Promise.all(
importTask.input.map(async (item) => this.processPage({ item, client }))
);
const taskOutput: ImportTaskOutput = parsedPages.map((parsedPage) => ({
externalId: parsedPage.externalId,
title: parsedPage.title,
emoji: parsedPage.emoji,
content: parsedPage.content,
author: parsedPage.author,
createdAt: parsedPage.createdAt,
updatedAt: parsedPage.updatedAt,
}));
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
parsedPages.flatMap((parsedPage) =>
parsedPage.children.map((childPage) => ({
type: childPage.type,
externalId: childPage.externalId,
parentExternalId: parsedPage.externalId,
collectionExternalId: parsedPage.collectionExternalId,
}))
);
return { taskOutput, childTasksInput };
}
/**
* Schedule the next `NotionAPIImportTask`.
*
* @param importTask ImportTask model associated with the `NotionAPIImportTask`.
* @returns Promise that resolves when the task is scheduled.
*/
protected async scheduleNextTask(
importTask: ImportTask<IntegrationService.Notion>
) {
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
return;
}
/**
* Fetch page data from Notion and convert it to expected output.
*
* @param item Object containing data about a notion page (or) database.
* @param client Notion client.
* @returns Promise of parsed page output that resolves when the task is scheduled.
*/
private async processPage({
item,
client,
}: {
item: ImportTaskInput<IntegrationService.Notion>[number];
client: NotionClient;
}): Promise<ParsePageOutput> {
const collectionExternalId = item.collectionExternalId ?? item.externalId;
// Convert Notion database to an empty page with "pages in database" as its children.
if (item.type === PageType.Database) {
const { pages, ...databaseInfo } = await client.fetchDatabase(
item.externalId
);
return {
...databaseInfo,
externalId: item.externalId,
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
collectionExternalId,
children: pages.map((page) => ({
type: page.type,
externalId: page.id,
})),
};
}
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
return {
...pageInfo,
externalId: item.externalId,
content: NotionConverter.page({ children: blocks } as NotionPage),
collectionExternalId,
children: this.parseChildPages(blocks),
};
}
/**
* Parse Notion page blocks to get its child pages and databases.
*
* @param pageBlocks Array of blocks representing the page's content.
* @returns Array containing child page and child database info.
*/
private parseChildPages(pageBlocks: Block[]): ChildPage[] {
const childPages: ChildPage[] = [];
pageBlocks.forEach((block) => {
if (block.type === "child_page") {
childPages.push({ type: PageType.Page, externalId: block.id });
} else if (block.type === "child_database") {
childPages.push({ type: PageType.Database, externalId: block.id });
} else if (block.children?.length) {
childPages.push(...this.parseChildPages(block.children));
}
});
return childPages;
}
}
@@ -0,0 +1,25 @@
import { Node } from "prosemirror-model";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import nodesWithEmptyTextNode from "@server/test/fixtures/notion-page-with-empty-text-nodes.json";
import allNodes from "@server/test/fixtures/notion-page.json";
import { NotionConverter, NotionPage } from "./NotionConverter";
describe("NotionConverter", () => {
it("converts a page", () => {
const response = NotionConverter.page({
children: allNodes,
} as NotionPage);
expect(response).toMatchSnapshot();
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
});
it("converts a page with empty text nodes", () => {
const response = NotionConverter.page({
children: nodesWithEmptyTextNode,
} as NotionPage);
expect(response).toMatchSnapshot();
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
});
});
@@ -0,0 +1,587 @@
import type {
BookmarkBlockObjectResponse,
BreadcrumbBlockObjectResponse,
BulletedListItemBlockObjectResponse,
DividerBlockObjectResponse,
Heading1BlockObjectResponse,
Heading2BlockObjectResponse,
Heading3BlockObjectResponse,
NumberedListItemBlockObjectResponse,
ParagraphBlockObjectResponse,
QuoteBlockObjectResponse,
RichTextItemResponse,
FileBlockObjectResponse,
PdfBlockObjectResponse,
ImageBlockObjectResponse,
EmbedBlockObjectResponse,
TableBlockObjectResponse,
ToDoBlockObjectResponse,
EquationBlockObjectResponse,
CodeBlockObjectResponse,
ToggleBlockObjectResponse,
PageObjectResponse,
VideoBlockObjectResponse,
CalloutBlockObjectResponse,
ColumnListBlockObjectResponse,
ColumnBlockObjectResponse,
LinkPreviewBlockObjectResponse,
SyncedBlockBlockObjectResponse,
LinkToPageBlockObjectResponse,
} from "@notionhq/client/build/src/api-endpoints";
import isArray from "lodash/isArray";
import { NoticeTypes } from "@shared/editor/nodes/Notice";
import { MentionType, ProsemirrorData, ProsemirrorDoc } from "@shared/types";
import Logger from "@server/logging/Logger";
import { Block } from "../../shared/types";
export type NotionPage = PageObjectResponse & {
children: Block[];
};
/** Convert Notion blocks to Outline data. */
export class NotionConverter {
/**
* Nodes which cannot contain block children in Outline, their children
* will be flattened into the parent.
*/
private static nodesWithoutBlockChildren = ["paragraph", "toggle"];
public static page(item: NotionPage): ProsemirrorDoc {
return {
type: "doc",
content: this.mapChildren(item),
};
}
private static mapChildren(item: Block | NotionPage) {
const mapChild = (
child: Block
): ProsemirrorData | ProsemirrorData[] | undefined => {
if (child.type === "child_page" || child.type === "child_database") {
return; // this will be created as a nested page, no need to handle/convert.
}
// @ts-expect-error Not all blocks have an interface
if (this[child.type]) {
// @ts-expect-error Not all blocks have an interface
const response = this[child.type](child);
if (
response &&
this.nodesWithoutBlockChildren.includes(response.type) &&
"children" in child
) {
return [response, ...this.mapChildren(child)];
}
return response;
}
Logger.warn("Encountered unknown Notion block", child);
return undefined;
};
let wrappingList;
const children = [] as ProsemirrorData[];
if (!item.children) {
return [];
}
for (const child of item.children) {
const mapped = mapChild(child);
if (!mapped) {
continue;
}
// Ensure lists are wrapped correctly we require a wrapping element
// whereas Notion does not
// TODO: Handle mixed list
if (child.type === "numbered_list_item") {
if (!wrappingList) {
wrappingList = {
type: "ordered_list",
content: [] as ProsemirrorData[],
};
}
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
continue;
}
if (child.type === "bulleted_list_item") {
if (!wrappingList) {
wrappingList = {
type: "bullet_list",
content: [] as ProsemirrorData[],
};
}
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
continue;
}
if (child.type === "to_do") {
if (!wrappingList) {
wrappingList = {
type: "checkbox_list",
content: [] as ProsemirrorData[],
};
}
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
continue;
}
if (wrappingList) {
children.push(wrappingList);
wrappingList = undefined;
}
children.push(...(isArray(mapped) ? mapped : [mapped]));
}
if (wrappingList) {
children.push(wrappingList);
}
return children;
}
private static callout(item: Block<CalloutBlockObjectResponse>) {
const colorToNoticeType: Record<string, NoticeTypes> = {
default_background: NoticeTypes.Info,
blue_background: NoticeTypes.Info,
purple_background: NoticeTypes.Info,
green_background: NoticeTypes.Success,
orange_background: NoticeTypes.Tip,
yellow_background: NoticeTypes.Tip,
pink_background: NoticeTypes.Warning,
red_background: NoticeTypes.Warning,
};
return {
type: "container_notice",
attrs: {
style:
colorToNoticeType[item.callout.color as string] ?? NoticeTypes.Info,
},
content: [
{
type: "paragraph",
content: item.callout.rich_text.map(this.rich_text).filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static column_list(item: Block<ColumnListBlockObjectResponse>) {
return this.mapChildren(item);
}
private static column(item: Block<ColumnBlockObjectResponse>) {
return this.mapChildren(item);
}
private static bookmark(item: BookmarkBlockObjectResponse) {
const caption = item.bookmark.caption
.map(this.rich_text_to_plaintext)
.join("");
return {
type: "paragraph",
content: [
{
text: caption || item.bookmark.url,
type: "text",
marks: [
{
type: "link",
attrs: {
href: item.bookmark.url,
title: null,
},
},
],
},
],
};
}
private static breadcrumb(_: BreadcrumbBlockObjectResponse) {
return undefined;
}
private static bulleted_list_item(
item: Block<BulletedListItemBlockObjectResponse>
) {
return {
type: "list_item",
content: [
{
type: "paragraph",
content: item.bulleted_list_item.rich_text
.map(this.rich_text)
.filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static code(item: CodeBlockObjectResponse) {
const text = item.code.rich_text.map(this.rich_text_to_plaintext).join("");
return {
type: "code_fence",
attrs: {
language: item.code.language,
},
content: text ? [{ type: "text", text }] : undefined,
};
}
private static numbered_list_item(
item: Block<NumberedListItemBlockObjectResponse>
) {
return {
type: "list_item",
content: [
{
type: "paragraph",
content: item.numbered_list_item.rich_text
.map(this.rich_text)
.filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static rich_text(item: RichTextItemResponse) {
const annotationToMark: Record<
keyof RichTextItemResponse["annotations"],
string
> = {
bold: "strong",
code: "code_inline",
italic: "em",
underline: "underline",
strikethrough: "strikethrough",
color: "highlight",
};
const mapAttrs = () =>
Object.entries(item.annotations)
.filter(([key]) => key !== "color")
.filter(([, enabled]) => enabled)
.map(([key]) => ({
type: annotationToMark[key as keyof typeof annotationToMark],
}));
if (item.type === "mention") {
if (item.mention.type === "page") {
return {
type: "mention",
attrs: {
type: MentionType.Document,
label: item.plain_text,
modelId: item.mention.page.id,
},
};
}
if (item.mention.type === "link_mention") {
return {
type: "text",
text: item.plain_text,
marks: [
{
type: "link",
attrs: {
href: item.mention.link_mention.href,
},
},
],
};
}
if (item.mention.type === "link_preview") {
return {
type: "text",
text: item.plain_text,
marks: [
{
type: "link",
attrs: {
href: item.mention.link_preview.url,
},
},
],
};
}
if (!item.plain_text) {
return undefined;
}
return {
type: "text",
text: item.plain_text,
};
}
if (item.type === "equation") {
return {
type: "math_inline",
content: [
{
type: "text",
text: item.equation.expression,
},
],
};
}
if (!item.text.content) {
return undefined;
}
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
}
private static rich_text_to_plaintext(item: RichTextItemResponse) {
return item.plain_text;
}
private static divider(_: DividerBlockObjectResponse) {
return {
type: "hr",
};
}
private static equation(item: EquationBlockObjectResponse) {
return {
type: "math_block",
content: item.equation.expression
? [
{
type: "text",
text: item.equation.expression,
},
]
: undefined,
};
}
private static embed(item: EmbedBlockObjectResponse) {
return {
type: "embed",
attrs: {
href: item.embed.url,
},
};
}
private static file(item: FileBlockObjectResponse) {
return {
type: "attachment",
attrs: {
href: "file" in item.file ? item.file.file.url : item.file.external.url,
title: item.file.name,
},
};
}
private static pdf(item: PdfBlockObjectResponse) {
return {
type: "attachment",
attrs: {
href: "file" in item.pdf ? item.pdf.file.url : item.pdf.external.url,
title: item.pdf.caption.map(this.rich_text_to_plaintext).join(""),
},
};
}
private static heading_1(item: Heading1BlockObjectResponse) {
return {
type: "heading",
attrs: {
level: 1,
},
content: item.heading_1.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static heading_2(item: Heading2BlockObjectResponse) {
return {
type: "heading",
attrs: {
level: 2,
},
content: item.heading_2.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static heading_3(item: Heading3BlockObjectResponse) {
return {
type: "heading",
attrs: {
level: 3,
},
content: item.heading_3.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static image(item: ImageBlockObjectResponse) {
return {
type: "paragraph",
content: [
{
type: "image",
attrs: {
src:
"file" in item.image
? item.image.file.url
: item.image.external.url,
alt: item.image.caption.map(this.rich_text_to_plaintext).join(""),
},
},
],
};
}
private static link_preview(item: LinkPreviewBlockObjectResponse) {
return {
type: "paragraph",
content: [
{
type: "text",
text: item.link_preview.url,
marks: [
{
type: "link",
attrs: {
href: item.link_preview.url,
},
},
],
},
],
};
}
private static link_to_page(item: LinkToPageBlockObjectResponse) {
if (item.link_to_page.type !== "page_id") {
return undefined;
}
return {
type: "mention",
attrs: {
modelId: item.link_to_page.page_id,
type: MentionType.Document,
label: "Page",
},
};
}
private static paragraph(item: ParagraphBlockObjectResponse) {
return {
type: "paragraph",
content: item.paragraph.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static quote(item: Block<QuoteBlockObjectResponse>) {
return {
type: "blockquote",
content: [
{
type: "paragraph",
content: item.quote.rich_text.map(this.rich_text).filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static synced_block(item: Block<SyncedBlockBlockObjectResponse>) {
return this.mapChildren(item);
}
private static table(
item: TableBlockObjectResponse & {
children: Array<{
table_row: {
cells: Array<Array<RichTextItemResponse>>;
};
type?: "table_row";
object?: "block";
}>;
}
) {
return {
type: "table",
content: item.children.map((tr, y) => ({
type: "tr",
content: tr.table_row.cells.map((td, x) => ({
type:
(item.table.has_row_header && y === 0) ||
(item.table.has_column_header && x === 0)
? "th"
: "td",
content: [
{
type: "paragraph",
content: td.map(this.rich_text).filter(Boolean),
},
],
})),
})),
};
}
private static toggle(item: ToggleBlockObjectResponse) {
return {
type: "paragraph",
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static to_do(item: Block<ToDoBlockObjectResponse>) {
return {
type: "checkbox_item",
attrs: {
checked: item.to_do.checked,
},
content: [
{
type: "paragraph",
content: item.to_do.rich_text.map(this.rich_text).filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static video(item: VideoBlockObjectResponse) {
if (item.video.type === "file") {
return {
type: "video",
attrs: {
src: item.video.file.url,
title: item.video.caption.map(this.rich_text_to_plaintext).join(""),
},
};
}
return {
type: "embed",
attrs: {
href: item.video.external.url,
},
};
}
}
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
import queryString from "query-string";
import env from "@shared/env";
import { IntegrationService } from "@shared/types";
import { settingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class NotionUtils {
public static tokenUrl = "https://api.notion.com/v1/oauth/token";
private static authBaseUrl = "https://api.notion.com/v1/oauth/authorize";
private static settingsUrl = settingsPath("import");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl(integrationId: string) {
const params = {
success: "",
service: IntegrationService.Notion,
integrationId,
};
return `${this.settingsUrl}?${queryString.stringify(params)}`;
}
static errorUrl(error: string) {
const params = {
error,
service: IntegrationService.Notion,
};
return `${this.settingsUrl}?${queryString.stringify(params)}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
params: undefined,
}
) {
return params
? `${baseUrl}/api/notion.callback?${params}`
: `${baseUrl}/api/notion.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.NOTION_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
response_type: "code",
owner: "user",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+18
View File
@@ -0,0 +1,18 @@
import { BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
export enum PageType {
Page = "page",
Database = "database",
}
export type Page = {
type: PageType;
id: string;
name: string;
emoji?: string;
};
// Transformed block structure with "children".
export type Block<T = BlockObjectResponse> = T & {
children?: Block[];
};
+28
View File
@@ -0,0 +1,28 @@
import { HttpsProxyAgent } from "https-proxy-agent";
import OAuth2Strategy, { Strategy } from "passport-oauth2";
export class OIDCStrategy extends Strategy {
constructor(
options: OAuth2Strategy.StrategyOptionsWithRequest,
verify: OAuth2Strategy.VerifyFunctionWithRequest
) {
super(options, verify);
if (process.env.https_proxy) {
const httpsProxyAgent = new HttpsProxyAgent(process.env.https_proxy);
this._oauth2.setAgent(httpsProxyAgent);
}
}
authenticate(req: any, options: any) {
options.originalQuery = req.query;
super.authenticate(req, options);
}
authorizationParams(options: any) {
return {
...(options.originalQuery || {}),
...(super.authorizationParams?.(options) || {}),
};
}
}
+2 -16
View File
@@ -2,7 +2,6 @@ import passport from "@outlinewiki/koa-passport";
import type { Context } from "koa";
import Router from "koa-router";
import get from "lodash/get";
import { Strategy } from "passport-oauth2";
import { slugifyDomain } from "@shared/utils/domains";
import { parseEmail } from "@shared/utils/email";
import accountProvisioner from "@server/commands/accountProvisioner";
@@ -21,24 +20,11 @@ import {
} from "@server/utils/passport";
import config from "../../plugin.json";
import env from "../env";
import { OIDCStrategy } from "./OIDCStrategy";
const router = new Router();
const scopes = env.OIDC_SCOPES.split(" ");
const authorizationParams = Strategy.prototype.authorizationParams;
Strategy.prototype.authorizationParams = function (options) {
return {
...(options.originalQuery || {}),
...(authorizationParams.bind(this)(options) || {}),
};
};
const authenticate = Strategy.prototype.authenticate;
Strategy.prototype.authenticate = function (req, options) {
options.originalQuery = req.query;
authenticate.bind(this)(req, options);
};
if (
env.OIDC_CLIENT_ID &&
env.OIDC_CLIENT_SECRET &&
@@ -48,7 +34,7 @@ if (
) {
passport.use(
config.id,
new Strategy(
new OIDCStrategy(
{
authorizationURL: env.OIDC_AUTH_URI,
tokenURL: env.OIDC_TOKEN_URI,
+3
View File
@@ -17,6 +17,9 @@ import { getTestServer } from "@server/test/support";
const server = getTestServer();
// Increase timeout for all tests in this file
jest.setTimeout(10000);
describe("#files.create", () => {
it("should fail with status 400 bad request if key is invalid", async () => {
const user = await buildUser();
@@ -231,6 +231,12 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "userMemberships.update":
// Ignored
return;
case "imports.create":
case "imports.update":
case "imports.processed":
case "imports.delete":
// Ignored
return;
default:
assertUnreachable(event);
}
-4
View File
@@ -1,4 +0,0 @@
export const TooManyConnections = {
code: 4503,
reason: "Too Many Connections",
};
@@ -1,12 +1,12 @@
import {
Extension,
connectedPayload,
onConnectPayload,
onDisconnectPayload,
} from "@hocuspocus/server";
import { TooManyConnections } from "@shared/collaboration/CloseEvents";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import { TooManyConnections } from "./CloseEvents";
import { withContext } from "./types";
@trace()
@@ -17,8 +17,10 @@ export class ConnectionLimitExtension implements Extension {
connectionsByDocument: Map<string, Set<string>> = new Map();
/**
* onDisconnect hook
* On disconnect hook
*
* @param data The disconnect payload
* @returns Promise
*/
onDisconnect({ documentName, socketId }: withContext<onDisconnectPayload>) {
const connections = this.connectionsByDocument.get(documentName);
@@ -41,10 +43,12 @@ export class ConnectionLimitExtension implements Extension {
}
/**
* connected hook
* @param data The connected payload
* On connect hook
*
* @param data The connect payload
* @returns Promise, resolving will allow the connection, rejecting will drop it
*/
connected({ documentName, socketId }: withContext<connectedPayload>) {
onConnect({ documentName, socketId }: withContext<onConnectPayload>) {
const connections =
this.connectionsByDocument.get(documentName) || new Set();
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
@@ -0,0 +1,40 @@
import { Extension, onConnectPayload } from "@hocuspocus/server";
import semver from "semver";
import { EditorUpdateError } from "@shared/collaboration/CloseEvents";
import EDITOR_VERSION from "@shared/editor/version";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import { withContext } from "./types";
@trace()
export class EditorVersionExtension implements Extension {
/**
* On connect hook prevents connections from clients with an outdated editor
* version. See the equivalent logic for API in /server/routes/api/middlewares/editor.ts
*
* @param data The connect payload
* @returns Promise, resolving will allow the connection, rejecting will drop.
*/
onConnect({ requestParameters }: withContext<onConnectPayload>) {
const clientVersion = requestParameters.get("editorVersion");
if (clientVersion) {
const parsedClientVersion = semver.parse(clientVersion);
const parsedServerVersion = semver.parse(EDITOR_VERSION);
if (
parsedClientVersion &&
parsedServerVersion &&
parsedClientVersion.major < parsedServerVersion.major
) {
Logger.debug(
"multiplayer",
`Dropping connection due to outdated editor version: ${clientVersion} < ${EDITOR_VERSION}`
);
return Promise.reject(EditorUpdateError);
}
}
return Promise.resolve();
}
}
+4 -1
View File
@@ -19,6 +19,7 @@ type Props = Optional<
| "collectionId"
| "parentDocumentId"
| "importId"
| "apiImportId"
| "template"
| "fullWidth"
| "sourceMetadata"
@@ -51,6 +52,7 @@ export default async function documentCreator({
templateDocument,
fullWidth,
importId,
apiImportId,
createdAt,
// allows override for import
updatedAt,
@@ -116,6 +118,7 @@ export default async function documentCreator({
templateId,
publishedAt,
importId,
apiImportId,
sourceMetadata,
fullWidth: fullWidth ?? templateDocument?.fullWidth,
icon: icon ?? templateDocument?.icon,
@@ -142,7 +145,7 @@ export default async function documentCreator({
teamId: document.teamId,
actorId: user.id,
data: {
source: importId ? "import" : undefined,
source: importId || apiImportId ? "import" : undefined,
title: document.title,
templateId,
},
-182
View File
@@ -1,182 +0,0 @@
import { NotificationEventType } from "@shared/types";
import { Event } from "@server/models";
import {
buildUser,
buildNotification,
buildDocument,
buildCollection,
} from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import notificationUpdater from "./notificationUpdater";
describe("notificationUpdater", () => {
it("should mark the notification as viewed", async () => {
const user = await buildUser();
const actor = await buildUser({
teamId: user.teamId,
});
const collection = await buildCollection({
teamId: user.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
collectionId: collection.id,
});
expect(notification.archivedAt).toBe(null);
expect(notification.viewedAt).toBe(null);
await withAPIContext(user, (ctx) =>
notificationUpdater(ctx, {
notification,
viewedAt: new Date(),
})
);
const event = await Event.findLatest({
teamId: user.teamId,
});
expect(notification.viewedAt).not.toBe(null);
expect(notification.archivedAt).toBe(null);
expect(event!.name).toEqual("notifications.update");
expect(event!.modelId).toEqual(notification.id);
});
it("should mark the notification as unseen", async () => {
const user = await buildUser();
const actor = await buildUser({
teamId: user.teamId,
});
const collection = await buildCollection({
teamId: user.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
collectionId: collection.id,
viewedAt: new Date(),
});
expect(notification.archivedAt).toBe(null);
expect(notification.viewedAt).not.toBe(null);
await withAPIContext(user, (ctx) =>
notificationUpdater(ctx, {
notification,
viewedAt: null,
})
);
const event = await Event.findLatest({
teamId: user.teamId,
});
expect(notification.viewedAt).toBe(null);
expect(notification.archivedAt).toBe(null);
expect(event!.name).toEqual("notifications.update");
expect(event!.modelId).toEqual(notification.id);
});
it("should archive the notification", async () => {
const user = await buildUser();
const actor = await buildUser({
teamId: user.teamId,
});
const collection = await buildCollection({
teamId: user.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
collectionId: collection.id,
});
expect(notification.archivedAt).toBe(null);
expect(notification.viewedAt).toBe(null);
await withAPIContext(user, (ctx) =>
notificationUpdater(ctx, {
notification,
archivedAt: new Date(),
})
);
const event = await Event.findLatest({
teamId: user.teamId,
});
expect(notification.viewedAt).toBe(null);
expect(notification.archivedAt).not.toBe(null);
expect(event!.name).toEqual("notifications.update");
expect(event!.modelId).toEqual(notification.id);
});
it("should unarchive the notification", async () => {
const user = await buildUser();
const actor = await buildUser({
teamId: user.teamId,
});
const collection = await buildCollection({
teamId: user.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
collectionId: collection.id,
archivedAt: new Date(),
});
expect(notification.archivedAt).not.toBe(null);
expect(notification.viewedAt).toBe(null);
await withAPIContext(user, (ctx) =>
notificationUpdater(ctx, {
notification,
archivedAt: null,
})
);
const event = await Event.findLatest({
teamId: user.teamId,
});
expect(notification.viewedAt).toBe(null);
expect(notification.archivedAt).toBeNull();
expect(event!.name).toEqual("notifications.update");
expect(event!.modelId).toEqual(notification.id);
});
});
-53
View File
@@ -1,53 +0,0 @@
import isUndefined from "lodash/isUndefined";
import { Event, Notification } from "@server/models";
import { APIContext } from "@server/types";
type Props = {
/** Notification to be updated */
notification: Notification;
/** Time at which notification was viewed */
viewedAt?: Date | null;
/** Time at which notification was archived */
archivedAt?: Date | null;
};
/**
* This command updates notification properties.
*
* @param ctx The originating request context
* @param Props The properties of the notification to update
* @returns Notification The updated notification
*/
export default async function notificationUpdater(
ctx: APIContext,
{ notification, viewedAt, archivedAt }: Props
): Promise<Notification> {
const { transaction } = ctx.state;
if (!isUndefined(viewedAt)) {
notification.viewedAt = viewedAt;
}
if (!isUndefined(archivedAt)) {
notification.archivedAt = archivedAt;
}
const changed = notification.changed();
if (changed) {
await notification.save({ transaction });
await Event.createFromContext(
ctx,
{
name: "notifications.update",
userId: notification.userId,
modelId: notification.id,
documentId: notification.documentId,
},
{
actorId: notification.userId,
teamId: notification.teamId,
}
);
}
return notification;
}
+12 -1
View File
@@ -33,7 +33,7 @@ export class Mailer {
transporter: Transporter | undefined;
constructor() {
if (env.SMTP_HOST) {
if (env.SMTP_HOST || env.SMTP_SERVICE) {
this.transporter = nodemailer.createTransport(this.getOptions());
}
if (useTestEmailService) {
@@ -198,6 +198,17 @@ export class Mailer {
};
private getOptions(): SMTPTransport.Options {
// nodemailer will use the service config to determine host/port
if (env.SMTP_SERVICE) {
return {
service: env.SMTP_SERVICE,
auth: {
user: env.SMTP_USERNAME,
pass: env.SMTP_PASSWORD,
},
};
}
return {
name: env.SMTP_NAME,
host: env.SMTP_HOST,
+13 -3
View File
@@ -15,7 +15,7 @@ import {
} from "class-validator";
import uniq from "lodash/uniq";
import { languages } from "@shared/i18n";
import { CannotUseWithout } from "@server/utils/validators";
import { CannotUseWith, CannotUseWithout } from "@server/utils/validators";
import Deprecated from "./models/decorators/Deprecated";
import { getArg } from "./utils/args";
import { Public, PublicEnvironmentRegister } from "./utils/decorators/Public";
@@ -291,10 +291,19 @@ export class Environment {
/**
* The host of your SMTP server for enabling emails.
*/
public SMTP_HOST = environment.SMTP_HOST;
@CannotUseWith("SMTP_SERVICE")
public SMTP_HOST = this.toOptionalString(environment.SMTP_HOST);
/**
* The service name of a well-known SMTP service for nodemailer.
* See https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/
*/
@CannotUseWith("SMTP_HOST")
public SMTP_SERVICE = this.toOptionalString(environment.SMTP_SERVICE);
@Public
public EMAIL_ENABLED = !!this.SMTP_HOST || this.isDevelopment;
public EMAIL_ENABLED =
!!(this.SMTP_HOST || this.SMTP_SERVICE) || this.isDevelopment;
/**
* Optional hostname of the client, used for identifying to the server
@@ -307,6 +316,7 @@ export class Environment {
*/
@IsNumber()
@IsOptional()
@CannotUseWith("SMTP_SERVICE")
public SMTP_PORT = this.toOptionalNumber(environment.SMTP_PORT);
/**
+6
View File
@@ -201,6 +201,12 @@ export function AuthenticationProviderDisabledError(
});
}
export function UnprocessableEntityError(
message = "Cannot process the request"
) {
return httpErrors(422, message, { id: "unprocessable_entity" });
}
export function ClientClosedRequestError(
message = "Client closed request before response was received"
) {
+1 -3
View File
@@ -48,9 +48,7 @@ class Metrics {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
ddMetrics.flush(resolve, reject);
});
return ddMetrics.flush();
}
}
@@ -0,0 +1,93 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.createTable(
"imports",
{
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
service: {
type: Sequelize.STRING,
allowNull: false,
},
state: {
type: Sequelize.STRING,
allowNull: false,
},
input: {
type: Sequelize.JSONB,
allowNull: false,
},
documentCount: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
integrationId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "integrations",
},
},
createdById: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
},
},
teamId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "teams",
},
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
},
{ transaction }
);
await queryInterface.addIndex("imports", ["service", "teamId"], {
transaction,
});
await queryInterface.addIndex("imports", ["state", "teamId"], {
transaction,
});
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeIndex("imports", ["service", "teamId"], {
transaction,
});
await queryInterface.removeIndex("imports", ["state", "teamId"], {
transaction,
});
await queryInterface.dropTable("imports", { transaction });
});
},
};
@@ -0,0 +1,67 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.createTable(
"import_tasks",
{
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
state: {
type: Sequelize.STRING,
allowNull: false,
},
input: {
type: Sequelize.JSONB,
allowNull: false,
},
output: {
type: Sequelize.JSONB,
allowNull: true,
},
importId: {
type: Sequelize.UUID,
allowNull: false,
onDelete: "cascade",
references: {
model: "imports",
},
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
},
{ transaction }
);
await queryInterface.addIndex("import_tasks", ["importId"], {
transaction,
});
await queryInterface.addIndex("import_tasks", ["state", "importId"], {
transaction,
});
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeIndex("import_tasks", ["importId"], {
transaction,
});
await queryInterface.removeIndex("import_tasks", ["state", "importId"], {
transaction,
});
await queryInterface.dropTable("import_tasks", { transaction });
});
},
};
@@ -0,0 +1,59 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"collections",
"apiImportId",
{
type: Sequelize.UUID,
allowNull: true,
references: {
model: "imports",
},
},
{ transaction }
);
await queryInterface.addColumn(
"documents",
"apiImportId",
{
type: Sequelize.UUID,
allowNull: true,
references: {
model: "imports",
},
},
{ transaction }
);
await queryInterface.addIndex("collections", ["apiImportId"], {
transaction,
});
await queryInterface.addIndex("documents", ["apiImportId"], {
transaction,
});
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeIndex("collections", [
"apiImportId",
{ transaction },
]);
await queryInterface.removeIndex("documents", [
"apiImportId",
{ transaction },
]);
await queryInterface.removeColumn("collections", "apiImportId", {
transaction,
});
await queryInterface.removeColumn("documents", "apiImportId", {
transaction,
});
});
},
};
@@ -0,0 +1,37 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"imports",
"error",
{
type: Sequelize.STRING,
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
"import_tasks",
"error",
{
type: Sequelize.STRING,
allowNull: true,
},
{ transaction }
);
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("imports", "error", { transaction });
await queryInterface.removeColumn("import_tasks", "error", {
transaction,
});
});
},
};
+54 -2
View File
@@ -1,4 +1,5 @@
/* eslint-disable lines-between-class-members */
import fractionalIndex from "fractional-index";
import find from "lodash/find";
import findIndex from "lodash/findIndex";
import remove from "lodash/remove";
@@ -11,6 +12,8 @@ import {
InferAttributes,
InferCreationAttributes,
EmptyResultError,
type CreateOptions,
type UpdateOptions,
} from "sequelize";
import {
Sequelize,
@@ -32,6 +35,8 @@ import {
BeforeDestroy,
IsDate,
AllowNull,
BeforeCreate,
BeforeUpdate,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
@@ -41,12 +46,15 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import slugify from "@shared/utils/slugify";
import { CollectionValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import removeIndexCollision from "@server/utils/removeIndexCollision";
import { generateUrlId } from "@server/utils/url";
import { ValidateIndex } from "@server/validation";
import Document from "./Document";
import FileOperation from "./FileOperation";
import Group from "./Group";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import Import from "./Import";
import Team from "./Team";
import User from "./User";
import UserMembership from "./UserMembership";
@@ -216,8 +224,8 @@ class Collection extends ParanoidModel<
color: string | null;
@Length({
max: 256,
msg: `index must be 256 characters or less`,
max: ValidateIndex.maxLength,
msg: `index must be ${ValidateIndex.maxLength} characters or less`,
})
@Column
index: string | null;
@@ -323,6 +331,30 @@ class Collection extends ParanoidModel<
}
}
@BeforeCreate
static async setIndex(model: Collection, options: CreateOptions<Collection>) {
if (model.index) {
model.index = await removeIndexCollision(model.teamId, model.index, {
transaction: options.transaction,
});
return;
}
const firstCollectionForTeam = await this.findOne({
where: {
teamId: model.teamId,
},
order: [
// using LC_COLLATE:"C" because we need byte order to drive the sorting
Sequelize.literal('"collection"."index" collate "C"'),
["updatedAt", "DESC"],
],
...options,
});
model.index = fractionalIndex(null, firstCollectionForTeam?.index ?? null);
}
@AfterCreate
static async onAfterCreate(
model: Collection,
@@ -342,6 +374,18 @@ class Collection extends ParanoidModel<
});
}
@BeforeUpdate
static async checkIndex(
model: Collection,
options: UpdateOptions<Collection>
) {
if (model.index && model.changed("index")) {
model.index = await removeIndexCollision(model.teamId, model.index, {
transaction: options.transaction,
});
}
}
// associations
@BelongsTo(() => FileOperation, "importId")
@@ -351,6 +395,13 @@ class Collection extends ParanoidModel<
@Column(DataType.UUID)
importId: string | null;
@BelongsTo(() => Import, "apiImportId")
apiImport: Import<any> | null;
@ForeignKey(() => Import)
@Column(DataType.UUID)
apiImportId: string | null;
@BelongsTo(() => User, "archivedById")
archivedBy?: User | null;
@@ -713,6 +764,7 @@ class Collection extends ParanoidModel<
index?: number,
options: FindOptions & {
save?: boolean;
silent?: boolean;
documentJson?: NavigationNode;
includeArchived?: boolean;
} = {}
+8
View File
@@ -57,6 +57,7 @@ import FileOperation from "./FileOperation";
import Group from "./Group";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import Import from "./Import";
import Revision from "./Revision";
import Star from "./Star";
import Team from "./Team";
@@ -537,6 +538,13 @@ class Document extends ArchivableModel<
@Column(DataType.UUID)
importId: string | null;
@BelongsTo(() => Import, "apiImportId")
apiImport: Import<any> | null;
@ForeignKey(() => Import)
@Column(DataType.UUID)
apiImportId: string | null;
@AllowNull
@Column(DataType.JSONB)
sourceMetadata: SourceMetadata | null;
+90
View File
@@ -0,0 +1,90 @@
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
BelongsTo,
Column,
DataType,
Default,
DefaultScope,
ForeignKey,
IsIn,
IsNumeric,
Table,
} from "sequelize-typescript";
import { type ImportInput } from "@shared/schema";
import { ImportableIntegrationService, ImportState } from "@shared/types";
import { ImportValidation } from "@shared/validations";
import Integration from "./Integration";
import Team from "./Team";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
@DefaultScope(() => ({
include: [
{
association: "createdBy",
required: true,
paranoid: false,
},
],
}))
@Table({ tableName: "imports", modelName: "import" })
@Fix
class Import<T extends ImportableIntegrationService> extends ParanoidModel<
InferAttributes<Import<T>>,
Partial<InferCreationAttributes<Import<T>>>
> {
@NotContainsUrl
@Length({
max: ImportValidation.maxNameLength,
msg: `name must be ${ImportValidation.maxNameLength} characters or less`,
})
@Column(DataType.STRING)
name: string;
@IsIn([Object.values(ImportableIntegrationService)])
@Column(DataType.STRING)
service: T;
@IsIn([Object.values(ImportState)])
@Column(DataType.STRING)
state: ImportState;
@Column(DataType.JSONB)
input: ImportInput<T>;
@IsNumeric
@Default(0)
@Column(DataType.INTEGER)
documentCount: number;
@Column
error: string | null;
// associations
@BelongsTo(() => Integration, "integrationId")
integration: Integration;
@ForeignKey(() => Integration)
@Column(DataType.UUID)
integrationId: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
}
export default Import;
+61
View File
@@ -0,0 +1,61 @@
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
AllowNull,
BelongsTo,
Column,
DataType,
ForeignKey,
IsIn,
Table,
} from "sequelize-typescript";
import { type ImportTaskInput, ImportTaskOutput } from "@shared/schema";
import { ImportableIntegrationService, ImportTaskState } from "@shared/types";
import Import from "./Import";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
// Not all fields are automatically inferred by Sequelize.
// see https://sequelize.org/docs/v7/models/model-typing/#manual-attribute-typing
type NonInferredAttributes<T extends ImportableIntegrationService> = {
input: ImportTaskInput<T>;
};
export type ImportTaskAttributes<T extends ImportableIntegrationService> =
InferAttributes<ImportTask<T>> & NonInferredAttributes<T>;
export type ImportTaskCreationAttributes<
T extends ImportableIntegrationService
> = Partial<InferCreationAttributes<ImportTask<T>>> &
Partial<NonInferredAttributes<T>>;
@Table({ tableName: "import_tasks", modelName: "import_task" })
@Fix
class ImportTask<T extends ImportableIntegrationService> extends IdModel<
ImportTaskAttributes<T>,
ImportTaskCreationAttributes<T>
> {
@IsIn([Object.values(ImportTaskState)])
@Column(DataType.STRING)
state: ImportTaskState;
@Column(DataType.JSONB)
input: ImportTaskInput<T>;
@AllowNull
@Column(DataType.JSONB)
output: ImportTaskOutput | null;
@Column
error: string | null;
// associations
@BelongsTo(() => Import, "importId")
import: Import<T>;
@ForeignKey(() => Import)
@Column(DataType.UUID)
importId: string;
}
export default ImportTask;
+1 -1
View File
@@ -190,7 +190,7 @@ class Team extends ParanoidModel<
* @return {boolean} Whether to show email login options
*/
get emailSigninEnabled(): boolean {
return this.guestSignin && (!!env.SMTP_HOST || env.isDevelopment);
return this.guestSignin && env.EMAIL_ENABLED;
}
get url() {
+8
View File
@@ -7,6 +7,7 @@ import {
} from "sequelize";
import {
BeforeCreate,
BeforeUpdate,
BelongsTo,
Column,
DataType,
@@ -69,6 +70,13 @@ class UserAuthentication extends IdModel<
model.lastValidatedAt = new Date();
}
@BeforeUpdate
static updateValidated(model: UserAuthentication) {
if (model.changed("accessToken")) {
model.lastValidatedAt = new Date();
}
}
// instance methods
/**
+6 -1
View File
@@ -280,6 +280,7 @@ class Model<
*
* @param query The query options.
* @param callback The function to call for each batch of results
* @return The total number of results processed.
*/
static async findAllInBatches<T extends Model>(
query: Replace<FindOptions<T>, "limit", "batchLimit"> & {
@@ -287,7 +288,8 @@ class Model<
totalLimit?: number;
},
callback: (results: Array<T>, query: FindOptions<T>) => Promise<void>
) {
): Promise<number> {
let total = 0;
const mappedQuery = {
...query,
offset: query.offset ?? 0,
@@ -299,12 +301,15 @@ class Model<
do {
// @ts-expect-error this T
results = await this.findAll<T>(mappedQuery);
total += results.length;
await callback(results, mappedQuery);
mappedQuery.offset += mappedQuery.limit;
} while (
results.length >= mappedQuery.limit &&
(mappedQuery.totalLimit ?? Infinity) > mappedQuery.offset
);
return total;
}
/**
@@ -9,7 +9,7 @@ import NotificationHelper from "./NotificationHelper";
describe("NotificationHelper", () => {
describe("getCommentNotificationRecipients", () => {
it("should return users who have notification enabled for comment creation and are subscribed to the document in case of parent comment", async () => {
it("should only return users who have notification enabled for comment creation and are subscribed to the document in case of new thread", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
@@ -54,7 +54,7 @@ describe("NotificationHelper", () => {
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
});
it("should return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
it("should only return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
@@ -112,32 +112,104 @@ describe("NotificationHelper", () => {
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(notificationEnabledUserInThread.id);
});
});
describe("getDocumentNotificationRecipients", () => {
it("should return all users who have notification enabled for the event", async () => {
it("should not return users who have notification disabled for comment creation and are in the thread in case of child comment", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const notificationEnabledUser = await buildUser({
const notificationEnabledUserInThread = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
notificationSettings: { [NotificationEventType.CreateComment]: false },
});
const notificationEnabledUserNotInThread = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const notificationDisabledUser = await buildUser({
teamId: document.teamId,
notificationSettings: {
[NotificationEventType.CreateComment]: false,
},
});
await Promise.all([
buildSubscription({
userId: documentAuthor.id,
documentId: document.id,
}),
buildSubscription({
userId: notificationEnabledUserInThread.id,
documentId: document.id,
}),
buildSubscription({
userId: notificationEnabledUserNotInThread.id,
documentId: document.id,
}),
buildSubscription({
userId: notificationDisabledUser.id,
documentId: document.id,
}),
]);
const parentComment = await buildComment({
documentId: document.id,
userId: notificationEnabledUserInThread.id,
});
const childComment = await buildComment({
documentId: document.id,
userId: documentAuthor.id,
parentCommentId: parentComment.id,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
await NotificationHelper.getCommentNotificationRecipients(
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: false,
actorId: documentAuthor.id,
});
childComment,
childComment.createdById
);
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
expect(recipients.length).toEqual(0);
});
it("should return users who have notification enabled and are in the thread but not explicitly subscribed to document", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const notificationEnabledUserInThread = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
await buildUser({
teamId: document.teamId,
notificationSettings: {
[NotificationEventType.CreateComment]: false,
},
});
const parentComment = await buildComment({
documentId: document.id,
userId: notificationEnabledUserInThread.id,
});
const childComment = await buildComment({
documentId: document.id,
userId: documentAuthor.id,
parentCommentId: parentComment.id,
});
const recipients =
await NotificationHelper.getCommentNotificationRecipients(
document,
childComment,
childComment.createdById
);
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(notificationEnabledUserInThread.id);
});
});
describe("getDocumentNotificationRecipients", () => {
it("should return users who have subscribed to the document", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
@@ -150,11 +222,17 @@ describe("NotificationHelper", () => {
documentId: document.id,
});
const deletedUser = await buildUser({ teamId: document.teamId });
await buildSubscription({
userId: deletedUser.id,
documentId: document.id,
});
await deletedUser.destroy();
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: documentAuthor.id,
});
@@ -178,7 +256,6 @@ describe("NotificationHelper", () => {
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: documentAuthor.id,
});
@@ -216,7 +293,6 @@ describe("NotificationHelper", () => {
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: documentAuthor.id,
});
@@ -235,20 +311,19 @@ describe("NotificationHelper", () => {
});
const notificationEnabledUser = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
notificationSettings: { [NotificationEventType.PublishDocument]: true },
});
// suspended user
await buildUser({
suspendedAt: new Date(),
teamId: document.teamId,
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
notificationSettings: { [NotificationEventType.PublishDocument]: true },
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: false,
notificationType: NotificationEventType.PublishDocument,
actorId: documentAuthor.id,
});
+75 -48
View File
@@ -14,7 +14,7 @@ import {
Comment,
View,
} from "@server/models";
import { can } from "@server/policies";
import { canUserAccessDocument } from "@server/utils/permissions";
import { ProsemirrorHelper } from "./ProsemirrorHelper";
export default class NotificationHelper {
@@ -60,18 +60,12 @@ export default class NotificationHelper {
comment: Comment,
actorId: string
): Promise<User[]> => {
let recipients = await this.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.CreateComment,
onlySubscribers: !comment.parentCommentId,
actorId,
});
let recipients: User[];
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(NotificationEventType.CreateComment)
);
if (recipients.length > 0 && comment.parentCommentId) {
// If this is a reply to another comment, we want to notify all users
// that are involved in the thread of this comment (i.e. the original
// comment and all replies to it).
if (comment.parentCommentId) {
const contextComments = await Comment.findAll({
attributes: ["createdById", "data"],
where: {
@@ -95,13 +89,37 @@ export default class NotificationHelper {
const userIdsInThread = uniq([
...createdUserIdsInThread,
...mentionedUserIdsInThread,
]);
recipients = recipients.filter((r) => userIdsInThread.includes(r.id));
]).filter((userId) => userId !== actorId);
recipients = await User.findAll({
where: {
id: {
[Op.in]: userIdsInThread,
},
teamId: document.teamId,
},
});
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(NotificationEventType.CreateComment)
);
} else {
recipients = await this.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.CreateComment,
actorId,
// We will check below, this just prevents duplicate queries
disableAccessCheck: true,
});
}
const filtered: User[] = [];
for (const recipient of recipients) {
if (recipient.isSuspended) {
continue;
}
// If this recipient has viewed the document since the comment was made
// then we can avoid sending them a useless notification, yay.
const view = await View.findOne({
@@ -119,7 +137,13 @@ export default class NotificationHelper {
"processor",
`suppressing notification to ${recipient.id} because doc viewed`
);
} else {
continue;
}
// Check the recipient has access to the collection this document is in. Just
// because they are subscribed doesn't mean they still have access to read
// the document.
if (await canUserAccessDocument(recipient, document.id)) {
filtered.push(recipient);
}
}
@@ -132,74 +156,77 @@ export default class NotificationHelper {
*
* @param document The document to get recipients for.
* @param notificationType The notification type for which to find the recipients.
* @param onlySubscribers Whether to consider only the users who have active subscription to the document.
* @param actorId The id of the user that performed the action.
* @param disableAccessCheck Whether to disable the access check for the document.
* @returns A list of recipients
*/
public static getDocumentNotificationRecipients = async ({
document,
notificationType,
onlySubscribers,
actorId,
disableAccessCheck = false,
}: {
document: Document;
notificationType: NotificationEventType;
onlySubscribers: boolean;
actorId: string;
disableAccessCheck?: boolean;
}): Promise<User[]> => {
// First find all the users that have notifications enabled for this event
// type at all and aren't the one that performed the action.
let recipients = await User.findAll({
where: {
id: {
[Op.ne]: actorId,
let recipients: User[];
if (notificationType === NotificationEventType.PublishDocument) {
recipients = await User.findAll({
where: {
id: {
[Op.ne]: actorId,
},
teamId: document.teamId,
notificationSettings: {
[notificationType]: true,
},
},
teamId: document.teamId,
},
});
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(notificationType)
);
// Filter further to only those that have a subscription to the document…
if (onlySubscribers) {
});
} else {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: recipients.map((recipient) => recipient.id),
userId: {
[Op.ne]: actorId,
},
event: SubscriptionType.Document,
[Op.or]: [
{ collectionId: document.collectionId },
{ documentId: document.id },
],
},
include: [
{
association: "user",
required: true,
},
],
});
const subscribedUserIds = subscriptions.map(
(subscription) => subscription.userId
);
recipients = recipients.filter((recipient) =>
subscribedUserIds.includes(recipient.id)
);
recipients = subscriptions.map((s) => s.user);
}
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(notificationType)
);
const filtered = [];
for (const recipient of recipients) {
if (!recipient.email || recipient.isSuspended) {
if (recipient.isSuspended) {
continue;
}
// Check the recipient has access to the collection this document is in. Just
// because they are subscribed doesn't mean they still have access to read
// the document.
const doc = await Document.findByPk(document.id, {
userId: recipient.id,
});
if (can(recipient, "read", doc)) {
if (
disableAccessCheck ||
(await canUserAccessDocument(recipient, document.id))
) {
filtered.push(recipient);
}
}
@@ -0,0 +1,20 @@
import { v4 as uuidv4 } from "uuid";
import env from "@server/env";
import SubscriptionHelper from "./SubscriptionHelper";
describe("SubscriptionHelper", () => {
describe("unsubscribeUrl", () => {
it("should return a valid unsubscribe URL", () => {
const userId = uuidv4();
const documentId = uuidv4();
const unsubscribeUrl = SubscriptionHelper.unsubscribeUrl(
userId,
documentId
);
expect(unsubscribeUrl).toContain(`${env.URL}/api/subscriptions.delete`);
expect(unsubscribeUrl).toContain(`userId=${userId}`);
expect(unsubscribeUrl).toContain(`documentId=${documentId}`);
});
});
});
+14 -3
View File
@@ -1,4 +1,5 @@
import crypto from "crypto";
import queryString from "query-string";
import env from "@server/env";
/**
@@ -15,12 +16,22 @@ export default class SubscriptionHelper {
* @returns The unsubscribe URL
*/
public static unsubscribeUrl(userId: string, documentId: string) {
return `${env.URL}/api/subscriptions.delete?token=${this.unsubscribeToken(
const token = this.unsubscribeToken(userId, documentId);
return `${env.URL}/api/subscriptions.delete?${queryString.stringify({
token,
userId,
documentId
)}&userId=${userId}&documentId=${documentId}`;
documentId,
})}`;
}
/**
* Generate a token for unsubscribing a user from a document or collection.
*
* @param userId The user ID to unsubscribe
* @param documentId The document ID to unsubscribe from
* @returns The unsubscribe token
*/
public static unsubscribeToken(userId: string, documentId: string) {
const hash = crypto.createHash("sha256");
hash.update(`${userId}-${env.SECRET_KEY}-${documentId}`);
+4
View File
@@ -24,6 +24,10 @@ export { default as Group } from "./Group";
export { default as GroupUser } from "./GroupUser";
export { default as Import } from "./Import";
export { default as ImportTask } from "./ImportTask";
export { default as Integration } from "./Integration";
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
+40 -1
View File
@@ -10,6 +10,7 @@ import {
buildDocument,
buildDraftDocument,
buildCollection,
buildAdmin,
} from "@server/test/factories";
import { serialize } from "./index";
@@ -355,7 +356,10 @@ describe("read document", () => {
});
describe("read_write document", () => {
for (const role of Object.values(UserRole)) {
const nonAdminRoles = Object.values(UserRole).filter(
(role) => role !== UserRole.Admin
);
for (const role of nonAdminRoles) {
it(`should allow write permissions for ${role}`, async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, role });
@@ -391,6 +395,41 @@ describe("read_write document", () => {
expect(abilities.move).toEqual(false);
});
}
it(`should allow write permissions for admin`, async () => {
const team = await buildTeam();
const user = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
const doc = await buildDocument({
teamId: team.id,
collectionId: collection.id,
});
await UserMembership.create({
userId: user.id,
documentId: doc.id,
permission: DocumentPermission.ReadWrite,
createdById: user.id,
});
// reload to get membership
const document = await Document.findByPk(doc.id, { userId: user.id });
const abilities = serialize(user, document);
expect(abilities.read).toBeTruthy();
expect(abilities.download).toBeTruthy();
expect(abilities.update).toBeTruthy();
expect(abilities.delete).toBeTruthy();
expect(abilities.subscribe).toBeTruthy();
expect(abilities.unsubscribe).toBeTruthy();
expect(abilities.comment).toBeTruthy();
expect(abilities.createChildDocument).toBeTruthy();
expect(abilities.manageUsers).toBeTruthy();
expect(abilities.archive).toBeTruthy();
expect(abilities.share).toEqual(false);
expect(abilities.move).toEqual(false);
});
});
describe("manage document", () => {
+3
View File
@@ -125,6 +125,7 @@ allow(User, "manageUsers", Document, (actor, document) =>
can(actor, "update", document),
or(
includesMembership(document, [DocumentPermission.Admin]),
and(isTeamAdmin(actor, document), can(actor, "read", document)),
can(actor, "updateDocument", document?.collection),
!!document?.isDraft && actor.id === document?.createdById
)
@@ -136,6 +137,7 @@ allow(User, "duplicate", Document, (actor, document) =>
can(actor, "update", document),
or(
includesMembership(document, [DocumentPermission.Admin]),
and(isTeamAdmin(actor, document), can(actor, "read", document)),
can(actor, "updateDocument", document?.collection),
!!document?.isDraft && actor.id === document?.createdById,
and(
@@ -233,6 +235,7 @@ allow(User, "archive", Document, (actor, document) =>
can(actor, "update", document),
or(
includesMembership(document, [DocumentPermission.Admin]),
and(isTeamAdmin(actor, document), can(actor, "read", document)),
can(actor, "updateDocument", document?.collection)
)
)
+1 -1
View File
@@ -5,7 +5,7 @@ import { and, isTeamAdmin, isTeamMutable, or } from "./utils";
allow(
User,
["createFileOperation", "createImport", "createExport"],
["createFileOperation", "createExport"],
Team,
// Note: Not checking for isTeamMutable here because we want to allow exporting data in read-only.
isTeamAdmin

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