Compare commits

...

54 Commits

Author SHA1 Message Date
Codegen Bot 0720a65de3 Update yarn.lock for React 18 dependencies 2025-05-20 03:33:22 +00:00
Codegen Bot 95d1f52c2e Update React and React DOM to version 18 and update related dependencies 2025-05-20 03:30:40 +00:00
Codegen Bot a52d9232a9 Update React types to match React 18 version and update testing library 2025-05-20 03:25:53 +00:00
Codegen Bot fe1672356a Update React types to match React 18 version and update testing library 2025-05-20 03:22:18 +00:00
codegen-sh[bot] 2a8d845cbc Update tsconfig.json for React 18 JSX transform 2025-05-20 03:09:58 +00:00
codegen-sh[bot] b593930338 Update ReactDOM.render to createRoot for React 18 2025-05-20 03:09:35 +00:00
codegen-sh[bot] 050200af36 Update React to version 18 2025-05-20 03:04:02 +00:00
codegen-sh[bot] 5274b99277 fix: Update handleDisclosureClick to handle undefined events (#9254)
* fix: Pass synthetic event to onDisclosureClick to prevent TypeError

* fix: Update handleDisclosureClick to handle undefined events

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-19 22:34:01 -04:00
dependabot[bot] 7cabefaf34 chore(deps): bump the aws group with 5 updates (#9250)
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.806.0` | `3.812.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.806.0` | `3.812.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.806.0` | `3.812.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.806.0` | `3.812.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.806.0` | `3.812.0` |


Updates `@aws-sdk/client-s3` from 3.806.0 to 3.812.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.812.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.806.0 to 3.812.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.812.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.806.0 to 3.812.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.812.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.806.0 to 3.812.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.812.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.806.0 to 3.812.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.812.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.812.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.812.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.812.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.812.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.812.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-19 19:31:32 -04:00
dependabot[bot] 81729ae72b chore(deps-dev): bump eslint-import-resolver-typescript (#9252)
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 3.8.0 to 3.10.1.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/v3.10.1/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.8.0...v3.10.1)

---
updated-dependencies:
- dependency-name: eslint-import-resolver-typescript
  dependency-version: 3.10.1
  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-19 17:56:34 -04:00
dependabot[bot] cd2d9fc218 chore(deps): bump @tanstack/react-table from 8.20.6 to 8.21.3 (#9249)
Bumps [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) from 8.20.6 to 8.21.3.
- [Release notes](https://github.com/TanStack/table/releases)
- [Commits](https://github.com/TanStack/table/commits/v8.21.3/packages/react-table)

---
updated-dependencies:
- dependency-name: "@tanstack/react-table"
  dependency-version: 8.21.3
  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-05-19 17:56:18 -04:00
dependabot[bot] 4d7340d70b chore(deps): bump @radix-ui/react-visually-hidden from 1.2.0 to 1.2.2 (#9248)
Bumps [@radix-ui/react-visually-hidden](https://github.com/radix-ui/primitives) from 1.2.0 to 1.2.2.
- [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.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-05-19 17:56:11 -04:00
Tom Moor e596b57cc2 chore: Add additional details to validation errors (#9243)
This will help us get to the bottom of Notion importer failures
2025-05-18 20:27:47 -04:00
Translate-O-Tron 58b6901b7b New Crowdin updates (#9046)
* 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 Japanese translations from Crowdin [ci skip]

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

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

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

* fix: New Portuguese, Brazilian 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 Korean translations from Crowdin [ci skip]

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

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

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

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

* fix: New Portuguese, Brazilian 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 Polish translations from Crowdin [ci skip]

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

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

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

* fix: New Polish 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 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 Spanish translations from Crowdin [ci skip]

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

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

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

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

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

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

* fix: New Hungarian 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 Hungarian translations from Crowdin [ci skip]

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

* touch

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-18 23:27:23 +00:00
Tom Moor b8fd239f2e Add additional logging to getFileStream failures (#9242) 2025-05-18 19:24:50 -04:00
Tom Moor 201fbb56eb perf: Add cache for document structure (#9196)
* Normalize Collection.findByPk

* Add caching of documentStructure

* fix: Do not set cache before transaction is flushed

* Mock Redis
2025-05-18 18:45:00 -04:00
Tom Moor 823b0442a2 fix: Image caption uncentered (#9239) 2025-05-18 18:51:50 +00:00
Tom Moor 4ff663e112 Adds menu option to apply template to existing document (#9236)
* Adds menu option to apply template to existing document

* Memoize, docs

* docs
2025-05-18 09:40:37 -04:00
codegen-sh[bot] e5ded0a6a5 Fix misalignment between email and comment mentions (#9234)
* Fix misalignment between email and comment mentions

* Add test

* Update ProsemirrorHelper.tsx

* Optimize user mention processing with batch loading

* Add test for multiple mentions

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-17 12:45:45 -04:00
Balázs Úr 784d075233 feat: Add Hungarian translations (#9230) 2025-05-17 12:22:03 -04:00
codegen-sh[bot] 1c9b300e25 feat: add lint:changed script to lint only unstaged files (#9235)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-17 12:21:15 -04:00
codegen-sh[bot] 870bf1157b fix: Increase defaultSignedUrlExpires from 60s to 5 minutes (#9233)
Closes #8921

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-17 13:07:01 +00:00
codegen-sh[bot] d2aba1de96 feat: Add POST method option to redirectOnClient (#9228)
* feat: Add POST method option to redirectOnClient helper

* Applied automatic fixes

* fix: Add missing closing HTML tag in redirectOnClient GET method

* fix: Use lodash escape for form field values to prevent XSS

* Applied automatic fixes

* fix: Add missing lodash/escape import

* Applied automatic fixes

* fix: Escape all URLs in redirectOnClient function

* Update index.ts

* fix: CSP

* Refactor CSP middleware

* docs, only use for email signin

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-17 09:06:32 -04:00
Hemachandar 052924d816 Resolve index collision when restoring collection (#9229)
* Resolve index collision when restoring collection

* use beforeUpdate hook
2025-05-16 23:56:46 -04:00
Apoorv Mishra 2fe887ef57 Refactor Placeholder plugin to make it configurable (#9224)
* fix: refactor to make configurable

* fix: make it a reusable plugin so that it can be colocated with nodes eligible for placeholders

* fix: cond -> condition

* fix: `pos` -> `$start` as param

* fix: cleanup
2025-05-16 20:58:06 -04:00
Tom Moor e288a5d38e fix: Remove # from stored filesystem keys (#9231) 2025-05-17 00:14:42 +00:00
Hemachandar dc5c3f5280 fix: Consider non-archived collections only for index computation (#9225) 2025-05-16 09:39:32 -04:00
Hemachandar 610721eed6 fix: Ensure collection withUser scope is not overriden (#9226) 2025-05-16 08:16:25 -04:00
Hemachandar d50f0986bb fix: Reset editing state when collection/document title is unmodified (#9221) 2025-05-15 14:12:26 +00:00
Tom Moor 90af35d4bd Update .env.sample (#9217) 2025-05-15 02:08:15 +00:00
Tom Moor 3810373195 fix: CleanupDeletedDocumentsTask attribute selection (#9215) 2025-05-14 20:45:29 -04:00
Tom Moor 3fd893e728 Add new markdown shortcut for tables to shortcut UI (#9216) 2025-05-14 23:52:32 +00:00
Tom Moor 13e3aaf861 chore: Add Sentry breadcrumbs to websocket messages for debugging (#9211) 2025-05-14 19:37:29 -04:00
Tom Moor b43ebabbaf fix: Apex redirect regression (#9214) 2025-05-14 19:37:16 -04:00
Tom Moor 42550a003a fix: Icon on collection home does not match sidebar when private (#9209) 2025-05-14 04:23:55 +00:00
Tom Moor 08b7c11461 Normalize Collection.findByPk (#9193) 2025-05-14 00:05:45 -04:00
Tom Moor 8a9a8cf751 fix: Archived documents should not show in @mention suggestions (#9208) 2025-05-13 22:47:58 -04:00
Tom Moor 2d6167e933 fix: Add encoding meta tag to exported HTML (#9207)
closes #9194
2025-05-13 22:47:48 -04:00
dependabot[bot] 6b05b101d0 chore(deps): bump the aws group with 5 updates (#9186)
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.803.0` | `3.806.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.803.0` | `3.806.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.803.0` | `3.806.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.803.0` | `3.806.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.803.0` | `3.806.0` |


Updates `@aws-sdk/client-s3` from 3.803.0 to 3.806.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.806.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.803.0 to 3.806.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.806.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.803.0 to 3.806.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.806.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.803.0 to 3.806.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.806.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.803.0 to 3.806.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.806.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.806.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.806.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.806.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.806.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.806.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-13 21:06:24 -04:00
Adam Roe 79fe73fbe1 fix: fall back to id_token when OIDC userinfo endpoint is sparse (#9172)
* Fall back to id_token if profile does not contain username or email

* More comments

* Add error handling to id_token decode

* simplify username fallback logic using nullish coalescing

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

* make id_token decoding more tolerant of malformed or invalid tokens

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-05-13 21:06:14 -04:00
MehdiBouzouaya 63376ed9c8 Fix flash of previous value after editing title in sidebar for documents (#9197)
* Fix flash of previous value after editing title in sidebar for documents

* Fix flash of previous value after editing title in sidebar for documents
2025-05-13 22:36:47 +00:00
MehdiBouzouaya 0cec66b3bb Fix: Add missing key prop to context menu items to avoid React warning (#9202) 2025-05-13 18:31:21 -04:00
dependabot[bot] fcc73e772b chore(deps): bump the babel group with 2 updates (#9185)
Bumps the babel group with 2 updates: [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) and [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli).


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

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

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-version: 7.27.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-version: 7.27.2
  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-12 19:29:38 -04:00
dependabot[bot] b5cb6128c4 chore(deps-dev): bump react-refresh from 0.14.2 to 0.17.0 (#9187)
Bumps [react-refresh](https://github.com/facebook/react/tree/HEAD/packages/react) from 0.14.2 to 0.17.0.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/react)

---
updated-dependencies:
- dependency-name: react-refresh
  dependency-version: 0.17.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-12 19:29:22 -04:00
dependabot[bot] 261226c110 chore(deps): bump @types/mailparser from 3.4.5 to 3.4.6 (#9188)
Bumps [@types/mailparser](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/mailparser) from 3.4.5 to 3.4.6.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/mailparser)

---
updated-dependencies:
- dependency-name: "@types/mailparser"
  dependency-version: 3.4.6
  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-12 19:05:25 -04:00
Tom Moor 6fff437196 fix: Incorrect translation string on disabled API keys (#9183) 2025-05-12 07:56:46 -04:00
Tom Moor 4f34e70d32 chore: Add automation to close PRs that never accept CLA (#9179)
* Add script to auto-close stale PRs

* Improve specificity
2025-05-11 17:14:55 -04:00
Tom Moor 4c04bd9359 v0.84.0 (#9176) 2025-05-11 15:58:16 +00:00
Tom Moor 16c8ae6132 Create README.md (#9174) 2025-05-11 12:56:26 +00:00
Tom Moor 30bba3a69b fix: JS error when no integrations are connected (#9170) 2025-05-11 02:05:18 +00:00
Tom Moor 32c1712fdc fix: Various cases that could leave file handles open on export (#9168)
* fix: Various cases that could leave file handles open on export

* Consolidate error handling
2025-05-10 17:48:24 -04:00
Tom Moor d392149860 fix: Non-integration plugins missing in settings (#9167)
Other minor refactors
2025-05-10 12:45:06 -04:00
Tom Moor 30108ebded chore: Move Zapier settings page to plugin (#9166) 2025-05-10 10:25:46 -04:00
Tom Moor d0bd2baa9f Add integrations page (#9155)
* update useSettings

* Integration page skeleton

* add descriptions

* update design

* Integration page style update

* clean up

* update integration card

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

* Update integration icon size

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

* Update all integrations menu item

* update IntegrationCard to use the `Text` component

* update card status

* fix: Google analytics never shows as installed
fix: Styling tweaks
Move webhooks out of integrations

* Add breadcrumbs

* Add filtering

* refactor

* Add hover state, tweak descriptions

---------

Co-authored-by: Tess99854 <tesnimesb@gmail.com>
Co-authored-by: Mahmoud Mohammed Ali <ibn.el4ai5@gmail.com>
Co-authored-by: Mahmoud Ali <mahmoud.ali.khallaf@gmail.com>
2025-05-10 09:59:41 -04:00
127 changed files with 5214 additions and 1997 deletions
+165 -140
View File
@@ -1,48 +1,80 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to produce this.
UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@postgres:5432/outline
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://redis:6379
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
# The port to expose the Outline server on, this should match what is configured
# in your docker-compose.yml
PORT=3000
# See [documentation](docs/SERVICES.md) on running a separate collaboration
# server, for normal operation this does not need to be set.
COLLABORATION_URL=
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# How many processes should be spawned. As a reasonable rule divide your servers
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Generate a hex-encoded 32-byte random key. Use `openssl rand -hex 32` in your
# terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to generate a random value.
UTILS_SECRET=generate_a_new_key
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––––– DATABASE –––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The database URL for your production database, including username, password, and database name.
DATABASE_URL=postgres://user:pass@postgres:5432/outline
# The in-memory database pool per-process settings. Ensure that the pool size that will not exceed
# the maximum number of connections allowed by your database. Defaults to 0 and 5.
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this line if you will not use SSL for connecting to Postgres. This is acceptable
# if the database and the application are on the same machine.
# PGSSLMODE=disable
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––– REDIS –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The Redis URL for your environment you can either specify an ioredis compatible url or a Base64
# encoded configuration object.
# DOCS: https://docs.getoutline.com/s/hosting/doc/redis-LGM4BFXYp4
REDIS_URL=redis://redis:6379
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE STORAGE –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Specify what storage system to use. Possible value is one of "s3" or "local".
# For "local", the avatar images and document attachments will be saved on local disk.
# For "local" images and document attachments will be saved on local disk, for "s3" they
# will be stored in an S3-compatible network store.
# DOCS: https://docs.getoutline.com/s/hosting/doc/file-storage-N4M0T6Ypu7
FILE_STORAGE=local
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
# which all attachments/images go. Make sure that the process has permissions to create
# this path and also to write files to it.
# which all attachments/images are stored. Make sure that the process has permissions to
# create this path and also to write files to it.
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
# Maximum allowed size for the uploaded attachment.
@@ -56,8 +88,8 @@ FILE_STORAGE_IMPORT_MAX_SIZE=
# and the files are temporary being automatically deleted after a period of time.
FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE=
# To support uploading of images for avatars and document attachments in a distributed
# architecture an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
# To support uploading of images for avatars and document attachments in a distributed
# architecture, an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
@@ -67,38 +99,55 @@ AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# –––––––––––––– AUTHENTICATION ––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––––– SSL –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is one
# of three ways to configure SSL and can be left empty.
# DOCS: https://docs.getoutline.com/s/hosting/doc/ssl-pzk7WO8d1n
SSL_KEY=
SSL_CERT=
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# or Microsoft is required for a working installation or you'll have no sign-in
# options.
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# To configure Slack auth, you'll need to create an Application at
# => https://api.slack.com/apps
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
SLACK_CLIENT_ID=get_a_key_from_slack
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
# To configure Google auth, you'll need to create an OAuth Client ID at
# => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
# https://<URL>/auth/google.callback
# Google sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/google-hOuvtCmTqQ
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# the guide for details on setting up your Azure App:
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
# Microsoft Entra / Azure AD sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/microsoft-entra-UVz6jsIOcv
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# To configure generic OIDC auth, you'll need some kind of identity provider.
# See documentation for whichever IdP you use to acquire the following info:
# Redirect URI is https://<URL>/auth/oidc.callback
# Discord sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/discord-g4JdWFFub6
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_SERVER_ID=
DISCORD_SERVER_ROLES=
# Generic OIDC provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/oidc-8CPBm6uC0I
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
@@ -116,83 +165,54 @@ OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
# To configure the GitHub integration, you'll need to create a GitHub App at
# => https://github.com/settings/apps
#
# When configuring the Client ID, add a redirect URL under "Permissions & events":
# https://<URL>/api/github.callback
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––– EMAIL –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# To support sending outgoing transactional emails such as "document updated" or
# email sign-in you'll need to connect an SMTP server. Service can be configured
# with any service from this list: https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/
# DOCS: https://docs.getoutline.com/s/hosting/doc/smtp-cqCJyZGMIB
SMTP_SERVICE=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– RATE LIMITER ––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Whether the rate limiter is enabled or not
RATE_LIMITER_ENABLED=true
# Individual endpoints have hardcoded rate limits that are enabled
# with the above setting, however this is a global rate limiter
# across all requests
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– INTEGRATIONS –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The GitHub integration allows previewing issue and pull request links
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Linear
# The Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
# When configuring the Client ID, add a redirect URL under "OAuth2":
# https://<URL>/auth/discord.callback
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
# integrated with.
# Used to verify that the user is a member of the server as well as server
# metadata such as nicknames, server icon and name.
DISCORD_SERVER_ID=
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
# allowed to access Outline. If this is not set, all members of the server
# will be allowed to access Outline.
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
# required if you do not use an external reverse proxy. See documentation:
# https://wiki.generaloutline.com/share/1c922644-40d8-41fe-98f9-df2b67239d45
SSL_KEY=
SSL_CERT=
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# Have the installation check for updates by sending anonymized statistics to
# the maintainers
ENABLE_UPDATES=true
# How many processes should be spawned. As a reasonable rule divide your servers
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# You can remove this line if your reverse proxy already logs incoming http
# requests and this ends up being duplicative
DEBUG=http
# Configure lowest severity level for server logs. Should be one of
# error, warn, info, http, verbose, debug and silly
LOG_LEVEL=info
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
@@ -202,29 +222,34 @@ SLACK_MESSAGE_ACTIONS=true
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
# DOCS: https://docs.getoutline.com/s/hosting/doc/sentry-jxcFttcDl5
SENTRY_DSN=
SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_SERVICE=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
# Enable importing pages from a Notion workspace
# DOCS: https://docs.getoutline.com/s/hosting/doc/notion-2v6g7WY3l3
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# Optionally enable rate limiter at application web server
RATE_LIMITER_ENABLED=true
# Configure default throttling parameters for rate limiter
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Iframely API config
# The Iframely integration allows previews of third-party content within Outline.
# For example, hovering over an external link will show a preview.
# DOCS: https://docs.getoutline.com/s/hosting/doc/iframely-HwLF1EZ9mo
IFRAMELY_URL=
IFRAMELY_API_KEY=
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––––– DEBUGGING ––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Have the installation check for updates by sending anonymized statistics to
# the maintainers
ENABLE_UPDATES=true
# Debugging categories to enable you can remove the default "http" value if
# your proxy already logs incoming http requests and this ends up being duplicative
DEBUG=http
# Configure lowest severity level for server logs. Should be one of
# error, warn, info, http, verbose, debug, or silly
LOG_LEVEL=info
+59
View File
@@ -0,0 +1,59 @@
name: Auto Close Unsigned PRs
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight UTC
jobs:
close-unsigned-prs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Close unsigned PRs
uses: actions/github-script@v6
with:
script: |
const now = new Date();
const TWO_WEEKS = 14 * 24 * 60 * 60 * 1000; // 14 days in milliseconds
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open'
});
for (const pr of prs.data) {
const prCreatedAt = new Date(pr.created_at);
const prAge = now - prCreatedAt;
if (prAge < TWO_WEEKS) continue;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const hasNotSignedComment = comments.data.some(comment =>
comment.body.toLowerCase().includes('https://cla-assistant.io/pull/badge/not_signed')
);
if (hasNotSignedComment) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: 'This PR has been automatically closed because it has been open for more than 14 days and has not accepted the CLA.'
});
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.83.0
Licensed Work: Outline 0.84.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-04-11
Change Date: 2029-05-11
Change License: Apache License, Version 2.0
+19 -10
View File
@@ -138,7 +138,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={index}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
@@ -154,7 +154,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
<MenuItem
id={`${item.title}-${index}`}
href={item.href}
key={index}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
@@ -176,7 +176,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={index}
key={`${item.type}-${item.title}-${index}`}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
@@ -185,18 +185,25 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
);
return item.tooltip ? (
<Tooltip content={item.tooltip} placement={"bottom"}>
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<>{menuItem}</>
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
return (
// Skip rendering empty submenus
return item.items.length > 0 ? (
<BaseMenuItem
key={index}
key={`${item.type}-${item.title}-${index}`}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
@@ -209,15 +216,17 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
{...menu}
/>
);
) : null;
}
if (item.type === "separator") {
return <Separator key={index} />;
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return <Header key={index}>{item.title}</Header>;
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
}
const _exhaustiveCheck: never = item;
+4 -1
View File
@@ -64,11 +64,12 @@ function EditableTitle(
async (ev) => {
ev.preventDefault();
ev.stopPropagation();
setIsEditing(false);
const trimmedValue = value.trim();
if (trimmedValue === originalValue || trimmedValue.length === 0) {
setValue(originalValue);
setIsEditing(false);
onCancel?.();
return;
}
@@ -80,6 +81,8 @@ function EditableTitle(
setValue(originalValue);
toast.error(error.message);
throw error;
} finally {
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit]
+5 -1
View File
@@ -45,6 +45,7 @@ type Props = {
onChange: (icon: string | null, color: string | null) => void;
onOpen?: () => void;
onClose?: () => void;
children?: React.ReactNode;
};
const IconPicker = ({
@@ -59,6 +60,7 @@ const IconPicker = ({
onOpen,
onClose,
borderOnHover,
children,
}: Props) => {
const { t } = useTranslation();
@@ -174,7 +176,9 @@ const IconPicker = ({
onClick={handlePopoverButtonClick}
$borderOnHover={borderOnHover}
>
{iconType && icon ? (
{children ? (
children
) : iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
+11 -3
View File
@@ -23,12 +23,20 @@ import ToggleButton from "./components/ToggleButton";
import Version from "./components/Version";
function SettingsSidebar() {
const { ui } = useStores();
const { ui, integrations } = useStores();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const configs = useSettingsConfig();
const groupedConfig = groupBy(configs, "group");
const groupedConfig = groupBy(
configs.filter((item) =>
item.group === "Integrations" && item.pluginId
? integrations.findByService(item.pluginId)
: true
),
"group"
);
const returnToApp = React.useCallback(() => {
history.push("/home");
@@ -65,7 +73,7 @@ function SettingsSidebar() {
to={item.path}
onClickIntent={item.preload}
active={
item.path !== settingsPath()
item.path.startsWith(settingsPath("templates"))
? location.pathname.startsWith(item.path)
: undefined
}
@@ -22,7 +22,7 @@ import SidebarContext from "./SidebarContext";
function Collections() {
const { documents, collections } = useStores();
const { t } = useTranslation();
const orderedCollections = collections.orderedData;
const orderedCollections = collections.allActive;
const params = React.useMemo(
() => ({
@@ -78,9 +78,9 @@ function StarredLink({ star }: Props) {
}, [documentId, documents]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
(ev?: React.MouseEvent<HTMLButtonElement>) => {
ev?.preventDefault();
ev?.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
},
[]
+14 -2
View File
@@ -1,3 +1,4 @@
import * as Sentry from "@sentry/react";
import invariant from "invariant";
import find from "lodash/find";
import { action, observable } from "mobx";
@@ -134,6 +135,15 @@ class WebsocketProvider extends React.Component<Props> {
throw err;
});
// add a listener for all events that logs a sentry breadcrumb
this.socket.onAny((event: string, data: Record<string, unknown>) => {
Sentry.addBreadcrumb({
category: "websocket",
message: `Received event: ${event}`,
data,
});
});
this.socket.on(
"entities",
action(async (event: WebsocketEntitiesEvent) => {
@@ -251,8 +261,10 @@ class WebsocketProvider extends React.Component<Props> {
}
policies.remove(document.id);
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
}
}
)
);
+35 -25
View File
@@ -1,10 +1,12 @@
import { action } from "mobx";
import { PlusIcon } from "outline-icons";
import { Plugin } from "prosemirror-state";
import { Node, ResolvedPos } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import { WidgetProps } from "@shared/editor/lib/Extension";
import { PlaceholderPlugin } from "@shared/editor/plugins/PlaceholderPlugin";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import Suggestion from "~/editor/extensions/Suggestion";
import BlockMenu from "../components/BlockMenu";
@@ -27,7 +29,10 @@ export default class BlockMenuExtension extends Suggestion {
const button = document.createElement("button");
button.className = "block-menu-trigger";
button.type = "button";
ReactDOM.render(<PlusIcon />, button);
button.addEventListener("click", this.handleClick);
const root = createRoot(button);
root.render(<PlusIcon />);
return button;
return [
...super.plugins,
@@ -49,7 +54,6 @@ export default class BlockMenuExtension extends Suggestion {
const decorations: Decoration[] = [];
const isEmptyNode = parent && parent.node.content.size === 0;
const isSlash = parent && parent.node.textContent === "/";
if (isEmptyNode) {
decorations.push(
@@ -69,33 +73,39 @@ export default class BlockMenuExtension extends Suggestion {
}
)
);
const isEmptyDoc = state.doc.textContent === "";
if (!isEmptyDoc) {
decorations.push(
Decoration.node(
parent.pos,
parent.pos + parent.node.nodeSize,
{
class: "placeholder",
"data-empty-text": this.options.dictionary.newLineEmpty,
}
)
);
}
} else if (isSlash) {
decorations.push(
Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, {
class: "placeholder",
"data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`,
})
);
}
return DecorationSet.create(state.doc, decorations);
},
},
}),
new PlaceholderPlugin([
{
condition: (
node: Node,
$start: ResolvedPos,
_parent: Node | null,
state: EditorState
) =>
$start.depth === 1 &&
node.textContent === "" &&
!!state.doc.textContent &&
state.selection.$from.pos === $start.pos + node.content.size,
text: this.options.dictionary.newLineEmpty,
},
{
condition: (
node: Node,
$start: ResolvedPos,
_parent: Node,
state: EditorState
) =>
$start.depth === 1 &&
node.textContent === "/" &&
state.selection.$from.pos === $start.pos + node.content.size,
text: ` ${this.options.dictionary.newLineWithSlash}`,
},
]),
];
}
+1 -2
View File
@@ -18,8 +18,7 @@ export default function useEmbeds(loadIfMissing = false) {
React.useEffect(() => {
async function fetchEmbedIntegrations() {
try {
await integrations.fetchPage({
limit: 100,
await integrations.fetchAll({
type: IntegrationType.Embed,
});
} catch (err) {
+17 -9
View File
@@ -13,20 +13,21 @@ import {
ImportIcon,
ShapesIcon,
Icon,
PlusIcon,
InternetIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import { Integrations } from "~/scenes/Settings/Integrations";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import isCloudHosted from "~/utils/isCloudHosted";
import { settingsPath } from "~/utils/routeHelpers";
import { useComputed } from "./useComputed";
import useCurrentTeam from "./useCurrentTeam";
import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
import useStores from "./useStores";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
@@ -43,24 +44,30 @@ const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
export type ConfigItem = {
name: string;
path: string;
icon: React.FC<ComponentProps<typeof Icon>>;
component: React.ComponentType;
description?: string;
preload?: () => void;
enabled: boolean;
group: string;
pluginId?: string;
};
const useSettingsConfig = () => {
const { integrations } = useStores();
const user = useCurrentUser();
const team = useCurrentTeam();
const can = usePolicy(team);
const { t } = useTranslation();
React.useEffect(() => {
void integrations.fetchAll();
}, [integrations]);
const config = useComputed(() => {
const items: ConfigItem[] = [
// Account
@@ -202,13 +209,12 @@ const useSettingsConfig = () => {
},
// Integrations
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
component: Zapier.Component,
preload: Zapier.preload,
enabled: can.update && isCloudHosted,
name: `${t("Install")}`,
path: settingsPath("integrations"),
component: Integrations,
enabled: true,
group: t("Integrations"),
icon: ZapierIcon,
icon: PlusIcon,
},
];
@@ -225,6 +231,8 @@ const useSettingsConfig = () => {
? integrationSettingsPath(plugin.id)
: settingsPath(plugin.id),
group: t(group),
pluginId: plugin.id,
description: plugin.value.description,
component: plugin.value.component.Component,
preload: plugin.value.component.preload,
enabled: plugin.value.enabled
+88
View File
@@ -0,0 +1,88 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
type Props = {
/** The document to which the templates will be applied */
document: Document;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
};
/**
* This hook provides a memoized list of menu items for both collection-specific
* templates and workspace-wide templates. It filters templates based on whether
* they are published and organizes them into appropriate sections.
*
* Collection-specific templates are displayed first, followed by workspace templates
* with a separator in between (if both types exist).
*
* @returns An array of MenuItem objects representing templates that can be applied
* to the current document. Returns an empty array if no callback is provided.
*/
export function useTemplateMenuItems({ document, onSelectTemplate }: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const templateToMenuItem = React.useCallback(
(template: Document): MenuItem => ({
type: "button",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<DocumentIcon />
),
onClick: () => onSelectTemplate?.(template),
}),
[user, onSelectTemplate]
);
const templates = documents.templates.filter(
(template) => template.publishedAt
);
const collectionItems = templates
.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === document.collectionId
)
.map(templateToMenuItem);
const workspaceTemplates = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToMenuItem);
const workspaceItems: MenuItem[] = React.useMemo(
() =>
workspaceTemplates.length
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
: [],
[t, workspaceTemplates]
);
if (!onSelectTemplate) {
return [];
}
return collectionItems
? workspaceItems.length
? [
...collectionItems,
{ type: "separator" } as MenuItem,
...workspaceItems,
]
: collectionItems
: workspaceItems;
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { LazyMotion } from "framer-motion";
import { KBarProvider } from "kbar";
import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";
import { createRoot } from "react-dom/client";
import { HelmetProvider } from "react-helmet-async";
import { Router } from "react-router-dom";
import stores from "~/stores";
@@ -79,7 +79,8 @@ if (element) {
</React.StrictMode>
);
render(<App />, element);
const root = createRoot(element);
root.render(<App />);
}
window.addEventListener("load", async () => {
+24 -1
View File
@@ -2,7 +2,13 @@ import capitalize from "lodash/capitalize";
import isEmpty from "lodash/isEmpty";
import noop from "lodash/noop";
import { observer } from "mobx-react";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import {
EditIcon,
InputIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
@@ -57,6 +63,7 @@ import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
import { MenuItem, MenuItemButton } from "~/types";
import { documentEditPath } from "~/utils/routeHelpers";
import { MenuContext, useMenuContext } from "./MenuContext";
@@ -76,6 +83,7 @@ type Props = {
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
onSelectTemplate?: (template: Document) => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Invoked when menu is opened */
@@ -147,6 +155,7 @@ type MenuContentProps = {
onOpen?: () => void;
onClose?: () => void;
onFindAndReplace?: () => void;
onSelectTemplate?: (template: Document) => void;
onRename?: () => void;
showDisplayOptions?: boolean;
showToggleEmbeds?: boolean;
@@ -156,6 +165,7 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
onOpen,
onClose,
onFindAndReplace,
onSelectTemplate,
onRename,
showDisplayOptions,
showToggleEmbeds,
@@ -218,6 +228,11 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
[collections.orderedData, handleRestore, policies]
);
const templateMenuItems = useTemplateMenuItems({
document,
onSelectTemplate,
});
return !isEmpty(can) ? (
<ContextMenu
{...menuState}
@@ -310,6 +325,12 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(moveTemplate, context),
{
type: "submenu",
title: t("Apply template"),
icon: <ShapesIcon />,
items: templateMenuItems,
},
actionToMenuItem(pinDocument, context),
actionToMenuItem(createDocumentFromTemplate, context),
{
@@ -383,6 +404,7 @@ function DocumentMenu({
modal = true,
showToggleEmbeds,
showDisplayOptions,
onSelectTemplate,
label,
onRename,
onOpen,
@@ -466,6 +488,7 @@ function DocumentMenu({
onOpen={onOpen}
onClose={onClose}
onRename={onRename}
onSelectTemplate={onSelectTemplate}
showDisplayOptions={showDisplayOptions}
showToggleEmbeds={showToggleEmbeds}
/>
+4 -53
View File
@@ -1,17 +1,13 @@
import { observer } from "mobx-react";
import { DocumentIcon, ShapesIcon } from "outline-icons";
import { ShapesIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
type Props = {
/** The document to which the templates will be applied */
@@ -23,57 +19,12 @@ type Props = {
};
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const templateToMenuItem = React.useCallback(
(tmpl: Document): MenuItem => ({
type: "button",
title: TextHelper.replaceTemplateVariables(tmpl.titleWithDefault, user),
icon: tmpl.icon ? (
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
) : (
<DocumentIcon />
),
onClick: () => onSelectTemplate(tmpl),
}),
[user, onSelectTemplate]
);
const templates = documents.templates.filter((tmpl) => tmpl.publishedAt);
const collectionItems = templates
.filter(
(tmpl) =>
!tmpl.isWorkspaceTemplate && tmpl.collectionId === document.collectionId
)
.map(templateToMenuItem);
const workspaceTemplates = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToMenuItem);
const workspaceItems: MenuItem[] = React.useMemo(
() =>
workspaceTemplates.length
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
: [],
[t, workspaceTemplates]
);
const items = collectionItems
? workspaceItems.length
? [
...collectionItems,
{ type: "separator" } as MenuItem,
...workspaceItems,
]
: collectionItems
: workspaceItems;
const items = useTemplateMenuItems({ onSelectTemplate, document });
if (!items.length) {
return null;
+6 -8
View File
@@ -12,7 +12,7 @@ import {
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
import { IconTitleWrapper } from "@shared/components/Icon";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
@@ -139,11 +139,7 @@ const CollectionScene = observer(function _CollectionScene() {
const hasOverview = can.update || collection?.hasDescription;
const fallbackIcon = collection ? (
<Icon
value={collection.icon ?? "collection"}
color={collection.color || undefined}
size={40}
/>
<CollectionIcon collection={collection} size={40} expanded />
) : null;
const tabProps = (path: CollectionPath) => ({
@@ -162,7 +158,7 @@ const CollectionScene = observer(function _CollectionScene() {
left={
collection.isArchived ? (
<CollectionBreadcrumb collection={collection} />
) : collection.isEmpty ? undefined : (
) : (
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
@@ -206,7 +202,9 @@ const CollectionScene = observer(function _CollectionScene() {
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
/>
>
{fallbackIcon}
</IconPicker>
</React.Suspense>
) : (
fallbackIcon
+19 -9
View File
@@ -4,7 +4,7 @@ import isEqual from "lodash/isEqual";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import { Node } from "prosemirror-model";
import { AllSelection } from "prosemirror-state";
import { AllSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import {
@@ -146,7 +146,17 @@ class DocumentScene extends React.Component<Props> {
}
}
replaceDocument = (template: Document | Revision) => {
/**
* Replaces the given selection with a template, if no selection is provided
* then the template is inserted at the beginning of the document.
*
* @param template The template to use
* @param selection The selection to replace, if any
*/
replaceSelection = (
template: Document | Revision,
selection?: TextSelection | AllSelection
) => {
const editorRef = this.editor.current;
if (!editorRef) {
@@ -154,6 +164,7 @@ class DocumentScene extends React.Component<Props> {
}
const { view, schema } = editorRef;
const sel = selection ?? TextSelection.near(view.state.doc.resolve(0));
const doc = Node.fromJSON(
schema,
ProsemirrorHelper.replaceTemplateVariables(
@@ -163,11 +174,7 @@ class DocumentScene extends React.Component<Props> {
);
if (doc) {
view.dispatch(
view.state.tr
.setSelection(new AllSelection(view.state.doc))
.replaceSelectionWith(doc)
);
view.dispatch(view.state.tr.setSelection(sel).replaceSelectionWith(doc));
}
this.isEditorDirty = true;
@@ -217,7 +224,10 @@ class DocumentScene extends React.Component<Props> {
});
if (response) {
await this.replaceDocument(response.data);
await this.replaceSelection(
response.data,
new AllSelection(editorRef.view.state.doc)
);
toast.success(t("Document restored"));
history.replace(this.props.document.url, history.location.state);
}
@@ -518,7 +528,7 @@ class DocumentScene extends React.Component<Props> {
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceDocument}
onSelectTemplate={this.replaceSelection}
onSave={this.onSave}
/>
<Main fullWidth={document.fullWidth} tocPosition={tocPos}>
@@ -387,6 +387,7 @@ function DocumentHeader({
neutral
/>
)}
onSelectTemplate={onSelectTemplate}
onFindAndReplace={editor?.commands.openFindAndReplace}
showToggleEmbeds={canToggleEmbeds}
showDisplayOptions
+4
View File
@@ -415,6 +415,10 @@ function KeyboardShortcuts() {
shortcut: <Key>---</Key>,
label: t("Horizontal divider"),
},
{
shortcut: <Key>{"|--"}</Key>,
label: t("Table"),
},
{
shortcut: <Key>{"```"}</Key>,
label: t("Code block"),
+1 -3
View File
@@ -67,9 +67,7 @@ function APIAndApps() {
/>
</Text>
) : (
<Trans>
{t("API keys have been disabled by an admin for your account")}
</Trans>
<Trans>API keys have been disabled by an admin for your account</Trans>
)}
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
+72
View File
@@ -0,0 +1,72 @@
import groupBy from "lodash/groupBy";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import IntegrationCard from "./components/IntegrationCard";
import { StickyFilters } from "./components/StickyFilters";
export function Integrations() {
const { t } = useTranslation();
const { integrations } = useStores();
const items = useSettingsConfig();
const [query, setQuery] = React.useState("");
const handleQuery = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
};
const groupedItems = groupBy(
items.filter(
(item) =>
item.group === "Integrations" &&
item.enabled &&
item.path !== settingsPath("integrations") &&
item.name.toLowerCase().includes(query.toLowerCase())
),
(item) =>
item.pluginId && integrations.findByService(item.pluginId)
? "connected"
: "available"
);
return (
<Scene title={t("Integrations")}>
<Heading>{t("Integrations")}</Heading>
<Text as="p" type="secondary">
<Trans>
Configure a variety of integrations with third-party services.
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleQuery}
/>
</StickyFilters>
<Cards gap={30} wrap>
{groupedItems.connected?.map((item) => (
<IntegrationCard key={item.path} integration={item} isConnected />
))}
{groupedItems.available?.map((item) => (
<IntegrationCard key={item.path} integration={item} />
))}
</Cards>
</Scene>
);
}
const Cards = styled(Flex)`
margin-top: 20px;
width: "100%";
`;
@@ -0,0 +1,93 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { ConfigItem } from "~/hooks/useSettingsConfig";
import Button from "../../../components/Button";
import Flex from "../../../components/Flex";
import Text from "../../../components/Text";
type Props = {
integration: ConfigItem;
isConnected?: boolean;
};
function IntegrationCard({ integration, isConnected }: Props) {
const { t } = useTranslation();
return (
<Card as={Link} to={integration.path}>
<Flex align="center" gap={8}>
<integration.icon size={48} />
<Flex auto column>
<Name>{integration.name}</Name>
{isConnected && <Status>{t("Connected")}</Status>}
</Flex>
<Button as="span" neutral>
{isConnected ? t("Configure") : t("Connect")}
</Button>
</Flex>
<Description>{integration.description}</Description>
</Card>
);
}
export default IntegrationCard;
const Card = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 20px;
width: 300px;
background: ${s("background")};
border: 1px solid ${s("inputBorder")};
color: ${s("text")};
border-radius: 8px;
transition: box-shadow 200ms ease;
cursor: var(--pointer);
&:hover {
box-shadow: rgba(0, 0, 0, 0.08) 0px 2px 4px, rgba(0, 0, 0, 0.06) 0px 4px 8px;
}
`;
const Name = styled(Text)`
margin: 0;
font-size: 16px;
font-weight: 600;
color: ${s("text")};
${ellipsis()}
`;
const Description = styled(Text)`
margin: 8px 0 0;
font-size: 15px;
max-width: 100%;
color: ${s("textTertiary")};
`;
const Status = styled(Text).attrs({
type: "secondary",
size: "small",
as: "span",
})`
display: inline-flex;
align-items: center;
&::after {
content: "";
display: inline-block;
width: 17px;
height: 17px;
background: radial-gradient(
circle at center,
${s("accent")} 0 33%,
transparent 33%
);
border-radius: 50%;
}
`;
@@ -0,0 +1,33 @@
import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Breadcrumb from "~/components/Breadcrumb";
import Scene from "~/components/Scene";
import { settingsPath } from "~/utils/routeHelpers";
export function IntegrationScene({
children,
...rest
}: React.ComponentProps<typeof Scene>) {
const { t } = useTranslation();
return (
<Scene
left={
<Breadcrumb
items={[
{
type: "route",
title: t("Integrations"),
icon: <SettingsIcon />,
to: settingsPath("integrations"),
},
]}
/>
}
{...rest}
>
{children}
</Scene>
);
}
+6
View File
@@ -10,6 +10,12 @@ class IntegrationsStore extends Store<Integration> {
super(rootStore, Integration);
}
findByService(service: string) {
return this.orderedData.find(
(integration) => integration.service === service
);
}
@computed
get orderedData(): Integration[] {
return naturalSort(Array.from(this.data.values()), "name");
+6
View File
@@ -104,6 +104,9 @@ export default abstract class Store<T extends Model> {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("archivedAt" in item && item.archivedAt) {
return false;
}
return true;
})
.slice(0, options?.maxResults);
@@ -114,6 +117,9 @@ export default abstract class Store<T extends Model> {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("archivedAt" in item && item.archivedAt) {
return false;
}
if ("searchContent" in item) {
const seachables =
typeof item.searchContent === "string"
+3 -1
View File
@@ -28,8 +28,10 @@ type PluginValueMap = {
after?: string;
/** The displayed icon of the plugin. */
icon: React.ElementType;
/** The settings screen somponent, should be lazy loaded. */
/** The lazy loaded settings screen component. */
component: LazyComponent<React.ComponentType>;
/** The description that will show on the plugins card. */
description?: string;
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
+20 -18
View File
@@ -14,6 +14,7 @@
"dev:backend": "NODE_ENV=development nodemon --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --ignore *.test.ts --ignore data/ --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
"dev:watch": "NODE_ENV=development yarn concurrently -n backend,frontend \"yarn dev:backend\" \"yarn vite:dev\"",
"lint": "eslint app server shared plugins",
"lint:changed": "git diff --name-only --diff-filter=ACMRTUXB | grep -E '\\.(js|jsx|ts|tsx)$' | xargs -r yarn eslint --fix",
"prepare": "husky install",
"postinstall": "yarn patch-package",
"install-local-ssl": "node ./server/scripts/install-local-ssl.js",
@@ -48,17 +49,17 @@
"> 0.25%, not dead"
],
"dependencies": {
"@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",
"@aws-sdk/client-s3": "3.812.0",
"@aws-sdk/lib-storage": "3.812.0",
"@aws-sdk/s3-presigned-post": "3.812.0",
"@aws-sdk/s3-request-presigner": "3.812.0",
"@aws-sdk/signature-v4-crt": "^3.812.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-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
@@ -86,15 +87,15 @@
"@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.2.0",
"@radix-ui/react-visually-hidden": "^1.2.2",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.6",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.2",
"@types/mailparser": "^3.4.5",
"@types/mailparser": "^3.4.6",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
@@ -197,13 +198,13 @@
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
"react": "^17.0.2",
"react": "^18.2.0",
"react-avatar-editor": "^13.0.2",
"react-color": "^2.17.3",
"react-day-picker": "^8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^17.0.2",
"react-dom": "^18.2.0",
"react-dropzone": "^11.7.1",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
@@ -263,11 +264,12 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.27.1",
"@babel/cli": "^7.27.2",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@testing-library/react": "^12.0.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
"@types/crypto-js": "^4.2.2",
@@ -307,10 +309,10 @@
"@types/png-chunks-extract": "^1.0.2",
"@types/quoted-printable": "^1.0.2",
"@types/randomstring": "^1.3.0",
"@types/react": "^17.0.34",
"@types/react": "^18.2.0",
"@types/react-avatar-editor": "^13.0.4",
"@types/react-color": "^3.0.13",
"@types/react-dom": "^17.0.11",
"@types/react-dom": "^18.2.0",
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
@@ -343,7 +345,7 @@
"discord-api-types": "^0.37.119",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.8.0",
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
@@ -361,7 +363,7 @@
"nodemon": "^3.1.10",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"react-refresh": "^0.17.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.5",
"terser": "^5.39.0",
@@ -379,6 +381,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "0.83.0",
"version": "0.84.0",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+50 -52
View File
@@ -1,5 +1,5 @@
import Router from "koa-router";
import { NotificationEventType } from "@shared/types";
import { Client, NotificationEventType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import SigninEmail from "@server/emails/templates/SigninEmail";
@@ -86,66 +86,64 @@ router.post(
}
);
router.get(
"email.callback",
validate(T.EmailCallbackSchema),
async (ctx: APIContext<T.EmailCallbackReq>) => {
const { token, client, follow } = ctx.input.query;
const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
const token = ctx.input.query?.token || ctx.input.body?.token;
const client = ctx.input.query?.client || ctx.input.body?.client;
const follow = ctx.input.query?.follow || ctx.input.body?.follow;
// The link in the email does not include the follow query param, this
// is to help prevent anti-virus, and email clients from pre-fetching the link
// and spending the token before the user clicks on it. Instead we redirect
// to the same URL with the follow query param added from the client side.
if (!follow) {
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
}
// The link in the email does not include the follow query param, this
// is to help prevent anti-virus, and email clients from pre-fetching the link
// and spending the token before the user clicks on it. Instead we redirect
// to the same URL with the follow query param added from the client side.
if (!follow) {
return ctx.redirectOnClient(ctx.request.href + "&follow=true", "POST");
}
let user!: User;
let user!: User;
try {
user = await getUserForEmailSigninToken(token as string);
} catch (err) {
ctx.redirect(`/?notice=expired-token`);
return;
}
try {
user = await getUserForEmailSigninToken(token as string);
} catch (err) {
ctx.redirect(`/?notice=expired-token`);
return;
}
if (!user.team.emailSigninEnabled) {
return ctx.redirect("/?notice=auth-error");
}
if (!user.team.emailSigninEnabled) {
return ctx.redirect("/?notice=auth-error");
}
if (user.isSuspended) {
return ctx.redirect("/?notice=user-suspended");
}
if (user.isSuspended) {
return ctx.redirect("/?notice=user-suspended");
}
if (user.isInvited) {
await new WelcomeEmail({
to: user.email,
role: user.role,
if (user.isInvited) {
await new WelcomeEmail({
to: user.email,
role: user.role,
teamUrl: user.team.url,
}).schedule();
const inviter = await user.$get("invitedBy");
if (inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)) {
await new InviteAcceptedEmail({
to: inviter.email,
inviterId: inviter.id,
invitedName: user.name,
teamUrl: user.team.url,
}).schedule();
const inviter = await user.$get("invitedBy");
if (
inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)
) {
await new InviteAcceptedEmail({
to: inviter.email,
inviterId: inviter.id,
invitedName: user.name,
teamUrl: user.team.url,
}).schedule();
}
}
// set cookies on response and redirect to team subdomain
await signIn(ctx, "email", {
user,
team: user.team,
isNewTeam: false,
isNewUser: false,
client,
});
}
);
// set cookies on response and redirect to team subdomain
await signIn(ctx, "email", {
user,
team: user.team,
isNewTeam: false,
isNewUser: false,
client: client ?? Client.Web,
});
};
router.get("email.callback", validate(T.EmailCallbackSchema), emailCallback);
router.post("email.callback", validate(T.EmailCallbackSchema), emailCallback);
export default router;
+6 -1
View File
@@ -13,7 +13,12 @@ export type EmailReq = z.infer<typeof EmailSchema>;
export const EmailCallbackSchema = BaseSchema.extend({
query: z.object({
token: z.string(),
token: z.string().optional(),
client: z.nativeEnum(Client).default(Client.Web),
follow: z.string().default(""),
}),
body: z.object({
token: z.string().optional(),
client: z.nativeEnum(Client).default(Client.Web),
follow: z.string().default(""),
}),
+3 -3
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -11,7 +12,6 @@ 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";
@@ -38,7 +38,7 @@ function GitHub() {
}, [integrations]);
return (
<Scene title="GitHub" icon={<GitHubIcon />}>
<IntegrationScene title="GitHub" icon={<GitHubIcon />}>
<Heading>GitHub</Heading>
{error === "access_denied" && (
@@ -146,7 +146,7 @@ function GitHub() {
</Trans>
</Notice>
)}
</Scene>
</IntegrationScene>
);
}
+2
View File
@@ -10,6 +10,8 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your GitHub account to Outline to enable rich, realtime, issue and pull request previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
+3 -9
View File
@@ -6,12 +6,12 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
@@ -40,12 +40,6 @@ function GoogleAnalytics() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({ measurementId: integration?.settings.measurementId });
}, [integration, reset]);
@@ -75,7 +69,7 @@ function GoogleAnalytics() {
);
return (
<Scene title={t("Google Analytics")} icon={<GoogleIcon />}>
<IntegrationScene title={t("Google Analytics")} icon={<GoogleIcon />}>
<Heading>{t("Google Analytics")}</Heading>
<Text as="p" type="secondary">
@@ -100,7 +94,7 @@ function GoogleAnalytics() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+2
View File
@@ -10,6 +10,8 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Measure adoption and engagement by sending view and event analytics directly to your GA4 dashboard.",
component: createLazyComponent(() => import("./Settings")),
},
},
+1 -1
View File
@@ -1,5 +1,5 @@
{
"id": "googleanalytics",
"id": "google-analytics",
"name": "Google Analytics",
"priority": 30,
"description": "Adds support for reporting analytics to a Google."
+2
View File
@@ -10,6 +10,8 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
+3 -9
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -42,12 +42,6 @@ function Matomo() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
measurementId: integration?.settings.measurementId,
@@ -82,7 +76,7 @@ function Matomo() {
);
return (
<Scene title="Matomo" icon={<Icon />}>
<IntegrationScene title="Matomo" icon={<Icon />}>
<Heading>Matomo</Heading>
<Text as="p" type="secondary">
@@ -121,7 +115,7 @@ function Matomo() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+2
View File
@@ -11,6 +11,8 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Track your docs with a self-hosted, open-source analytics platform, link Outline to Matomo for 100% data ownership, GDPR compliance, and deep usage insights on your own servers.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
+37 -7
View File
@@ -1,4 +1,5 @@
import passport from "@outlinewiki/koa-passport";
import JWT from "jsonwebtoken";
import type { Context } from "koa";
import Router from "koa-router";
import get from "lodash/get";
@@ -9,6 +10,7 @@ import {
OIDCMalformedUserInfoError,
AuthenticationError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
import passportMiddleware from "@server/middlewares/passport";
import { AuthenticationProvider, User } from "@server/models";
import { AuthenticationResult } from "@server/types";
@@ -58,7 +60,7 @@ if (
ctx: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number },
params: { expires_in: number; id_token: string },
_profile: unknown,
done: (
err: Error | null,
@@ -78,14 +80,39 @@ if (
accessToken
);
if (!profile.email) {
// Some providers, namely ADFS, don't provide anything more than the `sub` claim in the userinfo endpoint
// So, we'll decode the params.id_token and see if that contains what we need.
const token = (() => {
try {
const decoded = JWT.decode(params.id_token);
if (!decoded || typeof decoded !== "object") {
Logger.warn("Decoded id_token is not a valid object");
return {};
}
return decoded as {
email?: string;
preferred_username?: string;
sub?: string;
};
} catch (err) {
Logger.error("id_token decode threw error: ", err);
return {};
}
})();
const email = profile.email ?? token.email ?? null;
if (!email) {
throw AuthenticationError(
`An email field was not returned in the profile parameter, but is required.`
`An email field was not returned in the profile or id_token parameter, but is required.`
);
}
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const { domain } = parseEmail(profile.email);
const { domain } = parseEmail(email);
// Only a single OIDC provider is supported find the existing, if any.
const authenticationProvider = team
@@ -118,13 +145,16 @@ if (
// Claim name can be overriden using an env variable.
// Default is 'preferred_username' as per OIDC spec.
const username = get(profile, env.OIDC_USERNAME_CLAIM);
// This will default to the profile.preferred_username, but will fall back to preferred_username from the id_token
const username =
get(profile, env.OIDC_USERNAME_CLAIM) ??
get(token, env.OIDC_USERNAME_CLAIM);
const name = profile.name || username || profile.username;
const profileId = profile.sub ? profile.sub : profile.id;
if (!name) {
throw AuthenticationError(
`Neither a name or username was returned in the profile parameter, but at least one is required.`
`Neither a ${env.OIDC_USERNAME_CLAIM}, name or username was returned in the profile parameter, but at least one is required.`
);
}
@@ -138,7 +168,7 @@ if (
},
user: {
name,
email: profile.email,
email,
avatarUrl: profile.picture,
},
authenticationProvider: {
+4 -10
View File
@@ -6,6 +6,7 @@ import { IntegrationService, IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -13,7 +14,6 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -34,13 +34,7 @@ function Slack() {
const error = query.get("error");
React.useEffect(() => {
void collections.fetchPage({
limit: 100,
});
void integrations.fetchPage({
service: IntegrationService.Slack,
limit: 100,
});
void collections.fetchAll();
}, [collections, integrations]);
const commandIntegration = integrations.find({
@@ -67,7 +61,7 @@ function Slack() {
const appName = env.APP_NAME;
return (
<Scene title="Slack" icon={<SlackIcon />}>
<IntegrationScene title="Slack" icon={<SlackIcon />}>
<Heading>Slack</Heading>
{error === "access_denied" && (
@@ -205,7 +199,7 @@ function Slack() {
</List>
</>
)}
</Scene>
</IntegrationScene>
);
}
+2
View File
@@ -11,6 +11,8 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Search your knowledge base directly in Slack, get /outline search, rich link previews, and notifications on new or updated docs.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) =>
[UserRole.Member, UserRole.Admin].includes(user.role),
+3 -3
View File
@@ -156,9 +156,9 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
switch (type) {
case IntegrationType.Post: {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
authorize(user, "update", user.team);
+3 -9
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -44,12 +44,6 @@ function Umami() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
umamiWebsiteId: integration?.settings.measurementId,
@@ -85,7 +79,7 @@ function Umami() {
);
return (
<Scene title="Umami" icon={<Icon />}>
<IntegrationScene title="Umami" icon={<Icon />}>
<Heading>Umami</Heading>
<Text as="p" type="secondary">
@@ -145,7 +139,7 @@ function Umami() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+2
View File
@@ -11,6 +11,8 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Gain privacy-first insights into how your team consumes docs, inject your self-hosted Umami script across Outline pages to track views and engagement while retaining full control of your data.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
+4 -1
View File
@@ -8,8 +8,11 @@ PluginManager.add([
...config,
type: Hook.Settings,
value: {
group: "Integrations",
group: "Workspace",
after: "Shared Links",
icon: Icon,
description:
"Automate downstream workflows with real-time JSON POSTs, subscribe to events in Outline so external systems can react instantly.",
component: createLazyComponent(() => import("./Settings")),
},
},
@@ -1,20 +1,22 @@
import * as React from "react";
import { Helmet } from "react-helmet-async";
import { Trans } from "react-i18next";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import Heading from "~/components/Heading";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import ZapierIcon from "./Icon";
function Zapier() {
const { ui } = useStores();
const user = useCurrentUser();
const { resolvedTheme } = ui;
const appName = env.APP_NAME;
return (
<Scene title="Zapier" icon={<ZapierIcon />}>
<IntegrationScene title="Zapier" icon={<ZapierIcon />}>
<Heading>Zapier</Heading>
<Helmet>
<script
@@ -39,14 +41,15 @@ function Zapier() {
<zapier-app-directory
app="outline"
link-target="new-tab"
theme={resolvedTheme}
sign-up-email={user.email}
theme={resolvedTheme === "system" ? undefined : resolvedTheme}
hide="notion,confluence-cloud,confluence,google-docs,slack"
applimit={6}
introcopy="hide"
create-without-template="show"
use-this-zap="show"
/>
</Scene>
</IntegrationScene>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { createLazyComponent } from "~/components/LazyLoad";
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,
description:
"Connect your Outline workspace to Zapier to automate workflows and integrate with thousands of other tools.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+6
View File
@@ -0,0 +1,6 @@
{
"id": "zapier",
"name": "Zapier",
"description": "Adds a settings screen for connecting to Zapier.",
"deployments": ["cloud"]
}
+8 -12
View File
@@ -116,12 +116,10 @@ export default async function loadDocument({
if (canReadDocument) {
if (document.collectionId) {
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
collection = await Collection.findByPk(document.collectionId, {
includeDocumentStructure: true,
rejectOnEmpty: true,
});
}
return {
@@ -140,12 +138,10 @@ export default async function loadDocument({
// It is possible to disable sharing at the collection so we must check
if (document.collectionId) {
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
collection = await Collection.findByPk(document.collectionId, {
includeDocumentStructure: true,
rejectOnEmpty: true,
});
}
if (!collection?.sharing) {
+12 -17
View File
@@ -65,21 +65,18 @@ async function documentMover({
result.documents.push(document);
} else {
// Load the current and the next collection upfront and lock them
const collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId!,
{
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
}
);
const collection = await Collection.findByPk(document.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
});
let newCollection = collection;
if (collectionChanged) {
if (collectionId) {
newCollection = await Collection.scope(
"withDocumentStructure"
).findByPk(collectionId, {
newCollection = await Collection.findByPk(collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -148,13 +145,11 @@ async function documentMover({
if (collectionId) {
// Reload the collection to get relationship data
newCollection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId, {
transaction,
newCollection = await Collection.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
transaction,
});
result.collections.push(newCollection);
+6 -1
View File
@@ -296,7 +296,12 @@ export default abstract class BaseEmail<
return undefined;
}
let content = ProsemirrorHelper.toHTML(node, {
// Process user mentions to ensure they are uptodate with database
const processedNode = ProsemirrorHelper.toProsemirror(
await ProsemirrorHelper.processMentions(node)
);
let content = ProsemirrorHelper.toHTML(processedNode, {
centered: false,
});
@@ -37,9 +37,10 @@ export default class CollectionCreatedEmail extends BaseEmail<
}
protected async beforeSend(props: InputProps) {
const collection = await Collection.scope("withUser").findByPk(
props.collectionId
);
const collection = await Collection.findByPk(props.collectionId, {
includeOwner: true,
});
if (!collection) {
return false;
}
+46 -5
View File
@@ -13,6 +13,7 @@ import Router from "koa-router";
import { AddressInfo } from "net";
import stoppable from "stoppable";
import throng from "throng";
import escape from "lodash/escape";
import Logger from "./logging/Logger";
import services from "./services";
import { getArg } from "./utils/args";
@@ -88,13 +89,53 @@ async function start(_id: number, disconnect: () => void) {
app.use(defaultRateLimiter());
/** Perform a redirect on the browser so that the user's auth cookies are included in the request. */
app.context.redirectOnClient = function (url: string) {
app.context.redirectOnClient = function (
/** The URL to redirect to */
url: string,
/**
* The HTTP method to use for the redirect. Use POST when preventing links in emails from being
* clicked by bots. Otherwise, use GET.
*/
method: "GET" | "POST" = "GET"
) {
this.type = "text/html";
this.body = `
if (method === "POST") {
// For POST method, create a form that auto-submits
const urlObj = new URL(url);
const formAction = `${urlObj.origin}${urlObj.pathname}`;
const searchParams = urlObj.searchParams;
let formFields = "";
searchParams.forEach((value, key) => {
formFields += `<input type="hidden" name="${escape(
key
)}" value="${escape(value)}" />`;
});
this.body = `
<html>
<head>
<meta http-equiv="refresh" content="0;URL='${url}'"/>
</head>`;
<title>Redirecting…</title>
</head>
<body>
<form id="redirect-form" method="POST" action="${formAction}">
${formFields}
</form>
<script nonce="${this.state.cspNonce}">
document.getElementById('redirect-form').submit();
</script>
</body>
</html>`;
} else {
// Default GET method using meta refresh
this.body = `
<html>
<head>
<meta http-equiv="refresh" content="0;URL='${escape(url)}'" />
</head>
</html>`;
}
};
// Add a health check endpoint to all services
@@ -133,7 +174,7 @@ async function start(_id: number, disconnect: () => void) {
server.on("error", (err) => {
if ("code" in err && err.code === "EADDRINUSE") {
Logger.error(`Port ${normalizedPort} is already in use. Exiting…`, err);
Logger.error(`Port ${normalizedPort} is already in use. Exiting…`, err);
process.exit(0);
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { Next } from "koa";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import { Team } from "@server/models";
import { APIContext } from "@server/types";
@@ -37,7 +38,7 @@ export default function apexAuthRedirect<T>({
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
attributes: ["id", "subdomain"],
attributes: ["id", "domain", "subdomain"],
rejectOnEmpty: true,
});
@@ -45,6 +46,7 @@ export default function apexAuthRedirect<T>({
? ctx.redirect("/")
: ctx.redirectOnClient(getRedirectPath(ctx, team));
} catch (err) {
Logger.error("Error fetching team", err);
return ctx.redirect(getErrorPath(ctx));
}
} else {
+61
View File
@@ -0,0 +1,61 @@
import crypto from "crypto";
import { Context, Next } from "koa";
import { contentSecurityPolicy } from "koa-helmet";
import uniq from "lodash/uniq";
import env from "@server/env";
/**
* Create a Content Security Policy middleware for the application.
*/
export default function createCSPMiddleware() {
// Construct scripts CSP based on options in use
const defaultSrc = ["'self'"];
const scriptSrc = ["'self'"];
const styleSrc = ["'self'", "'unsafe-inline'"];
if (env.isCloudHosted) {
scriptSrc.push("www.googletagmanager.com");
scriptSrc.push("cdn.zapier.com");
styleSrc.push("cdn.zapier.com");
}
// Allow to load assets from Vite
if (!env.isProduction) {
scriptSrc.push(env.URL.replace(`:${env.PORT}`, ":3001"));
scriptSrc.push("localhost:3001");
}
if (env.GOOGLE_ANALYTICS_ID) {
scriptSrc.push("www.googletagmanager.com");
scriptSrc.push("www.google-analytics.com");
}
if (env.CDN_URL) {
scriptSrc.push(env.CDN_URL);
styleSrc.push(env.CDN_URL);
defaultSrc.push(env.CDN_URL);
}
return function cspMiddleware(ctx: Context, next: Next) {
ctx.state.cspNonce = crypto.randomBytes(16).toString("hex");
return contentSecurityPolicy({
directives: {
defaultSrc,
styleSrc,
scriptSrc: [
...uniq(scriptSrc),
env.DEVELOPMENT_UNSAFE_INLINE_CSP
? "'unsafe-inline'"
: `'nonce-${ctx.state.cspNonce}'`,
],
mediaSrc: ["*", "data:", "blob:"],
imgSrc: ["*", "data:", "blob:"],
frameSrc: ["*", "data:"],
// Do not use connect-src: because self + websockets does not work in
// Safari, ref: https://bugs.webkit.org/show_bug.cgi?id=201591
connectSrc: ["*"],
},
})(ctx, next);
};
}
+64 -10
View File
@@ -14,6 +14,8 @@ import {
EmptyResultError,
type CreateOptions,
type UpdateOptions,
type ScopeOptions,
type SaveOptions,
} from "sequelize";
import {
Sequelize,
@@ -38,6 +40,7 @@ import {
BeforeCreate,
BeforeUpdate,
DefaultScope,
AfterSave,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
@@ -47,6 +50,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import slugify from "@shared/utils/slugify";
import { CollectionValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import { CacheHelper } from "@server/utils/CacheHelper";
import removeIndexCollision from "@server/utils/removeIndexCollision";
import { generateUrlId } from "@server/utils/url";
import { ValidateIndex } from "@server/validation";
@@ -67,6 +71,9 @@ import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
type AdditionalFindOptions = {
userId?: string;
includeDocumentStructure?: boolean;
includeOwner?: boolean;
rejectOnEmpty?: boolean | Error;
};
@@ -330,6 +337,34 @@ class Collection extends ParanoidModel<
if (!model.content) {
model.content = await DocumentHelper.toJSON(model);
}
if (model.changed("documentStructure")) {
await CacheHelper.clearData(
CacheHelper.getCollectionDocumentsKey(model.id)
);
}
}
@AfterSave
static async cacheDocumentStructure(
model: Collection,
options: SaveOptions<Collection>
) {
if (model.changed("documentStructure")) {
const setData = () =>
CacheHelper.setData(
CacheHelper.getCollectionDocumentsKey(model.id),
model.documentStructure,
60
);
if (options.transaction) {
return (options.transaction.parent || options.transaction).afterCommit(
setData
);
}
await setData();
}
}
@BeforeDestroy
@@ -392,8 +427,11 @@ class Collection extends ParanoidModel<
model: Collection,
options: UpdateOptions<Collection>
) {
if (model.index && model.changed("index")) {
model.index = await removeIndexCollision(model.teamId, model.index, {
if (
(model.index && model.changed("index")) ||
(!model.archivedAt && model.changed("archivedAt"))
) {
model.index = await removeIndexCollision(model.teamId, model.index!, {
transaction: options.transaction,
});
}
@@ -466,9 +504,9 @@ class Collection extends ParanoidModel<
* @returns userIds
*/
static async membershipUserIds(collectionId: string) {
const collection = await this.scope("withAllMemberships").findByPk(
collectionId
);
const collection = await this.scope("withAllMemberships").findOne({
where: { id: collectionId },
});
if (!collection) {
return [];
}
@@ -485,6 +523,7 @@ class Collection extends ParanoidModel<
/**
* Overrides the standard findByPk behavior to allow also querying by urlId
* and loading memberships for a user passed in by `userId`
*
* @param id uuid or urlId
* @param options FindOptions
@@ -506,16 +545,31 @@ class Collection extends ParanoidModel<
return null;
}
const { includeDocumentStructure, includeOwner, userId, ...rest } = options;
const scopes: (string | ScopeOptions)[] = [
includeDocumentStructure ? "withDocumentStructure" : "defaultScope",
{
method: ["withMembership", userId],
},
];
if (includeOwner) {
scopes.push("withUser");
}
const scope = this.scope(scopes);
if (isUUID(id)) {
const collection = await this.findOne({
const collection = await scope.findOne({
where: {
id,
},
...options,
...rest,
rejectOnEmpty: false,
});
if (!collection && options.rejectOnEmpty) {
if (!collection && rest.rejectOnEmpty) {
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
}
@@ -524,7 +578,7 @@ class Collection extends ParanoidModel<
const match = id.match(UrlHelper.SLUG_URL_REGEX);
if (match) {
const collection = await this.findOne({
const collection = await scope.findOne({
where: {
urlId: match[1],
},
@@ -532,7 +586,7 @@ class Collection extends ParanoidModel<
rejectOnEmpty: false,
});
if (!collection && options.rejectOnEmpty) {
if (!collection && rest.rejectOnEmpty) {
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
}
+26 -33
View File
@@ -426,13 +426,11 @@ class Document extends ArchivableModel<
return;
}
const collection = await Collection.scope("withDocumentStructure").findByPk(
model.collectionId,
{
transaction,
lock: Transaction.LOCK.UPDATE,
}
);
const collection = await Collection.findByPk(model.collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
if (!collection) {
return;
}
@@ -453,9 +451,8 @@ class Document extends ArchivableModel<
}
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(model.collectionId!, {
const collection = await Collection.findByPk(model.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
@@ -667,10 +664,11 @@ class Document extends ArchivableModel<
/**
* Overrides the standard findByPk behavior to allow also querying by urlId
* and loading memberships for a user passed in by `userId`
*
* @param id uuid or urlId
* @param options FindOptions
* @returns A promise resolving to a collection instance or null
* @returns A promise resolving to a document instance or null
*/
static async findByPk(
id: Identifier,
@@ -695,7 +693,7 @@ class Document extends ArchivableModel<
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
"withDrafts",
options.includeState ? "withState" : "withoutState",
includeState ? "withState" : "withoutState",
{
method: ["withViews", userId],
},
@@ -943,9 +941,8 @@ class Document extends ArchivableModel<
}
if (!this.template && this.collectionId) {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId, {
const collection = await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -1012,13 +1009,11 @@ class Document extends ArchivableModel<
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction.LOCK.UPDATE,
}
)
? await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.UPDATE,
})
: undefined;
if (collection) {
@@ -1049,13 +1044,11 @@ class Document extends ArchivableModel<
archive = async (user: User, options?: FindOptions) => {
const { transaction } = { ...options };
const collection = this.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction?.LOCK.UPDATE,
}
)
? await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.UPDATE,
})
: undefined;
if (collection) {
@@ -1076,7 +1069,8 @@ class Document extends ArchivableModel<
) => {
const { transaction } = { ...options };
const collection = collectionId
? await Collection.scope("withDocumentStructure").findByPk(collectionId, {
? await Collection.findByPk(collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.UPDATE,
})
@@ -1128,9 +1122,8 @@ class Document extends ArchivableModel<
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId!, {
const collection = await Collection.findByPk(this.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
+179 -1
View File
@@ -1,9 +1,187 @@
import { faker } from "@faker-js/faker";
import { DeepPartial } from "utility-types";
import { MentionType, ProsemirrorData } from "@shared/types";
import { buildProseMirrorDoc } from "@server/test/factories";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
describe("ProsemirrorHelper", () => {
describe("processMentions", () => {
it("should handle deleted users", async () => {
const user = await buildUser();
const mentionAttrs: MentionAttrs = {
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
type: MentionType.User,
label: "test.user",
actorId: user.id,
modelId: user.id,
};
await user.destroy();
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
type: "paragraph",
content: [
{
type: "text",
text: "a paragraph with ",
},
{
type: "mention",
attrs: mentionAttrs,
},
{
type: "text",
text: " mentioned",
},
],
};
const doc = buildProseMirrorDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: "some content in a paragraph",
},
],
},
mentionedParagraph,
]);
const newDoc = await ProsemirrorHelper.processMentions(doc);
expect(newDoc.content?.[1]?.content?.[1].attrs?.label).toEqual("Unknown");
});
it("should handle updated users", async () => {
const user = await buildUser();
const mentionAttrs: MentionAttrs = {
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
type: MentionType.User,
label: "test.user",
actorId: user.id,
modelId: user.id,
};
await user.update({
name: faker.name.firstName(),
});
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
type: "paragraph",
content: [
{
type: "text",
text: "a paragraph with ",
},
{
type: "mention",
attrs: mentionAttrs,
},
{
type: "text",
text: " mentioned",
},
],
};
const doc = buildProseMirrorDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: "some content in a paragraph",
},
],
},
mentionedParagraph,
]);
const newDoc = await ProsemirrorHelper.processMentions(doc);
expect(newDoc.content?.[1]?.content?.[1].attrs?.label).toEqual(user.name);
});
it("should handle multiple renamed users", async () => {
const firstUser = await buildUser();
const secondUser = await buildUser();
const firstMentionAttrs: MentionAttrs = {
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
type: MentionType.User,
label: "first.user",
actorId: firstUser.id,
modelId: firstUser.id,
};
const secondMentionAttrs: MentionAttrs = {
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
type: MentionType.User,
label: "second.user",
actorId: secondUser.id,
modelId: secondUser.id,
};
const firstNewName = faker.name.firstName();
const secondNewName = faker.name.firstName();
await firstUser.update({
name: firstNewName,
});
await secondUser.update({
name: secondNewName,
});
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
type: "paragraph",
content: [
{
type: "text",
text: "a paragraph with ",
},
{
type: "mention",
attrs: firstMentionAttrs,
},
{
type: "text",
text: " and ",
},
{
type: "mention",
attrs: secondMentionAttrs,
},
{
type: "text",
text: " mentioned",
},
],
};
const doc = buildProseMirrorDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: "some content in a paragraph",
},
],
},
mentionedParagraph,
]);
const newDoc = await ProsemirrorHelper.processMentions(doc);
expect(newDoc.content?.[1]?.content?.[1].attrs?.label).toEqual(
firstNewName
);
expect(newDoc.content?.[1]?.content?.[3].attrs?.label).toEqual(
secondNewName
);
});
});
describe("getNodeForMentionEmail", () => {
it("should return the paragraph node", () => {
const mentionAttrs: MentionAttrs = {
+77 -1
View File
@@ -21,6 +21,7 @@ import { schema, parser } from "@server/editor";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import Attachment from "@server/models/Attachment";
import User from "@server/models/User";
import FileStorage from "@server/storage/files";
export type HTMLOptions = {
@@ -490,7 +491,7 @@ export class ProsemirrorHelper {
// Render the Prosemirror document using virtual DOM and serialize the
// result to a string
const dom = new JSDOM(
`<!DOCTYPE html>${
`<!DOCTYPE html><meta charset="utf-8">${
options?.includeStyles === false ? "" : styleTags
}${html}`
);
@@ -558,4 +559,79 @@ export class ProsemirrorHelper {
return dom.serialize();
}
/**
* Processes mentions in the Prosemirror data, ensuring that mentions
* for deleted users are displayed as "@unknown" and updated names are
* displayed correctly.
*
* @param data The ProsemirrorData object to process
* @returns The processed ProsemirrorData with updated mentions
*/
static async processMentions(data: ProsemirrorData | Node) {
const json = "toJSON" in data ? (data.toJSON() as ProsemirrorData) : data;
// First pass: collect all user IDs from mentions
const userIds: string[] = [];
function collectUserIds(node: ProsemirrorData) {
if (
node.type === "mention" &&
node.attrs?.type === MentionType.User &&
node.attrs?.modelId
) {
userIds.push(node.attrs.modelId as string);
}
if (node.content) {
for (const child of node.content) {
collectUserIds(child);
}
}
}
collectUserIds(json);
// Load all users in a single query
const uniqueUserIds = [...new Set(userIds)];
const users = uniqueUserIds.length
? await User.findAll({
where: {
id: uniqueUserIds,
},
attributes: ["id", "name"],
})
: [];
// Create a map for quick lookup
const userMap = new Map();
users.forEach((user) => {
userMap.set(user.id, user.name);
});
// Second pass: transform mentions with loaded user data
function transformMentions(node: ProsemirrorData) {
if (
node.type === "mention" &&
node.attrs?.type === MentionType.User &&
node.attrs?.modelId
) {
const userId = node.attrs.modelId as string;
node.attrs = {
...node.attrs,
label: userMap.get(userId) || "Unknown",
};
}
if (node.content) {
for (const child of node.content) {
transformMentions(child);
}
}
return node;
}
return transformMentions(json);
}
}
+39 -39
View File
@@ -14,9 +14,9 @@ describe("admin", () => {
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({ teamId: team.id });
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", admin.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: admin.id,
});
const abilities = serialize(admin, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.update).toBeTruthy();
@@ -36,9 +36,9 @@ describe("admin", () => {
permission: null,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.readDocument).toEqual(false);
expect(abilities.updateDocument).toEqual(false);
@@ -59,9 +59,9 @@ describe("admin", () => {
permission: CollectionPermission.Read,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.readDocument).toBeTruthy();
expect(abilities.updateDocument).toBeTruthy();
@@ -87,9 +87,9 @@ describe("member", () => {
},
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: member.id,
});
const abilities = serialize(member, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.update).toBeTruthy();
@@ -116,9 +116,9 @@ describe("member", () => {
},
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: member.id,
});
const abilities = serialize(member, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.update).toBe(false);
@@ -161,9 +161,9 @@ describe("member", () => {
permission: CollectionPermission.Read,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -189,9 +189,9 @@ describe("member", () => {
},
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: member.id,
});
const abilities = serialize(member, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.update).not.toBeTruthy();
@@ -232,9 +232,9 @@ describe("member", () => {
},
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: member.id,
});
const abilities = serialize(member, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -279,9 +279,9 @@ describe("member", () => {
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -331,9 +331,9 @@ describe("viewer", () => {
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -361,9 +361,9 @@ describe("viewer", () => {
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -409,9 +409,9 @@ describe("viewer", () => {
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -462,9 +462,9 @@ describe("guest", () => {
permission: CollectionPermission.Read,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
+18 -12
View File
@@ -353,9 +353,9 @@ export default class WebsocketsProcessor {
case "collections.remove_user": {
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", event.userId],
}).findByPk(event.collectionId),
Collection.findByPk(event.collectionId, {
userId: event.userId,
}),
User.findByPk(event.userId),
]);
if (!user) {
@@ -424,9 +424,9 @@ export default class WebsocketsProcessor {
async (groupUsers) => {
for (const groupUser of groupUsers) {
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", groupUser.userId],
}).findByPk(event.collectionId),
Collection.findByPk(event.collectionId, {
userId: groupUser.userId,
}),
User.findByPk(groupUser.userId),
]);
if (!user) {
@@ -716,9 +716,12 @@ export default class WebsocketsProcessor {
presentGroupMembership(groupMembership)
);
const collection = await Collection.scope({
method: ["withMembership", event.userId],
}).findByPk(groupMembership.collectionId);
const collection = await Collection.findByPk(
groupMembership.collectionId,
{
userId: event.userId,
}
);
if (cannot(user, "read", collection)) {
// tell any user clients to disconnect from the websocket channel for the collection
@@ -772,9 +775,12 @@ export default class WebsocketsProcessor {
.to(`user-${groupUser.userId}`)
.emit("collections.remove_group", payload);
const collection = await Collection.scope({
method: ["withMembership", groupUser.userId],
}).findByPk(groupMembership.collectionId);
const collection = await Collection.findByPk(
groupMembership.collectionId,
{
userId: groupUser.userId,
}
);
if (cannot(groupUser.user, "read", collection)) {
// tell any user clients to disconnect from the websocket channel for the collection
@@ -17,8 +17,10 @@ export default class CleanupDeletedDocumentsTask extends BaseTask<Props> {
"task",
`Permanently destroying upto ${limit} documents older than 30 days…`
);
const documents = await Document.scope("withDrafts").findAll({
attributes: ["id", "teamId", "content", "text", "deletedAt"],
const documents = await Document.scope([
"withDrafts",
"withoutState",
]).findAll({
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), 30),
@@ -16,9 +16,9 @@ export default class CollectionSubscriptionRemoveUserTask extends BaseTask<Colle
return;
}
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
const collection = await Collection.findByPk(event.collectionId, {
userId: user.id,
});
if (can(user, "read", collection)) {
Logger.debug(
@@ -1917,4 +1917,34 @@ describe("#collections.restore", () => {
expect(body.data.archivedAt).toBe(null);
expect(collection.documentStructure).not.toBe(null);
});
it("should resolve index collision when restoring", async () => {
const admin = await buildAdmin();
let collection = await buildCollection({
teamId: admin.teamId,
});
let archivedCollection = await buildCollection({
teamId: admin.teamId,
archivedAt: new Date(),
archivedById: admin.id,
});
[collection, archivedCollection] = await Promise.all([
collection.update({ index: "P" }, { hooks: false }),
archivedCollection.update({ index: "P" }, { hooks: false }),
]);
expect(collection.index).toEqual("P");
expect(archivedCollection.index).toEqual("P");
const res = await server.post("/api/collections.restore", {
body: {
token: admin.getJwtToken(),
id: archivedCollection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.archivedAt).toBe(null);
expect(body.data.index).not.toBe("P");
});
});
+51 -49
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import Router from "koa-router";
import { Sequelize, Op, WhereOptions } from "sequelize";
import {
@@ -39,6 +38,7 @@ import {
presentFileOperation,
} from "@server/presenters";
import { APIContext } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { collectionIndexing } from "@server/utils/indexing";
import pagination from "../middlewares/pagination";
@@ -96,12 +96,11 @@ router.post(
},
});
// we must reload the collection to get memberships for policy presenter
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id, {
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
transaction,
rejectOnEmpty: true,
});
invariant(reloaded, "collection not found");
ctx.body = {
data: await presentCollection(ctx, reloaded),
@@ -118,11 +117,14 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope([
"defaultScope",
"withArchivedBy",
{
method: ["withMembership", user.id],
},
"withArchivedBy",
]).findByPk(id);
]).findOne({
where: { id },
});
authorize(user, "read", collection);
@@ -140,16 +142,27 @@ router.post(
async (ctx: APIContext<T.CollectionsDocumentsReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(id);
const collection = await Collection.findByPk(id, {
userId: user.id,
});
authorize(user, "readDocument", collection);
const documentStructure = await CacheHelper.getDataOrSet(
CacheHelper.getCollectionDocumentsKey(collection.id),
async () =>
(
await Collection.findByPk(collection.id, {
attributes: ["documentStructure"],
includeDocumentStructure: true,
rejectOnEmpty: true,
})
).documentStructure,
60
);
ctx.body = {
data: collection.documentStructure || [],
data: documentStructure || [],
};
}
);
@@ -201,9 +214,7 @@ router.post(
const { user } = ctx.state.auth;
const [collection, group] = await Promise.all([
Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, { transaction }),
Collection.findByPk(id, { userId: user.id, transaction }),
Group.findByPk(groupId, { transaction }),
]);
authorize(user, "update", collection);
@@ -248,9 +259,8 @@ router.post(
const { transaction } = ctx.state;
const [collection, group] = await Promise.all([
Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
Collection.findByPk(id, {
userId: user.id,
transaction,
}),
Group.findByPk(groupId, {
@@ -286,9 +296,9 @@ router.post(
const { id, query, permission } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
const collection = await Collection.findByPk(id, {
userId: user.id,
});
authorize(user, "read", collection);
let where: WhereOptions<GroupMembership> = {
@@ -356,9 +366,7 @@ router.post(
const { id, userId, permission } = ctx.input.body;
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction }),
Collection.findByPk(id, { userId, transaction }),
User.findByPk(userId, { transaction }),
]);
authorize(actor, "update", collection);
@@ -402,9 +410,7 @@ router.post(
const { id, userId } = ctx.input.body;
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction }),
Collection.findByPk(id, { userId, transaction }),
User.findByPk(userId, { transaction }),
]);
authorize(actor, "update", collection);
@@ -435,9 +441,9 @@ router.post(
const { id, query, permission } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
const collection = await Collection.findByPk(id, {
userId: user.id,
});
authorize(user, "read", collection);
let where: WhereOptions<UserMembership> = {
@@ -503,9 +509,10 @@ router.post(
const team = await Team.findByPk(user.teamId, { transaction });
authorize(user, "createExport", team);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, { transaction });
const collection = await Collection.findByPk(id, {
userId: user.id,
transaction,
});
authorize(user, "export", collection);
const fileOperation = await collectionExporter({
@@ -576,9 +583,8 @@ router.post(
} = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
const collection = await Collection.findByPk(id, {
userId: user.id,
transaction,
});
authorize(user, "update", collection);
@@ -814,9 +820,8 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
const collection = await Collection.findByPk(id, {
userId: user.id,
transaction,
});
@@ -845,11 +850,8 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(id, {
const collection = await Collection.findByPk(id, {
userId: user.id,
transaction,
rejectOnEmpty: true,
});
@@ -905,11 +907,11 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
transaction,
const collection = await Collection.findByPk(id, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
transaction,
});
authorize(user, "restore", collection);
+3 -1
View File
@@ -154,7 +154,9 @@ router.post(
]);
comments.forEach((comment) => (comment.document = document));
} else if (collectionId) {
const collection = await Collection.findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
const include = [
{
+49 -58
View File
@@ -133,12 +133,10 @@ router.post(
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where[Op.and].push({ collectionId: [collectionId] });
const collection = await Collection.scope([
sort === "index" ? "withDocumentStructure" : "defaultScope",
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: sort === "index",
});
authorize(user, "readDocument", collection);
@@ -331,9 +329,9 @@ router.post(
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where = { ...where, collectionId };
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
@@ -512,9 +510,9 @@ router.post(
const { user } = ctx.state.auth;
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
}
@@ -821,15 +819,20 @@ router.post(
const destCollectionId = collectionId ?? sourceCollectionId;
const srcCollection = sourceCollectionId
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(sourceCollectionId, { paranoid: false })
? await Collection.findByPk(sourceCollectionId, {
userId: user.id,
includeDocumentStructure: true,
paranoid: false,
transaction,
})
: undefined;
const destCollection = destCollectionId
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(destCollectionId)
? await Collection.findByPk(destCollectionId, {
userId: user.id,
includeDocumentStructure: true,
transaction,
})
: undefined;
// In case of workspace templates, both source and destination collections are undefined.
@@ -931,9 +934,9 @@ router.post(
let collaboratorIds = undefined;
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
}
@@ -1027,9 +1030,9 @@ router.post(
teamId = user.teamId;
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
}
@@ -1118,9 +1121,10 @@ router.post(
authorize(user, "update", original);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "createDocument", collection);
} else {
authorize(user, "createTemplate", user.team);
@@ -1205,9 +1209,10 @@ router.post(
collectionId,
"collectionId is required to publish a draft without collection"
);
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId!, { transaction });
collection = await Collection.findByPk(collectionId!, {
userId: user.id,
transaction,
});
}
if (document.parentDocumentId) {
@@ -1261,9 +1266,10 @@ router.post(
authorize(user, "read", document);
const collection = collectionId
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction })
? await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
})
: document?.collection;
if (collection) {
@@ -1323,9 +1329,10 @@ router.post(
authorize(user, "move", document);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "updateDocument", collection);
} else if (document.template) {
authorize(user, "updateTemplate", user.team);
@@ -1503,13 +1510,8 @@ router.post(
const file = ctx.input.file;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findOne({
where: {
id: collectionId,
teamId: user.teamId,
},
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "createDocument", collection);
let parentDocument;
@@ -1606,14 +1608,8 @@ router.post(
});
if (parentDocument?.collectionId) {
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findOne({
where: {
id: parentDocument.collectionId,
teamId: user.teamId,
},
transaction,
collection = await Collection.findByPk(parentDocument.collectionId, {
userId: user.id,
});
}
@@ -1621,13 +1617,8 @@ router.post(
collection,
});
} else if (collectionId) {
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findOne({
where: {
id: collectionId,
teamId: user.teamId,
},
collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "createDocument", collection);
+3 -3
View File
@@ -61,9 +61,9 @@ router.post(
if (collectionId) {
where = { ...where, collectionId };
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
} else {
const collectionIds = await user.collectionIds({
+4 -3
View File
@@ -33,9 +33,10 @@ router.post(
authorize(user, "read", document);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "update", collection);
authorize(user, "pin", document);
} else {
+4 -3
View File
@@ -55,9 +55,10 @@ router.post(
authorize(user, "read", document);
const collection = document.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
document.collectionId
)
? await Collection.findByPk(document.collectionId, {
userId: user.id,
includeDocumentStructure: true,
})
: undefined;
const parentIds = collection?.getDocumentParents(documentId);
const parentShare = parentIds
+4 -3
View File
@@ -37,9 +37,10 @@ router.post(
}
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "star", collection);
}
@@ -34,9 +34,10 @@ router.post(
};
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction: ctx.state.transaction,
});
authorize(user, "read", collection);
where.collectionId = collectionId;
@@ -78,9 +79,9 @@ router.post(
};
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
where.collectionId = collectionId;
@@ -116,9 +117,9 @@ router.post(
const { event, collectionId, documentId } = ctx.input.body;
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "subscribe", collection);
} else {
@@ -1,6 +1,7 @@
import Router from "koa-router";
import { Op } from "sequelize";
import { Sequelize } from "sequelize-typescript";
import { StatusFilter } from "@shared/types";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { User } from "@server/models";
@@ -28,6 +29,7 @@ router.post(
query,
offset,
limit,
statusFilter: [StatusFilter.Published],
}),
User.findAll({
where: {
-2
View File
@@ -260,8 +260,6 @@ router.get(
// The link in the email does not include the follow query param, this
// is to help prevent anti-virus, and email clients from pre-fetching the link
// and spending the token before the user clicks on it. Instead we redirect
// to the same URL with the follow query param added from the client side.
if (!follow) {
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
}
+7 -60
View File
@@ -1,13 +1,8 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import crypto from "crypto";
import { Server } from "https";
import Koa from "koa";
import compress from "koa-compress";
import {
contentSecurityPolicy,
dnsPrefetchControl,
referrerPolicy,
} from "koa-helmet";
import { dnsPrefetchControl, referrerPolicy } from "koa-helmet";
import mount from "koa-mount";
import enforceHttps, {
httpsResolver,
@@ -17,6 +12,7 @@ import { Second } from "@shared/utils/time";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import Metrics from "@server/logging/Metrics";
import csp from "@server/middlewares/csp";
import ShutdownHelper, { ShutdownOrder } from "@server/utils/ShutdownHelper";
import { initI18n } from "@server/utils/i18n";
import routes from "../routes";
@@ -24,32 +20,6 @@ import api from "../routes/api";
import auth from "../routes/auth";
import oauth from "../routes/oauth";
// Construct scripts CSP based on services in use by this installation
const defaultSrc = ["'self'"];
const scriptSrc = ["'self'", "www.googletagmanager.com"];
const styleSrc = ["'self'", "'unsafe-inline'"];
if (env.isCloudHosted) {
scriptSrc.push("cdn.zapier.com");
styleSrc.push("cdn.zapier.com");
}
// Allow to load assets from Vite
if (!env.isProduction) {
scriptSrc.push(env.URL.replace(`:${env.PORT}`, ":3001"));
scriptSrc.push("localhost:3001");
}
if (env.GOOGLE_ANALYTICS_ID) {
scriptSrc.push("www.google-analytics.com");
}
if (env.CDN_URL) {
scriptSrc.push(env.CDN_URL);
styleSrc.push(env.CDN_URL);
defaultSrc.push(env.CDN_URL);
}
export default function init(app: Koa = new Koa(), server?: Server) {
void initI18n();
@@ -76,10 +46,6 @@ export default function init(app: Koa = new Koa(), server?: Server) {
app.use(compress());
app.use(mount("/oauth", oauth));
app.use(mount("/auth", auth));
app.use(mount("/api", api));
// Monitor server connections
if (server) {
setInterval(() => {
@@ -96,31 +62,10 @@ export default function init(app: Koa = new Koa(), server?: Server) {
Metrics.gaugePerInstance("connections.count", 0);
});
// Sets common security headers by default, such as no-sniff, hsts, hide powered
// by etc, these are applied after auth and api so they are only returned on
// standard non-XHR accessed routes
app.use((ctx, next) => {
ctx.state.cspNonce = crypto.randomBytes(16).toString("hex");
app.use(mount("/api", api));
return contentSecurityPolicy({
directives: {
defaultSrc,
styleSrc,
scriptSrc: [
...scriptSrc,
env.DEVELOPMENT_UNSAFE_INLINE_CSP
? "'unsafe-inline'"
: `'nonce-${ctx.state.cspNonce}'`,
],
mediaSrc: ["*", "data:", "blob:"],
imgSrc: ["*", "data:", "blob:"],
frameSrc: ["*", "data:"],
// Do not use connect-src: because self + websockets does not work in
// Safari, ref: https://bugs.webkit.org/show_bug.cgi?id=201591
connectSrc: ["*"],
},
})(ctx, next);
});
// Apply CSP middleware after API as these responses are rendered in the browser
app.use(csp());
// Allow DNS prefetching for performance, we do not care about leaking requests
// to our own CDN's
@@ -135,6 +80,8 @@ export default function init(app: Koa = new Koa(), server?: Server) {
})
);
app.use(mount("/oauth", oauth));
app.use(mount("/auth", auth));
app.use(mount(routes));
return app;
+3 -3
View File
@@ -191,9 +191,9 @@ async function authenticated(io: IO.Server, socket: SocketWithAuth) {
// user is joining a collection channel, because their permissions have
// changed, granting them access.
if (event.collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
const collection = await Collection.findByPk(event.collectionId, {
userId: user.id,
});
if (can(user, "read", collection)) {
await socket.join(`collection-${event.collectionId}`);
+47
View File
@@ -0,0 +1,47 @@
import { EventEmitter } from "events";
// Create a mock Redis client with all needed methods mocked
class RedisMock extends EventEmitter {
constructor() {
super();
}
get = jest.fn().mockResolvedValue(null);
set = jest.fn().mockResolvedValue("OK");
del = jest.fn().mockResolvedValue(1);
keys = jest.fn().mockResolvedValue([]);
ping = jest.fn().mockResolvedValue("PONG");
disconnect = jest.fn();
setMaxListeners = jest.fn();
}
// Mock the RedisAdapter class
class RedisAdapter extends RedisMock {
constructor(_url: string | undefined, _options = {}) {
super();
}
private static client: RedisAdapter;
private static subscriber: RedisAdapter;
public static get defaultClient(): RedisAdapter {
return (
this.client ||
(this.client = new this(undefined, {
connectionNameSuffix: "client",
}))
);
}
public static get defaultSubscriber(): RedisAdapter {
return (
this.subscriber ||
(this.subscriber = new this(undefined, {
maxRetriesPerRequest: null,
connectionNameSuffix: "subscriber",
}))
);
}
}
export default RedisAdapter;
+1 -1
View File
@@ -10,7 +10,7 @@ import fetch, { chromeUserAgent, RequestInit } from "@server/utils/fetch";
export default abstract class BaseStorage {
/** The default number of seconds until a signed URL expires. */
public static defaultSignedUrlExpires = 60;
public static defaultSignedUrlExpires = 300;
/**
* Returns a presigned post for uploading files to the storage provider.
+28 -3
View File
@@ -8,7 +8,7 @@ import invariant from "invariant";
import JWT from "jsonwebtoken";
import safeResolvePath from "resolve-path";
import env from "@server/env";
import { ValidationError } from "@server/errors";
import { InternalError, ValidationError } from "@server/errors";
import Logger from "@server/logging/Logger";
import BaseStorage from "./BaseStorage";
@@ -132,8 +132,33 @@ export default class LocalStorage extends BaseStorage {
};
}
public getFileStream(key: string, range?: { start: number; end: number }) {
return Promise.resolve(fs.createReadStream(this.getFilePath(key), range));
public async getFileStream(
key: string,
range?: { start: number; end: number }
) {
const filePath = this.getFilePath(key);
const exists = await fs.pathExists(filePath);
if (!exists) {
throw InternalError(`File not found at ${key}`);
}
if (range) {
if (
typeof range.start !== "number" ||
typeof range.end !== "number" ||
range.start < 0 ||
range.end < range.start
) {
throw ValidationError("Invalid range specified");
}
}
try {
return fs.createReadStream(filePath, range);
} catch (err) {
Logger.error(`Failed to create read stream`, err, { filePath });
throw ValidationError("Unable to read file");
}
}
public stat(key: string) {
+3 -3
View File
@@ -416,9 +416,9 @@ export async function buildDocument(
if (overrides.collectionId && overrides.publishedAt !== null) {
collection = collection
? await Collection.scope("withDocumentStructure").findByPk(
overrides.collectionId
)
? await Collection.findByPk(overrides.collectionId, {
includeDocumentStructure: true,
})
: undefined;
await collection?.addDocumentToStructure(document, 0);
+8 -1
View File
@@ -7,6 +7,11 @@ require("@server/storage/database");
jest.mock("bull");
// Enable mocks for Redis-related modules
jest.mock("@server/storage/redis");
jest.mock("@server/utils/MutexLock");
jest.mock("@server/utils/CacheHelper");
// This is needed for the relative manual mock to be picked up
jest.mock("../queues");
@@ -34,7 +39,9 @@ jest.mock("@aws-sdk/s3-request-presigner", () => ({
getSignedUrl: jest.fn(),
}));
afterAll(() => Redis.defaultClient.disconnect());
afterAll(() => {
Redis.defaultClient.disconnect();
});
beforeEach(() => {
env.URL = sharedEnv.URL = "https://app.outline.dev";
+4
View File
@@ -125,4 +125,8 @@ export class CacheHelper {
public static getUnfurlKey(teamId: string, url = "") {
return `unfurl:${teamId}:${url}`;
}
public static getCollectionDocumentsKey(collectionId: string) {
return `cd:${collectionId}`;
}
}
+54 -28
View File
@@ -41,7 +41,7 @@ export default class ZipHelper {
prefix: "export-",
postfix: ".zip",
},
(err, path) => {
(err, filePath) => {
if (err) {
return reject(err);
}
@@ -51,13 +51,24 @@ export default class ZipHelper {
currentFile: null,
};
const handleError = (error: Error) => {
dest.destroy();
fs.remove(filePath)
.catch((rmErr) => {
Logger.error("Failed to remove tmp file", rmErr);
})
.finally(() => {
reject(error);
});
};
const dest = fs
.createWriteStream(path)
.createWriteStream(filePath)
.on("finish", () => {
Logger.debug("utils", "Writing zip complete", { path });
return resolve(path);
Logger.debug("utils", "Writing zip complete", { path: filePath });
return resolve(filePath);
})
.on("error", reject);
.on("error", handleError);
zip
.generateNodeStream(
@@ -85,11 +96,9 @@ export default class ZipHelper {
}
}
)
.on("error", (rErr) => {
dest.end();
reject(rErr);
})
.pipe(dest);
.on("error", handleError)
.pipe(dest)
.on("error", handleError);
}
);
});
@@ -126,32 +135,38 @@ export default class ZipHelper {
const fileName = Buffer.from(entry.fileName).toString("utf8");
Logger.debug("utils", "Extracting zip entry", { fileName });
const processNext = (error?: NodeJS.ErrnoException | null) => {
if (error) {
zipfile.close();
reject(error);
return;
}
zipfile.readEntry();
};
if (validateFileName(fileName)) {
Logger.warn("Invalid zip entry", { fileName });
zipfile.readEntry();
} else if (/\/$/.test(fileName)) {
processNext();
return;
}
if (/\/$/.test(fileName)) {
// directory file names end with '/'
fs.mkdirp(
path.join(outputDir, fileName),
function (mErr: Error) {
if (mErr) {
return reject(mErr);
}
zipfile.readEntry();
}
fs.mkdirp(path.join(outputDir, fileName), (mkErr) =>
processNext(mkErr)
);
} else {
// file entry
zipfile.openReadStream(entry, function (rErr, readStream) {
if (rErr) {
return reject(rErr);
return processNext(rErr);
}
// ensure parent directory exists
fs.mkdirp(
path.join(outputDir, path.dirname(fileName)),
function (mkErr) {
if (mkErr) {
return reject(mkErr);
return processNext(mkErr);
}
const location = trimFileAndExt(
@@ -163,15 +178,20 @@ export default class ZipHelper {
);
const dest = fs
.createWriteStream(location)
.on("error", reject);
.on("error", (error) => {
readStream.destroy();
dest.destroy();
processNext(error);
});
readStream
.on("error", (rsErr) => {
dest.end();
reject(rsErr);
.on("error", (error) => {
dest.destroy();
readStream.destroy();
processNext(error);
})
.on("end", function () {
zipfile.readEntry();
processNext();
})
.pipe(dest);
}
@@ -180,8 +200,14 @@ export default class ZipHelper {
}
});
zipfile.on("close", resolve);
zipfile.on("error", reject);
zipfile.on("error", (error) => {
zipfile.close();
reject(error);
});
} catch (zErr) {
if (zipfile) {
zipfile.close();
}
reject(zErr);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { Day } from "@shared/utils/time";
/**
* A Mock Helper class for server-side cache management
*/
export class CacheHelper {
// Default expiry time for cache data in seconds
private static defaultDataExpiry = Day.seconds;
/**
* Mocked method that resolves with the callback result
*/
public static async getDataOrSet<T>(
key: string,
callback: () => Promise<T | undefined>,
_expiry: number,
_lockTimeout: number
): Promise<T | undefined> {
return await callback();
}
/**
* Mocked method that resolves with undefined
*/
public static async getData<T>(_key: string): Promise<T | undefined> {
return undefined;
}
/**
* Mocked method that resolves with void
*/
public static async setData<T>(_key: string, _data: T, _expiry?: number) {
return;
}
/**
* Mocked method that resolves with void
*/
public static async clearData(_prefix: string) {
return;
}
/**
* These are real methods that don't require mocking as they don't
* interact with Redis directly
*/
public static getUnfurlKey(teamId: string, url = "") {
return `unfurl:${teamId}:${url}`;
}
public static getCollectionDocumentsKey(collectionId: string) {
return `cd:${collectionId}`;
}
}
+18
View File
@@ -0,0 +1,18 @@
export class MutexLock {
// Default expiry time for acquiring lock in milliseconds
public static defaultLockTimeout = 4000;
/**
* Returns the mock redlock instance
*/
public static get lock() {
return {
acquire: jest.fn().mockResolvedValue({
release: jest.fn().mockResolvedValue(true),
expiration: Date.now() + 10000,
}),
};
}
private static redlock: any;
}
+8
View File
@@ -56,4 +56,12 @@ describe("#ValidateKey.sanitize", () => {
ValidateKey.sanitize(`public/${uuid1}/${uuid2}/../../malicious_key`)
).toEqual(`public/${uuid1}/${uuid2}/malicious_key`);
});
it("should remove problematic characters", () => {
const uuid1 = uuidv4();
const uuid2 = uuidv4();
expect(ValidateKey.sanitize(`public/${uuid1}/${uuid2}/test#:*?`)).toEqual(
`public/${uuid1}/${uuid2}/test`
);
});
});
+22 -16
View File
@@ -26,7 +26,7 @@ export function assertArray(
message?: string
): asserts value {
if (!isArrayLike(value)) {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is not an array`);
}
}
@@ -55,13 +55,11 @@ export function assertKeysIn(
Object.keys(obj).forEach((key) => assertIn(key, Object.values(type)));
}
export const assertSort = (
value: string,
model: any,
message = "Invalid sort parameter"
) => {
export const assertSort = (value: string, model: any, message?: string) => {
if (!Object.keys(model.rawAttributes).includes(value)) {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not a valid sort field`
);
}
};
@@ -72,7 +70,7 @@ export function assertNotEmpty(
assertPresent(value, message);
if (typeof value === "string" && value.trim() === "") {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is empty`);
}
}
@@ -81,7 +79,7 @@ export function assertEmail(
message?: string
): asserts value {
if (typeof value !== "string" || !validator.isEmail(value)) {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is not a valid email`);
}
}
@@ -121,10 +119,12 @@ export function assertUuid(
message?: string
): asserts value {
if (typeof value !== "string") {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not a string, expected UUID`
);
}
if (!validator.isUUID(value)) {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is not a valid UUID`);
}
}
@@ -137,13 +137,17 @@ export const assertPositiveInteger = (
min: 0,
})
) {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not a positive integer`
);
}
};
export const assertHexColor = (value: string, message?: string) => {
if (!validateColorHex(value)) {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not a valid hex color`
);
}
};
@@ -153,7 +157,9 @@ export const assertValueInArray = (
message?: string
) => {
if (!values.includes(value)) {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not in the allowed values`
);
}
};
@@ -162,7 +168,7 @@ export const assertIndexCharacters = (
message = "index must be between x20 to x7E ASCII"
) => {
if (!validateIndexCharacters(value)) {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is not a valid index`);
}
};
@@ -209,7 +215,7 @@ export class ValidateKey {
.slice(0, -1)
.filter((part) => part !== "" && part !== ".." && part !== ".")
.join("/")
.concat(`/${sanitize(filename)}`);
.concat(`/${sanitize(filename.replace(/#/g, ""))}`);
};
public static message = "Must be of the form <bucket>/<uuid>/<uuid>/<name>";
+1
View File
@@ -708,6 +708,7 @@ img.ProseMirror-separator {
resize: none;
user-select: text;
margin: 0 auto !important;
width: 100%;
max-width: 100vw;
}
-49
View File
@@ -1,49 +0,0 @@
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import Extension from "../lib/Extension";
export default class Placeholder extends Extension {
get name() {
return "empty-placeholder";
}
get defaultOptions() {
return {
emptyNodeClass: "placeholder",
placeholder: "",
};
}
get plugins() {
return [
new Plugin({
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = [];
const completelyEmpty =
doc.childCount <= 1 &&
doc.content.size <= 2 &&
doc.textContent === "";
if (completelyEmpty) {
doc.descendants((node, pos) => {
if (pos !== 0 || node.type.name !== "paragraph") {
return;
}
const decoration = Decoration.node(pos, pos + node.nodeSize, {
class: this.options.emptyNodeClass,
"data-empty-text": this.options.placeholder,
});
decorations.push(decoration);
});
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import FileExtension from "../components/FileExtension";
import { isRemoteTransaction } from "./multiplayer";
import { recreateTransform } from "./prosemirror-recreate-transform";
@@ -100,7 +100,8 @@ const uploadPlaceholder = new Plugin({
subtitle.className = "subtitle";
subtitle.innerText = "Uploading…";
ReactDOM.render(<FileExtension title={action.add.file.name} />, icon);
const root = createRoot(icon);
root.render(<FileExtension title={action.add.file.name} />);
element.appendChild(icon);
element.appendChild(title);
+29 -1
View File
@@ -1,4 +1,11 @@
import { NodeSpec } from "prosemirror-model";
import isNull from "lodash/isNull";
import {
NodeSpec,
Node as ProsemirrorNode,
ResolvedPos,
} from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { PlaceholderPlugin } from "../plugins/PlaceholderPlugin";
import Node from "./Node";
export default class Doc extends Node {
@@ -11,4 +18,25 @@ export default class Doc extends Node {
content: "block+",
};
}
get plugins() {
return [
new PlaceholderPlugin([
{
condition: (
node: ProsemirrorNode,
$start: ResolvedPos,
parent: ProsemirrorNode | null,
state: EditorState
) =>
node.textContent === "" &&
!isNull(parent) &&
parent.type === state.doc.type &&
parent.childCount === 1 &&
$start.index($start.depth - 1) === 0,
text: this.options.placeholder,
},
]),
];
}
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { wrappingInputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import { Command, EditorState, Transaction } from "prosemirror-state";
import * as React from "react";
import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import { Primitive } from "utility-types";
import toggleWrap from "../commands/toggleWrap";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
@@ -107,7 +107,8 @@ export default class Notice extends Node {
icon = document.createElement("div");
icon.className = "icon";
ReactDOM.render(component, icon);
const root = createRoot(icon);
root.render(component);
}
return [
-2
View File
@@ -1,7 +1,6 @@
import DateTime from "../extensions/DateTime";
import History from "../extensions/History";
import MaxLength from "../extensions/MaxLength";
import Placeholder from "../extensions/Placeholder";
import TrailingNode from "../extensions/TrailingNode";
import Extension from "../lib/Extension";
import Bold from "../marks/Bold";
@@ -64,7 +63,6 @@ export const basicExtensions: Nodes = [
Strikethrough,
History,
TrailingNode,
Placeholder,
MaxLength,
DateTime,
];
@@ -0,0 +1,73 @@
import filter from "lodash/filter";
import find from "lodash/find";
import map from "lodash/map";
import { Node, ResolvedPos } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
type Config = Array<{
/** Condition to meet for the placeholder to be applied to a node */
condition: (
/** Node to which the placeholder is expected to be applied */
node: Node,
/** Resolved position corresponding to start of node */
$start: ResolvedPos,
/** Parent of node to which the placeholder is expected to be applied */
parent: Node | null,
state: EditorState
) => boolean;
/** Placeholder text */
text: string;
}>;
export class PlaceholderPlugin extends Plugin {
private config: Config;
constructor(config: Config) {
super({
props: {
decorations: (state) => {
const decorations: Decoration[] = map(
this.placeholders(state),
(placeholder) =>
Decoration.node(placeholder.from, placeholder.to, {
class: "placeholder",
"data-empty-text": placeholder.text,
})
);
return DecorationSet.create(state.doc, decorations);
},
},
});
this.config = config;
}
private placeholders(state: EditorState) {
const paras: Array<{
node: Node;
$start: ResolvedPos;
parent: Node | null;
}> = [];
state.doc.descendants((node, pos, parent) => {
if (node.type.name === "paragraph") {
paras.push({ node, $start: state.doc.resolve(pos + 1), parent });
}
});
return filter(
map(paras, (para) => {
const condMet = find(this.config, (conf) =>
conf.condition(para.node, para.$start, para.parent, state)
);
return condMet
? {
from: para.$start.pos - 1,
to: para.$start.pos - 1 + para.node.nodeSize,
text: condMet.text,
}
: undefined;
}),
(placeholder) => placeholder !== undefined
);
}
}
+3
View File
@@ -0,0 +1,3 @@
Do not edit files in `/locales` directly these are machine generated. Translations are provided through CrowdIn, see the documentation here:
https://github.com/outline/outline/blob/main/docs/TRANSLATION.md
+4
View File
@@ -48,6 +48,10 @@ export const languageOptions: LanguageOption[] = [
label: "한국어 (Korean)",
value: "ko_KR",
},
{
label: "Magyar (Hungarian)",
value: "hu_HU",
},
{
label: "Nederland (Dutch, Netherlands)",
value: "nl_NL",
+84 -10
View File
@@ -120,6 +120,8 @@
"Log out": "Odhlásit se",
"Mark notifications as read": "Označit upozornění jako přečtená",
"Archive all notifications": "Archivovat všechny notifikace",
"New App": "New App",
"New Application": "New Application",
"Restore revision": "Obnovit revizi",
"Link copied": "Odkaz zkopírován",
"Dark": "Temný",
@@ -170,8 +172,6 @@
"Deleting": "Mazání",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.": "Jste si jisti? Smazání sbírky <em>{{collectionName}}</em> je trvalé a nelze vrátit zpět. Všechny publikované dokumenty ze smazané sbírky budou přesunuty do koše.",
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "<em>{{collectionName}}</em> se také používá jako domovská stránka odstraněním se obnoví výchozí nastavení.",
"Sorry, an error occurred saving the collection": "Omlouváme se, při ukládání sbírky došlo k chybě",
"Add a description": "Přidat popis",
"Type a command or search": "Zadejte příkaz nebo začněte vyhledávat",
"Choose a template": "Vybrat šablonu",
"Are you sure you want to permanently delete this entire comment thread?": "Jste si jisti, že chcete natrvalo odstranit vlákno komentářů?",
@@ -308,6 +308,13 @@
"Unknown": "Neznámý",
"Mark all as read": "Označit vše jako přečtené",
"You're all caught up": "Již nic nového",
"Icon": "Icon",
"My App": "My App",
"Tagline": "Tagline",
"A short description": "A short description",
"Callback URLs": "Callback URLs",
"Published": "Zveřejněno",
"Allow this app to be installed by other workspaces": "Allow this app to be installed by other workspaces",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reagoval s {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} a {{ secondUsername }} reagovali s {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} a {{ count }} reagovali s {{ emoji }}",
@@ -395,7 +402,6 @@
"Star document": "Označit dokument",
"Template created, go ahead and customize it": "Šablona vytvořena, pokračujte a přizpůsobte ji",
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Vytvoření šablony z <em>{{titleWithDefault}}</em> je nedestruktivní akce vytvoříme kopii dokumentu a přeměníme ji na šablonu, kterou lze použít jako výchozí bod pro nové dokumenty.",
"Published": "Zveřejněno",
"Enable other members to use the template immediately": "Enable other members to use the template immediately",
"Location": "Location",
"Admins can manage the workspace and access billing.": "Administrátoři mohou spravovat pracovní prostor a přistupovat k fakturacím.",
@@ -512,14 +518,17 @@
"Unsubscribed from document": "Upozornění vypnuta",
"Unsubscribed from collection": "Unsubscribed from collection",
"Account": "Účet",
"API Keys": "API Keys",
"API & Apps": "API & Apps",
"Details": "Podrobnosti",
"Security": "Zabezpečení",
"Features": "Funkce",
"Members": "Uživatelé",
"Groups": "Skupiny",
"API Keys": "API Keys",
"Applications": "Applications",
"Shared Links": "Sdílené odkazy",
"Import": "Import",
"Install": "Install",
"Integrations": "Integrace",
"Revoke token": "Odvolat tokeny",
"Revoke": "Zrušit",
@@ -538,6 +547,7 @@
"Document options": "Možnosti dokumentů",
"Choose a collection": "Vybrat sbírku",
"Subscription inherited from collection": "Subscription inherited from collection",
"Apply template": "Apply template",
"Enable embeds": "Povolit embed vkládání",
"Export options": "Možnosti exportu",
"Group members": "Členové skupiny",
@@ -551,6 +561,10 @@
"New child document": "Nový vložený dokument",
"Save in workspace": "Save in workspace",
"Notification settings": "Nastavení oznámení",
"Revoke {{ appName }}": "Revoke {{ appName }}",
"Revoking": "Odvolávání",
"Are you sure you want to revoke access?": "Are you sure you want to revoke access?",
"Delete app": "Delete app",
"Revision options": "Možnosti revize",
"Share link revoked": "Odkaz na sdílení zrušen",
"Share link copied": "Odkaz pro sdílení zkopírován",
@@ -604,6 +618,8 @@
"{{ groupsCount }} groups with access": "{{ groupsCount }} skupina s přístupem",
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} skupin s přístupem",
"Archived by {{userName}}": "Archivoval {{userName}}",
"Sorry, an error occurred saving the collection": "Omlouváme se, při ukládání sbírky došlo k chybě",
"Add a description": "Přidat popis",
"Share": "Sdílet",
"Overview": "Přehled",
"Recently updated": "Nedávno aktualizováno",
@@ -805,6 +821,8 @@
"Authentication failed this login method was disabled by a workspace admin.": "Ověření se nezdařilo tento způsob přihlášení byl zakázán správcem týmu.",
"The workspace you are trying to join requires an invite before you can create an account.<1></1>Please request an invite from your workspace admin and try again.": "Pracovní prostor, ke kterému se pokoušíte připojit, vyžaduje před vytvořením účtu pozvánku.<1></1> Požádejte správce pracovního prostoru o pozvánku a zkuste to znovu.",
"Sorry, an unknown error occurred.": "Sorry, an unknown error occurred.",
"Choose a workspace": "Choose a workspace",
"Choose an {{ appName }} workspace or login to continue connecting this app": "Choose an {{ appName }} workspace or login to continue connecting this app",
"Login": "Přihlášení",
"Error": "Chyba",
"Failed to load configuration.": "Nepodařilo se načíst konfiguraci.",
@@ -822,6 +840,34 @@
"You signed in with {{ authProviderName }} last time.": "Naposledy jste se přihlásili pomocí {{ authProviderName }}.",
"Or": "Nebo",
"Already have an account? Go to <1>login</1>.": "Máte již účet? <1>Přihlaste se</1>.",
"An error occurred": "An error occurred",
"The OAuth client could not be found, please check the provided client ID": "The OAuth client could not be found, please check the provided client ID",
"The OAuth client could not be loaded, please check the redirect URI is valid": "The OAuth client could not be loaded, please check the redirect URI is valid",
"Required OAuth parameters are missing": "Required OAuth parameters are missing",
"Authorize": "Authorize",
"{{ appName }} wants to access {{ teamName }}": "{{ appName }} wants to access {{ teamName }}",
"By <em>{{ developerName }}</em>": "By <em>{{ developerName }}</em>",
"{{ appName }} will be able to access your account and perform the following actions": "{{ appName }} will be able to access your account and perform the following actions",
"read": "read",
"write": "write",
"read and write": "read and write",
"API keys": "API keys",
"attachments": "attachments",
"collections": "collections",
"comments": "comments",
"documents": "documents",
"events": "events",
"groups": "groups",
"integrations": "integrations",
"notifications": "notifications",
"reactions": "reactions",
"pins": "pins",
"shares": "shares",
"users": "users",
"teams": "teams",
"workspace": "workspace",
"Read all data": "Read all data",
"Write all data": "Write all data",
"Any collection": "Jakákoli sbírka",
"All time": "All time",
"Past day": "Včera",
@@ -837,15 +883,37 @@
"Something went wrong": "Something went wrong",
"Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists",
"No documents found for your search filters.": "Pro zadaný požadavek nebyly nalezeny žádné dokumenty.",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
"API keys have been disabled by an admin for your account": "API keys have been disabled by an admin for your account",
"Application access": "Application access",
"Manage which third-party and internal applications have been granted access to your {{ appName }} account.": "Manage which third-party and internal applications have been granted access to your {{ appName }} account.",
"API": "API",
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
"Application published": "Application published",
"Application updated": "Application updated",
"Client secret rotated": "Client secret rotated",
"Rotate secret": "Rotate secret",
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.": "Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.",
"Displayed to users when authorizing": "Displayed to users when authorizing",
"Developer information shown to users when authorizing": "Developer information shown to users when authorizing",
"Developer name": "Developer name",
"Developer URL": "Developer URL",
"Allow users from other workspaces to authorize this app": "Allow users from other workspaces to authorize this app",
"Credentials": "Credentials",
"OAuth client ID": "OAuth client ID",
"The public identifier for this app": "The public identifier for this app",
"OAuth client secret": "OAuth client secret",
"Store this value securely, do not expose it publicly": "Store this value securely, do not expose it publicly",
"Where users are redirected after authorizing this app": "Where users are redirected after authorizing this app",
"Authorization URL": "Authorization URL",
"Where users are redirected to authorize this app": "Where users are redirected to authorize this app",
"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>.": "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>.",
"by {{ name }}": "by {{ name }}",
"Last used": "Naposledy použito",
"No expiry": "Bez vypršení platnosti",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "Klíč API byl zkopírován do schránky",
"Copied": "Zkopírováno",
"Revoking": "Odvolávání",
"Are you sure you want to revoke the {{ tokenName }} token?": "Skutečně chcete zrušit token {{ tokenName }}?",
"Disconnect integration": "Odebrat integraci",
"Connected": "Připojeno",
@@ -892,7 +960,7 @@
"No people matching your search": "Vašemu vyhledávání neodpovídají žádní lidé",
"No people left to add": "Nezbývají žádní lidé, které by bylo možné přidat",
"Date created": "Datum vytvoření",
"Upload": "Nahrát",
"Crop Image": "Crop Image",
"Crop image": "Crop image",
"Uploading": "Nahrávání",
"How does this work?": "Jak to funguje?",
@@ -907,8 +975,12 @@
"{{ count }} document imported_plural": "{{ count }} dokumentů importováno",
"You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "Můžete importovat soubor zip, který byl dříve exportován z instalace Outline budou importovány sbírky, dokumenty a obrázky. V aplikaci Outline otevřete <em>Export</em> na postranním panelu Nastavení a klikněte na <em>Exportovat data</em>.",
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Přetáhněte soubor zip z možnosti exportu Markdown z {{appName}} nebo kliknutím nahrajte",
"Configure": "Configure",
"Connect": "Připojit",
"Last active": "Poslední aktivita",
"Guest": "Host",
"Never used": "Never used",
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "Are you sure you want to delete the {{ appName }} application? This cannot be undone.",
"Shared by": "Sdíleno uživatelem",
"Date shared": "Datum zveřejnění",
"Last accessed": "Poslední přístup",
@@ -965,6 +1037,7 @@
"Enterprise": "Podnik",
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Rychle přeneste své stávající dokumenty, stránky a soubory z jiných nástrojů a služeb do {{appName}}. Jakékoli HTML, Markdown a textové dokumenty můžete také přetáhnout přímo do sbírky v aplikaci.",
"Recent imports": "Nedávné importy",
"Configure a variety of integrations with third-party services.": "Configure a variety of integrations with third-party services.",
"Could not load members": "Could not load members",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.": "Zde je uveden každý, kdo se přihlásil do {{appName}}. Je možné, že existuje více uživatelů, kteří mají přístup přes {{signinMethods}}, ale ještě se nepřihlásili.",
"Receive a notification whenever a new document is published": "Dostat upozornění, když bude publikován nový obsah",
@@ -993,8 +1066,6 @@
"Unsubscription successful. Your notification settings were updated": "Odhlášení bylo úspěšné. Nastavení oznámení bylo aktualizováno",
"Manage when and where you receive email notifications.": "Spravujte, kdy a kde budete dostávat e-mailová upozornění.",
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "Integrace e-mailu je momentálně zakázána. Nastavte přidružené proměnné parametry a restartujte server, abyste povolili oznámení.",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Vytvořte si osobní API klíče pro ověření pomocí API rozhraní a ovládejte programově\n data vašeho pracovního prostoru. API klíče mají stejná oprávnění jako váš uživatelský účet.\n Další detaily naleznete ve <em>vývojářské dokumentaci</em>.",
"Personal keys": "Osobní klíče",
"Preferences saved": "Nastavení uložena",
"Delete account": "Odstranit účet",
"Manage settings that affect your personal experience.": "Spravujte nastavení, která ovlivňují vaše uživatelské rozhraní.",
@@ -1051,7 +1122,6 @@
"You can create templates to help your team create consistent and accurate documentation.": "Můžete vytvářet šablony, které vašemu týmu pomohou vytvořit konzistentní a přesnou dokumentaci.",
"Alphabetical": "Abecedně",
"There are no templates just yet.": "Zatím nejsou k dispozici žádné šablony.",
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier je platforma, která umožňuje {{appName}} snadnou integraci s tisíci dalších obchodních nástrojů. Automatizujte své pracovní postupy, synchronizujte data a další.",
"A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "Potvrzovací kód byl odeslán na vaši e-mailovou adresu, zadejte jej prosím níže pro trvalé odstranění tohoto pracovního prostoru.",
"Confirmation code": "Potvrzovací kód",
"Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Smazání <1>{{workspaceName}}</1> pracovního prostoru odstraní všechny kolekce, dokumenty, uživatele a související data. Budete okamžitě odhlášeni z {{appName}}.",
@@ -1077,7 +1147,6 @@
"Expires today": "Vyprší dnes",
"Expires tomorrow": "Vyprší zítra",
"Expires {{ date }}": "Vyprší {{ date }}",
"Connect": "Připojit",
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Jejda, pro připojení {{appName}} k vašemu týmu musíte přijmout oprávnění ve Slacku. Zkusit znovu?",
"Something went wrong while authenticating your request. Please try logging in again.": "Při ověřování vašeho požadavku se něco pokazilo. Zkuste se prosím přihlásit znovu.",
"The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.": "Vlastník GitHub účtu byl požádán o instalaci aplikace {{githubAppName}} GitHub. Po schválení budou náhledy zobrazeny pro příslušné odkazy.",
@@ -1089,6 +1158,10 @@
"Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.": "Přidejte ID měření Google Analytics 4, abyste mohli odesílat zobrazení dokumentů a analýzy z pracovního prostoru do svého vlastního účtu Google Analytics.",
"Measurement ID": "ID měření",
"Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.": "Na hlavním panelu administrátora Google Analytics vytvořte stream „Web“ a zkopírujte ID měření z vygenerovaného fragmentu kódu k instalaci.",
"Whoops, you need to accept the permissions in Linear to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in Linear to connect {{appName}} to your workspace. Try again?",
"Enable previews of Linear issues in documents by connecting a Linear workspace to {appName}.": "Enable previews of Linear issues in documents by connecting a Linear workspace to {appName}.",
"Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?": "Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?",
"The Linear integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Linear integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.": "Nastavte si instalaci Matomo pro odesílání zobrazení a analýz pracovního prostoru do vlastní Matomo instance.",
"Instance URL": "URL instance",
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "URL adresa Matomo instance. Pokud používáte Matomo Cloud, adresa má na konci matomo.cloud/",
@@ -1144,6 +1217,7 @@
"Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.": "Webhooky lze použít k upozornění vaší aplikace, když dojde k události v {{appName}}. Události jsou odesílány jako požadavek https s datovou částí JSON téměř v reálném čase.",
"Inactive": "Neaktivní",
"Create a webhook": "Vytvořit webhook",
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier je platforma, která umožňuje {{appName}} snadnou integraci s tisíci dalších obchodních nástrojů. Automatizujte své pracovní postupy, synchronizujte data a další.",
"Never logged in": "Nikdy nepřihlášen",
"Online now": "Online",
"Online {{ timeAgo }}": "Online {{ timeAgo }}",
+84 -10
View File
@@ -120,6 +120,8 @@
"Log out": "Log ud",
"Mark notifications as read": "Markér notifikationer som læst",
"Archive all notifications": "Arkiver alle notifikationer",
"New App": "New App",
"New Application": "New Application",
"Restore revision": "Gendan revision",
"Link copied": "Link kopieret",
"Dark": "Mørk",
@@ -170,8 +172,6 @@
"Deleting": "Sletter",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.",
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
"Type a command or search": "Type a command or search",
"Choose a template": "Choose a template",
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
@@ -308,6 +308,13 @@
"Unknown": "Ukendt",
"Mark all as read": "Marker alle som læst",
"You're all caught up": "Du er helt ajour",
"Icon": "Icon",
"My App": "My App",
"Tagline": "Tagline",
"A short description": "A short description",
"Callback URLs": "Callback URLs",
"Published": "Published",
"Allow this app to be installed by other workspaces": "Allow this app to be installed by other workspaces",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
@@ -395,7 +402,6 @@
"Star document": "Star document",
"Template created, go ahead and customize it": "Skabelon oprettet. Gør med den hvad du vil",
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Oprettelse af en skabelon fra <em>{{titleWithDefault}}</em> er en ikke-destruktiv handling en kopi af dokumentet vil blive lavet til en skabelon, der kan bruges som udgangspunkt for nye dokumenter.",
"Published": "Published",
"Enable other members to use the template immediately": "Enable other members to use the template immediately",
"Location": "Location",
"Admins can manage the workspace and access billing.": "Admins can manage the workspace and access billing.",
@@ -512,14 +518,17 @@
"Unsubscribed from document": "Unsubscribed from document",
"Unsubscribed from collection": "Unsubscribed from collection",
"Account": "Account",
"API Keys": "API Keys",
"API & Apps": "API & Apps",
"Details": "Details",
"Security": "Security",
"Features": "Features",
"Members": "Members",
"Groups": "Groups",
"API Keys": "API Keys",
"Applications": "Applications",
"Shared Links": "Shared Links",
"Import": "Import",
"Install": "Install",
"Integrations": "Integrations",
"Revoke token": "Revoke token",
"Revoke": "Revoke",
@@ -538,6 +547,7 @@
"Document options": "Document options",
"Choose a collection": "Choose a collection",
"Subscription inherited from collection": "Subscription inherited from collection",
"Apply template": "Apply template",
"Enable embeds": "Enable embeds",
"Export options": "Export options",
"Group members": "Gruppemedlemmer",
@@ -551,6 +561,10 @@
"New child document": "New child document",
"Save in workspace": "Save in workspace",
"Notification settings": "Notification settings",
"Revoke {{ appName }}": "Revoke {{ appName }}",
"Revoking": "Revoking",
"Are you sure you want to revoke access?": "Are you sure you want to revoke access?",
"Delete app": "Delete app",
"Revision options": "Revision options",
"Share link revoked": "Share link revoked",
"Share link copied": "Share link copied",
@@ -604,6 +618,8 @@
"{{ groupsCount }} groups with access": "{{ groupsCount }} group with access",
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access",
"Archived by {{userName}}": "Archived by {{userName}}",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
"Share": "Del",
"Overview": "Overview",
"Recently updated": "Recently updated",
@@ -805,6 +821,8 @@
"Authentication failed this login method was disabled by a workspace admin.": "Authentication failed this login method was disabled by a workspace admin.",
"The workspace you are trying to join requires an invite before you can create an account.<1></1>Please request an invite from your workspace admin and try again.": "The workspace you are trying to join requires an invite before you can create an account.<1></1>Please request an invite from your workspace admin and try again.",
"Sorry, an unknown error occurred.": "Sorry, an unknown error occurred.",
"Choose a workspace": "Choose a workspace",
"Choose an {{ appName }} workspace or login to continue connecting this app": "Choose an {{ appName }} workspace or login to continue connecting this app",
"Login": "Login",
"Error": "Error",
"Failed to load configuration.": "Failed to load configuration.",
@@ -822,6 +840,34 @@
"You signed in with {{ authProviderName }} last time.": "You signed in with {{ authProviderName }} last time.",
"Or": "Or",
"Already have an account? Go to <1>login</1>.": "Already have an account? Go to <1>login</1>.",
"An error occurred": "An error occurred",
"The OAuth client could not be found, please check the provided client ID": "The OAuth client could not be found, please check the provided client ID",
"The OAuth client could not be loaded, please check the redirect URI is valid": "The OAuth client could not be loaded, please check the redirect URI is valid",
"Required OAuth parameters are missing": "Required OAuth parameters are missing",
"Authorize": "Authorize",
"{{ appName }} wants to access {{ teamName }}": "{{ appName }} wants to access {{ teamName }}",
"By <em>{{ developerName }}</em>": "By <em>{{ developerName }}</em>",
"{{ appName }} will be able to access your account and perform the following actions": "{{ appName }} will be able to access your account and perform the following actions",
"read": "read",
"write": "write",
"read and write": "read and write",
"API keys": "API keys",
"attachments": "attachments",
"collections": "collections",
"comments": "comments",
"documents": "documents",
"events": "events",
"groups": "groups",
"integrations": "integrations",
"notifications": "notifications",
"reactions": "reactions",
"pins": "pins",
"shares": "shares",
"users": "users",
"teams": "teams",
"workspace": "workspace",
"Read all data": "Read all data",
"Write all data": "Write all data",
"Any collection": "Any collection",
"All time": "All time",
"Past day": "Past day",
@@ -837,15 +883,37 @@
"Something went wrong": "Something went wrong",
"Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists",
"No documents found for your search filters.": "No documents found for your search filters.",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
"API keys have been disabled by an admin for your account": "API keys have been disabled by an admin for your account",
"Application access": "Application access",
"Manage which third-party and internal applications have been granted access to your {{ appName }} account.": "Manage which third-party and internal applications have been granted access to your {{ appName }} account.",
"API": "API",
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
"Application published": "Application published",
"Application updated": "Application updated",
"Client secret rotated": "Client secret rotated",
"Rotate secret": "Rotate secret",
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.": "Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.",
"Displayed to users when authorizing": "Displayed to users when authorizing",
"Developer information shown to users when authorizing": "Developer information shown to users when authorizing",
"Developer name": "Developer name",
"Developer URL": "Developer URL",
"Allow users from other workspaces to authorize this app": "Allow users from other workspaces to authorize this app",
"Credentials": "Credentials",
"OAuth client ID": "OAuth client ID",
"The public identifier for this app": "The public identifier for this app",
"OAuth client secret": "OAuth client secret",
"Store this value securely, do not expose it publicly": "Store this value securely, do not expose it publicly",
"Where users are redirected after authorizing this app": "Where users are redirected after authorizing this app",
"Authorization URL": "Authorization URL",
"Where users are redirected to authorize this app": "Where users are redirected to authorize this app",
"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>.": "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>.",
"by {{ name }}": "by {{ name }}",
"Last used": "Last used",
"No expiry": "No expiry",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "API key copied to clipboard",
"Copied": "Copied",
"Revoking": "Revoking",
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
"Disconnect integration": "Disconnect integration",
"Connected": "Connected",
@@ -892,7 +960,7 @@
"No people matching your search": "No people matching your search",
"No people left to add": "No people left to add",
"Date created": "Date created",
"Upload": "Upload",
"Crop Image": "Crop Image",
"Crop image": "Crop image",
"Uploading": "Uploading",
"How does this work?": "How does this work?",
@@ -907,8 +975,12 @@
"{{ count }} document imported_plural": "{{ count }} documents imported",
"You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload",
"Configure": "Configure",
"Connect": "Connect",
"Last active": "Last active",
"Guest": "Guest",
"Never used": "Never used",
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "Are you sure you want to delete the {{ appName }} application? This cannot be undone.",
"Shared by": "Shared by",
"Date shared": "Date shared",
"Last accessed": "Last accessed",
@@ -965,6 +1037,7 @@
"Enterprise": "Enterprise",
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
"Recent imports": "Recent imports",
"Configure a variety of integrations with third-party services.": "Configure a variety of integrations with third-party services.",
"Could not load members": "Could not load members",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.": "Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
@@ -993,8 +1066,6 @@
"Unsubscription successful. Your notification settings were updated": "Unsubscription successful. Your notification settings were updated",
"Manage when and where you receive email notifications.": "Manage when and where you receive email notifications.",
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
"Personal keys": "Personal keys",
"Preferences saved": "Preferences saved",
"Delete account": "Delete account",
"Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.",
@@ -1051,7 +1122,6 @@
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
"Alphabetical": "Alphabetical",
"There are no templates just yet.": "There are no templates just yet.",
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.",
"A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.",
"Confirmation code": "Confirmation code",
"Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.",
@@ -1077,7 +1147,6 @@
"Expires today": "Expires today",
"Expires tomorrow": "Expires tomorrow",
"Expires {{ date }}": "Expires {{ date }}",
"Connect": "Connect",
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?",
"Something went wrong while authenticating your request. Please try logging in again.": "Something went wrong while authenticating your request. Please try logging in again.",
"The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.": "The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.",
@@ -1089,6 +1158,10 @@
"Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.": "Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.",
"Measurement ID": "Measurement ID",
"Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.": "Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.",
"Whoops, you need to accept the permissions in Linear to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in Linear to connect {{appName}} to your workspace. Try again?",
"Enable previews of Linear issues in documents by connecting a Linear workspace to {appName}.": "Enable previews of Linear issues in documents by connecting a Linear workspace to {appName}.",
"Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?": "Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?",
"The Linear integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Linear integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.": "Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.",
"Instance URL": "Instance URL",
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/",
@@ -1144,6 +1217,7 @@
"Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.": "Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.",
"Inactive": "Inactive",
"Create a webhook": "Create a webhook",
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.",
"Never logged in": "Never logged in",
"Online now": "Online now",
"Online {{ timeAgo }}": "Online {{ timeAgo }}",

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