Compare commits

..

73 Commits

Author SHA1 Message Date
Tom Moor fa7f8d3592 fix: ExportDocumentTreeTask needs documentStructure 2025-05-07 19:38:30 -04:00
codegen-sh[bot] c598c61afe Add PromQL as a code highlighting option in the editor (#9146)
* Add PromQL as a code highlighting option in the editor

* Update code.ts

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-07 19:11:47 -04:00
Tom Moor 68b07eb466 fix: withoutState scope should include state as fallback (#9145) 2025-05-07 18:49:33 -04:00
Tom Moor 06a149407a fix: withoutState scope should include state as fallback (#9144) 2025-05-07 09:00:42 -04:00
Tom Moor b9387734c7 perf: Remove documentStructure from default query select (#9141)
* perf: Remove documentStructure from default query select

* test
2025-05-07 07:47:57 -04:00
dependabot[bot] 810b7908e4 chore(deps): bump the aws group with 5 updates (#9136)
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.797.0` | `3.802.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.797.0` | `3.802.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.797.0` | `3.802.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.797.0` | `3.802.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.796.0` | `3.800.0` |


Updates `@aws-sdk/client-s3` from 3.797.0 to 3.802.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.802.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.797.0 to 3.802.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.802.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.797.0 to 3.802.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.802.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.797.0 to 3.802.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.802.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.796.0 to 3.800.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.800.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.800.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-05-06 08:02:14 -04:00
dependabot[bot] 6b76a898fa chore(deps): bump the babel group with 9 updates (#9139)
Bumps the babel group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.26.10` | `7.27.1` |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-class-properties](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-class-properties) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator) | `7.27.0` | `7.27.1` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.26.9` | `7.27.1` |
| [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) | `7.26.3` | `7.27.1` |
| [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) | `7.27.0` | `7.27.1` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.27.0` | `7.27.1` |


Updates `@babel/core` from 7.26.10 to 7.27.1
- [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.1/packages/babel-core)

Updates `@babel/plugin-proposal-decorators` from 7.25.9 to 7.27.1
- [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.1/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-class-properties` from 7.25.9 to 7.27.1
- [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.1/packages/babel-plugin-transform-class-properties)

Updates `@babel/plugin-transform-destructuring` from 7.25.9 to 7.27.1
- [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.1/packages/babel-plugin-transform-destructuring)

Updates `@babel/plugin-transform-regenerator` from 7.27.0 to 7.27.1
- [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.1/packages/babel-plugin-transform-regenerator)

Updates `@babel/preset-env` from 7.26.9 to 7.27.1
- [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.1/packages/babel-preset-env)

Updates `@babel/preset-react` from 7.26.3 to 7.27.1
- [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.1/packages/babel-preset-react)

Updates `@babel/cli` from 7.27.0 to 7.27.1
- [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.1/packages/babel-cli)

Updates `@babel/preset-typescript` from 7.27.0 to 7.27.1
- [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.1/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-class-properties"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-react"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-version: 7.27.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-version: 7.27.1
  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-05-05 21:05:41 -04:00
dependabot[bot] 8ba83e2173 chore(deps): bump react-medium-image-zoom from 5.2.13 to 5.2.14 (#9137)
Bumps [react-medium-image-zoom](https://github.com/rpearce/react-medium-image-zoom) from 5.2.13 to 5.2.14.
- [Release notes](https://github.com/rpearce/react-medium-image-zoom/releases)
- [Changelog](https://github.com/rpearce/react-medium-image-zoom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rpearce/react-medium-image-zoom/compare/v5.2.13...v5.2.14)

---
updated-dependencies:
- dependency-name: react-medium-image-zoom
  dependency-version: 5.2.14
  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-05-05 21:05:27 -04:00
dependabot[bot] 5a4b8c5faa chore(deps): bump validator and @types/validator (#9138)
Bumps [validator](https://github.com/validatorjs/validator.js) and [@types/validator](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/validator). These dependencies needed to be updated together.

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

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

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: "@types/validator"
  dependency-version: 13.15.0
  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-05-05 21:05:14 -04:00
Tom Moor 3f8bdf7ac2 Refactor withMembershipScope (#9134) 2025-05-04 18:37:01 -04:00
Tom Moor 9c4b4f4989 fix: Chained scopes overwrite (#9133) 2025-05-04 22:16:38 +00:00
Hemachandar c5d534b2ad Add script to resolve existing collection index collisions (#8810)
* Add script to resolve existing collection index collisions

* Remove debug logging

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-05-04 16:12:09 -04:00
Tom Moor bed3d1078e fix: More guards against empty text nodes (#9132) 2025-05-04 20:11:02 +00:00
Tom Moor 83e87254c6 fix: Invisible JS error (#9131) 2025-05-04 13:59:21 +00:00
Tom Moor f576ddccfe chore: Add 10 more brand icons (#9130) 2025-05-04 13:18:37 +00:00
Tom Moor 0a674eacfa fix: Improve behavior when hitting backspace/delete with table cell selections (#9129) 2025-05-04 08:51:48 -04:00
Tom Moor ceac57bd64 Pick collection color based on existing collections (#9128) 2025-05-04 03:09:08 +00:00
Tom Moor 97f31e3f2a fix: Cannot create document through @mention on collection overview (#9127) 2025-05-03 22:13:54 -04:00
Tom Moor a06671e8ce OAuth provider (#8884)
This PR contains the necessary work to make Outline an OAuth provider including:

- OAuth app registration
- OAuth app management
- Private / public apps (Public in cloud only)
- Full OAuth 2.0 spec compatible authentication flow
- Granular scopes
- User token management screen in settings
- Associated API endpoints for programatic access
2025-05-03 19:40:18 -04:00
Tom Moor fd3c21d28b Remove withCollectionPermissions scope (#9124)
* Remove withCollectionPermissions scope

* defaultScopeWithUser -> withUserScope

* fix: Include withDrafts in groupMemberships.list

* rename
2025-05-03 12:00:54 -04:00
Tom Moor c0c36bacbb fix: Error loading collection (#9123) 2025-05-03 02:18:56 +00:00
Tom Moor 7bd1ea7c40 chore/attachments-sw-cache (#9122) 2025-05-02 22:15:39 -04:00
Tom Moor 5ebb1e8a61 feat: Add input rule to create new tables (#9118) 2025-05-02 08:19:57 -04:00
Tom Moor 96d6987858 fix: Mobile toolbar overlaps with home indicator (#9119) 2025-05-02 08:19:48 -04:00
Tom Moor 3602198cd8 fix: Subtle collection loading bug (#9120) 2025-05-02 08:19:41 -04:00
dependabot[bot] 00bab31cff chore(deps): bump vite from 6.3.3 to 6.3.4 (#9112)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.3 to 6.3.4.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.4
  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-04-30 18:16:38 -04:00
Tom Moor 3ef2b7cf42 fix: Backlinks should be ordered alphabetically (#9106) 2025-04-30 02:17:03 +00:00
Tom Moor 18743da2fc fix: bold inline code marks cause formatting to split (#9105)
* fix: Inline code mark split around bold

* Show inline formatting options + code in toolbar
2025-04-30 01:50:52 +00:00
dependabot[bot] fe1307d7e7 chore(deps): bump the aws group with 5 updates (#9086)
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.787.0` | `3.797.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.787.0` | `3.797.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.787.0` | `3.797.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.787.0` | `3.797.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.787.0` | `3.796.0` |


Updates `@aws-sdk/client-s3` from 3.787.0 to 3.797.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.797.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.787.0 to 3.797.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.797.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.787.0 to 3.797.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.797.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.787.0 to 3.797.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.797.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.787.0 to 3.796.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.796.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.796.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-04-29 06:48:04 -04:00
codegen-sh[bot] a226889143 Update task scheduling to use instance method (#9092)
* Update task scheduling to use instance method

* Delete update_task_schedule.sh

* Applied automatic fixes

* tsc

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-29 06:47:51 -04:00
Tom Moor 347f033802 fix: Notifications received for draft with access but no subscription (#9099) 2025-04-29 06:45:15 -04:00
Tom Moor f5c659f902 fix: Prevent cross-domain websocket connections to on-premise instances (#9064) 2025-04-28 17:27:40 -04:00
Hemachandar 722d10e7de Implement type-safe schedule method for tasks (#9079)
* Implement type-safe task scheduler

* introduce 'schedule' instance method

* typo
2025-04-28 17:27:24 -04:00
Hemachandar ce001547b5 fix: Check pasted text is url before creating an URL object (#9082) 2025-04-28 17:27:12 -04:00
dependabot[bot] 8d05e2b095 chore(deps): bump pg from 8.14.1 to 8.15.6 (#9084)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.14.1 to 8.15.6.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.15.6/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.15.6
  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-04-28 17:26:54 -04:00
dependabot[bot] 19e40cf814 chore(deps-dev): bump nodemon from 3.1.9 to 3.1.10 (#9085)
Bumps [nodemon](https://github.com/remy/nodemon) from 3.1.9 to 3.1.10.
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.9...v3.1.10)

---
updated-dependencies:
- dependency-name: nodemon
  dependency-version: 3.1.10
  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-04-28 17:26:28 -04:00
dependabot[bot] 2bb9b50637 chore(deps): bump react-portal from 4.2.2 to 4.3.0 (#9087)
Bumps [react-portal](https://github.com/tajo/react-portal) from 4.2.2 to 4.3.0.
- [Release notes](https://github.com/tajo/react-portal/releases)
- [Commits](https://github.com/tajo/react-portal/commits)

---
updated-dependencies:
- dependency-name: react-portal
  dependency-version: 4.3.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-04-28 17:26:18 -04:00
Tom Moor 4885612661 Switch Linear to actor=app method (#9074) 2025-04-27 15:01:23 +00:00
Tom Moor e2dd6221f8 Extract subdomain auth redirect (#9070)
* Extract subdomain auth redirect

* docs
2025-04-27 10:55:05 -04:00
Hemachandar 7f513a6950 fix: Store Linear workspace logo only when it's available (#9072) 2025-04-27 09:26:36 -04:00
Tom Moor 6440d78b6f fix: Double fetch on refactored paginated list (#9068) 2025-04-26 21:35:41 +00:00
Tom Moor 7e05fc1017 Revert "Add recency boost to search results (#9038)" (#9065)
This reverts commit 2bc47cfcef.
2025-04-26 16:44:49 +00:00
Tom Moor 2bc47cfcef Add recency boost to search results (#9038)
* Add recency boost to search helpers

* Restore tests

* Use boost
2025-04-26 08:27:45 -04:00
Hemachandar e8e46a438c fix: Store Linear workspace logo in storage (#9061)
* fix: Store Linear workspace logo in Outline

* use async task

* Move task into plugin

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-25 23:18:21 -04:00
Tom Moor 3156f62e94 Vite 5 -> 6 upgrade (#9057)
* Vite 5 -> 6

* Revert i18next-parser upgrade

* rolldown

* fix build

* tsc
2025-04-25 18:22:53 -04:00
Hemachandar 9274f56ef6 Show correct icon & color for GitHub draft PR (#9063) 2025-04-25 17:37:54 -04:00
Hemachandar 4bb9ac40c7 fix: Linear status icon completion percentage edge case (#9062) 2025-04-25 13:17:28 -04:00
Tom Moor 36772f1444 fix: Heading weight changes when linkified (#9058) 2025-04-25 12:53:28 +00:00
Tom Moor e503225f04 fix: Tidying mention hover cards (#9051)
* Tidying hover card layout

* Handle backticks in titles (common on GitHub + Linear)

* Improve label display
2025-04-24 23:49:19 -04:00
codegen-sh[bot] 762140e493 Add mcp to reserved subdomains (#9052)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-04-25 03:07:39 +00:00
Hemachandar 21e756c357 Check collection (or) document when processing page/database in Notion import (#9047) 2025-04-24 21:22:39 -04:00
codegen-sh[bot] 2cc5846f1b Truncate Notion document titles to fit validation limits (#9041)
closes #9040
2025-04-24 11:57:19 +00:00
Hemachandar de6c1735d9 feat: Linear integration (#9037)
* linear settings and oauth

* unfurl

* unfurl impl fix for recent merge from main

* fetch labels

* state icon

* linear icon

* uninstall hook

* lint

* i18n

* cleanup

* use workspace key, reduce icon size

* determine completion percentage

* extract completionPercentage to separate method
2025-04-24 07:50:48 -04:00
codegen-sh[bot] b7c13f092b refactor: Convert PaginatedList component to functional style (#9030)
* refactor: Convert PaginatedList component to functional style

* tsc

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-24 07:03:18 -04:00
Tom Moor 298298223b fix: Allow viewers to read templates (#9042) 2025-04-24 07:02:57 -04:00
YKDZ 21f37c0d14 Display breadcrumb instead of collection name when link and mention document (#8938)
* feat: Display breadcrumb instead of collection name when link and mention document

* feat: Use maxDepth instead of reversedLength in DocumentBreadcrumb

* fix: Category will never display in DocumentBreadcrumb

* fix: Wrong output when maxDepth <= 0

* fix: Wrong hook denpendency

* fix: eslint issues

* Update DocumentBreadcrumb.tsx

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-24 02:12:27 +00:00
Tom Moor 18bc93c9c2 Add additional CSP protection to files.get endpoint (#9039) 2025-04-23 21:53:54 -04:00
Tom Moor 6a12822829 fix: Embeds not enabled on collection overview (#9034)
fix: Disabled embeds show unusable resize handle
2025-04-23 12:21:44 +00:00
Translate-O-Tron adcab68b59 New Crowdin updates (#9033)
* fix: New Polish translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-23 11:59:18 +00:00
codegen-sh[bot] 943fd7e2e1 refactor: Convert Frame component to functional component (#8943)
* refactor: Convert Frame component to functional component

* fix: Fix linting issues in Frame component

* tsc

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-23 01:53:29 +00:00
Tom Moor 01db19a0b1 fix: Cannot load avatars in some instances (#9025) 2025-04-22 21:23:51 -04:00
Hemachandar 51cb5bffce Cache issueSources for embed integrations (#8952)
* Cache `issueSources` for embed integrations

* lock model before update
2025-04-22 09:59:39 -04:00
Hemachandar d37b7fa31e Transform issue and pull_request to unfurl shape in plugin (#9006)
* Transform issue and pull_request to unfurl shape in plugin

* better typings

* add todo
2025-04-22 07:00:44 -04:00
dependabot[bot] f86225c332 chore(deps): bump vite-plugin-pwa from 0.20.3 to 0.21.2 (#9021)
Bumps [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) from 0.20.3 to 0.21.2.
- [Release notes](https://github.com/vite-pwa/vite-plugin-pwa/releases)
- [Commits](https://github.com/vite-pwa/vite-plugin-pwa/compare/v0.20.3...v0.21.2)

---
updated-dependencies:
- dependency-name: vite-plugin-pwa
  dependency-version: 0.21.2
  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-04-22 06:51:47 -04:00
Tom Moor e53c90f25f fix: Input validation on desktop app subdomain dialog (#9004)
* Improve validation on desktop subdomain switch modal

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* lint

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-04-21 20:02:48 -04:00
dependabot[bot] d84d5a4b09 chore(deps): bump @notionhq/client from 2.2.16 to 2.3.0 (#9022)
Bumps [@notionhq/client](https://github.com/makenotion/notion-sdk-js) from 2.2.16 to 2.3.0.
- [Release notes](https://github.com/makenotion/notion-sdk-js/releases)
- [Commits](https://github.com/makenotion/notion-sdk-js/compare/v2.2.16...v2.3.0)

---
updated-dependencies:
- dependency-name: "@notionhq/client"
  dependency-version: 2.3.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-04-21 19:30:22 -04:00
dependabot[bot] 0031fc1562 chore(deps): bump dotenv from 16.4.7 to 16.5.0 (#9020)
Bumps [dotenv](https://github.com/motdotla/dotenv) from 16.4.7 to 16.5.0.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v16.4.7...v16.5.0)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 16.5.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-04-21 19:29:23 -04:00
dependabot[bot] 9b73635727 chore(deps): bump @radix-ui/react-visually-hidden from 1.1.2 to 1.2.0 (#9023)
Bumps [@radix-ui/react-visually-hidden](https://github.com/radix-ui/primitives) from 1.1.2 to 1.2.0.
- [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-visually-hidden"
  dependency-version: 1.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-04-21 19:29:04 -04:00
dependabot[bot] 5cefb534cc chore(deps): bump rfc6902 from 5.1.1 to 5.1.2 (#9024)
Bumps [rfc6902](https://github.com/chbrown/rfc6902) from 5.1.1 to 5.1.2.
- [Commits](https://github.com/chbrown/rfc6902/compare/v5.1.1...v5.1.2)

---
updated-dependencies:
- dependency-name: rfc6902
  dependency-version: 5.1.2
  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-04-21 19:28:52 -04:00
Tom Moor 8fb6f7f8c6 fix: Overflow on math blocks (#9026) 2025-04-21 19:28:30 -04:00
Tom Moor 6b497cf1ec fix: IME composition between backticks (#9011) 2025-04-19 16:24:22 -04:00
Tom Moor 05a61927af fix: Improve settings table layout on mobile (#9012) 2025-04-19 16:24:14 -04:00
Tom Moor 2b07f412e2 fix: Image caption is not correctly centered on full-width image (#9013) 2025-04-18 19:31:36 -04:00
280 changed files with 11553 additions and 4271 deletions
+4
View File
@@ -127,6 +127,10 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Linear
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
+25
View File
@@ -0,0 +1,25 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createOAuthClient = createAction({
name: ({ t }) => t("New App"),
analyticsName: "New App",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New Application"),
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+2 -2
View File
@@ -11,7 +11,7 @@ import { ActionContext } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map((session) => ({
id: `switch-${session.id}`,
name: session.name,
@@ -44,7 +44,7 @@ export const switchTeam = createAction({
section: TeamSection,
visible: ({ stores }) =>
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
children: createTeamsList,
children: switchTeamsList,
});
export const createTeam = createAction({
+19 -12
View File
@@ -13,6 +13,11 @@ export enum AvatarSize {
Upload = 64,
}
export enum AvatarVariant {
Round = "round",
Square = "square",
}
export interface IAvatar {
avatarUrl: string | null;
color?: string;
@@ -23,6 +28,8 @@ export interface IAvatar {
type Props = {
/** The size of the avatar */
size: AvatarSize;
/** The variant of the avatar */
variant?: AvatarVariant;
/** The source of the avatar image, if not passing a model. */
src?: string;
/** The avatar model, if not passing a source. */
@@ -38,14 +45,14 @@ type Props = {
};
function Avatar(props: Props) {
const { model, style, ...rest } = props;
const { model, style, variant = AvatarVariant.Round, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative style={style}>
<Relative style={style} $variant={variant} $size={props.size}>
{src && !error ? (
<CircleImg onError={handleError} src={src} {...rest} />
<Image onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} {...rest}>
{model.initial}
@@ -61,19 +68,19 @@ Avatar.defaultProps = {
size: AvatarSize.Medium,
};
const Relative = styled.div`
const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>`
position: relative;
user-select: none;
flex-shrink: 0;
`;
const CircleImg = styled.img<{ size: number }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
flex-shrink: 0;
border-radius: ${(props) =>
props.$variant === AvatarVariant.Round ? "50%" : `${props.$size / 8}px`};
overflow: hidden;
`;
const Image = styled.img<{ size: number }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
`;
export default Avatar;
-2
View File
@@ -13,7 +13,6 @@ const Initials = styled(Flex)<{
}>`
align-items: center;
justify-content: center;
border-radius: 50%;
width: 100%;
height: 100%;
color: ${(props) =>
@@ -23,7 +22,6 @@ const Initials = styled(Flex)<{
background-color: ${(props) => props.color ?? props.theme.textTertiary};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
flex-shrink: 0;
// adjust font size down for each additional character
+23 -5
View File
@@ -1,3 +1,4 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
@@ -18,6 +19,7 @@ import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -30,6 +32,26 @@ export interface FormData {
permission: CollectionPermission | undefined;
}
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
const hasMultipleCollections = collections.orderedData.length > 1;
const collectionColors = uniq(
collections.orderedData.map((c) => c.color).filter(Boolean)
) as string[];
const iconColor = React.useMemo(
() =>
collection?.color ??
// If all the existing collections have the same color, use that color,
// otherwise pick a random color from the palette
(hasMultipleCollections && collectionColors.length === 1
? collectionColors[0]
: randomElement(colorPalette)),
[collection?.color]
);
return iconColor;
};
export const CollectionForm = observer(function CollectionForm_({
handleSubmit,
collection,
@@ -42,11 +64,7 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const iconColor = useIconColor(collection);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
+44 -11
View File
@@ -18,6 +18,13 @@ type Props = {
children?: React.ReactNode;
document: Document;
onlyText?: boolean;
reverse?: boolean;
/**
* Maximum number of items to show in the breadcrumb.
* If value is less than or equals to 0, no items will be shown.
* If value is undefined, all items will be shown.
*/
maxDepth?: number;
};
function useCategory(document: Document): MenuInternalLink | null {
@@ -54,7 +61,7 @@ function useCategory(document: Document): MenuInternalLink | null {
}
function DocumentBreadcrumb(
{ document, children, onlyText }: Props,
{ document, children, onlyText, reverse = false, maxDepth }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { collections } = useStores();
@@ -65,6 +72,7 @@ function DocumentBreadcrumb(
? collections.get(document.collectionId)
: undefined;
const can = usePolicy(collection);
const depth = maxDepth === undefined ? undefined : Math.max(0, maxDepth);
React.useEffect(() => {
void document.loadRelations({ withoutPolicies: true });
@@ -91,20 +99,23 @@ function DocumentBreadcrumb(
};
}
const path = document.pathTo;
const path = document.pathTo.slice(0, -1);
const items = React.useMemo(() => {
const output = [];
const output: MenuInternalLink[] = [];
if (depth === 0) {
return output;
}
if (category) {
output.push(category);
}
if (collectionNode) {
output.push(collectionNode);
}
path.slice(0, -1).forEach((node: NavigationNode) => {
path.forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
output.push({
type: "route",
@@ -121,21 +132,43 @@ function DocumentBreadcrumb(
},
});
});
return output;
}, [t, path, category, sidebarContext, collectionNode]);
return reverse
? depth !== undefined
? output.slice(-depth)
: output
: depth !== undefined
? output.slice(0, depth)
: output;
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
if (!collections.isLoaded) {
return null;
}
if (onlyText === true) {
if (onlyText) {
if (depth === 0) {
return <></>;
}
const slicedPath = reverse
? path.slice(depth && -depth)
: path.slice(0, depth);
const showCollection =
collection &&
(!reverse || depth === undefined || slicedPath.length < depth);
return (
<>
{collection?.name}
{path.slice(0, -1).map((node: NavigationNode) => (
{showCollection && collection.name}
{slicedPath.map((node: NavigationNode, index: number) => (
<React.Fragment key={node.id}>
<SmallSlash />
{showCollection && <SmallSlash />}
{node.title || t("Untitled")}
{!showCollection && index !== slicedPath.length - 1 && (
<SmallSlash />
)}
</React.Fragment>
))}
</>
+2 -2
View File
@@ -46,10 +46,10 @@ function DocumentViews({ document, isOpen }: Props) {
return (
<>
{isOpen && (
<PaginatedList
<PaginatedList<User>
aria-label={t("Viewers")}
items={users}
renderItem={(model: User) => {
renderItem={(model) => {
const view = documentViews.find((v) => v.userId === model.id);
const isPresent = presentIds.includes(model.id);
const isEditing = editingIds.includes(model.id);
+2 -2
View File
@@ -56,7 +56,7 @@ const FilterOptions = ({
: "";
const renderItem = React.useCallback(
(option: TFilterOption) => (
(option) => (
<MenuItem
key={option.key}
onClick={() => {
@@ -174,7 +174,7 @@ const FilterOptions = ({
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
+18 -7
View File
@@ -2,7 +2,6 @@ import { transparentize } from "polished";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import { getTextColor } from "@shared/utils/color";
import Text from "~/components/Text";
export const CARD_MARGIN = 10;
@@ -33,7 +32,7 @@ export const Title = styled(Text).attrs({ as: "h2", size: "large" })`
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 4px;
gap: 6px;
`;
export const Info = styled(StyledText).attrs(() => ({
@@ -60,15 +59,27 @@ export const Thumbnail = styled.img`
export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
color?: string;
}>`
background-color: ${(props) =>
props.color ?? props.theme.backgroundSecondary};
color: ${(props) =>
props.color ? getTextColor(props.color) : props.theme.text};
border: 1px solid ${(props) => props.theme.divider};
width: fit-content;
border-radius: 2em;
padding: 0 8px;
padding: 1px 8px 1px 20px;
margin-right: 0.5em;
margin-top: 0.5em;
position: relative;
flex-shrink: 0;
&::after {
content: "";
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: ${(props) =>
props.color || props.theme.backgroundSecondary};
}
`;
export const CardContent = styled.div`
@@ -1,7 +1,13 @@
import * as React from "react";
import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import {
IntegrationService,
UnfurlResourceType,
UnfurlResponse,
} from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Text from "../Text";
@@ -23,6 +29,11 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
ref: React.Ref<HTMLDivElement>
) {
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -31,13 +42,18 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
<CardContent>
<Flex gap={2} column>
<Title>
<IssueStatusIcon status={state.name} color={state.color} />
<StyledIssueStatusIcon
service={service}
state={state}
size={18}
/>
<span>
{title}&nbsp;<Text type="tertiary">{id}</Text>
<Backticks content={title} />
&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Flex align="center" gap={6}>
<Avatar src={author.avatarUrl} size={18} />
<Info>
<Trans>
{{ authorName }} created{" "}
@@ -62,4 +78,8 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
);
});
const StyledIssueStatusIcon = styled(IssueStatusIcon)`
margin-top: 2px;
`;
export default HoverPreviewIssue;
@@ -1,5 +1,7 @@
import * as React from "react";
import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
@@ -31,13 +33,14 @@ const HoverPreviewPullRequest = React.forwardRef(
<CardContent>
<Flex gap={2} column>
<Title>
<PullRequestIcon status={state.name} color={state.color} />
<StyledPullRequestIcon size={18} state={state} />
<span>
{title}&nbsp;<Text type="tertiary">{id}</Text>
<Backticks content={title} />
&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Flex align="center" gap={6}>
<Avatar src={author.avatarUrl} size={18} />
<Info>
<Trans>
{{ authorName }} opened{" "}
@@ -55,4 +58,8 @@ const HoverPreviewPullRequest = React.forwardRef(
}
);
const StyledPullRequestIcon = styled(PullRequestIcon)`
margin-top: 2px;
`;
export default HoverPreviewPullRequest;
@@ -79,11 +79,11 @@ function Notifications(
</Header>
<React.Suspense fallback={null}>
<Scrollable ref={ref} flex topShadow>
<PaginatedList
<PaginatedList<Notification>
fetch={notifications.fetchPage}
options={{ archived: false }}
items={isOpen ? notifications.orderedData : undefined}
renderItem={(item: Notification) => (
renderItem={(item) => (
<NotificationListItem
key={item.id}
notification={item}
@@ -0,0 +1,142 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { OAuthClientValidation } from "@shared/validations";
import OAuthClient from "~/models/oauth/OAuthClient";
import ImageInput from "~/scenes/Settings/components/ImageInput";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import isCloudHosted from "~/utils/isCloudHosted";
import Switch from "../Switch";
export interface FormData {
name: string;
developerName: string;
developerUrl: string;
description: string;
avatarUrl: string;
redirectUris: string[];
published: boolean;
}
export const OAuthClientForm = observer(function OAuthClientForm_({
handleSubmit,
oauthClient,
}: {
handleSubmit: (data: FormData) => void;
oauthClient?: OAuthClient;
}) {
const { t } = useTranslation();
const {
register,
handleSubmit: formHandleSubmit,
formState,
getValues,
setFocus,
setError,
control,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: oauthClient?.name ?? "",
description: oauthClient?.description ?? "",
avatarUrl: oauthClient?.avatarUrl ?? "",
redirectUris: oauthClient?.redirectUris ?? [],
published: oauthClient?.published ?? false,
},
});
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<>
<label style={{ marginBottom: "1em" }}>
<LabelText>{t("Icon")}</LabelText>
<Controller
control={control}
name="avatarUrl"
render={({ field }) => (
<ImageInput
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
id: oauthClient?.id,
avatarUrl: field.value,
initial: getValues().name[0],
}}
borderRadius={0}
/>
)}
/>
</label>
<Input
type="text"
label={t("Name")}
placeholder={t("My App")}
{...register("name", {
required: true,
maxLength: OAuthClientValidation.maxNameLength,
})}
autoComplete="off"
autoFocus
flex
/>
<Input
type="text"
label={t("Tagline")}
placeholder={t("A short description")}
{...register("description", {
maxLength: OAuthClientValidation.maxDescriptionLength,
})}
flex
/>
<Controller
control={control}
name="redirectUris"
render={({ field }) => (
<Input
type="textarea"
label={t("Callback URLs")}
placeholder="https://example.com/callback"
ref={field.ref}
value={field.value.join("\n")}
rows={Math.max(2, field.value.length + 1)}
onChange={(event) => {
field.onChange(event.target.value.split("\n"));
}}
required
/>
)}
/>
{isCloudHosted && (
<Switch
{...register("published")}
label={t("Published")}
note={t("Allow this app to be installed by other workspaces")}
/>
)}
</>
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{oauthClient
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
@@ -0,0 +1,33 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import { OAuthClientForm, FormData } from "./OAuthClientForm";
type Props = {
onSubmit: () => void;
};
export const OAuthClientNew = observer(function OAuthClientNew_({
onSubmit,
}: Props) {
const { oauthClients } = useStores();
const history = useHistory();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const oauthClient = await oauthClients.save(data);
onSubmit?.();
history.push(settingsPath("applications", oauthClient.id));
} catch (error) {
toast.error(error.message);
}
},
[oauthClients, history, onSubmit]
);
return <OAuthClientForm handleSubmit={handleSubmit} />;
});
+3 -3
View File
@@ -10,7 +10,7 @@ type Props = {
fetch: (options: any) => Promise<Document[] | undefined>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
empty?: JSX.Element;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
@@ -34,7 +34,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
const { t } = useTranslation();
return (
<PaginatedList
<PaginatedList<Document>
aria-label={t("Documents")}
items={documents}
empty={empty}
@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index) => (
renderItem={(item, _index) => (
<DocumentListItem
key={item.id}
document={item}
+1 -1
View File
@@ -10,7 +10,7 @@ type Props = {
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
empty?: JSX.Element;
};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
+23 -15
View File
@@ -1,13 +1,15 @@
import "../stores";
import { render } from "@testing-library/react";
import { TFunction } from "i18next";
import { Provider } from "mobx-react";
import * as React from "react";
import { getI18n } from "react-i18next";
import { Pagination } from "@shared/constants";
import { Component as PaginatedList } from "./PaginatedList";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const i18n = getI18n();
const authStore = {};
const props = {
i18n,
@@ -17,19 +19,23 @@ describe("PaginatedList", () => {
it("with no items renders nothing", () => {
const result = render(
<PaginatedList items={[]} renderItem={render} {...props} />
<Provider auth={authStore}>
<PaginatedList items={[]} renderItem={render} {...props} />
</Provider>
);
expect(result.container.innerHTML).toEqual("");
});
it("with no items renders empty prop", async () => {
const result = render(
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>
<Provider auth={authStore}>
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>{" "}
</Provider>
);
await expect(
result.findAllByText("Sorry, no results")
@@ -42,13 +48,15 @@ describe("PaginatedList", () => {
id: "one",
};
render(
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>
<Provider auth={authStore}>
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>{" "}
</Provider>
);
expect(fetch).toHaveBeenCalledWith({
...options,
+244 -194
View File
@@ -1,265 +1,315 @@
import isEqual from "lodash/isEqual";
import { observable, action, computed } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { Pagination } from "@shared/constants";
import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
import withStores from "~/components/withStores";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePrevious from "~/hooks/usePrevious";
import { dateToHeading } from "~/utils/date";
/**
* Base interface for items that can be paginated
* @interface PaginatedItem
*/
export interface PaginatedItem {
/** Unique identifier for the item */
id?: string;
/** Last update timestamp of the item */
updatedAt?: string;
/** Creation timestamp of the item */
createdAt?: string;
}
type Props<T> = WithTranslation &
RootStore &
React.HTMLAttributes<HTMLDivElement> & {
fetch?: (
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: T[];
className?: string;
renderItem: (item: T, index: number) => React.ReactNode;
renderError?: (options: {
error: Error;
retry: () => void;
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
listRef?: React.RefObject<HTMLDivElement>;
};
/**
* Props for the PaginatedList component
* @template T Type of items in the list, must extend PaginatedItem
*/
interface Props<T extends PaginatedItem>
extends React.HTMLAttributes<HTMLDivElement> {
/**
* Function to fetch paginated data. Should return a promise resolving to an array of items
* @param options Pagination and other query options
*/
fetch?: (
options: Record<string, any> | undefined
) => Promise<unknown[] | undefined> | undefined;
@observer
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
Props<T>
> {
@observable
error?: Error;
/** Additional options to pass to the fetch function */
options?: Record<string, any>;
@observable
isFetchingMore = false;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@observable
isFetching = false;
/** Content to display when the list is empty */
empty?: JSX.Element | null;
@observable
isFetchingInitial = !this.props.items?.length;
/** Optional loading state content */
loading?: JSX.Element | null;
@observable
fetchCounter = 0;
/** Array of items to display in the list */
items?: T[];
@observable
renderCount = Pagination.defaultLimit;
/** CSS class name to apply to the list container */
className?: string;
@observable
offset = 0;
/**
* Function to render each individual item in the list
* @param item The item to render
* @param index The index of the item in the list
*/
renderItem: (item: T, index: number) => React.ReactNode;
@observable
allowLoadMore = true;
/**
* Function to render error state
* @param options Object containing error details and retry function
*/
renderError?: (options: {
/** Details of the error */
error: Error;
/** Function to retry the fetch operation */
retry: () => void;
}) => JSX.Element;
componentDidMount() {
void this.fetchResults();
}
/**
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
componentDidUpdate(prevProps: Props<T>) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
) {
this.reset();
void this.fetchResults();
}
}
/**
* Handler for escape key press
* @param ev Keyboard event object
*/
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
reset = () => {
this.offset = 0;
this.allowLoadMore = true;
this.renderCount = Pagination.defaultLimit;
this.isFetching = false;
this.isFetchingInitial = false;
this.isFetchingMore = false;
};
/** Reference to the list container element */
listRef?: React.RefObject<HTMLDivElement>;
}
@action
fetchResults = async () => {
if (!this.props.fetch) {
/**
* A reusable component that renders a paginated list with infinite scrolling
* and optional date-based section headings.
*
* @template T Type of the list items, must extend PaginatedItem
*/
const PaginatedList = <T extends PaginatedItem>({
fetch,
options,
heading,
empty = null,
loading = null,
items = [],
className,
renderItem,
renderError,
renderHeading,
onEscape,
listRef,
...rest
}: Props<T>): JSX.Element | null => {
const user = useCurrentUser({ rejectOnEmpty: false });
const { t } = useTranslation();
const [error, setError] = React.useState<Error | undefined>();
const [isFetchingMore, setIsFetchingMore] = React.useState(false);
const [isFetching, setIsFetching] = React.useState(false);
const [isFetchingInitial, setIsFetchingInitial] = React.useState(
!items?.length
);
const [fetchCounter, setFetchCounter] = React.useState(0);
const [renderCount, setRenderCount] = React.useState(Pagination.defaultLimit);
const [offset, setOffset] = React.useState(0);
const [allowLoadMore, setAllowLoadMore] = React.useState(true);
const reset = React.useCallback(() => {
setOffset(0);
setAllowLoadMore(true);
setRenderCount(Pagination.defaultLimit);
setIsFetching(false);
setIsFetchingInitial(false);
setIsFetchingMore(false);
}, []);
const fetchResults = React.useCallback(async () => {
if (!fetch) {
return;
}
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = this.props.options?.limit ?? Pagination.defaultLimit;
this.error = undefined;
setIsFetching(true);
const counter = fetchCounter + 1;
setFetchCounter(counter);
const limit = options?.limit ?? Pagination.defaultLimit;
setError(undefined);
try {
const results = await this.props.fetch({
const results = await fetch({
limit,
offset: this.offset,
...this.props.options,
offset,
...options,
});
if (this.offset !== 0) {
this.renderCount += limit;
if (offset !== 0) {
setRenderCount((prevCount) => prevCount + limit);
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
setAllowLoadMore(false);
} else {
this.offset += limit;
setOffset((prevOffset) => prevOffset + limit);
}
this.isFetchingInitial = false;
setIsFetchingInitial(false);
} catch (err) {
this.error = err;
setError(err);
} finally {
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
if (counter >= fetchCounter) {
setIsFetching(false);
setIsFetchingMore(false);
}
}
};
}, [fetch, fetchCounter, offset, options]);
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were currently fetching
if (!this.allowLoadMore || this.isFetching) {
const loadMoreResults = React.useCallback(async () => {
// Don't paginate if there aren't more results or we're currently fetching
if (!allowLoadMore || isFetching) {
return;
}
// If there are already cached results that we haven't yet rendered because
// of lazy rendering then show another page.
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
const leftToRender = (items?.length ?? 0) - renderCount;
if (leftToRender > 0) {
this.renderCount += Pagination.defaultLimit;
setRenderCount((prevCount) => prevCount + Pagination.defaultLimit);
}
// If there are less than a pages results in the cache go ahead and fetch
// another page from the server
if (leftToRender <= Pagination.defaultLimit) {
this.isFetchingMore = true;
await this.fetchResults();
setIsFetchingMore(true);
await fetchResults();
}
};
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
@computed
get itemsToRender() {
return this.props.items?.slice(0, this.renderCount) ?? [];
}
const prevFetch = usePrevious(fetch);
const prevOptions = usePrevious(options);
render() {
const {
items = [],
heading,
auth,
empty = null,
renderHeading,
renderError,
onEscape,
} = this.props;
// Initial fetch on mount
React.useEffect(() => {
if (fetch) {
void fetchResults();
}
}, [fetch]);
const showLoading =
this.isFetching &&
!this.isFetchingMore &&
(!items?.length || (this.fetchCounter <= 1 && this.isFetchingInitial));
if (showLoading) {
return (
this.props.loading || (
<DelayedMount>
<div className={this.props.className}>
<PlaceholderList count={5} />
</div>
</DelayedMount>
)
);
// Handle updates to fetch or options
React.useEffect(() => {
if (!prevFetch || !prevOptions) {
return; // Skip on initial mount since it's handled by the above effect
}
if (items?.length === 0) {
if (this.error && renderError) {
return renderError({ error: this.error, retry: this.fetchResults });
}
return empty;
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
reset();
void fetchResults();
}
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
// Computed property equivalent
const itemsToRender = React.useMemo(
() => items?.slice(0, renderCount) ?? [],
[items, renderCount]
);
const showLoading =
isFetching &&
!isFetchingMore &&
(!items?.length || (fetchCounter <= 1 && isFetchingInitial));
if (showLoading) {
return (
<>
{heading}
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
className={this.props.className}
items={this.itemsToRender}
ref={this.props.listRef}
>
{() => {
let previousHeading = "";
return this.itemsToRender.map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
"updatedAt" in item && item.updatedAt
? item.updatedAt
: "createdAt" in item && item.createdAt
? item.createdAt
: previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (
children &&
(!previousHeading || currentHeading !== previousHeading)
) {
previousHeading = currentHeading;
return (
<React.Fragment
key={"id" in item && item.id ? item.id : index}
>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
});
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
loading || (
<DelayedMount>
<div className={className}>
<PlaceholderList count={5} />
</div>
)}
</>
</DelayedMount>
)
);
}
}
export const Component = PaginatedList;
if (items?.length === 0) {
if (error && renderError) {
return renderError({ error, retry: fetchResults });
}
export default withTranslation()(withStores(PaginatedList));
return empty;
}
return (
<React.Fragment>
{heading}
<ArrowKeyNavigation
aria-label={rest["aria-label"]}
onEscape={onEscape}
className={className}
items={itemsToRender}
ref={listRef}
>
{() => {
let previousHeading = "";
return itemsToRender.map((item, index) => {
const children = renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
"updatedAt" in item && item.updatedAt
? item.updatedAt
: "createdAt" in item && item.createdAt
? item.createdAt
: previousHeading;
const currentHeading = dateToHeading(
currentDate,
t,
user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (
children &&
(!previousHeading || currentHeading !== previousHeading)
) {
previousHeading = currentHeading;
return (
<React.Fragment key={"id" in item && item.id ? item.id : index}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
});
}}
</ArrowKeyNavigation>
{allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={renderCount} onEnter={loadMoreResults} />
</div>
)}
</React.Fragment>
);
};
export default PaginatedList;
+2 -2
View File
@@ -200,7 +200,7 @@ function SearchPopover({ shareId, className }: Props) {
style={{ zIndex: depths.sidebar + 1 }}
shrink
>
<PaginatedList
<PaginatedList<SearchResult>
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
@@ -209,7 +209,7 @@ function SearchPopover({ shareId, className }: Props) {
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item: SearchResult, index) => (
renderItem={(item, index) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
+3 -3
View File
@@ -12,7 +12,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import TeamMenu from "~/menus/TeamMenu";
import { homePath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
@@ -62,7 +62,7 @@ function AppSidebar() {
<DndProvider backend={HTML5Backend} options={html5Options}>
<DragPlaceholder />
<OrganizationMenu>
<TeamMenu>
{(props: SidebarButtonProps) => (
<SidebarButton
{...props}
@@ -91,7 +91,7 @@ function AppSidebar() {
</Tooltip>
</SidebarButton>
)}
</OrganizationMenu>
</TeamMenu>
<Overflow>
<Section>
<SidebarLink
+1
View File
@@ -321,6 +321,7 @@ const Container = styled(Flex)<ContainerProps>`
z-index: ${depths.mobileSidebar};
max-width: 80%;
min-width: 280px;
padding-left: var(--sal);
${fadeOnDesktopBackgrounded()}
@media print {
@@ -82,12 +82,12 @@ function ArchiveLink() {
</div>
{expanded === true ? (
<Relative>
<PaginatedList
<PaginatedList<Collection>
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
renderItem={(item) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
@@ -54,7 +54,7 @@ function Collections() {
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
<PaginatedList<Collection>
options={params}
aria-label={t("Collections")}
items={collections.allActive}
@@ -69,7 +69,7 @@ function Collections() {
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
renderItem={(item, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
@@ -38,10 +38,10 @@ function StarredLink({ star }: Props) {
const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
const collection = collections.get(collectionId);
const collection = collectionId ? collections.get(collectionId) : undefined;
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = starredSidebarContext(
star.documentId ?? star.collectionId
star.documentId ?? star.collectionId ?? ""
);
const [expanded, setExpanded] = useState(
(star.documentId
+3 -1
View File
@@ -335,6 +335,7 @@ const TR = styled.div<{ $columns: string }>`
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
overflow: hidden;
&:last-child {
border-bottom: 0;
@@ -357,7 +358,8 @@ const TD = styled.span`
padding: 10px 6px;
font-size: 14px;
text-wrap: wrap;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
&:first-child {
font-size: 15px;
+4 -1
View File
@@ -1,8 +1,11 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import { Avatar } from "./Avatar";
import { AvatarVariant } from "./Avatar/Avatar";
const TeamLogo = styled(Avatar)`
const TeamLogo = styled(Avatar).attrs({
variant: AvatarVariant.Square,
})`
border-radius: 4px;
box-shadow: inset 0 0 0 1px ${s("divider")};
border: 0;
+6 -1
View File
@@ -7,6 +7,7 @@ import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { getSafeAreaInsets } from "@shared/utils/browser";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
import useEventListener from "~/hooks/useEventListener";
@@ -241,12 +242,16 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
if (props.active) {
const rect = document.body.getBoundingClientRect();
const safeAreaInsets = getSafeAreaInsets();
return (
<ReactPortal>
<MobileWrapper
ref={menuRef}
style={{
bottom: `calc(100% - ${height - rect.y}px)`,
bottom: `calc(100% - ${
height - rect.y - safeAreaInsets.bottom
}px)`,
}}
>
{props.children}
+9 -1
View File
@@ -10,6 +10,7 @@ import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { hideScrollbars, s } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
@@ -253,7 +254,14 @@ const LinkEditor: React.FC<Props> = ({
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
key={doc.id}
subtitle={doc.collection?.name}
subtitle={
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
}
title={doc.title}
icon={
doc.icon ? (
+11 -3
View File
@@ -11,6 +11,7 @@ import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import {
DocumentsSection,
@@ -57,7 +58,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users])
}, [search, documents, users, collections])
);
React.useEffect(() => {
@@ -68,7 +69,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
React.useEffect(() => {
if (actorId && !loading) {
const items = users
const items: MentionItem[] = users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
@@ -112,7 +113,14 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collection?.name,
subtitle: (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
),
section: DocumentsSection,
appendSpace: true,
attrs: {
+3 -2
View File
@@ -6,6 +6,7 @@ import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isUrl } from "@shared/utils/urls";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -29,9 +30,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const user = useCurrentUser({ rejectOnEmpty: false });
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (url) {
if (pastedText && isUrl(pastedText)) {
const url = new URL(pastedText);
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
+2 -1
View File
@@ -2,7 +2,8 @@ import Extension from "@shared/editor/lib/Extension";
import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—");
// Note that the suppression of pipe here prevents conflict with table creation rule.
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
+3 -3
View File
@@ -67,7 +67,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+B`,
icon: <BoldIcon />,
active: isMarkActive(schema.marks.strong),
visible: !isCode && (!isMobile || !isEmpty),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
name: "em",
@@ -75,7 +75,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+I`,
icon: <ItalicIcon />,
active: isMarkActive(schema.marks.em),
visible: !isCode && (!isMobile || !isEmpty),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
name: "strikethrough",
@@ -83,7 +83,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+D`,
icon: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCode && (!isMobile || !isEmpty),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.mark,
+14
View File
@@ -0,0 +1,14 @@
import { getCookie } from "tiny-cookie";
export type Sessions = Record<
string,
{
name: string;
logoUrl: string;
url: string;
}
>;
export function useLoggedInSessions(): Sessions {
return JSON.parse(getCookie("sessions") || "{}");
}
+1 -1
View File
@@ -59,7 +59,7 @@ export default function useRequest<T = unknown>(
if (makeRequestOnMount) {
void request();
}
}, [request, makeRequestOnMount]);
}, []);
return { data, loading, loaded, error, request };
}
+16 -6
View File
@@ -13,6 +13,7 @@ import {
ImportIcon,
ShapesIcon,
Icon,
InternetIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@@ -28,7 +29,8 @@ import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
@@ -86,12 +88,12 @@ const useSettingsConfig = () => {
icon: EmailIcon,
},
{
name: t("API Keys"),
path: settingsPath("personal-api-keys"),
component: PersonalApiKeys,
enabled: can.createApiKey && !can.listApiKeys,
name: t("API & Apps"),
path: settingsPath("api-and-apps"),
component: APIAndApps,
enabled: true,
group: t("Account"),
icon: CodeIcon,
icon: PadlockIcon,
},
// Workspace
{
@@ -150,6 +152,14 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: CodeIcon,
},
{
name: t("Applications"),
path: settingsPath("applications"),
component: Applications,
enabled: can.listOAuthClients,
group: t("Workspace"),
icon: InternetIcon,
},
{
name: t("Shared Links"),
path: settingsPath("shares"),
+57
View File
@@ -0,0 +1,57 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import useStores from "~/hooks/useStores";
type Props = {
/** The OAuthAuthentication to associate with the menu */
oauthAuthentication: OAuthAuthentication;
};
function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
const handleRevoke = React.useCallback(() => {
dialogs.openModal({
title: t("Revoke {{ appName }}", {
appName: oauthAuthentication.oauthClient.name,
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await oauthAuthentication.deleteAll();
dialogs.closeAllModals();
}}
submitText={t("Revoke")}
savingText={`${t("Revoking")}`}
danger
>
{t("Are you sure you want to revoke access?")}
</ConfirmationDialog>
),
});
}, [t, dialogs, oauthAuthentication]);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<MenuItem {...menu} onClick={handleRevoke} dangerous>
{t("Revoke")}
</MenuItem>
</ContextMenu>
</>
);
}
export default observer(OAuthAuthenticationMenu);
+68
View File
@@ -0,0 +1,68 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import OAuthClient from "~/models/oauth/OAuthClient";
import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
type Props = {
/** The oauthClient to associate with the menu */
oauthClient: OAuthClient;
/** Whether to show the edit button */
showEdit?: boolean;
};
function OAuthClientMenu({ oauthClient, showEdit }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
const handleDelete = React.useCallback(() => {
dialogs.openModal({
title: t("Delete app"),
content: (
<OAuthClientDeleteDialog
onSubmit={dialogs.closeAllModals}
oauthClient={oauthClient}
/>
),
});
}, [t, dialogs, oauthClient]);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<Template
{...menu}
items={[
{
type: "route",
title: `${t("Edit")}`,
visible: showEdit,
to: settingsPath("applications", oauthClient.id),
},
{
type: "separator",
},
{
type: "button",
dangerous: true,
title: `${t("Delete")}`,
onClick: handleDelete,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(OAuthClientMenu);
@@ -10,7 +10,7 @@ import {
} from "~/actions/definitions/navigation";
import {
createTeam,
createTeamsList,
switchTeamsList,
desktopLoginTeam,
} from "~/actions/definitions/teams";
import useActionContext from "~/hooks/useActionContext";
@@ -22,7 +22,7 @@ type Props = {
children?: React.ReactNode;
};
const OrganizationMenu: React.FC = ({ children }: Props) => {
const TeamMenu: React.FC = ({ children }: Props) => {
const menu = useMenuState({
unstable_offset: [4, -4],
placement: "bottom-start",
@@ -44,7 +44,7 @@ const OrganizationMenu: React.FC = ({ children }: Props) => {
// menu is not cached at all.
const actions = React.useMemo(
() => [
...createTeamsList(context),
...switchTeamsList(context),
createTeam,
desktopLoginTeam,
separator(),
@@ -64,4 +64,4 @@ const OrganizationMenu: React.FC = ({ children }: Props) => {
);
};
export default observer(OrganizationMenu);
export default observer(TeamMenu);
+10
View File
@@ -331,6 +331,16 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
/**
* Returns the documents that link to this document.
*
* @returns documents that link to this document
*/
@computed
get backlinks(): Document[] {
return this.store.getBacklinkedDocuments(this.id);
}
/**
* Returns users that have been individually given access to the document.
*
+1 -1
View File
@@ -22,7 +22,7 @@ class Star extends Model {
document?: Document;
/** The collection ID that is starred. */
collectionId: string;
collectionId?: string;
/** The collection that is starred. */
@Relation(() => Collection, { onDelete: "cascade" })
+39
View File
@@ -0,0 +1,39 @@
import { action, observable } from "mobx";
import { client } from "~/utils/ApiClient";
import User from "../User";
import ParanoidModel from "../base/ParanoidModel";
import Field from "../decorators/Field";
import Relation from "../decorators/Relation";
import OAuthClient from "./OAuthClient";
class OAuthAuthentication extends ParanoidModel {
static modelName = "OAuthAuthentication";
/** A list of scopes that this authentication has access to */
@Field
@observable
scope: string[];
@Relation(() => User)
user: User;
userId: string;
oauthClient: Pick<OAuthClient, "id" | "name" | "clientId" | "avatarUrl">;
oauthClientId: string;
lastActiveAt: string;
@action
public async deleteAll() {
await client.post(`/${this.store.apiEndpoint}.delete`, {
oauthClientId: this.oauthClientId,
scope: this.scope,
});
return this.store.remove(this.id);
}
}
export default OAuthAuthentication;
+92
View File
@@ -0,0 +1,92 @@
import invariant from "invariant";
import { observable, runInAction } from "mobx";
import queryString from "query-string";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import User from "../User";
import ParanoidModel from "../base/ParanoidModel";
import Field from "../decorators/Field";
import Relation from "../decorators/Relation";
class OAuthClient extends ParanoidModel {
static modelName = "OAuthClient";
/** The human-readable name of this app */
@Field
@observable
name: string;
/** A short description of this app */
@Field
@observable
description: string | null;
/** The name of the developer of this app */
@Field
@observable
developerName: string | null;
/** The URL of the developer of this app */
@Field
@observable
developerUrl: string | null;
/** The URL of the avatar of the developer of this app */
@Field
@observable
avatarUrl: string | null;
/** The public identifier of this app */
@Field
clientId: string;
/** The secret key used to authenticate this app */
@Field
@observable
clientSecret: string;
/** Whether this app is published (available to other workspaces) */
@Field
@observable
published: boolean;
/** A list of valid redirect URIs for this app */
@Field
@observable
redirectUris: string[];
@Relation(() => User)
createdBy: User;
createdById: string;
// instance methods
public async rotateClientSecret() {
const res = await client.post("/oauthClients.rotate_secret", {
id: this.id,
});
invariant(res.data, "Failed to rotate client secret");
runInAction("OAuthClient#rotateSecret", () => {
this.clientSecret = res.data.clientSecret;
});
}
public get initial() {
return this.name[0];
}
public get authorizationUrl(): string {
const params = {
client_id: this.clientId,
redirect_uri: this.redirectUris[0],
response_type: "code",
scope: "read",
};
return `${env.URL}/oauth/authorize?${queryString.stringify(params)}`;
}
}
export default OAuthClient;
+66 -44
View File
@@ -42,55 +42,77 @@ const RedirectDocument = ({
/>
);
/**
* The authenticated routes are all the routes of the application that require
* the user to be logged in.
*/
function AuthenticatedRoutes() {
const team = useCurrentTeam();
const can = usePolicy(team);
return (
<WebsocketProvider>
<AuthenticatedLayout>
<React.Suspense
fallback={
<CenteredContent>
<PlaceholderDocument />
</CenteredContent>
}
>
<Switch>
{can.createDocument && (
<Route exact path={draftsPath()} component={Drafts} />
)}
{can.createDocument && (
<Route exact path={archivePath()} component={Archive} />
)}
{can.createDocument && (
<Route exact path={trashPath()} component={Trash} />
)}
<Route path={`${homePath()}/:tab?`} component={Home} />
<Redirect from="/dashboard" to={homePath()} />
<Redirect exact from="/starred" to={homePath()} />
<Redirect exact from="/templates" to={settingsPath("templates")} />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab?" component={Collection} />
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={Document}
/>
<Route exact path={`/doc/${slug}/insights`} component={Document} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path={`${searchPath()}/:query?`} component={Search} />
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={Error404} />
</Switch>
</React.Suspense>
</AuthenticatedLayout>
</WebsocketProvider>
<Switch>
<WebsocketProvider>
<AuthenticatedLayout>
<React.Suspense
fallback={
<CenteredContent>
<PlaceholderDocument />
</CenteredContent>
}
>
<Switch>
{can.createDocument && (
<Route exact path={draftsPath()} component={Drafts} />
)}
{can.createDocument && (
<Route exact path={archivePath()} component={Archive} />
)}
{can.createDocument && (
<Route exact path={trashPath()} component={Trash} />
)}
<Route path={`${homePath()}/:tab?`} component={Home} />
<Redirect from="/dashboard" to={homePath()} />
<Redirect exact from="/starred" to={homePath()} />
<Redirect
exact
from="/templates"
to={settingsPath("templates")}
/>
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route
exact
path="/collection/:id/:tab?"
component={Collection}
/>
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={Document}
/>
<Route
exact
path={`/doc/${slug}/insights`}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route
exact
path={`${searchPath()}/:query?`}
component={Search}
/>
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={Error404} />
</Switch>
</React.Suspense>
</AuthenticatedLayout>
</WebsocketProvider>
</Switch>
);
}
+8 -6
View File
@@ -6,14 +6,15 @@ import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
import env from "~/env";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazyWithRetry from "~/utils/lazyWithRetry";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
const Authenticated = lazyWithRetry(() => import("~/components/Authenticated"));
const AuthenticatedRoutes = lazyWithRetry(() => import("./authenticated"));
const SharedDocument = lazyWithRetry(() => import("~/scenes/Document/Shared"));
const Login = lazyWithRetry(() => import("~/scenes/Login"));
const Logout = lazyWithRetry(() => import("~/scenes/Logout"));
const Authenticated = lazy(() => import("~/components/Authenticated"));
const AuthenticatedRoutes = lazy(() => import("./authenticated"));
const SharedDocument = lazy(() => import("~/scenes/Document/Shared"));
const Login = lazy(() => import("~/scenes/Login"));
const Logout = lazy(() => import("~/scenes/Logout"));
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
export default function Routes() {
useQueryNotices();
@@ -43,6 +44,7 @@ export default function Routes() {
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
<Route exact path="/oauth/authorize" component={OAuthAuthorize} />
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
<Route exact path="/s/:shareId" component={SharedDocument} />
+7
View File
@@ -7,6 +7,7 @@ import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers";
const Application = lazy(() => import("~/scenes/Settings/Application"));
const Document = lazy(() => import("~/scenes/Document"));
export default function SettingsRoutes() {
@@ -22,6 +23,12 @@ export default function SettingsRoutes() {
component={config.component}
/>
))}
{/* TODO: Refactor these exceptions into config? */}
<Route
exact
path={`${settingsPath("applications")}/:id`}
component={Application}
/>
<Route
exact
path={`${settingsPath("templates")}/${matchDocumentSlug}`}
@@ -6,15 +6,18 @@ import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import Text from "~/components/Text";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
import { Properties } from "~/types";
const extensions = withUIExtensions(richExtensions);
@@ -22,8 +25,8 @@ type Props = {
collection: Collection;
};
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
function Overview({ collection }: Props) {
const { documents, collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
@@ -54,6 +57,24 @@ function CollectionDescription({ collection }: Props) {
[childOffsetHeight]
);
const onCreateLink = React.useCallback(
async (params: Properties<Document>) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
data: ProsemirrorHelper.getEmptyDocument(),
...params,
},
{
publish: true,
}
);
return newDocument.url;
},
[collection, documents]
);
return (
<>
{collections.isSaving && <LoadingIndicator />}
@@ -65,11 +86,11 @@ function CollectionDescription({ collection }: Props) {
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
editorStyle={editorStyle}
embedsDisabled
/>
<div ref={childRef} />
</React.Suspense>
@@ -84,4 +105,4 @@ const Placeholder = styled(Text)`
min-height: 27px;
`;
export default observer(CollectionDescription);
export default observer(Overview);
+8 -14
View File
@@ -20,7 +20,6 @@ import Collection from "~/models/Collection";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSearchPage from "~/components/InputSearchPage";
@@ -46,6 +45,7 @@ import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -66,7 +66,6 @@ const CollectionScene = observer(function _CollectionScene() {
const location = useLocation();
const { t } = useTranslation();
const { documents, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
@@ -120,21 +119,16 @@ const CollectionScene = observer(function _CollectionScene() {
React.useEffect(() => {
async function fetchData() {
if ((!can || !collection) && !error && !isFetching) {
try {
setError(undefined);
setFetching(true);
await collections.fetch(id);
} catch (err) {
setError(err);
} finally {
setFetching(false);
}
try {
setError(undefined);
await collections.fetch(id);
} catch (err) {
setError(err);
}
}
void fetchData();
}, [collections, isFetching, collection, error, id, can]);
}, []);
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
@@ -265,7 +259,7 @@ const CollectionScene = observer(function _CollectionScene() {
path={collectionPath(collection.path, CollectionPath.Overview)}
>
{hasOverview ? (
<CollectionDescription collection={collection} />
<Overview collection={collection} />
) : (
<Redirect
to={{
+2 -2
View File
@@ -144,10 +144,10 @@ function Insights() {
small
/>
)}
<PaginatedList
<PaginatedList<User>
aria-label={t("Contributors")}
items={document.collaborators}
renderItem={(model: User) => (
renderItem={(model) => (
<ListItem
key={model.id}
title={model.name}
@@ -18,7 +18,7 @@ type Props = {
};
function References({ document }: Props) {
const { collections, documents } = useStores();
const { documents } = useStores();
const user = useCurrentUser();
const location = useLocation();
const locationSidebarContext = useLocationSidebarContext();
@@ -27,10 +27,8 @@ function References({ document }: Props) {
void documents.fetchBacklinks(document.id);
}, [documents, document.id]);
const backlinks = documents.getBacklinkedDocuments(document.id);
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const backlinks = document.backlinks;
const collection = document.collection;
const children = collection
? collection.getChildrenForDocument(document.id)
: [];
@@ -13,7 +13,6 @@ import { Config } from "~/stores/AuthStore";
import { AvatarSize } from "~/components/Avatar";
import ButtonLarge from "~/components/ButtonLarge";
import ChangeLanguage from "~/components/ChangeLanguage";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import OutlineIcon from "~/components/Icons/OutlineIcon";
@@ -30,21 +29,23 @@ import {
} from "~/hooks/useLastVisitedPath";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop } from "~/styles";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import { detectLanguage } from "~/utils/language";
import { homePath } from "~/utils/routeHelpers";
import AuthenticationProvider from "./components/AuthenticationProvider";
import BackButton from "./components/BackButton";
import Notices from "./components/Notices";
import { BackButton } from "./components/BackButton";
import { Background } from "./components/Background";
import { Centered } from "./components/Centered";
import { Notices } from "./components/Notices";
import { getRedirectUrl, navigateToSubdomain } from "./urls";
type Props = {
children?: (config?: Config) => React.ReactNode;
onBack?: () => void;
};
function Login({ children }: Props) {
function Login({ children, onBack }: Props) {
const location = useLocation();
const query = useQuery();
const notice = query.get("notice");
@@ -110,9 +111,9 @@ function Login({ children }: Props) {
if (error) {
return (
<Background>
<BackButton />
<BackButton onBack={onBack} />
<ChangeLanguage locale={detectLanguage()} />
<Centered align="center" justify="center" column auto>
<Centered>
<PageTitle title={t("Login")} />
<Heading centered>{t("Error")}</Heading>
<Note>
@@ -142,9 +143,9 @@ function Login({ children }: Props) {
if (isCloudHosted && isCustomDomain && !config.name) {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered align="center" justify="center" column auto>
<Centered>
<PageTitle title={t("Custom domain setup")} />
<Heading centered>{t("Almost there")}</Heading>
<Note>
@@ -160,17 +161,10 @@ function Login({ children }: Props) {
if (Desktop.isElectron() && notice === "domain-required") {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered
as="form"
onSubmit={handleGoSubdomain}
align="center"
justify="center"
column
auto
>
<Centered as="form" onSubmit={handleGoSubdomain}>
<Heading centered>{t("Choose workspace")}</Heading>
<Note>
{t(
@@ -206,8 +200,8 @@ function Login({ children }: Props) {
if (emailLinkSentTo) {
return (
<Background>
<BackButton config={config} />
<Centered align="center" justify="center" column auto>
<BackButton onBack={onBack} config={config} />
<Centered>
<PageTitle title={t("Check your email")} />
<CheckEmailIcon size={38} />
<Heading centered>{t("Check your email")}</Heading>
@@ -241,10 +235,10 @@ function Login({ children }: Props) {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered align="center" justify="center" gap={12} column auto>
<Centered gap={12}>
<PageTitle
title={config.name ? `${config.name} ${t("Login")}` : t("Login")}
/>
@@ -336,14 +330,6 @@ const CheckEmailIcon = styled(EmailIcon)`
margin-bottom: -1.5em;
`;
const Background = styled(Fade)`
width: 100vw;
height: 100%;
background: ${s("background")};
display: flex;
${draggableOnDesktop()}
`;
const Logo = styled.div`
margin-bottom: -4px;
`;
@@ -389,12 +375,4 @@ const Or = styled.hr`
}
`;
const Centered = styled(Flex)`
user-select: none;
width: 90vw;
height: 100%;
max-width: 320px;
margin: 0 auto;
`;
export default observer(Login);
+260
View File
@@ -0,0 +1,260 @@
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import { s } from "@shared/styles";
import { parseDomain } from "@shared/utils/domains";
import type OAuthClient from "~/models/oauth/OAuthClient";
import ButtonLarge from "~/components/ButtonLarge";
import ChangeLanguage from "~/components/ChangeLanguage";
import Heading from "~/components/Heading";
import LoadingIndicator from "~/components/LoadingIndicator";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useLoggedInSessions } from "~/hooks/useLoggedInSessions";
import useQuery from "~/hooks/useQuery";
import useRequest from "~/hooks/useRequest";
import { client } from "~/utils/ApiClient";
import { BadRequestError, NotFoundError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import { detectLanguage } from "~/utils/language";
import Login from "./Login";
import { OAuthScopeHelper } from "./OAuthScopeHelper";
import { Background } from "./components/Background";
import { Centered } from "./components/Centered";
import { ConnectHeader } from "./components/ConnectHeader";
import { TeamSwitcher } from "./components/TeamSwitcher";
export default function OAuthAuthorize() {
const team = useCurrentTeam({ rejectOnEmpty: false });
const sessions = useLoggedInSessions();
// We're self-hosted or on a team subdomain already, just show the authorize screen.
if (team) {
return <Authorize />;
}
// Cloud hosted and on root domain show the workspace switcher.
const isAppRoot =
parseDomain(window.location.hostname).host === parseDomain(env.URL).host;
const hasLoggedInSessions = Object.keys(sessions).length > 0;
if (isCloudHosted && hasLoggedInSessions && isAppRoot) {
return <TeamSwitcher sessions={sessions} />;
}
return <Login />;
}
/**
* Authorize component is responsible for handling the OAuth authorization process.
* It retrieves the OAuth client information, displays the authorization request,
* and allows the user to either authorize or cancel the request.
*/
function Authorize() {
const team = useCurrentTeam();
const params = useQuery();
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const timeoutRef = React.useRef<number>();
const {
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
state,
scope,
} = Object.fromEntries(params);
const [scopes] = React.useState(() => scope?.split(" ") ?? []);
const { error: clientError, data: response } = useRequest<{
data: OAuthClient;
}>(() => client.post("/oauthClients.info", { clientId, redirectUri }), true);
const handleCancel = () => {
if (redirectUri && !clientError) {
const url = new URL(redirectUri);
url.searchParams.set("error", "access_denied");
window.location.href = url.toString();
return;
}
if (window.history.length) {
window.history.back();
} else {
window.location.href = "/";
}
};
const handleSubmit = () => {
setIsSubmitting(true);
timeoutRef.current = window.setTimeout(() => setIsSubmitting(false), 5000);
};
React.useEffect(
() => () => {
timeoutRef.current && window.clearTimeout(timeoutRef.current);
},
[]
);
const missingParams = [
!clientId && "client_id",
!redirectUri && "redirect_uri",
!responseType && "response_type",
!scope && "scope",
!state && "state",
].filter(Boolean);
if (missingParams.length || clientError) {
return (
<Background>
<Centered>
<StyledHeading>{t("An error occurred")}</StyledHeading>
{clientError instanceof NotFoundError ? (
<Text as="p" type="secondary">
{t(
"The OAuth client could not be found, please check the provided client ID"
)}
<Pre>{clientId}</Pre>
</Text>
) : clientError instanceof BadRequestError ? (
<Text as="p" type="secondary">
{t(
"The OAuth client could not be loaded, please check the redirect URI is valid"
)}
<Pre>{redirectUri}</Pre>
</Text>
) : (
<Text as="p" type="secondary">
{t("Required OAuth parameters are missing")}
<Pre>
{missingParams.map((param) => (
<>
{param}
<br />
</>
))}
</Pre>
</Text>
)}
</Centered>
</Background>
);
}
if (!response) {
return <LoadingIndicator />;
}
const { name, developerName, developerUrl } = response.data;
return (
<Background>
<ChangeLanguage locale={detectLanguage()} />
<PageTitle title={t("Authorize")} />
<Centered gap={12}>
<ConnectHeader team={team} oauthClient={response.data} />
<StyledHeading>
{t(`{{ appName }} wants to access {{ teamName }}`, {
appName: name,
teamName: team.name,
})}
</StyledHeading>
{developerName && (
<Text type="secondary" as="p" style={{ marginTop: -12 }}>
<Trans
defaults="By <em>{{ developerName }}</em>"
values={{
developerName,
}}
components={{
em: developerUrl ? (
<Text
as="a"
type="secondary"
weight="bold"
href={developerUrl}
target="_blank"
rel="noopener noreferrer"
/>
) : (
<strong />
),
}}
/>
</Text>
)}
<Text type="tertiary" as="p">
{t(
"{{ appName }} will be able to access your account and perform the following actions",
{
appName: name,
}
)}
:
</Text>
<ul style={{ width: "100%", paddingLeft: "1em", marginTop: 0 }}>
{OAuthScopeHelper.normalizeScopes(scopes, t).map((item) => (
<li key={item}>
<Text type="secondary">{item}</Text>
</li>
))}
</ul>
<form
method="POST"
action="/oauth/authorize"
style={{ width: "100%" }}
onSubmit={handleSubmit}
>
<input type="hidden" name="client_id" value={clientId ?? ""} />
<input type="hidden" name="redirect_uri" value={redirectUri ?? ""} />
<input
type="hidden"
name="response_type"
value={responseType ?? ""}
/>
<input type="hidden" name="state" value={state ?? ""} />
<input type="hidden" name="scope" value={scope ?? ""} />
{codeChallenge && (
<input type="hidden" name="code_challenge" value={codeChallenge} />
)}
{codeChallengeMethod && (
<input
type="hidden"
name="code_challenge_method"
value={codeChallengeMethod}
/>
)}
<Flex gap={8} justify="space-between">
<Button type="button" onClick={handleCancel} neutral>
{t("Cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{t("Authorize")}
</Button>
</Flex>
</form>
</Centered>
</Background>
);
}
const Button = styled(ButtonLarge)`
width: calc(50% - 4px);
`;
const StyledHeading = styled(Heading).attrs({
as: "h2",
centered: true,
})`
margin-top: 0;
`;
const Pre = styled.pre`
background: ${s("backgroundSecondary")};
padding: 16px;
border-radius: 4px;
font-size: 12px;
white-space: pre-wrap;
`;
+56
View File
@@ -0,0 +1,56 @@
import type { TFunction } from "i18next";
import capitalize from "lodash/capitalize";
import uniq from "lodash/uniq";
import { Scope } from "@shared/types";
export class OAuthScopeHelper {
public static normalizeScopes(scopes: string[], t: TFunction): string[] {
const methodToReadable = {
list: t("read"),
info: t("read"),
read: t("read"),
write: t("write"),
create: t("write"),
update: t("write"),
delete: t("write"),
"*": t("read and write"),
};
const translatedNamespaces = {
apiKeys: t("API keys"),
attachments: t("attachments"),
collections: t("collections"),
comments: t("comments"),
documents: t("documents"),
events: t("events"),
groups: t("groups"),
integrations: t("integrations"),
notifications: t("notifications"),
reactions: t("reactions"),
pins: t("pins"),
shares: t("shares"),
users: t("users"),
teams: t("teams"),
"*": t("workspace"),
};
const normalizedScopes = scopes.map((scope) => {
if (scope === Scope.Read) {
return t("Read all data");
}
if (scope === Scope.Write) {
return t("Write all data");
}
const [namespace, method] = scope.replace("/api/", "").split(/[:\.]/g);
const readableMethod =
methodToReadable[method as keyof typeof methodToReadable] ?? method;
const translatedNamespace =
translatedNamespaces[namespace as keyof typeof translatedNamespaces] ??
namespace;
return capitalize(`${readableMethod} ${translatedNamespace}`);
});
return uniq(normalizedScopes);
}
}
+10 -1
View File
@@ -10,12 +10,21 @@ import isCloudHosted from "~/utils/isCloudHosted";
type Props = {
config?: Config;
onBack?: () => void;
};
export default function BackButton({ config }: Props) {
export function BackButton({ onBack, config }: Props) {
const { t } = useTranslation();
const isSubdomain = !!config?.hostname;
if (onBack) {
return (
<Link onClick={onBack}>
<BackIcon /> {t("Back")}
</Link>
);
}
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
return null;
}
@@ -0,0 +1,12 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import Fade from "~/components/Fade";
import { draggableOnDesktop } from "~/styles";
export const Background = styled(Fade)`
width: 100vw;
height: 100%;
background: ${s("background")};
display: flex;
${draggableOnDesktop()}
`;
+15
View File
@@ -0,0 +1,15 @@
import styled from "styled-components";
import Flex from "@shared/components/Flex";
export const Centered = styled(Flex).attrs({
align: "center",
justify: "center",
column: true,
auto: true,
})`
user-select: none;
width: 90vw;
height: 100%;
max-width: 320px;
margin: 0 auto;
`;
@@ -0,0 +1,40 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import Flex from "@shared/components/Flex";
import Text from "@shared/components/Text";
import type Team from "~/models/Team";
import type OAuthClient from "~/models/oauth/OAuthClient";
import { Avatar } from "~/components/Avatar";
import { AvatarSize, AvatarVariant } from "~/components/Avatar/Avatar";
type Props = {
team: Team;
oauthClient: OAuthClient;
};
export function ConnectHeader({ team, oauthClient }: Props) {
return (
<Text type="tertiary">
<Flex gap={12} align="center">
<Avatar
variant={AvatarVariant.Square}
model={{
avatarUrl: oauthClient.avatarUrl,
initial: oauthClient.name[0],
}}
size={AvatarSize.XXLarge}
alt={oauthClient.name}
/>
<MoreIcon />
<Avatar
variant={AvatarVariant.Square}
model={team}
size={AvatarSize.XXLarge}
alt={team.name}
/>
</Flex>
</Text>
);
}
+4 -1
View File
@@ -39,7 +39,10 @@ export function LoginDialog() {
maxLength={255}
autoComplete="off"
placeholder={t("subdomain")}
{...register("subdomain", { required: true, pattern: /^[a-z\d-]+$/ })}
{...register("subdomain", {
required: true,
pattern: /^[a-z\d-]{1,63}$/,
})}
>
<Domain>.getoutline.com</Domain>
</Input>
+1 -1
View File
@@ -123,7 +123,7 @@ function Message({ notice }: { notice: string }) {
}
}
export default function Notices() {
export function Notices() {
const query = useQuery();
const notice = query.get("notice");
@@ -0,0 +1,109 @@
import { ArrowIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "@shared/components/Text";
import { s } from "@shared/styles";
import { AvatarSize } from "~/components/Avatar";
import Avatar, { AvatarVariant } from "~/components/Avatar/Avatar";
import ChangeLanguage from "~/components/ChangeLanguage";
import Heading from "~/components/Heading";
import OutlineIcon from "~/components/Icons/OutlineIcon";
import env from "~/env";
import type { Sessions } from "~/hooks/useLoggedInSessions";
import { detectLanguage } from "~/utils/language";
import Login from "../Login";
import { Background } from "./Background";
import { Centered } from "./Centered";
type Props = { sessions: Sessions };
export function TeamSwitcher({ sessions }: Props) {
const { t } = useTranslation();
const [showLogin, setShowLogin] = React.useState(false);
const url = new URL(window.location.href);
const appName = env.APP_NAME;
if (showLogin) {
return <Login onBack={() => setShowLogin(false)} />;
}
return (
<Background>
<ChangeLanguage locale={detectLanguage()} />
<Centered>
<OutlineIcon size={AvatarSize.XXLarge} />
<StyledHeading>{t("Choose a workspace")}</StyledHeading>
<Text type="tertiary" as="p">
{t(
"Choose an {{ appName }} workspace or login to continue connecting this app",
{ appName }
)}
.
</Text>
{Object.keys(sessions)?.map((teamId) => {
const session = sessions[teamId];
const location = session.url + url.pathname + url.search;
return (
<TeamLink href={location} key={session.url}>
<Avatar
variant={AvatarVariant.Square}
model={{
avatarUrl: session.logoUrl,
initial: session.name[0],
}}
size={AvatarSize.Large}
alt={session.name}
/>
{session.name}
<StyledArrowIcon />
</TeamLink>
);
})}
<TeamLink onClick={() => setShowLogin(true)}>
<ArrowIcon size={AvatarSize.Large} />
{t("Login to workspace")}
</TeamLink>
</Centered>
</Background>
);
}
const StyledArrowIcon = styled(ArrowIcon)`
position: absolute;
transition: all 0.2s ease-in-out;
opacity: 0;
right: 12px;
`;
const TeamLink = styled.a`
position: relative;
left: -8px;
right: -8px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
margin: 4px;
border-radius: 8px;
width: 100%;
color: ${s("text")};
font-weight: ${s("fontWeightMedium")};
&:hover {
background: ${s("listItemHoverBackground")};
${StyledArrowIcon} {
opacity: 1;
right: 8px;
}
}
`;
const StyledHeading = styled(Heading).attrs({
as: "h2",
centered: true,
})`
margin-top: 0;
`;
+3
View File
@@ -0,0 +1,3 @@
import Login from "./Login";
export default Login;
+10 -1
View File
@@ -3,6 +3,15 @@ import { parseDomain } from "@shared/utils/domains";
import env from "~/env";
import Desktop from "~/utils/Desktop";
function validateAndEncodeSubdomain(subdomain: string): string {
const encodedSubdomain = encodeURIComponent(subdomain);
const urlPattern = /^[a-z\d-]{1,63}$/;
if (!urlPattern.test(encodedSubdomain)) {
throw new Error("Invalid subdomain");
}
return `https://${encodedSubdomain}.getoutline.com`;
}
/**
* If we're on a custom domain or a subdomain then the auth must point to the
* apex (env.URL) for authentication so that the state cookie can be set and read.
@@ -36,7 +45,7 @@ export async function navigateToSubdomain(subdomain: string) {
.toLowerCase()
.trim()
.replace(/^https?:\/\//, "");
const host = `https://${normalizedSubdomain}.getoutline.com`;
const host = validateAndEncodeSubdomain(normalizedSubdomain);
await Desktop.bridge?.addCustomHost(host);
window.location.href = host;
}
+107
View File
@@ -0,0 +1,107 @@
import { observer } from "mobx-react";
import { PadlockIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import ApiKey from "~/models/ApiKey";
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import env from "~/env";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import ApiKeyListItem from "./components/ApiKeyListItem";
import OAuthAuthenticationListItem from "./components/OAuthAuthenticationListItem";
function APIAndApps() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const { apiKeys, oauthAuthentications } = useStores();
const can = usePolicy(team);
const context = useActionContext();
const appName = env.APP_NAME;
return (
<Scene
title={t("API & Apps")}
icon={<PadlockIcon />}
actions={
<>
{can.createApiKey && (
<Action>
<Button
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
context={context}
/>
</Action>
)}
</>
}
>
<Heading>{t("API & Apps")}</Heading>
<h2>{t("API keys")}</h2>
{can.createApiKey ? (
<Text as="p" type="secondary">
<Trans
defaults="Create personal API keys to authenticate with the API and programatically control
your workspace's data. For more details see the <em>developer documentation</em>."
components={{
em: (
<a
href="https://www.getoutline.com/developers"
target="_blank"
rel="noreferrer"
/>
),
}}
/>
</Text>
) : (
<Trans>
{t("API keys have been disabled by an admin for your account")}
</Trans>
)}
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
renderItem={(apiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
<PaginatedList
fetch={oauthAuthentications.fetchPage}
items={oauthAuthentications.orderedData}
heading={
<>
<h2>{t("Application access")}</h2>
<Text as="p" type="secondary">
{t(
"Manage which third-party and internal applications have been granted access to your {{ appName }} account.",
{ appName }
)}
</Text>
</>
}
renderItem={(oauthAuthentication: OAuthAuthentication) => (
<OAuthAuthenticationListItem
key={oauthAuthentication.id}
oauthAuthentication={oauthAuthentication}
/>
)}
/>
</Scene>
);
}
export default observer(APIAndApps);
+2 -3
View File
@@ -58,11 +58,10 @@ function ApiKeys() {
}}
/>
</Text>
<PaginatedList
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<h2>{t("All")}</h2>}
renderItem={(apiKey: ApiKey) => (
renderItem={(apiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
+325
View File
@@ -0,0 +1,325 @@
import { observer } from "mobx-react";
import { CopyIcon, InternetIcon, ReplaceIcon } from "outline-icons";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { OAuthClientValidation } from "@shared/validations";
import OAuthClient from "~/models/oauth/OAuthClient";
import Breadcrumb from "~/components/Breadcrumb";
import Button from "~/components/Button";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ContentEditable from "~/components/ContentEditable";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import { FormData } from "~/components/OAuthClient/OAuthClientForm";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Tooltip from "~/components/Tooltip";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import OAuthClientMenu from "~/menus/OAuthClientMenu";
import isCloudHosted from "~/utils/isCloudHosted";
import { settingsPath } from "~/utils/routeHelpers";
import { ActionRow } from "./components/ActionRow";
import { CopyButton } from "./components/CopyButton";
import ImageInput from "./components/ImageInput";
import SettingRow from "./components/SettingRow";
type Props = {
oauthClient: OAuthClient;
};
const LoadingState = observer(function LoadingState() {
const { id } = useParams<{ id: string }>();
const { oauthClients } = useStores();
const oauthClient = oauthClients.get(id);
const { request } = useRequest(() => oauthClients.fetch(id));
React.useEffect(() => {
if (!oauthClient) {
void request();
}
}, [oauthClient]);
if (!oauthClient) {
return <LoadingIndicator />;
}
return <Application oauthClient={oauthClient} />;
});
const Application = observer(function Application({ oauthClient }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const {
register,
handleSubmit: formHandleSubmit,
formState,
getValues,
setError,
control,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: oauthClient.name ?? "",
developerName: oauthClient.developerName ?? "",
developerUrl: oauthClient.developerUrl ?? "",
description: oauthClient.description ?? "",
avatarUrl: oauthClient.avatarUrl ?? "",
redirectUris: oauthClient.redirectUris ?? [],
published: oauthClient.published ?? false,
},
});
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await oauthClient.save(data);
toast.success(
oauthClient.published
? t("Application published")
: t("Application updated")
);
} catch (error) {
toast.error(error.message);
}
},
[oauthClient, t]
);
const handleRotateSecret = React.useCallback(async () => {
const onDelete = async () => {
try {
await oauthClient.rotateClientSecret();
toast.success(t("Client secret rotated"));
} catch (err) {
toast.error(err.message);
}
};
dialogs.openModal({
title: t("Rotate secret"),
content: (
<ConfirmationDialog onSubmit={onDelete} danger>
{t(
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials."
)}
</ConfirmationDialog>
),
});
}, [t, dialogs, oauthClient]);
return (
<Scene
title={oauthClient.name}
left={
<Breadcrumb
items={[
{
type: "route",
title: t("Applications"),
to: settingsPath("applications"),
icon: <InternetIcon />,
},
]}
/>
}
actions={<OAuthClientMenu oauthClient={oauthClient} showEdit={false} />}
>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Heading>
<Controller
control={control}
name="name"
render={({ field }) => (
<ContentEditable
value={field.value}
placeholder={t("Name")}
onChange={field.onChange}
maxLength={OAuthClientValidation.maxNameLength}
/>
)}
/>
</Heading>
<SettingRow
label={t("Icon")}
name="avatarUrl"
description={t("Displayed to users when authorizing")}
>
<Controller
control={control}
name="avatarUrl"
render={({ field }) => (
<ImageInput
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
id: oauthClient.id,
avatarUrl: field.value,
initial: getValues().name[0],
}}
borderRadius={0}
/>
)}
/>
</SettingRow>
<SettingRow
name="description"
label={t("Tagline")}
description={t("A short description")}
>
<Input
type="text"
{...register("description", {
maxLength: OAuthClientValidation.maxDescriptionLength,
})}
flex
/>
</SettingRow>
<SettingRow
name="details"
label={t("Details")}
description={t(
"Developer information shown to users when authorizing"
)}
border={isCloudHosted}
>
<Input
type="text"
label={t("Developer name")}
{...register("developerName", {
maxLength: OAuthClientValidation.maxDeveloperNameLength,
})}
flex
/>
<Input
type="text"
label={t("Developer URL")}
{...register("developerUrl", {
maxLength: OAuthClientValidation.maxDeveloperUrlLength,
})}
flex
/>
</SettingRow>
{isCloudHosted && (
<SettingRow
name="published"
label={t("Published")}
description={t(
"Allow users from other workspaces to authorize this app"
)}
border={false}
>
<Switch id="published" {...register("published")} />
</SettingRow>
)}
<h2>{t("Credentials")}</h2>
<SettingRow
name="clientId"
label={t("OAuth client ID")}
description={t("The public identifier for this app")}
>
<Input id="clientId" value={oauthClient.clientId} readOnly>
<CopyButton
value={oauthClient.clientId}
success={t("Copied to clipboard")}
tooltip={t("Copy")}
icon={<CopyIcon size={20} />}
/>
</Input>
</SettingRow>
<SettingRow
name="clientSecret"
label={t("OAuth client secret")}
description={t(
"Store this value securely, do not expose it publicly"
)}
>
<Input
id="clientSecret"
type="password"
value={oauthClient.clientSecret}
readOnly
>
<Tooltip content={t("Rotate secret")} placement="top">
<NudeButton type="button" onClick={handleRotateSecret}>
<ReplaceIcon size={20} />
</NudeButton>
</Tooltip>
<CopyButton
value={oauthClient.clientSecret}
success={t("Copied to clipboard")}
tooltip={t("Copy")}
icon={<CopyIcon size={20} />}
/>
</Input>
</SettingRow>
<SettingRow
name="redirectUris"
label={t("Callback URLs")}
description={t(
"Where users are redirected after authorizing this app"
)}
>
<Controller
control={control}
name="redirectUris"
render={({ field }) => (
<Input
id="redirectUris"
type="textarea"
placeholder="https://example.com/callback"
ref={field.ref}
value={field.value.join("\n")}
rows={Math.max(2, field.value.length + 1)}
onChange={(event) => {
field.onChange(event.target.value.split("\n"));
}}
required
/>
)}
/>
</SettingRow>
<SettingRow
name="authorizationUrl"
label={t("Authorization URL")}
description={t("Where users are redirected to authorize this app")}
border={false}
>
<Input
id="authorizationUrl"
value={oauthClient.authorizationUrl}
readOnly
>
<CopyButton
value={oauthClient.authorizationUrl}
success={t("Copied to clipboard")}
tooltip={t("Copy link")}
/>
</Input>
</SettingRow>
<ActionRow>
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</ActionRow>
</form>
</Scene>
);
});
export default LoadingState;
@@ -1,42 +1,40 @@
import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import { InternetIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import ApiKey from "~/models/ApiKey";
import OAuthClient from "~/models/oauth/OAuthClient";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import { createOAuthClient } from "~/actions/definitions/oauthClients";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import ApiKeyListItem from "./components/ApiKeyListItem";
import OAuthClientListItem from "./components/OAuthClientListItem";
function PersonalApiKeys() {
function Applications() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const { apiKeys } = useStores();
const { oauthClients } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
title={t("API")}
icon={<CodeIcon />}
title={t("Applications")}
icon={<InternetIcon />}
actions={
<>
{can.createApiKey && (
{can.createOAuthClient && (
<Action>
<Button
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
value={`${t("New App")}`}
action={createOAuthClient}
context={context}
/>
</Action>
@@ -44,12 +42,10 @@ function PersonalApiKeys() {
</>
}
>
<Heading>{t("API")}</Heading>
<Heading>{t("Applications")}</Heading>
<Text as="p" type="secondary">
<Trans
defaults="Create personal API keys to authenticate with the API and programatically control
your workspace's data. API keys have the same permissions as your user account.
For more details see the <em>developer documentation</em>."
defaults="Applications allow you to build internal or public integrations with Outline and provide secure access via OAuth. For more details see the <em>developer documentation</em>."
components={{
em: (
<a
@@ -61,17 +57,15 @@ function PersonalApiKeys() {
}}
/>
</Text>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
<PaginatedList<OAuthClient>
fetch={oauthClients.fetchPage}
items={oauthClients.orderedData}
renderItem={(oauthClient) => (
<OAuthClientListItem key={oauthClient.id} oauthClient={oauthClient} />
)}
/>
</Scene>
);
}
export default observer(PersonalApiKeys);
export default observer(Applications);
+10 -1
View File
@@ -108,7 +108,16 @@ function Details() {
toast.error(err.message);
}
},
[team, name, subdomain, defaultCollectionId, publicBranding, customTheme, t]
[
tocPosition,
team,
name,
subdomain,
defaultCollectionId,
publicBranding,
customTheme,
t,
]
);
const handleNameChange = React.useCallback(
+2 -2
View File
@@ -48,7 +48,7 @@ function Export() {
{t("Export data")}
</Button>
<br />
<PaginatedList
<PaginatedList<FileOperation>
items={fileOperations.exports}
fetch={fileOperations.fetchPage}
options={{
@@ -59,7 +59,7 @@ function Export() {
<Trans>Recent exports</Trans>
</h2>
}
renderItem={(item: FileOperation) => (
renderItem={(item) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
/>
+2 -2
View File
@@ -183,7 +183,7 @@ function Import() {
))}
</div>
<br />
<PaginatedList
<PaginatedList<ImportModel | FileOperation>
items={allImports}
fetch={fetchImports}
heading={
@@ -191,7 +191,7 @@ function Import() {
<Trans>Recent imports</Trans>
</h2>
}
renderItem={(item: ImportModel | FileOperation) =>
renderItem={(item) =>
item instanceof ImportModel ? (
<ImportListItem key={item.id} importModel={item} />
) : (
@@ -0,0 +1,50 @@
import { LinkIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import CopyToClipboard from "~/components/CopyToClipboard";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
type Props = {
/** The value to be copied */
value: string;
/** The message to show when the value is copied */
success: string;
/** The tooltip message */
tooltip: string;
/** An optional icon */
icon?: React.ReactNode;
};
/**
* A button that copies a value to the clipboard when clicked and shows a
* single icon.
*/
export function CopyButton({
value,
success,
tooltip,
icon = <LinkIcon size={20} />,
}: Props) {
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const handleCopied = React.useCallback(() => {
timeout.current = setTimeout(() => {
toast.message(success);
}, 100);
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, [success]);
return (
<Tooltip content={tooltip} placement="top">
<CopyToClipboard text={value} onCopy={handleCopied}>
<NudeButton type="button">{icon}</NudeButton>
</CopyToClipboard>
</Tooltip>
);
}
@@ -268,14 +268,14 @@ export const ViewGroupMembersDialog = observer(function ({
<Subheading>
<Trans>Members</Trans>
</Subheading>
<PaginatedList
<PaginatedList<User>
items={users.inGroup(group.id)}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(user: User) => (
renderItem={(user) => (
<GroupMemberListItem
key={user.id}
user={user}
@@ -382,7 +382,7 @@ const AddPeopleToGroupDialog = observer(function ({
<PlaceholderList count={5} />
</DelayedMount>
) : (
<PaginatedList
<PaginatedList<User>
empty={
query ? (
<Empty>{t("No people matching your search")}</Empty>
@@ -392,7 +392,7 @@ const AddPeopleToGroupDialog = observer(function ({
}
items={users.notInGroup(group.id, query)}
fetch={query ? undefined : users.fetchPage}
renderItem={(item: User) => (
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
user={item}
+13 -7
View File
@@ -1,8 +1,10 @@
import { EditIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Avatar, AvatarSize, IAvatar } from "~/components/Avatar";
import { AvatarVariant } from "~/components/Avatar/Avatar";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload";
@@ -17,10 +19,18 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
return (
<Flex gap={8} justify="space-between">
<ImageBox>
<ImageUpload onSuccess={onSuccess} {...rest}>
<StyledAvatar model={model} size={AvatarSize.Upload} />
<ImageUpload
onSuccess={onSuccess}
submitText={t("Crop Image")}
{...rest}
>
<Avatar
model={model}
size={AvatarSize.Upload}
variant={AvatarVariant.Square}
/>
<Flex auto align="center" justify="center" className="upload">
{t("Upload")}
<EditIcon />
</Flex>
</ImageUpload>
</ImageBox>
@@ -38,10 +48,6 @@ const avatarStyles = `
height: ${AvatarSize.Upload}px;
`;
const StyledAvatar = styled(Avatar)`
border-radius: 8px;
`;
const ImageBox = styled(Flex)`
${avatarStyles};
position: relative;
+28 -16
View File
@@ -2,6 +2,7 @@ import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "@shared/components/Text";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -14,6 +15,7 @@ import {
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import UserMenu from "~/menus/UserMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
@@ -27,6 +29,7 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
export function MembersTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const isMobile = useMobile();
const columns = React.useMemo<TableColumn<User>[]>(
() =>
@@ -38,13 +41,20 @@ export function MembersTable({ canManage, ...rest }: Props) {
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
<Avatar model={user} size={AvatarSize.Large} />{" "}
<Flex column>
<Text>
{user.name} {currentUser.id === user.id && `(${t("You")})`}
</Text>
{isMobile && canManage && (
<Text type="tertiary">{user.email}</Text>
)}
</Flex>
</Flex>
),
width: "4fr",
},
canManage
canManage && !isMobile
? {
type: "data",
id: "email",
@@ -54,17 +64,19 @@ export function MembersTable({ canManage, ...rest }: Props) {
width: "4fr",
}
: undefined,
{
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
isMobile
? undefined
: {
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "role",
@@ -85,7 +97,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
),
width: "2fr",
width: "80px",
},
canManage
? {
@@ -97,7 +109,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
}
: undefined,
]),
[t, currentUser, canManage]
[t, currentUser, canManage, isMobile]
);
return (
@@ -0,0 +1,61 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import { OAuthScopeHelper } from "~/scenes/Login/OAuthScopeHelper";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { AvatarVariant } from "~/components/Avatar/Avatar";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import OAuthAuthenticationMenu from "~/menus/OAuthAuthenticationMenu";
type Props = {
/** The OAuthAuthentication to display */
oauthAuthentication: OAuthAuthentication;
};
const OAuthAuthenticationListItem = ({ oauthAuthentication }: Props) => {
const { t } = useTranslation();
const subtitle = (
<>
<Text type="tertiary">
{oauthAuthentication.lastActiveAt ? (
<>
{t("Last active")}{" "}
<Time dateTime={oauthAuthentication.lastActiveAt} addSuffix />
</>
) : (
t("Never used")
)}{" "}
&middot;{" "}
</Text>
<Text type="tertiary" ellipsis>
{OAuthScopeHelper.normalizeScopes(oauthAuthentication.scope, t).join(
", "
)}
</Text>
</>
);
return (
<ListItem
key={oauthAuthentication.id}
image={
<Avatar
model={oauthAuthentication.oauthClient}
size={AvatarSize.Large}
variant={AvatarVariant.Square}
/>
}
title={oauthAuthentication.oauthClient.name}
subtitle={subtitle}
actions={
<OAuthAuthenticationMenu oauthAuthentication={oauthAuthentication} />
}
/>
);
};
export default observer(OAuthAuthenticationListItem);
@@ -0,0 +1,37 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import OAuthClient from "~/models/oauth/OAuthClient";
import ConfirmationDialog from "~/components/ConfirmationDialog";
type Props = {
oauthClient: OAuthClient;
onSubmit: () => void;
};
export default function OAuthClientDeleteDialog({
oauthClient,
onSubmit,
}: Props) {
const { t } = useTranslation();
const handleSubmit = async () => {
await oauthClient.delete();
onSubmit();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Delete")}
savingText={`${t("Deleting")}`}
danger
>
{t(
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.",
{
appName: oauthClient.name,
}
)}
</ConfirmationDialog>
);
}
@@ -0,0 +1,55 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import OAuthClient from "~/models/oauth/OAuthClient";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { AvatarVariant } from "~/components/Avatar/Avatar";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import OAuthClientMenu from "~/menus/OAuthClientMenu";
import { settingsPath } from "~/utils/routeHelpers";
type Props = {
oauthClient: OAuthClient;
};
const OAuthClientListItem = ({ oauthClient }: Props) => {
const { t } = useTranslation();
const user = useCurrentUser();
const subtitle = (
<>
<Text type="tertiary">
{t(`Created`)} <Time dateTime={oauthClient.createdAt} addSuffix />{" "}
{oauthClient.createdById === user.id
? ""
: t(`by {{ name }}`, { name: user.name })}
</Text>
</>
);
return (
<ListItem
key={oauthClient.id}
image={
<Avatar
model={oauthClient}
size={AvatarSize.Large}
variant={AvatarVariant.Square}
/>
}
title={
<Link to={settingsPath("applications", oauthClient.id)}>
<Text>{oauthClient.name}</Text>
</Link>
}
subtitle={subtitle}
actions={<OAuthClientMenu oauthClient={oauthClient} />}
/>
);
};
export default observer(OAuthClientListItem);
@@ -39,7 +39,7 @@ const Column = styled.div`
flex: 1;
&:first-child {
min-width: 70%;
min-width: 65%;
}
&:last-child {
+7
View File
@@ -186,6 +186,13 @@ export default class CollectionsStore extends Store<Collection> {
statusFilter: [CollectionStatusFilter.Archived],
});
get(id: string): Collection | undefined {
return (
this.data.get(id) ??
this.orderedData.find((collection) => id.endsWith(collection.urlId))
);
}
@computed
get archived(): Collection[] {
return orderBy(this.orderedData, "archivedAt", "desc").filter(
+2 -2
View File
@@ -300,8 +300,8 @@ export default class DocumentsStore extends Store<Document> {
const documentIds = this.backlinks.get(documentId) || [];
return orderBy(
compact(documentIds.map((id) => this.data.get(id))),
"updatedAt",
"desc"
"title",
"asc"
);
}
+7
View File
@@ -21,6 +21,13 @@ class IntegrationsStore extends Store<Integration> {
(integration) => integration.service === IntegrationService.GitHub
);
}
@computed
get linear(): Integration<IntegrationType.Embed>[] {
return this.orderedData.filter(
(integration) => integration.service === IntegrationService.Linear
);
}
}
export default IntegrationsStore;
+11
View File
@@ -0,0 +1,11 @@
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import RootStore from "./RootStore";
import Store from "./base/Store";
export default class OAuthAuthenticationsStore extends Store<OAuthAuthentication> {
apiEndpoint = "oauthAuthentications";
constructor(rootStore: RootStore) {
super(rootStore, OAuthAuthentication);
}
}
+11
View File
@@ -0,0 +1,11 @@
import OAuthClient from "~/models/oauth/OAuthClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
export default class OAuthClientsStore extends Store<OAuthClient> {
apiEndpoint = "oauthClients";
constructor(rootStore: RootStore) {
super(rootStore, OAuthClient);
}
}
+23 -2
View File
@@ -18,6 +18,8 @@ import ImportsStore from "./ImportsStore";
import IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore";
import NotificationsStore from "./NotificationsStore";
import OAuthAuthenticationsStore from "./OAuthAuthenticationsStore";
import OAuthClientsStore from "./OAuthClientsStore";
import PinsStore from "./PinsStore";
import PoliciesStore from "./PoliciesStore";
import RevisionsStore from "./RevisionsStore";
@@ -49,6 +51,8 @@ export default class RootStore {
integrations: IntegrationsStore;
memberships: MembershipsStore;
notifications: NotificationsStore;
oauthAuthentications: OAuthAuthenticationsStore;
oauthClients: OAuthClientsStore;
presence: DocumentPresenceStore;
pins: PinsStore;
policies: PoliciesStore;
@@ -80,6 +84,8 @@ export default class RootStore {
this.registerStore(IntegrationsStore);
this.registerStore(MembershipsStore);
this.registerStore(NotificationsStore);
this.registerStore(OAuthAuthenticationsStore, "oauthAuthentications");
this.registerStore(OAuthClientsStore, "oauthClients");
this.registerStore(PinsStore);
this.registerStore(PoliciesStore);
this.registerStore(RevisionsStore);
@@ -110,8 +116,9 @@ export default class RootStore {
*/
public getStoreForModelName<K extends keyof RootStore>(modelName: string) {
const storeName = this.getStoreNameForModelName(modelName);
invariant(storeName, `No store found for model name "${modelName}"`);
const store = this[storeName];
invariant(store, `No store found for model name "${modelName}"`);
return store as RootStore[K];
}
@@ -139,10 +146,24 @@ export default class RootStore {
// @ts-expect-error TS thinks we are instantiating an abstract class.
const store = new StoreClass(this);
const storeName = name ?? this.getStoreNameForModelName(store.modelName);
invariant(storeName, `No store found for model name "${store.modelName}"`);
this[storeName] = store;
}
private getStoreNameForModelName(modelName: string) {
return pluralize(lowerFirst(modelName)) as keyof RootStore;
for (const key of Object.keys(this)) {
const store = this[key as keyof RootStore];
if (store && "modelName" in store && store.modelName === modelName) {
return key as keyof RootStore;
}
}
const storeName = pluralize(lowerFirst(modelName)) as keyof RootStore;
if (storeName) {
return storeName;
}
return undefined;
}
}
+15
View File
@@ -27,6 +27,16 @@ export const isURLMentionable = ({
);
}
case IntegrationService.Linear: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "linear.app" &&
settings.linear?.workspace.key === pathParts[1] // ensure installed workspace key matches with the provided url.
);
}
default:
return false;
}
@@ -52,6 +62,11 @@ export const determineMentionType = ({
: undefined;
}
case IntegrationService.Linear: {
const type = pathParts[2];
return type === "issue" ? MentionType.Issue : undefined;
}
default:
return;
}
+2 -2
View File
@@ -27,8 +27,8 @@ export function trashPath(): string {
return "/trash";
}
export function settingsPath(section?: string): string {
return "/settings" + (section ? `/${section}` : "");
export function settingsPath(...args: string[]): string {
return "/settings" + (args.length > 0 ? `/${args.join("/")}` : "");
}
export function commentPath(document: Document, comment: Comment): string {
+32 -30
View File
@@ -48,18 +48,18 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.787.0",
"@aws-sdk/lib-storage": "3.787.0",
"@aws-sdk/s3-presigned-post": "3.787.0",
"@aws-sdk/s3-request-presigner": "3.787.0",
"@aws-sdk/signature-v4-crt": "^3.787.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.27.0",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@aws-sdk/client-s3": "3.803.0",
"@aws-sdk/lib-storage": "3.803.0",
"@aws-sdk/s3-presigned-post": "3.803.0",
"@aws-sdk/s3-request-presigner": "3.803.0",
"@aws-sdk/signature-v4-crt": "^3.803.0",
"@babel/core": "^7.27.1",
"@babel/plugin-proposal-decorators": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.27.1",
"@babel/plugin-transform-regenerator": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
@@ -79,12 +79,14 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@notionhq/client": "^2.2.16",
"@linear/sdk": "^39.0.0",
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-visually-hidden": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.2.0",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
@@ -114,7 +116,7 @@
"date-fns": "^3.6.0",
"dd-trace": "^5.40.0",
"diff": "^5.2.0",
"dotenv": "^16.4.7",
"dotenv": "^16.5.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
@@ -173,13 +175,13 @@
"passport-oauth2": "^1.8.0",
"passport-slack-oauth2": "^1.2.0",
"patch-package": "^7.0.2",
"pg": "^8.14.1",
"pg": "^8.15.6",
"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.7.0",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
@@ -191,7 +193,7 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.38.1",
"prosemirror-view": "^1.39.1",
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
@@ -206,9 +208,9 @@
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.13",
"react-medium-image-zoom": "5.2.14",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-portal": "^4.3.0",
"react-router-dom": "^5.3.4",
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
@@ -219,13 +221,14 @@
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
"resolve-path": "^1.4.0",
"rfc6902": "^5.1.1",
"rfc6902": "^5.1.2",
"sanitize-filename": "^1.6.3",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.7.1",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"sequelize-encrypted": "^1.0.0",
"sequelize-strict-attributes": "^1.0.2",
"sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
"slugify": "^1.6.6",
@@ -246,10 +249,10 @@
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"validator": "13.15.0",
"vaul": "^1.1.2",
"vite": "^5.4.18",
"vite-plugin-pwa": "^0.20.3",
"vite": "^6.3.4",
"vite-plugin-pwa": "^0.21.2",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -260,8 +263,8 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@babel/cli": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@testing-library/react": "^12.0.0",
@@ -326,7 +329,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.12.1",
"@types/validator": "^13.15.0",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
@@ -355,12 +358,12 @@
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^13.3.0",
"nodemon": "^3.1.9",
"nodemon": "^3.1.10",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.3",
"rollup-plugin-webpack-stats": "^2.0.5",
"terser": "^5.39.0",
"typescript": "^5.8.3",
"vite-plugin-static-copy": "^0.17.0",
@@ -374,7 +377,6 @@
"node-fetch": "^2.7.0",
"js-yaml": "^3.14.1",
"qs": "6.9.7",
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.83.0",
@@ -0,0 +1,40 @@
import { Endpoints } from "@octokit/types";
import { IssueSource } from "@shared/schema";
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration } from "@server/models";
import { BaseIssueProvider } from "@server/utils/BaseIssueProvider";
import { GitHub } from "./github";
// This is needed to handle Octokit paginate response type mismatch.
type ReposForInstallation =
Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"];
export class GitHubIssueProvider extends BaseIssueProvider {
constructor() {
super(IntegrationService.GitHub);
}
async fetchSources(
integration: Integration<IntegrationType.Embed>
): Promise<IssueSource[]> {
const client = await GitHub.authenticateAsInstallation(
integration.settings.github!.installation.id
);
const sources: IssueSource[] = [];
for await (const response of client.requestRepos()) {
const repos = response.data as unknown as ReposForInstallation;
sources.push(
...repos.map<IssueSource>((repo) => ({
id: String(repo.id),
name: repo.name,
owner: { id: String(repo.owner.id), name: repo.owner.login },
service: IntegrationService.GitHub,
}))
);
}
return sources;
}
}
+30 -53
View File
@@ -1,12 +1,12 @@
import Router from "koa-router";
import find from "lodash/find";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import { createContext } from "@server/context";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration, Team } from "@server/models";
import { IntegrationAuthentication, Integration } from "@server/models";
import { APIContext } from "@server/types";
import { GitHubUtils } from "../../shared/GitHubUtils";
import { GitHub } from "../github";
@@ -16,10 +16,17 @@ const router = new Router();
router.get(
"github.callback",
auth({
optional: true,
}),
auth({ optional: true }),
validate(T.GitHubCallbackSchema),
apexAuthRedirect<T.GitHubCallbackReq>({
getTeamId: (ctx) => ctx.input.query.state,
getRedirectPath: (ctx, team) =>
GitHubUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => GitHubUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.GitHubCallbackReq>) => {
const {
@@ -42,33 +49,6 @@ router.get(
return;
}
// 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(
GitHubUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
}
}
const client = await GitHub.authenticateAsUser(code!, teamId);
const installationsByUser = await client.requestAppInstallations();
const installation = find(
@@ -88,30 +68,27 @@ router.get(
},
{ transaction }
);
await Integration.create(
{
service: IntegrationService.GitHub,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
github: {
installation: {
id: installationId!,
account: {
id: installation.account?.id,
name:
// @ts-expect-error Property 'login' does not exist on type
installation.account?.login,
avatarUrl: installation.account?.avatar_url,
},
await Integration.createWithCtx(createContext({ user, transaction }), {
service: IntegrationService.GitHub,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
github: {
installation: {
id: installationId!,
account: {
id: installation.account?.id,
name:
// @ts-expect-error Property 'login' does not exist on type
installation.account?.login,
avatarUrl: installation.account?.avatar_url,
},
},
},
},
{ transaction }
);
});
ctx.redirect(GitHubUtils.url);
}
);
+93 -21
View File
@@ -4,36 +4,55 @@ import {
type OAuthWebFlowAuthOptions,
type InstallationAuthOptions,
} from "@octokit/auth-app";
import { Endpoints, OctokitResponse } from "@octokit/types";
import { Octokit } from "octokit";
import pluralize from "pluralize";
import {
IntegrationService,
IntegrationType,
JSONObject,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration, User } from "@server/models";
import { UnfurlSignature } from "@server/types";
import { UnfurlIssueAndPR, UnfurlSignature } from "@server/types";
import { GitHubUtils } from "../shared/GitHubUtils";
import env from "./env";
type PR =
Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"];
type Issue =
Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"];
const requestPlugin = (octokit: Octokit) => ({
requestPR: async (params: ReturnType<typeof GitHub.parseUrl>) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{id}`, {
owner: params?.owner,
repo: params?.repo,
id: params?.id,
requestRepos: () =>
octokit.paginate.iterator(
octokit.rest.apps.listReposAccessibleToInstallation,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
),
requestPR: async (params: NonNullable<ReturnType<typeof GitHub.parseUrl>>) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
owner: params.owner,
repo: params.repo,
pull_number: params.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}),
requestIssue: async (params: ReturnType<typeof GitHub.parseUrl>) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{id}`, {
owner: params?.owner,
repo: params?.repo,
id: params?.id,
requestIssue: async (
params: NonNullable<ReturnType<typeof GitHub.parseUrl>>
) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
owner: params.owner,
repo: params.repo,
issue_number: params.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -56,14 +75,14 @@ const requestPlugin = (octokit: Octokit) => ({
*/
requestResource: async function requestResource(
resource: ReturnType<typeof GitHub.parseUrl>
): Promise<{ data?: JSONObject }> {
): Promise<OctokitResponse<Issue | PR> | undefined> {
switch (resource?.type) {
case UnfurlResourceType.PR:
return this.requestPR(resource);
return this.requestPR(resource) as Promise<OctokitResponse<PR>>;
case UnfurlResourceType.Issue:
return this.requestIssue(resource);
return this.requestIssue(resource) as Promise<OctokitResponse<Issue>>;
default:
return { data: undefined };
return;
}
},
@@ -91,7 +110,10 @@ export class GitHub {
private static appOctokit: Octokit;
private static supportedResources = Object.values(UnfurlResourceType);
private static supportedResources = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
];
/**
* Parses a given URL and returns resource identifiers for GitHub specific URLs
@@ -111,7 +133,7 @@ export class GitHub {
const type = parts[3]
? (pluralize.singular(parts[3]) as UnfurlResourceType)
: undefined;
const id = parts[4];
const id = Number(parts[4]);
if (!type || !GitHub.supportedResources.includes(type)) {
return;
@@ -204,14 +226,64 @@ export class GitHub {
const client = await GitHub.authenticateAsInstallation(
integration.settings.github!.installation.id
);
const { data } = await client.requestResource(resource);
if (!data) {
const res = await client.requestResource(resource);
if (!res) {
return { error: "Resource not found" };
}
return { ...data, type: resource.type };
return GitHub.transformData(res.data, resource.type);
} catch (err) {
Logger.warn("Failed to fetch resource from GitHub", err);
return { error: err.message || "Unknown error" };
}
};
private static transformData(data: Issue | PR, type: UnfurlResourceType) {
if (type === UnfurlResourceType.Issue) {
const issue = data as Issue;
return {
type: UnfurlResourceType.Issue,
url: issue.html_url,
id: `#${issue.number}`,
title: issue.title,
description: issue.body_text ?? null,
author: {
name: issue.user?.login ?? "",
avatarUrl: issue.user?.avatar_url ?? "",
},
labels: issue.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: issue.state,
color: GitHubUtils.getColorForStatus(issue.state),
},
createdAt: issue.created_at,
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
}
const pr = data as PR;
const prState = pr.merged ? "merged" : pr.state;
return {
type: UnfurlResourceType.PR,
url: pr.html_url,
id: `#${pr.number}`,
title: pr.title,
description: pr.body,
author: {
name: pr.user.login,
avatarUrl: pr.user.avatar_url,
},
state: {
name: prState,
color: GitHubUtils.getColorForStatus(prState, !!pr.draft),
draft: pr.draft,
},
createdAt: pr.created_at,
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
}
}
+5
View File
@@ -1,6 +1,7 @@
import { Minute } from "@shared/utils/time";
import { PluginManager, Hook } from "@server/utils/PluginManager";
import config from "../plugin.json";
import { GitHubIssueProvider } from "./GitHubIssueProvider";
import router from "./api/github";
import env from "./env";
import { GitHub } from "./github";
@@ -20,6 +21,10 @@ if (enabled) {
type: Hook.API,
value: router,
},
{
type: Hook.IssueProvider,
value: new GitHubIssueProvider(),
},
{
type: Hook.UnfurlProvider,
value: { unfurl: GitHub.unfurl, cacheExpiry: Minute.seconds },
+2 -2
View File
@@ -45,10 +45,10 @@ export class GitHubUtils {
return `${this.url}?install_request=true`;
}
public static getColorForStatus(status: string) {
public static getColorForStatus(status: string, isDraftPR: boolean = false) {
switch (status) {
case "open":
return "#238636";
return isDraftPR ? "#848d97" : "#238636";
case "done":
return "#a371f7";
case "closed":
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
d="M3.93091 12.8481C4.11753 14.6298 4.89358 16.3615 6.25902 17.727C7.62446 19.0923 9.35612 19.8684 11.1378 20.0551L3.93091 12.8481Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M3.89929 11.5437L12.4422 20.0865C13.1671 20.0459 13.8876 19.9084 14.5827 19.6738L4.31194 9.4032C4.07743 10.0982 3.93988 10.8187 3.89929 11.5437Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M4.67981 8.49828L15.4875 19.306C16.0482 19.0374 16.5845 18.7005 17.0837 18.2953L5.6905 6.90222C5.28537 7.40142 4.94847 7.93759 4.67981 8.49828Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M6.29602 6.23494C9.46213 3.10852 14.5632 3.12079 17.7141 6.27173C20.865 9.42266 20.8774 14.5237 17.7509 17.6898L6.29602 6.23494Z"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import LinearIcon from "./Icon";
import { LinearConnectButton } from "./components/LinearButton";
function Linear() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
React.useEffect(() => {
void integrations.fetchAll({
service: IntegrationService.Linear,
withRelations: true,
});
}, [integrations]);
return (
<Scene title="Linear" icon={<LinearIcon />}>
<Heading>Linear</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in Linear to connect{" "}
{{ appName }} to your workspace. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{env.LINEAR_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Enable previews of Linear issues in documents by connecting a
Linear workspace to {appName}.
</Trans>
</Text>
{integrations.linear.length ? (
<>
<Heading as="h2">
<Flex justify="space-between" auto>
{t("Connected")}
<LinearConnectButton icon={<PlusIcon />} />
</Flex>
</Heading>
<List>
{integrations.linear.map((integration) => {
const linearWorkspace =
integration.settings?.linear?.workspace;
const integrationCreatedBy = integration.user
? integration.user.name
: undefined;
return (
<ListItem
key={linearWorkspace?.id}
small
title={linearWorkspace?.name}
subtitle={
integrationCreatedBy ? (
<>
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
&middot;{" "}
<Time
dateTime={integration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
) : (
<PlaceholderText />
)
}
image={
<TeamLogo
src={linearWorkspace?.logoUrl}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={integration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?"
)}
/>
}
/>
);
})}
</List>
</>
) : (
<p>
<LinearConnectButton icon={<LinearIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The Linear integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
export default observer(Linear);
@@ -0,0 +1,23 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { LinearUtils } from "../../shared/LinearUtils";
export function LinearConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(LinearUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
+7
View File
@@ -0,0 +1,7 @@
{
"id": "linear",
"name": "Linear",
"priority": 15,
"description": "Adds a Linear integration for link unfurling and converting links to mentions.",
"after": "github"
}
+93
View File
@@ -0,0 +1,93 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration } from "@server/models";
import { APIContext } from "@server/types";
import { Linear } from "../linear";
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
import * as T from "./schema";
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
const router = new Router();
router.get(
"linear.callback",
auth({
optional: true,
}),
validate(T.LinearCallbackSchema),
apexAuthRedirect<T.LinearCallbackReq>({
getTeamId: (ctx) => LinearUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
LinearUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => LinearUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.LinearCallbackReq>) => {
const { code, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(LinearUtils.errorUrl(error));
return;
}
// validation middleware ensures that code is non-null at this point.
const oauth = await Linear.oauthAccess(code!);
const workspace = await Linear.getInstalledWorkspace(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Linear,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
scopes: oauth.scope.split(" "),
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.Embed>
>(
{
service: IntegrationService.Linear,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
linear: {
workspace: {
id: workspace.id,
name: workspace.name,
key: workspace.urlKey,
logoUrl: workspace.logoUrl,
},
},
},
},
{ transaction }
);
transaction.afterCommit(async () => {
if (workspace.logoUrl) {
await new UploadLinearWorkspaceLogoTask().schedule({
integrationId: integration.id,
logoUrl: workspace.logoUrl,
});
}
});
ctx.redirect(LinearUtils.successUrl());
}
);
export default router;
+17
View File
@@ -0,0 +1,17 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const LinearCallbackSchema = 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 LinearCallbackReq = z.infer<typeof LinearCallbackSchema>;
+25
View File
@@ -0,0 +1,25 @@
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 LinearPluginEnvironment extends Environment {
/**
* Linear OAuth2 app client id. To enable integration with Linear.
*/
@Public
@IsOptional()
public LINEAR_CLIENT_ID = this.toOptionalString(environment.LINEAR_CLIENT_ID);
/**
* Linear OAuth2 app client secret. To enable integration with Linear.
*/
@IsOptional()
@CannotUseWithout("LINEAR_CLIENT_ID")
public LINEAR_CLIENT_SECRET = this.toOptionalString(
environment.LINEAR_CLIENT_SECRET
);
}
export default new LinearPluginEnvironment();
+32
View File
@@ -0,0 +1,32 @@
import { Minute } from "@shared/utils/time";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./api/linear";
import env from "./env";
import { Linear } from "./linear";
import UploadLinearWorkspaceLogoTask from "./tasks/UploadLinearWorkspaceLogoTask";
import { uninstall } from "./uninstall";
const enabled = !!env.LINEAR_CLIENT_ID && !!env.LINEAR_CLIENT_SECRET;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
{
type: Hook.Task,
value: UploadLinearWorkspaceLogoTask,
},
{
type: Hook.UnfurlProvider,
value: { unfurl: Linear.unfurl, cacheExpiry: Minute.seconds },
},
{
type: Hook.Uninstall,
value: uninstall,
},
]);
}

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