Compare commits

...

30 Commits

Author SHA1 Message Date
codegen-sh[bot] a8c2612734 Add admin role to GroupUser
This change adds an admin role to GroupUser that allows group admins to:
1. Administer other users in the group
2. Change the group name

Changes include:
- Database migration to add isAdmin field to group_users table
- Updated GroupUser model to include isAdmin field
- Added isGroupAdmin policy utility function
- Updated group policy to allow group admins to update groups
- Added API endpoints for managing admin status
- Updated GroupUsersStore to handle admin functionality
- Added tests for the new functionality
2025-08-28 09:40:16 +00:00
Tom Moor 8c11b6cfc8 Map export endpoint to read permissions (#10019) 2025-08-27 04:29:11 -04:00
Tom Moor d858289159 fix: Image caption parsed as sep paragraph on copy/paste (#10020)
closes #9985
2025-08-27 04:29:01 -04:00
Tom Moor 52d420bd98 chore: Suppress lint warnings in CI (confuses AI) (#10021)
Auto fix where possible in pre-commit hook
2025-08-27 03:03:14 -04:00
dependabot[bot] 386eebb117 chore(deps): bump tmp from 0.2.4 to 0.2.5 (#10023)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.4 to 0.2.5.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.4...v0.2.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 03:03:00 -04:00
dependabot[bot] d0993c3393 chore(deps): bump mermaid from 11.10.0 to 11.10.1 (#10024)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.10.0 to 11.10.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.10.0...mermaid@11.10.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 03:02:52 -04:00
dependabot[bot] 54d17503bf chore(deps): bump @css-inline/css-inline-wasm from 0.14.3 to 0.17.0 (#10025)
Bumps [@css-inline/css-inline-wasm](https://github.com/Stranger6667/css-inline) from 0.14.3 to 0.17.0.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/v0.14.3...v0.17.0)

---
updated-dependencies:
- dependency-name: "@css-inline/css-inline-wasm"
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 03:02:41 -04:00
Hemachandar 0de2a3dc98 Skip Notion linked database views (#10018) 2025-08-26 16:14:44 -04:00
dependabot[bot] 73ac18bbde chore(deps): bump the aws group with 5 updates (#10006)
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.864.0` | `3.873.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.864.0` | `3.873.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.864.0` | `3.873.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.864.0` | `3.873.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.864.0` | `3.873.0` |


Updates `@aws-sdk/client-s3` from 3.864.0 to 3.873.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.873.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.864.0 to 3.873.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.873.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.864.0 to 3.873.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.873.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.864.0 to 3.873.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.873.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.864.0 to 3.873.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.873.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 10:51:05 -04:00
Hemachandar 18dcef8ce4 Include collection attachments in json export (#10010) 2025-08-26 10:50:55 -04:00
Hemachandar 7458228df0 Use leafText when converting mention nodes to its text content (#10011) 2025-08-26 10:45:27 -04:00
dependabot[bot] 7c93f8a039 chore(deps): bump @linear/sdk from 39.0.0 to 39.2.1 (#10012)
Bumps [@linear/sdk](https://github.com/linear/linear) from 39.0.0 to 39.2.1.
- [Release notes](https://github.com/linear/linear/releases)
- [Commits](https://github.com/linear/linear/compare/@linear/sdk@39.0.0...@linear/sdk@39.2.1)

---
updated-dependencies:
- dependency-name: "@linear/sdk"
  dependency-version: 39.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 10:39:49 -04:00
dependabot[bot] d6a126d974 chore(deps): bump the radix-ui group with 8 updates (#10013)
Bumps the radix-ui group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives) | `1.1.11` | `1.1.12` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives) | `1.1.14` | `1.1.15` |
| [@radix-ui/react-one-time-password-field](https://github.com/radix-ui/primitives) | `0.1.7` | `0.1.8` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives) | `1.1.14` | `1.1.15` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives) | `2.2.5` | `2.2.6` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives) | `1.2.5` | `1.2.6` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives) | `1.1.12` | `1.1.13` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives) | `1.2.7` | `1.2.8` |


Updates `@radix-ui/react-collapsible` from 1.1.11 to 1.1.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dialog` from 1.1.14 to 1.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-one-time-password-field` from 0.1.7 to 0.1.8
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-popover` from 1.1.14 to 1.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-select` from 2.2.5 to 2.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-switch` from 1.2.5 to 1.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tabs` from 1.1.12 to 1.1.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tooltip` from 1.2.7 to 1.2.8
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-collapsible"
  dependency-version: 1.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-one-time-password-field"
  dependency-version: 0.1.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
...

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@getoutline.com>
2025-08-26 10:39:38 -04:00
dependabot[bot] 779fb1d568 chore(deps): bump core-js from 3.41.0 to 3.45.1 (#10007)
Bumps [core-js](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js) from 3.41.0 to 3.45.1.
- [Release notes](https://github.com/zloirock/core-js/releases)
- [Changelog](https://github.com/zloirock/core-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zloirock/core-js/commits/v3.45.1/packages/core-js)

---
updated-dependencies:
- dependency-name: core-js
  dependency-version: 3.45.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 03:04:29 -04:00
dependabot[bot] a0ce14f2a2 chore(deps): bump @dotenvx/dotenvx from 1.48.4 to 1.49.0 (#10008)
Bumps [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) from 1.48.4 to 1.49.0.
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.48.4...v1.49.0)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 03:04:21 -04:00
dependabot[bot] 091abf0b9d chore(deps): bump @fortawesome/react-fontawesome (#10009) 2025-08-26 02:43:17 -04:00
dependabot[bot] 342c42194e chore(deps): bump @radix-ui/react-popover from 1.1.14 to 1.1.15 (#10004) 2025-08-26 02:02:05 -04:00
Hemachandar 8383a0ee1e fix: Sync draft comment from local storage when navigating between documents (#9997) 2025-08-26 02:15:43 +05:30
Hemachandar 19a696942e fix: Use event keycode for determining ToC shortcut keys (#10002) 2025-08-26 02:15:28 +05:30
Hemachandar f1a5e95f77 chore: Dependabot group for radix-ui (#10001) 2025-08-26 01:41:15 +05:30
dependabot[bot] 99fedfa354 chore(deps): bump mermaid from 11.9.0 to 11.10.0 (#9983)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.9.0 to 11.10.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.9.0...mermaid@11.10.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.10.0
  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-08-21 07:56:00 -04:00
codegen-sh[bot] 9da73202c7 chore: upgrade vite-plugin-pwa to v1.0.3 and rollup-plugin-webpack-stats to v2.1.3 (#9982)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-08-21 07:55:13 -04:00
Tom Moor 30db7bc554 chore: Remove focused comment state from router (#9962)
* chore: Refactor comment state from router

* Handle edge cases

* refactor

* feedback
2025-08-19 10:51:08 -04:00
Tom Moor b40eaf4184 refactor: getByUrl (#9975) 2025-08-19 08:30:05 -04:00
Tom Moor 3aff344501 fix: Unable to use DATABASE_HOST env (#9977) 2025-08-19 08:29:53 -04:00
dependabot[bot] 0f812d70c1 chore(deps): bump @radix-ui/react-dropdown-menu from 2.1.15 to 2.1.16 (#9969)
Bumps [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives) from 2.1.15 to 2.1.16.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 20:03:53 -04:00
dependabot[bot] 125e9c2e0b chore(deps): bump the babel group with 4 updates (#9968)
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/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) and [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli).


Updates `@babel/core` from 7.28.0 to 7.28.3
- [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.28.3/packages/babel-core)

Updates `@babel/plugin-transform-regenerator` from 7.28.1 to 7.28.3
- [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.28.3/packages/babel-plugin-transform-regenerator)

Updates `@babel/preset-env` from 7.28.0 to 7.28.3
- [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.28.3/packages/babel-preset-env)

Updates `@babel/cli` from 7.28.0 to 7.28.3
- [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.28.3/packages/babel-cli)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 19:32:55 -04:00
dependabot[bot] 95402b4b52 chore(deps): bump dd-trace from 5.62.0 to 5.63.0 (#9966)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.62.0 to 5.63.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.62.0...v5.63.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.63.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 19:32:42 -04:00
dependabot[bot] d01e3ad09c chore(deps): bump ukkonen from 2.1.0 to 2.2.0 (#9967)
Bumps [ukkonen](https://github.com/sunesimonsen/ukkonen) from 2.1.0 to 2.2.0.
- [Changelog](https://github.com/sunesimonsen/ukkonen/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sunesimonsen/ukkonen/compare/v2.1.0...v2.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 19:32:17 -04:00
dependabot[bot] edb6d44bdc chore(deps-dev): bump discord-api-types from 0.37.119 to 0.38.20 (#9965)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.37.119 to 0.38.20.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.37.119...0.38.20)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-version: 0.38.20
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 19:31:50 -04:00
38 changed files with 1009 additions and 655 deletions
+3
View File
@@ -26,3 +26,6 @@ updates:
aws:
patterns:
- "@aws-sdk/*"
radix-ui:
patterns:
- "@radix-ui/*"
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint
- run: yarn lint --quiet
types:
needs: build
-21
View File
@@ -3,7 +3,6 @@ import { toast } from "sonner";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
@@ -50,16 +49,6 @@ export const resolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onResolve();
toast.success(t("Thread resolved"));
},
@@ -82,16 +71,6 @@ export const unresolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onUnresolve();
},
});
+11
View File
@@ -11,9 +11,15 @@ class DocumentContext {
/** The editor instance for this document */
editor?: Editor;
/** The ID of the currently focused comment, or null if no comment is focused */
@observable
focusedCommentId: string | null = null;
/** Whether the editor has been initialized */
@observable
isEditorInitialized: boolean = false;
/** The headings in the document */
@observable
headings: Heading[] = [];
@@ -39,6 +45,11 @@ class DocumentContext {
this.isEditorInitialized = initialized;
};
@action
setFocusedCommentId = (commentId: string | null) => {
this.focusedCommentId = commentId;
};
@action
updateState = () => {
this.updateHeadings();
+1 -1
View File
@@ -57,7 +57,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
}
if (isDocumentUrl(navigateTo)) {
const document = documents.getByUrl(navigateTo);
const document = documents.get(navigateTo);
if (document) {
navigateTo = document.path;
}
+35 -4
View File
@@ -1,13 +1,44 @@
import { useLocation } from "react-router-dom";
import useQuery from "~/hooks/useQuery";
import useStores from "./useStores";
import { useDocumentContext } from "~/components/DocumentContext";
import { useEffect } from "react";
import { useHistory } from "react-router-dom";
export default function useFocusedComment() {
/**
* Custom hook to retrieve the currently focused comment in a document.
* It checks both the document context and the query string for the comment ID.
* If a comment is focused, it returns the comment itself or the parent thread if it exists
*/
export function useFocusedComment() {
const { comments } = useStores();
const location = useLocation<{ commentId?: string }>();
const context = useDocumentContext();
const query = useQuery();
const focusedCommentId = location.state?.commentId || query.get("commentId");
const focusedCommentId = context.focusedCommentId || query.get("commentId");
const comment = focusedCommentId ? comments.get(focusedCommentId) : undefined;
const history = useHistory();
// Move the query string into context
useEffect(() => {
if (focusedCommentId && context.focusedCommentId !== focusedCommentId) {
context.setFocusedCommentId(focusedCommentId);
}
}, [focusedCommentId, context]);
// Clear query string from location
useEffect(() => {
if (focusedCommentId) {
const params = new URLSearchParams(history.location.search);
if (params.get("commentId") === focusedCommentId) {
params.delete("commentId");
history.replace({
pathname: history.location.pathname,
search: params.toString(),
state: history.location.state,
});
}
}
}, [focusedCommentId, history]);
return comment?.parentCommentId
? comments.get(comment.parentCommentId)
+10 -1
View File
@@ -1,9 +1,10 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { Primitive } from "utility-types";
import Storage from "@shared/utils/Storage";
import { isBrowser } from "@shared/utils/browser";
import Logger from "~/utils/Logger";
import useEventListener from "./useEventListener";
import usePrevious from "./usePrevious";
type Options = {
/* Whether to listen and react to changes in the value from other tabs */
@@ -41,6 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
defaultValue: T,
options?: Options
): [T, (value: T) => void] {
const previousKey = usePrevious(key);
const [storedValue, setStoredValue] = useState(() => {
if (!isBrowser) {
return defaultValue;
@@ -65,6 +67,13 @@ export default function usePersistedState<T extends Primitive | object>(
[key, storedValue]
);
// Sync state when key changes
useEffect(() => {
if (previousKey !== key) {
setStoredValue(Storage.get(key) ?? defaultValue);
}
}, [previousKey, key, defaultValue]);
// Listen to the key changing in other tabs so we can keep UI in sync
useEventListener("storage", (event: StorageEvent) => {
if (options?.listen !== false && event.key === key && event.newValue) {
+3
View File
@@ -22,6 +22,9 @@ class GroupUser extends Model {
/** The group that the user belongs to. */
@Relation(() => Group, { onDelete: "cascade" })
group: Group;
/** Whether the user is an admin of the group. */
isAdmin: boolean;
}
export default GroupUser;
+1 -2
View File
@@ -75,8 +75,7 @@ const CollectionScene = observer(function _CollectionScene() {
const id = params.id || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const collection: Collection | null | undefined = collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
@@ -1,23 +1,22 @@
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { InputSelect, Option } from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useQuery from "~/hooks/useQuery";
import { CommentSortType } from "~/types";
const CommentSortMenu = () => {
type Props = {
/** Callback when the sort type changes */
onChange?: (sortType: CommentSortType | "resolved") => void;
/** Whether resolved comments are being viewed */
viewingResolved?: boolean;
};
const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
const { t } = useTranslation();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const history = useHistory();
const user = useCurrentUser();
const params = useQuery();
const preferredSortType = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
@@ -25,42 +24,23 @@ const CommentSortMenu = () => {
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved ? "resolved" : preferredSortType;
const handleChange = React.useCallback(
(val: string) => {
if (val === "resolved") {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
return;
(val: CommentSortType | "resolved") => {
if (val !== "resolved") {
if (val !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
val === CommentSortType.OrderInDocument
);
void user.save();
}
}
const sortType = val as CommentSortType;
if (sortType !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
sortType === CommentSortType.OrderInDocument
);
void user.save();
}
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
onChange?.(val);
},
[history, location, sidebarContext, user, preferredSortType]
[user, onChange, preferredSortType]
);
const options: Option[] = React.useMemo(
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -17,7 +16,6 @@ import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -51,14 +49,11 @@ function CommentThread({
collapseNumDisplayed = 3,
}: Props) {
const [scrollOnMount] = React.useState(focused && !window.location.hash);
const { editor } = useDocumentContext();
const { editor, setFocusedCommentId } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
const replyRef = React.useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
const user = useCurrentUser();
@@ -102,14 +97,7 @@ function CommentThread({
!(event.target as HTMLElement).classList.contains("comment") &&
event.defaultPrevented === false
) {
history.replace({
search: location.search,
pathname: location.pathname,
state: {
commentId: undefined,
sidebarContext,
},
});
setFocusedCommentId(null);
}
});
@@ -118,15 +106,7 @@ function CommentThread({
}, [editor, thread.id]);
const handleClickThread = () => {
history.replace({
// Clear any commentId from the URL when explicitly focusing a thread
search: thread.isResolved ? "resolved=" : "",
pathname: location.pathname.replace(/\/history$/, ""),
state: {
commentId: thread.id,
sidebarContext,
},
});
setFocusedCommentId(thread.id);
};
const handleClickExpand = (ev: React.SyntheticEvent) => {
@@ -30,6 +30,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
import CommentEditor from "./CommentEditor";
import { HighlightedText } from "./HighlightText";
import { useDocumentContext } from "~/components/DocumentContext";
/**
* Hook to calculate if we should display a timestamp on a comment
@@ -111,6 +112,7 @@ function CommentThreadItem({
onEditStart,
onEditEnd,
}: Props) {
const { setFocusedCommentId } = useDocumentContext();
const { t } = useTranslation();
const user = useCurrentUser();
const [data, setData] = React.useState(comment.data);
@@ -154,6 +156,9 @@ function CommentThreadItem({
const handleUpdate = React.useCallback(
(attrs: { resolved: boolean }) => {
onUpdate?.(comment.id, attrs);
if ("resolved" in attrs) {
setFocusedCommentId(null);
}
},
[comment.id, onUpdate]
);
+19 -6
View File
@@ -13,7 +13,7 @@ import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import { useFocusedComment } from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -31,11 +31,13 @@ function Comments() {
const { editor, isEditorInitialized } = useDocumentContext();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
const document = documents.getByUrl(match.params.documentSlug);
const document = documents.get(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
const query = useQuery();
const [viewingResolved, setViewingResolved] = useState(
query.get("resolved") !== null || focusedComment?.isResolved || false
);
const scrollableRef = useRef<HTMLDivElement | null>(null);
const prevThreadCount = useRef(0);
const isAtBottom = useRef(true);
@@ -43,6 +45,13 @@ function Comments() {
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
// Account for the resolved status of the comment changing
useEffect(() => {
if (focusedComment && focusedComment.isResolved !== viewingResolved) {
setViewingResolved(focusedComment.isResolved);
}
}, [focusedComment, viewingResolved]);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document?.id}-new`,
undefined
@@ -57,7 +66,6 @@ function Comments() {
}
: { type: CommentSortType.MostRecent };
const viewingResolved = params.get("resolved") === "";
const threads = !document
? []
: viewingResolved
@@ -124,7 +132,12 @@ function Comments() {
title={
<Flex align="center" justify="space-between" auto>
<span>{t("Comments")}</span>
<CommentSortMenu />
<CommentSortMenu
viewingResolved={viewingResolved}
onChange={(val) => {
setViewingResolved(val === "resolved");
}}
/>
</Flex>
}
onClose={() => ui.set({ commentsExpanded: false })}
@@ -67,9 +67,7 @@ function DataLoader({ match, children }: Props) {
const { revisionId, documentSlug } = match.params;
// Allows loading by /doc/slug-<urlId> or /doc/<id>
const document =
documents.getByUrl(match.params.documentSlug) ??
documents.get(match.params.documentSlug);
const document = documents.get(match.params.documentSlug);
if (document) {
setDocument(document);
+8 -29
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useHistory, useRouteMatch } from "react-router-dom";
import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import Text from "@shared/components/Text";
import { richExtensions, withComments } from "@shared/editor/nodes";
@@ -19,7 +19,7 @@ import Time from "~/components/Time";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import { useFocusedComment } from "~/hooks/useFocusedComment";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
@@ -59,11 +59,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const titleRef = React.useRef<RefHandle>(null);
const { t } = useTranslation();
const match = useRouteMatch();
const { setFocusedCommentId } = useDocumentContext();
const focusedComment = useFocusedComment();
const { ui, comments } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const team = useCurrentTeam({ rejectOnEmpty: false });
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const params = useQuery();
const {
@@ -95,18 +95,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
(focusedComment.isResolved && !viewingResolved) ||
(!focusedComment.isResolved && viewingResolved)
) {
history.replace({
search: focusedComment.isResolved ? "resolved=" : "",
pathname: location.pathname,
state: {
commentId: focusedComment.id,
sidebarContext,
},
});
setFocusedCommentId(focusedComment.id);
}
ui.set({ commentsExpanded: true });
}
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
}, [focusedComment, ui, document.id, params]);
// Save document when blurring title, but delay so that if clicking on a
// button this is allowed to execute first.
@@ -127,16 +120,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[focusAtStart, ref]
);
const handleClickComment = React.useCallback(
(commentId: string) => {
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId, sidebarContext },
});
},
[history, sidebarContext]
);
// Create a Comment model in local store when a comment mark is created, this
// acts as a local draft before submission.
const handleDraftComment = React.useCallback(
@@ -156,13 +139,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
);
comment.id = commentId;
comments.add(comment);
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId, sidebarContext },
});
setFocusedCommentId(commentId);
},
[comments, user?.id, props.id, history, sidebarContext]
[comments, user?.id, props.id]
);
// Soft delete the Comment model when associated mark is totally removed.
@@ -258,7 +237,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
userId={user?.id}
focusedCommentId={focusedComment?.id}
onClickCommentMark={
commentingEnabled && can.comment ? handleClickComment : undefined
commentingEnabled && can.comment ? setFocusedCommentId : undefined
}
onCreateCommentMark={
commentingEnabled && can.comment ? handleDraftComment : undefined
+1 -1
View File
@@ -166,7 +166,7 @@ function DocumentHeader({
);
useKeyDown(
(event) => event.ctrlKey && event.altKey && event.key === "˙",
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
handleToggle,
{
allowInInput: true,
+1 -1
View File
@@ -34,7 +34,7 @@ function History() {
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const document = documents.getByUrl(match.params.documentSlug);
const document = documents.get(match.params.documentSlug);
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
const [eventsOffset, setEventsOffset] = React.useState(0);
+1 -6
View File
@@ -1,5 +1,4 @@
import invariant from "invariant";
import find from "lodash/find";
import isEmpty from "lodash/isEmpty";
import orderBy from "lodash/orderBy";
import sortBy from "lodash/sortBy";
@@ -186,7 +185,7 @@ export default class CollectionsStore extends Store<Collection> {
statusFilter: [CollectionStatusFilter.Archived],
});
get(id: string): Collection | undefined {
get(id: string = ""): Collection | undefined {
return (
this.data.get(id) ??
this.orderedData.find((collection) => id.endsWith(collection.urlId))
@@ -242,10 +241,6 @@ export default class CollectionsStore extends Store<Collection> {
return this.orderedData.map((collection) => collection.asNavigationNode);
}
getByUrl(url: string): Collection | null | undefined {
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
}
async delete(collection: Collection) {
await super.delete(collection);
await this.rootStore.documents.fetchRecentlyUpdated();
+1 -8
View File
@@ -1,7 +1,6 @@
import invariant from "invariant";
import compact from "lodash/compact";
import filter from "lodash/filter";
import find from "lodash/find";
import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
@@ -460,7 +459,7 @@ export default class DocumentsStore extends Store<Document> {
@action
prefetchDocument = async (id: string) => {
if (!this.data.get(id) && !this.getByUrl(id)) {
if (!this.get(id)) {
return this.fetch(id, {
prefetch: true,
});
@@ -746,12 +745,6 @@ export default class DocumentsStore extends Store<Document> {
return subscription?.delete();
};
getByUrl = (url = ""): Document | undefined =>
find(
this.orderedData,
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
);
getCollectionForDocument(document: Document) {
return document.collectionId
? this.rootStore.collections.get(document.collectionId)
+33 -1
View File
@@ -43,10 +43,19 @@ export default class GroupUsersStore extends Store<GroupUser> {
};
@action
async create({ groupId, userId }: { groupId: string; userId: string }) {
async create({
groupId,
userId,
isAdmin = false,
}: {
groupId: string;
userId: string;
isAdmin?: boolean;
}) {
const res = await client.post("/groups.add_user", {
id: groupId,
userId,
isAdmin,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
@@ -70,6 +79,29 @@ export default class GroupUsersStore extends Store<GroupUser> {
});
}
@action
async updateUser({
groupId,
userId,
isAdmin,
}: {
groupId: string;
userId: string;
isAdmin: boolean;
}) {
const res = await client.post("/groups.update_user", {
id: groupId,
userId,
isAdmin,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
}
@action
removeGroupMemberships = (groupId: string) => {
this.data.forEach((_, key) => {
+1 -1
View File
@@ -33,7 +33,7 @@ export function settingsPath(...args: string[]): string {
export function commentPath(document: Document, comment: Comment): string {
return `${documentPath(document)}?commentId=${comment.id}${
comment.isResolved ? "&resolved=" : ""
comment.isResolved ? "&resolved=1" : ""
}`;
}
+1 -1
View File
@@ -3,7 +3,7 @@ export default {
// TypeScript files
"**/*.[tj]s?(x)": [
(f) => `prettier --write ${f.join(" ")}`,
(f) => (f.length > 20 ? `yarn lint` : `oxlint ${f.join(" ")}`),
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix`),
() => `yarn build:i18n`,
() => "git add shared/i18n/locales/en_US/translation.json",
],
+30 -30
View File
@@ -51,32 +51,32 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.864.0",
"@aws-sdk/lib-storage": "3.864.0",
"@aws-sdk/s3-presigned-post": "3.864.0",
"@aws-sdk/s3-request-presigner": "3.864.0",
"@aws-sdk/signature-v4-crt": "^3.864.0",
"@babel/core": "^7.27.7",
"@aws-sdk/client-s3": "3.873.0",
"@aws-sdk/lib-storage": "3.873.0",
"@aws-sdk/s3-presigned-post": "3.873.0",
"@aws-sdk/s3-request-presigner": "3.873.0",
"@aws-sdk/signature-v4-crt": "^3.873.0",
"@babel/core": "^7.28.3",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-regenerator": "^7.28.1",
"@babel/preset-env": "^7.28.0",
"@babel/plugin-transform-regenerator": "^7.28.3",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.12.0",
"@css-inline/css-inline-wasm": "^0.14.3",
"@css-inline/css-inline-wasm": "^0.17.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dotenvx/dotenvx": "^1.48.4",
"@dotenvx/dotenvx": "^1.49.0",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.5",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.3",
"@fortawesome/react-fontawesome": "^0.2.6",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@hocuspocus/extension-redis": "1.1.2",
"@hocuspocus/extension-throttle": "1.1.2",
@@ -84,22 +84,22 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^39.0.0",
"@linear/sdk": "^39.2.1",
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.4",
"@octokit/webhooks": "^13.9.1",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-one-time-password-field": "^0.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-one-time-password-field": "^0.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.2",
"@sentry/node": "^7.120.4",
"@sentry/react": "^7.120.4",
@@ -123,11 +123,11 @@
"content-disposition": "^0.5.4",
"cookie": "^0.7.0",
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.37.0",
"core-js": "^3.45.1",
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.62.0",
"dd-trace": "^5.63.0",
"diff": "^5.2.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
@@ -170,7 +170,7 @@
"markdown-it": "^14.1.0",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
"mermaid": "11.9.0",
"mermaid": "11.10.1",
"mime-types": "^3.0.1",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
@@ -255,17 +255,17 @@
"styled-normalize": "^8.1.1",
"throng": "^5.0.0",
"tiny-cookie": "^2.5.1",
"tmp": "^0.2.4",
"tmp": "^0.2.5",
"tunnel-agent": "^0.6.0",
"turndown": "^7.2.0",
"ukkonen": "^2.1.0",
"ukkonen": "^2.2.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.15.15",
"vaul": "^1.1.2",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-pwa": "^1.0.2",
"vite-plugin-pwa": "1.0.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -276,7 +276,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@babel/cli": "^7.28.0",
"@babel/cli": "^7.28.3",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
@@ -351,7 +351,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.119",
"discord-api-types": "^0.38.20",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"ioredis-mock": "^8.9.0",
@@ -365,7 +365,7 @@
"prettier": "^3.6.2",
"react-refresh": "^0.17.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.1.3",
"rollup-plugin-webpack-stats": "2.1.3",
"terser": "^5.43.1",
"typescript": "^5.9.2",
"yarn-deduplicate": "^6.0.2"
@@ -21,6 +21,11 @@ type ParsePageOutput = ImportTaskOutput[number] & {
};
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
private skippableErrorMessages = [
"Database retrievals do not support linked databases",
"does not contain any data sources accessible by this API bot", // error msg for linked database views
];
/**
* Process the Notion import task.
* This fetches data from Notion and converts it to task output.
@@ -138,8 +143,8 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
if (
error.code === APIErrorCode.ObjectNotFound ||
error.code === APIErrorCode.Unauthorized ||
error.message.includes(
"Database retrievals do not support linked databases"
this.skippableErrorMessages.some((errorMsg) =>
error.message.includes(errorMsg)
)
) {
Logger.warn(
+2 -2
View File
@@ -78,7 +78,7 @@ export class Environment {
/**
* The url of the database.
*/
@IsNotEmpty()
@IsOptional()
@IsUrl({
require_tld: false,
allow_underscores: true,
@@ -91,7 +91,7 @@ export class Environment {
"DATABASE_USER",
"DATABASE_PASSWORD",
])
public DATABASE_URL = environment.DATABASE_URL ?? "";
public DATABASE_URL = this.toOptionalString(environment.DATABASE_URL);
/**
* Database host for individual component configuration.
@@ -0,0 +1,15 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("group_users", "isAdmin", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},
async down(queryInterface) {
await queryInterface.removeColumn("group_users", "isAdmin");
},
};
+3
View File
@@ -65,6 +65,9 @@ class GroupUser extends Model<
@Column(DataType.UUID)
createdById: string;
@Column(DataType.BOOLEAN)
isAdmin: boolean;
get modelId() {
return this.groupId;
}
@@ -20,6 +20,7 @@ export default class AuthenticationHelper {
info: Scope.Read,
search: Scope.Read,
documents: Scope.Read,
export: Scope.Read,
};
/**
+11 -3
View File
@@ -1,6 +1,6 @@
import { Group, User, Team } from "@server/models";
import { allow } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable } from "./utils";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, isGroupAdmin } from "./utils";
allow(User, "createGroup", Team, (actor, team) =>
and(
@@ -26,10 +26,18 @@ allow(User, "read", Group, (actor, team) =>
)
);
allow(User, ["update", "delete"], Group, (actor, team) =>
allow(User, "update", Group, async (actor, group) => {
return and(
//
await isGroupAdmin(actor, group),
isTeamMutable(actor)
);
});
allow(User, "delete", Group, (actor, group) =>
and(
//
isTeamAdmin(actor, team),
isTeamAdmin(actor, group),
isTeamMutable(actor)
)
);
+33
View File
@@ -100,3 +100,36 @@ export function isCloudHosted() {
}
return true;
}
/**
* Check if the actor is an admin of the group.
*
* @param actor The actor to check
* @param model The group model to check
* @returns True if the actor is an admin of the group
*/
export async function isGroupAdmin(
actor: User,
model: Model | null | undefined
): Promise<boolean> {
if (!model || !("id" in model)) {
return false;
}
// Team admins are always group admins
if (isTeamAdmin(actor, model)) {
return true;
}
// Check if the user is a group admin
const { GroupUser } = await import("@server/models");
const membership = await GroupUser.findOne({
where: {
userId: actor.id,
groupId: model.id,
isAdmin: true,
},
});
return !!membership;
}
+1
View File
@@ -9,6 +9,7 @@ export default function presentGroupUser(
id: `${membership.userId}-${membership.groupId}`,
userId: membership.userId,
groupId: membership.groupId,
isAdmin: membership.isAdmin,
user: options?.includeUser ? presentUser(membership.user) : undefined,
};
}
+44 -27
View File
@@ -72,6 +72,35 @@ export default class ExportJSONTask extends ExportTask {
attachments: {},
};
async function addAttachments(attachments: Attachment[]) {
await Promise.all(
attachments.map(async (attachment) => {
zip.file(
attachment.key,
new Promise<Buffer>((resolve) => {
attachment.buffer.then(resolve).catch((err) => {
Logger.warn(`Failed to read attachment from storage`, {
attachmentId: attachment.id,
teamId: attachment.teamId,
error: err.message,
});
resolve(Buffer.from(""));
});
}),
{
date: attachment.updatedAt,
createFolders: true,
}
);
output.attachments[attachment.id] = {
...omit(presentAttachment(attachment), "url"),
key: attachment.key,
};
})
);
}
async function addDocumentTree(nodes: NavigationNode[]) {
for (const node of nodes) {
const document = await Document.findByPk(node.id, {
@@ -82,7 +111,7 @@ export default class ExportJSONTask extends ExportTask {
continue;
}
const attachments = includeAttachments
const documentAttachments = includeAttachments
? await Attachment.findAll({
where: {
teamId: document.teamId,
@@ -93,32 +122,7 @@ export default class ExportJSONTask extends ExportTask {
})
: [];
await Promise.all(
attachments.map(async (attachment) => {
zip.file(
attachment.key,
new Promise<Buffer>((resolve) => {
attachment.buffer.then(resolve).catch((err) => {
Logger.warn(`Failed to read attachment from storage`, {
attachmentId: attachment.id,
teamId: attachment.teamId,
error: err.message,
});
resolve(Buffer.from(""));
});
}),
{
date: attachment.updatedAt,
createFolders: true,
}
);
output.attachments[attachment.id] = {
...omit(presentAttachment(attachment), "url"),
key: attachment.key,
};
})
);
await addAttachments(documentAttachments);
output.documents[document.id] = {
id: document.id,
@@ -146,6 +150,19 @@ export default class ExportJSONTask extends ExportTask {
}
}
const collectionAttachments = includeAttachments
? await Attachment.findAll({
where: {
teamId: collection.teamId,
id: ProsemirrorHelper.parseAttachmentIds(
DocumentHelper.toProsemirror(collection)
),
},
})
: [];
await addAttachments(collectionAttachments);
if (collection.documentStructure) {
await addDocumentTree(collection.documentStructure);
}
+177
View File
@@ -61,6 +61,7 @@ describe("#groups.update", () => {
});
expect(res.status).toEqual(403);
});
describe("when user is admin", () => {
let user: User, group: Group;
beforeEach(async () => {
@@ -91,7 +92,53 @@ describe("#groups.update", () => {
expect(body.data.name).toBe("Test");
expect(body.data.externalId).toBe("123");
});
});
describe("when user is group admin", () => {
let user: User, group: Group;
beforeEach(async () => {
user = await buildUser();
group = await buildGroup({
teamId: user.teamId,
});
// Make the user a group admin
const admin = await buildAdmin({
teamId: user.teamId,
});
await server.post("/api/groups.add_user", {
body: {
token: admin.getJwtToken(),
id: group.id,
userId: user.id,
isAdmin: true,
},
});
});
it("allows group admin to edit a group", async () => {
const res = await server.post("/api/groups.update", {
body: {
token: user.getJwtToken(),
id: group.id,
name: "Test by Group Admin",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("Test by Group Admin");
});
});
describe("when checking for noop updates", () => {
let user: User, group: Group;
beforeEach(async () => {
user = await buildAdmin();
group = await buildGroup({
teamId: user.teamId,
});
});
it("does not create an event if the update is a noop", async () => {
const res = await server.post("/api/groups.update", {
body: {
@@ -554,6 +601,27 @@ describe("#groups.add_user", () => {
expect(users.length).toEqual(1);
});
it("should add user to group as admin", async () => {
const user = await buildAdmin();
const anotherUser = await buildUser({
teamId: user.teamId,
});
const group = await buildGroup({
teamId: user.teamId,
});
const res = await server.post("/api/groups.add_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.groupMemberships[0].isAdmin).toEqual(true);
});
it("should require authentication", async () => {
const res = await server.post("/api/groups.add_user");
expect(res.status).toEqual(401);
@@ -668,3 +736,112 @@ describe("#groups.remove_user", () => {
expect(body).toMatchSnapshot();
});
});
describe("#groups.update_user", () => {
it("should update user admin status in group", async () => {
const user = await buildAdmin();
const anotherUser = await buildUser({
teamId: user.teamId,
});
const group = await buildGroup({
teamId: user.teamId,
});
// First add the user to the group
await server.post("/api/groups.add_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
},
});
// Then update the user to be an admin
const res = await server.post("/api/groups.update_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.groupMemberships[0].isAdmin).toEqual(true);
// Update the user to not be an admin
const res2 = await server.post("/api/groups.update_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: false,
},
});
const body2 = await res2.json();
expect(res2.status).toEqual(200);
expect(body2.data.groupMemberships[0].isAdmin).toEqual(false);
});
it("should require authentication", async () => {
const res = await server.post("/api/groups.update_user");
expect(res.status).toEqual(401);
});
it("should require admin", async () => {
const user = await buildUser();
const anotherUser = await buildUser({
teamId: user.teamId,
});
const group = await buildGroup({
teamId: user.teamId,
});
// Add the user to the group
const admin = await buildAdmin({
teamId: user.teamId,
});
await server.post("/api/groups.add_user", {
body: {
token: admin.getJwtToken(),
id: group.id,
userId: anotherUser.id,
},
});
// Try to update as non-admin
const res = await server.post("/api/groups.update_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: true,
},
});
expect(res.status).toEqual(403);
});
it("should 404 if user is not in group", async () => {
const user = await buildAdmin();
const anotherUser = await buildUser({
teamId: user.teamId,
});
const group = await buildGroup({
teamId: user.teamId,
});
const res = await server.post("/api/groups.update_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: true,
},
});
expect(res.status).toEqual(404);
});
});
+49 -1
View File
@@ -251,7 +251,7 @@ router.post(
validate(T.GroupsAddUserSchema),
transaction(),
async (ctx: APIContext<T.GroupsAddUserReq>) => {
const { id, userId } = ctx.input.body;
const { id, userId, isAdmin } = ctx.input.body;
const actor = ctx.state.auth.user;
const { transaction } = ctx.state;
@@ -270,11 +270,17 @@ router.post(
},
defaults: {
createdById: actor.id,
isAdmin: isAdmin || false,
},
},
{ name: "add_user" }
);
// If the user already exists in the group, update the admin status if provided
if (isAdmin !== undefined && groupUser.isAdmin !== isAdmin) {
await groupUser.update({ isAdmin });
}
groupUser.user = user;
ctx.body = {
@@ -322,4 +328,46 @@ router.post(
}
);
router.post(
"groups.update_user",
auth(),
validate(T.GroupsUpdateUserSchema),
transaction(),
async (ctx: APIContext<T.GroupsUpdateUserReq>) => {
const { id, userId, isAdmin } = ctx.input.body;
const actor = ctx.state.auth.user;
const { transaction } = ctx.state;
const group = await Group.findByPk(id, { transaction });
authorize(actor, "update", group);
const user = await User.findByPk(userId, { transaction });
authorize(actor, "read", user);
const groupUser = await GroupUser.unscoped().findOne({
where: {
groupId: group.id,
userId: user.id,
},
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!groupUser) {
ctx.throw(404, "User is not a member of this group");
}
await groupUser.update({ isAdmin });
groupUser.user = user;
ctx.body = {
data: {
users: [presentUser(user)],
groupMemberships: [presentGroupUser(groupUser, { includeUser: true })],
groups: [await presentGroup(group)],
},
};
}
);
export default router;
+13
View File
@@ -85,6 +85,8 @@ export const GroupsAddUserSchema = z.object({
body: BaseIdSchema.extend({
/** User Id */
userId: z.string().uuid(),
/** Whether the user is an admin of the group */
isAdmin: z.boolean().optional().default(false),
}),
});
@@ -98,3 +100,14 @@ export const GroupsRemoveUserSchema = z.object({
});
export type GroupsRemoveUserReq = z.infer<typeof GroupsRemoveUserSchema>;
export const GroupsUpdateUserSchema = z.object({
body: BaseIdSchema.extend({
/** User Id */
userId: z.string().uuid(),
/** Whether the user is an admin of the group */
isAdmin: z.boolean(),
}),
});
export type GroupsUpdateUserReq = z.infer<typeof GroupsUpdateUserSchema>;
+1
View File
@@ -120,6 +120,7 @@ export default class Mention extends Node {
toPlainText(node),
],
toPlainText,
leafText: toPlainText,
};
}
+18 -1
View File
@@ -3,6 +3,7 @@ import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
import deleteEmptyFirstParagraph from "../commands/deleteEmptyFirstParagraph";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Node from "./Node";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
export default class Paragraph extends Node {
get name() {
@@ -13,7 +14,23 @@ export default class Paragraph extends Node {
return {
content: "inline*",
group: "block",
parseDOM: [{ tag: "p" }],
parseDOM: [
{
tag: "p",
getAttrs: (dom) => {
if (!(dom instanceof HTMLElement)) {
return false;
}
// We must suppress image captions from being parsed as a separate paragraph.
if (dom.classList.contains(EditorStyleHelper.imageCaption)) {
return false;
}
return {};
},
},
],
toDOM: () => ["p", { dir: "auto" }, 0],
};
}
+445 -440
View File
File diff suppressed because it is too large Load Diff