Compare commits

...

51 Commits

Author SHA1 Message Date
Tom Moor 2e16496450 wip 2024-08-10 21:50:33 -04:00
Tom Moor d28e23dd8e fix: Public shared docs not correctly using cache 2024-08-10 20:59:33 -04:00
Tom Moor d8145ac370 fix: Image and video resize calculation 2024-08-10 19:38:00 -04:00
Tom Moor fbc4a7fcbd fix: Prevent attachment key modification 2024-08-10 18:27:30 -04:00
Tom Moor 04ecf14cc8 fix: Flash of scrolled document on nested shared links 2024-08-10 16:57:21 -04:00
Tom Moor 4eae1f1db3 fix: Copied internal links in shared documents are incorrect (#7368)
* Add internalUrlBase option for toJSON

* Return correct internal urls for shared data

* test
2024-08-10 09:36:03 -07:00
Tom Moor fd4ab0077d fix: Protect against 'position out of range' 2024-08-10 08:43:31 -04:00
Tom Moor d6c074102b Remove paranoid deletion from GroupUser (#7356) 2024-08-10 05:16:31 -07:00
Tom Moor 2beab0c274 fix: Mobile toolbar can display on printed docs depending on browser size
closes #7367
2024-08-10 08:02:40 -04:00
Tom Moor 4f35b8ea0d chore: 411 -> 387 lint warnings 2024-08-09 16:11:35 +01:00
Tom Moor e4cbf0a34a fix: Pasting into placeholder should replace placeholder
fix: Improved click behavior for placeholders

closes #7346
2024-08-08 21:56:30 +01:00
Tom Moor d79ce99629 fix: Enter in code block in list exits code block
closes #7363
2024-08-08 21:31:03 +01:00
Apoorv Mishra 8bf488de0b Fetch collections upon initial load (#7358)
* fix: don't hide sidebar if collections are not loaded, loading drafts should be enough

* fix: preload collections

* fix: remove duplicate API call
2024-08-08 12:52:20 -07:00
Tom Moor d420319b28 fix: readTemplate permission false if team unmutable 2024-08-06 22:05:22 +01:00
Tom Moor 413bcfa7de chore: Port zodIconType 2024-08-05 21:33:25 +01:00
dependabot[bot] 363f1fffca chore(deps): bump pg from 8.11.5 to 8.12.0 (#7351)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.11.5 to 8.12.0.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.12.0/packages/pg)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 09:53:17 -07:00
dependabot[bot] 3e7b61c9d7 chore(deps-dev): bump eslint-plugin-react from 7.34.3 to 7.35.0 (#7349)
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.34.3 to 7.35.0.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.34.3...v7.35.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  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>
2024-08-05 09:47:22 -07:00
dependabot[bot] ac0488a4d6 chore(deps): bump prosemirror-view from 1.33.8 to 1.33.9 (#7347)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.33.8 to 1.33.9.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.33.8...1.33.9)

---
updated-dependencies:
- dependency-name: prosemirror-view
  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>
2024-08-05 09:46:14 -07:00
Hemachandar 41af3a107e chore: Remove emoji column from documents and revisions (#7144)
* chore: Remove emoji column from documents and revisions

* fix: Incorrect icon color on collections in share menu

* Update types.ts

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2024-08-05 01:08:20 -07:00
Tom Moor 964ba78d75 fix: Incorrect icon color on collections in share menu 2024-08-04 22:31:47 +01:00
Apoorv Mishra 340109d9a3 Make share dialog scrollable (#7344)
* fix: make share dialog scrollable

* fix: rename

* fix: mobile

* fix: useMaxHeight margin calculation

* cleanup

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-08-04 08:59:54 -07:00
Tom Moor 6c430dc747 fix: Do not parse response stream twice on 502 error 2024-08-02 20:03:33 +01:00
Tom Moor 93f12d8846 Shift-Ctrl-c added as editor shortcut for toggling code blocks 2024-08-02 17:39:37 +01:00
dependabot[bot] a93655bf6e chore(deps-dev): bump rollup-plugin-webpack-stats from 0.2.4 to 0.4.1 (#7322)
Bumps [rollup-plugin-webpack-stats](https://github.com/relative-ci/rollup-plugin-webpack-stats) from 0.2.4 to 0.4.1.
- [Release notes](https://github.com/relative-ci/rollup-plugin-webpack-stats/releases)
- [Commits](https://github.com/relative-ci/rollup-plugin-webpack-stats/compare/v0.2.4...v0.4.1)

---
updated-dependencies:
- dependency-name: rollup-plugin-webpack-stats
  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>
2024-08-02 01:32:37 -07:00
Tom Moor e2b4fa456b chore: Improve debugging for BadGatewayError
closes #7307
2024-08-01 22:19:08 +01:00
Tom Moor cd04c4a8bf Improve buildAttachment construction in tests 2024-08-01 22:01:49 +01:00
Tom Moor bf7fb8aa68 fix: User avatar not correct cleaned up, closes #7337 2024-08-01 22:01:22 +01:00
Tom Moor 08a6376947 fix: Improve sanitization on file keys 2024-08-01 20:24:46 +01:00
Tom Moor a120427943 fix: Image download timeouts importing document should not exceed overall request timeout 2024-08-01 09:58:44 +01:00
Translate-O-Tron 59e97eba2b New Crowdin updates (#7284)
* fix: New Spanish translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New French translations from Crowdin [ci skip]
2024-08-01 01:52:23 -07:00
dependabot[bot] 80b59b1174 chore(deps): bump prosemirror-schema-list from 1.3.0 to 1.4.1 (#7324)
Bumps [prosemirror-schema-list](https://github.com/prosemirror/prosemirror-schema-list) from 1.3.0 to 1.4.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-schema-list/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-schema-list/compare/1.3.0...1.4.1)

---
updated-dependencies:
- dependency-name: prosemirror-schema-list
  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>
2024-07-31 06:49:17 -07:00
dependabot[bot] 6a17e8deec chore(deps): bump datadog-metrics from 0.11.1 to 0.11.2 (#7323)
Bumps [datadog-metrics](https://github.com/dbader/node-datadog-metrics) from 0.11.1 to 0.11.2.
- [Release notes](https://github.com/dbader/node-datadog-metrics/releases)
- [Commits](https://github.com/dbader/node-datadog-metrics/compare/v0.11.1...v0.11.2)

---
updated-dependencies:
- dependency-name: datadog-metrics
  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>
2024-07-31 03:22:57 -07:00
dependabot[bot] cd0aba119b chore(deps): bump prosemirror-commands from 1.5.2 to 1.6.0 (#7325)
Bumps [prosemirror-commands](https://github.com/prosemirror/prosemirror-commands) from 1.5.2 to 1.6.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-commands/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-commands/compare/1.5.2...1.6.0)

---
updated-dependencies:
- dependency-name: prosemirror-commands
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 03:22:45 -07:00
dependabot[bot] eca17ec63d chore(deps-dev): bump @types/react-color from 3.0.10 to 3.0.12 (#7326)
Bumps [@types/react-color](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-color) from 3.0.10 to 3.0.12.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-color)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 03:22:23 -07:00
Brian Krausz e164c4e7ca Fix icon naming (#7327) 2024-07-30 02:38:15 -04:00
Tom Moor bead9ae79a fix: replaceImagesWithAttachments ran twice during documents.import 2024-07-29 23:50:37 +01:00
Tom Moor 336e424b8b docs 2024-07-27 16:10:09 -04:00
Tom Moor 0bb993634a fix: Allow starring drafts from document lists 2024-07-27 15:48:32 -04:00
Tom Moor 2f26e76b1e chore: Add transactions to stars mutations 2024-07-27 15:47:23 -04:00
Tom Moor 93a89eeef3 chore: Add transaction to integrations.update 2024-07-27 15:41:55 -04:00
Tom Moor 6e6a5014af chore: Add transactions to groups mutations 2024-07-27 15:37:45 -04:00
Tom Moor 3da1945bea perf: Optimize common path in presentDocument to not include JSON parsing 2024-07-27 15:12:11 -04:00
Hemachandar c2fbb31e77 Workspace templates (#7150)
* feat: Workspace templates

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-07-27 08:38:16 -07:00
Hemachandar 4c999d00d2 fix: use all properties from zip when importing a collection (#7318)
* fix: use all properties from zip when importing a collection

* use shared defaults
2024-07-27 08:31:00 -07:00
Tom Moor 738449a7d0 fix: Catch Iframely non-json response correctly in lib.
closes #7306
2024-07-27 09:49:56 -04:00
Tom Moor ae80128396 chore: aws-sdk upgrade 2024-07-27 09:47:59 -04:00
Tom Moor 1da5ac0bfe chore: Remove no longer required resolutions 2024-07-27 09:47:59 -04:00
Apoorv Mishra f56f240d9b Remove trailing spaces from search query (#7314)
* fix: tsquery err

* fix: test
2024-07-26 20:27:56 -07:00
github-actions[bot] 7de0ffb7f7 chore: Auto Compress Images (#7310)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2024-07-26 05:49:18 -07:00
Baboon 0e667c5d3d add Dropbox embeddings support (#7299)
* add Dropbox embedder support

* Update embeds.ts

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2024-07-26 05:47:35 -07:00
Tom Moor 465c935879 fix: Remove .at usage, closes #7305 2024-07-26 08:47:09 -04:00
147 changed files with 3285 additions and 1915 deletions
+4
View File
@@ -189,6 +189,10 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
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)
+10 -10
View File
@@ -72,7 +72,7 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
@@ -98,10 +98,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: CollectionSection,
icon: <PadlockIcon />,
visible: ({ stores, activeCollectionId }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, stores, activeCollectionId }) => {
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -143,7 +143,7 @@ export const starCollection = createAction({
section: CollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -153,7 +153,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -170,7 +170,7 @@ export const unstarCollection = createAction({
section: CollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -180,7 +180,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -196,13 +196,13 @@ export const deleteCollection = createAction({
section: CollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, stores, t }) => {
perform: ({ activeCollectionId, t }) => {
if (!activeCollectionId) {
return;
}
@@ -230,7 +230,7 @@ export const createTemplate = createAction({
section: CollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
+87 -9
View File
@@ -37,10 +37,10 @@ import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection, TrashSection } from "~/actions/sections";
import env from "~/env";
@@ -223,7 +223,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId) {
if (document?.collectionId || document?.template) {
await document.save(undefined, {
publish: true,
});
@@ -688,7 +688,7 @@ export const createTemplateFromDocument = createAction({
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update
stores.policies.abilities(activeCollectionId).updateDocument
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -714,11 +714,11 @@ export const openRandomDocument = createAction({
const documentPaths = stores.collections.pathsToDocuments.filter(
(path) => path.type === "document" && path.id !== activeDocumentId
);
const documentPath =
const randomPath =
documentPaths[Math.round(Math.random() * documentPaths.length)];
if (documentPath) {
history.push(documentPath.url);
if (randomPath) {
history.push(randomPath.url);
}
},
});
@@ -735,11 +735,50 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -763,6 +802,44 @@ export const moveDocument = createAction({
},
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Archive document",
@@ -997,7 +1074,8 @@ export const rootDocumentActions = [
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
moveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
+2 -2
View File
@@ -17,7 +17,7 @@ export const restoreRevision = createAction({
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId, stores }) =>
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId }) => {
event?.preventDefault();
@@ -47,7 +47,7 @@ export const copyLinkToRevision = createAction({
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, stores, t }) => {
perform: async ({ activeDocumentId, t }) => {
if (!activeDocumentId) {
return;
}
+3 -3
View File
@@ -18,7 +18,7 @@ export const inviteUser = createAction({
icon: <PlusIcon />,
keywords: "team member workspace user",
section: UserSection,
visible: ({ stores }) =>
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
@@ -40,7 +40,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
})}…`,
analyticsName: "Update user role",
section: UserSection,
visible: ({ stores }) => {
visible: () => {
const can = stores.policies.abilities(user.id);
return UserRoleHelper.isRoleHigher(role, user.role)
@@ -70,7 +70,7 @@ export const deleteUserActionFactory = (userId: string) =>
keywords: "leave",
dangerous: true,
section: UserSection,
visible: ({ stores }) => stores.policies.abilities(userId).delete,
visible: () => stores.policies.abilities(userId).delete,
perform: ({ t }) => {
const user = stores.users.get(userId);
if (!user) {
+2 -2
View File
@@ -69,8 +69,8 @@ function CommandBarItem(
) : (
""
)}
{sc.split("+").map((s) => (
<Key key={s}>{s}</Key>
{sc.split("+").map((key) => (
<Key key={key}>{key}</Key>
))}
</React.Fragment>
))}
+6 -7
View File
@@ -76,8 +76,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const canStar = !document.isArchived && !document.isTemplate;
return (
<DocumentLink
@@ -111,11 +110,6 @@ function DocumentListItem(
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
content={t("Only visible to you")}
@@ -125,6 +119,11 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
@@ -1,49 +0,0 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize();
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [document, history, t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Trans
defaults="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."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
+1
View File
@@ -57,6 +57,7 @@ function ResolvedCollectionIcon({
size={size}
initial={collection.initial}
className={className}
forceColor={inputColor ? true : false}
/>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { LocationDescriptor, LocationDescriptorObject } from "history";
import * as React from "react";
import { match, NavLink, Route } from "react-router-dom";
import { type match, NavLink, Route } from "react-router-dom";
type Props = React.ComponentProps<typeof NavLink> & {
children?: (
+6 -6
View File
@@ -59,9 +59,9 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
const { active, over } = event;
if (over && active.id !== over.id) {
setItems((items) => {
const activePos = items.indexOf(active.id as string);
const overPos = items.indexOf(over.id as string);
setItems((existing) => {
const activePos = existing.indexOf(active.id as string);
const overPos = existing.indexOf(over.id as string);
const overIndex = pins[overPos]?.index || null;
const nextIndex = pins[overPos + 1]?.index || null;
@@ -78,10 +78,10 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
? fractionalIndex(prevIndex, overIndex)
: fractionalIndex(overIndex, nextIndex),
})
.catch(() => setItems(items));
.catch(() => setItems(existing));
// Update the order in state immediately
return arrayMove(items, activePos, overPos);
return arrayMove(existing, activePos, overPos);
});
}
},
@@ -112,7 +112,7 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
<AnimatePresence initial={false}>
{items.map((documentId) => {
const document = documents.get(documentId);
const pin = pins.find((pin) => pin.documentId === documentId);
const pin = pins.find((p) => p.documentId === documentId);
return document ? (
<DocumentCard
@@ -1,13 +1,15 @@
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import { GroupIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import InputSelectPermission from "~/components/InputSelectPermission";
import Scrollable from "~/components/Scrollable";
import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
@@ -23,32 +25,43 @@ type Props = {
invitedInSession: string[];
};
function CollectionMemberList({ collection, invitedInSession }: Props) {
export function AccessControlList({ collection, invitedInSession }: Props) {
const { memberships, groupMemberships } = useStores();
const can = usePolicy(collection);
const { t } = useTranslation();
const theme = useTheme();
const collectionId = collection.id;
const { request: fetchMemberships } = useRequest(
const { request: fetchMemberships, data: membershipData } = useRequest(
React.useCallback(
() => memberships.fetchAll({ id: collectionId }),
[memberships, collectionId]
)
);
const { request: fetchGroupMemberships } = useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ id: collectionId }),
[groupMemberships, collectionId]
)
);
const { request: fetchGroupMemberships, data: groupMembershipData } =
useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ id: collectionId }),
[groupMemberships, collectionId]
)
);
React.useEffect(() => {
void fetchMemberships();
void fetchGroupMemberships();
}, [fetchMemberships, fetchGroupMemberships]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const { maxHeight, calcMaxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
});
React.useEffect(() => {
calcMaxHeight();
});
const permissions = React.useMemo(
() =>
[
@@ -73,8 +86,43 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
[t]
);
if (!membershipData || !groupMembershipData) {
return null;
}
return (
<>
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{ maxHeight }}
>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
style={{ margin: 0 }}
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}}
disabled={!can.update}
value={collection?.permission}
labelHidden
nude
/>
</div>
}
/>
{groupMemberships
.inCollection(collection.id)
.sort((a, b) =>
@@ -173,8 +221,11 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
}
/>
))}
</>
</ScrollableContainer>
);
}
export default observer(CollectionMemberList);
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
`;
@@ -1,18 +1,15 @@
import { isEmail } from "class-validator";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { BackIcon, UserIcon } from "outline-icons";
import { BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import User from "~/models/User";
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
import InputSelectPermission from "~/components/InputSelectPermission";
import NudeButton from "~/components/NudeButton";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
@@ -22,15 +19,14 @@ import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types";
import { Permission } from "~/types";
import { collectionPath, urlify } from "~/utils/routeHelpers";
import { Wrapper, presence } from "../components";
import { CopyLinkButton } from "../components/CopyLinkButton";
import { ListItem } from "../components/ListItem";
import { PermissionAction } from "../components/PermissionAction";
import { SearchInput } from "../components/SearchInput";
import { Suggestions } from "../components/Suggestions";
import CollectionMemberList from "./CollectionMemberList";
import { AccessControlList } from "./AccessControlList";
type Props = {
/** The collection to share. */
@@ -42,7 +38,6 @@ type Props = {
};
function SharePopover({ collection, visible, onRequestClose }: Props) {
const theme = useTheme();
const team = useCurrentTeam();
const { groupMemberships, users, groups, memberships } = useStores();
const { t } = useTranslation();
@@ -367,35 +362,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
)}
<div style={{ display: picker ? "none" : "block" }}>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
style={{ margin: 0 }}
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}}
disabled={!can.update}
value={collection?.permission}
labelHidden
nude
/>
</div>
}
/>
<CollectionMemberList
<AccessControlList
collection={collection}
invitedInSession={invitedInSession}
/>
@@ -0,0 +1,266 @@
import { observer } from "mobx-react";
import { MoreIcon, QuestionMarkIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { Pagination } from "@shared/constants";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Share from "~/models/Share";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Avatar from "../../Avatar";
import { AvatarSize } from "../../Avatar/Avatar";
import CollectionIcon from "../../Icons/CollectionIcon";
import Tooltip from "../../Tooltip";
import { Separator } from "../components";
import { ListItem } from "../components/ListItem";
import DocumentMemberList from "./DocumentMemberList";
import PublicAccess from "./PublicAccess";
type Props = {
/** The document being shared. */
document: Document;
/** List of users that have been invited during the current editing session */
invitedInSession: string[];
/** The existing share model, if any. */
share: Share | null | undefined;
/** The existing share parent model, if any. */
sharedParent: Share | null | undefined;
/** Callback fired when the popover requests to be closed. */
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
};
export const AccessControlList = observer(
({
document,
invitedInSession,
share,
sharedParent,
onRequestClose,
visible,
}: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
const { userMemberships } = useStores();
const collectionSharingDisabled = document.collection?.sharing === false;
const team = useCurrentTeam();
const can = usePolicy(document);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const { maxHeight, calcMaxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
margin: 24,
});
const {
loading: loadingDocumentMembers,
request: fetchDocumentMembers,
data,
} = useRequest(
React.useCallback(
() =>
userMemberships.fetchDocumentMemberships({
id: document.id,
limit: Pagination.defaultLimit,
}),
[userMemberships, document.id]
)
);
React.useEffect(() => {
void fetchDocumentMembers();
}, [fetchDocumentMembers]);
React.useEffect(() => {
calcMaxHeight();
});
if (loadingDocumentMembers) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return (
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{ maxHeight }}
>
{collection ? (
<>
{collection.permission ? (
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<AccessTooltip>
{collection?.permission === CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
</AccessTooltip>
}
/>
) : usersInCollection ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<ListItem
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} showBorder={false} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : (
<>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
actions={
<AccessTooltip
content={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
}
/>
</>
)}
{team.sharing && can.share && !collectionSharingDisabled && visible && (
<>
{document.members.length ? <Separator /> : null}
<PublicAccess
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={onRequestClose}
/>
</>
)}
</ScrollableContainer>
);
}
);
const AccessTooltip = ({
children,
content,
}: {
children?: React.ReactNode;
content?: string;
}) => {
const { t } = useTranslation();
return (
<Flex align="center" gap={2}>
<Text type="secondary" size="small">
{children}
</Text>
<Tooltip content={content ?? t("Access inherited from collection")}>
<QuestionMarkIcon size={18} />
</Tooltip>
</Flex>
);
};
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.SVG ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.SVG ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores();
const { request } = useRequest(() =>
memberships.fetchPage({ limit: 1, id: collection!.id })
);
React.useEffect(() => {
if (collection && !collection.permission) {
void request();
}
}, [collection]);
return collection
? collection.permission
? true
: users.inCollection(collection.id).length > 1
: false;
}
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
`;
@@ -4,13 +4,10 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { Pagination } from "@shared/constants";
import Document from "~/models/Document";
import UserMembership from "~/models/UserMembership";
import LoadingIndicator from "~/components/LoadingIndicator";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { homePath } from "~/utils/routeHelpers";
import MemberListItem from "./DocumentMemberListItem";
@@ -26,27 +23,12 @@ type Props = {
function DocumentMembersList({ document, invitedInSession }: Props) {
const { userMemberships } = useStores();
const user = useCurrentUser();
const history = useHistory();
const can = usePolicy(document);
const { t } = useTranslation();
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
useRequest(
React.useCallback(
() =>
userMemberships.fetchDocumentMemberships({
id: document.id,
limit: Pagination.defaultLimit,
}),
[userMemberships, document.id]
)
);
React.useEffect(() => {
void fetchDocumentMembers();
}, [fetchDocumentMembers]);
const handleRemoveUser = React.useCallback(
async (item) => {
try {
@@ -105,10 +87,6 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
[document.members, invitedInSession]
);
if (loadingDocumentMembers) {
return <LoadingIndicator />;
}
return (
<>
{members.map((item) => (
@@ -1,167 +0,0 @@
import { observer } from "mobx-react";
import { MoreIcon, QuestionMarkIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useCurrentUser from "~/hooks/useCurrentUser";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Avatar from "../../Avatar";
import { AvatarSize } from "../../Avatar/Avatar";
import CollectionIcon from "../../Icons/CollectionIcon";
import Tooltip from "../../Tooltip";
import { ListItem } from "../components/ListItem";
type Props = {
/** The document being shared. */
document: Document;
children: React.ReactNode;
};
export const OtherAccess = observer(({ document, children }: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
return (
<>
{collection ? (
<>
{collection.permission ? (
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<AccessTooltip>
{collection?.permission === CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
</AccessTooltip>
}
/>
) : usersInCollection ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<ListItem
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
{children}
</>
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} showBorder={false} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{children}
</>
) : (
<>
{children}
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
actions={
<AccessTooltip
content={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
}
/>
</>
)}
</>
);
});
const AccessTooltip = ({
children,
content,
}: {
children?: React.ReactNode;
content?: string;
}) => {
const { t } = useTranslation();
return (
<Flex align="center" gap={2}>
<Text type="secondary" size="small">
{children}
</Text>
<Tooltip content={content ?? t("Access inherited from collection")}>
<QuestionMarkIcon size={18} />
</Tooltip>
</Flex>
);
};
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.SVG ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.SVG ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores();
const { request } = useRequest(() =>
memberships.fetchPage({ limit: 1, id: collection!.id })
);
React.useEffect(() => {
if (collection && !collection.permission) {
void request();
}
}, [collection]);
return collection
? collection.permission
? true
: users.inCollection(collection.id).length > 1
: false;
}
@@ -22,14 +22,12 @@ import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import { Permission } from "~/types";
import { documentPath, urlify } from "~/utils/routeHelpers";
import { Separator, Wrapper, presence } from "../components";
import { Wrapper, presence } from "../components";
import { CopyLinkButton } from "../components/CopyLinkButton";
import { PermissionAction } from "../components/PermissionAction";
import { SearchInput } from "../components/SearchInput";
import { Suggestions } from "../components/Suggestions";
import DocumentMembersList from "./DocumentMemberList";
import { OtherAccess } from "./OtherAccess";
import PublicAccess from "./PublicAccess";
import { AccessControlList } from "./AccessControlList";
type Props = {
/** The document to share. */
@@ -60,7 +58,6 @@ function SharePopover({
const [picker, showPicker, hidePicker] = useBoolean();
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
const collectionSharingDisabled = document.collection?.sharing === false;
const [permission, setPermission] = React.useState<DocumentPermission>(
DocumentPermission.Read
);
@@ -341,24 +338,14 @@ function SharePopover({
)}
<div style={{ display: picker ? "none" : "block" }}>
<OtherAccess document={document}>
<DocumentMembersList
document={document}
invitedInSession={invitedInSession}
/>
</OtherAccess>
{team.sharing && can.share && !collectionSharingDisabled && visible && (
<>
{document.members.length ? <Separator /> : null}
<PublicAccess
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={onRequestClose}
/>
</>
)}
<AccessControlList
document={document}
invitedInSession={invitedInSession}
share={share}
sharedParent={sharedParent}
visible={visible}
onRequestClose={onRequestClose}
/>
</div>
</Wrapper>
);
@@ -68,7 +68,7 @@ export const Suggestions = observer(
const user = useCurrentUser();
const theme = useTheme();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const maxHeight = useMaxHeight({
const { maxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
});
+3 -2
View File
@@ -34,7 +34,7 @@ import TrashLink from "./components/TrashLink";
function AppSidebar() {
const { t } = useTranslation();
const { documents, ui } = useStores();
const { documents, ui, collections } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team);
@@ -42,8 +42,9 @@ function AppSidebar() {
React.useEffect(() => {
if (!user.isViewer) {
void documents.fetchDrafts();
void collections.fetchAll();
}
}, [documents, user.isViewer]);
}, [documents, collections, user.isViewer]);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
@@ -100,7 +100,7 @@ const CollectionLink: React.FC<Props> = ({
),
});
} else {
await documents.move(id, collection.id);
await documents.move({ documentId: id, collectionId: collection.id });
if (!expanded) {
onDisclosureClick();
@@ -116,8 +116,8 @@ const CollectionLink: React.FC<Props> = ({
}),
});
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
setIsEditing(isEditing);
const handleTitleEditing = React.useCallback((value: boolean) => {
setIsEditing(value);
}, []);
const handlePrefetch = React.useCallback(() => {
@@ -52,7 +52,11 @@ function CollectionLinkChildren({
if (!collection) {
return;
}
void documents.move(item.id, collection.id, undefined, 0);
void documents.move({
documentId: item.id,
collectionId: collection.id,
index: 0,
});
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
@@ -53,7 +53,6 @@ function Collections() {
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
fetch={collections.fetchPage}
options={params}
aria-label={t("Collections")}
items={collections.orderedData}
@@ -128,13 +128,13 @@ function InnerDocumentLink(
}, [prefetchDocument, node]);
const handleTitleChange = React.useCallback(
async (title: string) => {
async (value: string) => {
if (!document) {
return;
}
await documents.update({
id: document.id,
title,
title: value,
});
},
[documents, document]
@@ -187,7 +187,11 @@ function InnerDocumentLink(
if (!collection) {
return;
}
await documents.move(item.id, collection.id, node.id);
await documents.move({
documentId: item.id,
collectionId: collection.id,
parentDocumentId: node.id,
});
setExpanded(true);
},
canDrop: (item, monitor) =>
@@ -249,11 +253,21 @@ function InnerDocumentLink(
}
if (expanded) {
void documents.move(item.id, collection.id, node.id, 0);
void documents.move({
documentId: item.id,
collectionId: collection.id,
parentDocumentId: node.id,
index: 0,
});
return;
}
void documents.move(item.id, collection.id, parentId, index + 1);
void documents.move({
documentId: item.id,
collectionId: collection.id,
parentDocumentId: parentId,
index: index + 1,
});
},
collect: (monitor) => ({
isOverReorder: monitor.isOver(),
+22
View File
@@ -0,0 +1,22 @@
import React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
const Label = ({ icon, value }: { icon: React.ReactNode; value: string }) => (
<Flex align="center" gap={4}>
<IconWrapper>{icon}</IconWrapper>
{value}
</Flex>
);
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
overflow: hidden;
flex-shrink: 0;
`;
export default Label;
@@ -0,0 +1,113 @@
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AvatarSize } from "~/components/Avatar/Avatar";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect, { Option } from "~/components/InputSelect";
import TeamLogo from "~/components/TeamLogo";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Label from "./Label";
type Props = {
/** Collection ID to select by default. */
defaultCollectionId?: string | null;
/** Callback to be called when a collection is selected. */
onSelect: (collectionId: string | null) => void;
};
const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
const { loading, error } = useRequest(
React.useCallback(async () => {
if (!collections.isLoaded) {
await collections.fetchAll({
limit: 100,
});
}
}, [collections])
);
const workspaceOption: Option | null = can.createTemplate
? {
label: (
<Label
icon={<TeamLogo model={team} size={AvatarSize.Toast} />}
value={t("Workspace")}
/>
),
value: "workspace",
}
: null;
const collectionOptions: Option[] = React.useMemo(
() =>
collections.orderedData.reduce<Option[]>((memo, collection) => {
const canCollection = policies.abilities(collection.id);
if (canCollection.createDocument) {
memo.push({
label: (
<Label
icon={<CollectionIcon collection={collection} />}
value={collection.name}
/>
),
value: collection.id,
});
}
return memo;
}, []),
[collections.orderedData, policies]
);
const options: Option[] = workspaceOption
? collectionOptions.length
? [
workspaceOption,
...collectionOptions.map((opt, idx) => {
if (idx !== 0) {
return opt;
}
opt.divider = true;
return opt;
}),
]
: [workspaceOption]
: collectionOptions;
const handleSelection = React.useCallback(
(value: string | null) => {
onSelect(value === "workspace" ? null : value);
},
[onSelect]
);
if (error) {
toast.error(t("Collections could not be loaded, please reload the app"));
}
if (loading || !options.length) {
return null;
}
return (
<InputSelect
value={defaultCollectionId ?? "workspace"}
options={options}
onChange={handleSelection}
ariaLabel={t("Location")}
label={t("Location")}
/>
);
};
export default observer(SelectLocation);
+82
View File
@@ -0,0 +1,82 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Switch from "~/components/Switch";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const [publish, setPublish] = React.useState(true);
const [collectionId, setCollectionId] = React.useState(
document.collectionId ?? null
);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
collectionId,
publish,
});
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId, publish]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Flex column gap={12}>
<div>
<Trans
defaults="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."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</div>
<SelectLocation
defaultCollectionId={collectionId}
onSelect={setCollectionId}
/>
<Switch
name="publish"
label={t("Published")}
note={t("Enable other members to use the template immediately")}
checked={publish}
onChange={handlePublishChange}
/>
</Flex>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
@@ -304,6 +304,10 @@ const MobileWrapper = styled.div`
height: 100px;
background-color: ${s("menuBackground")};
}
@media print {
display: none;
}
`;
const Wrapper = styled.div<WrapperProps>`
+1 -1
View File
@@ -123,7 +123,7 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
name: "code_block",
title: dictionary.codeBlock,
icon: <CodeIcon />,
shortcut: "^ ⇧ \\",
shortcut: "^ ⇧ c",
keywords: "script",
},
{
+11 -7
View File
@@ -39,18 +39,22 @@ export default function useEditorClickHandlers({ shareId }: Params) {
return;
}
// If we're navigating to a share link from a non-share link then open it in a new tab
if (shareId && navigateTo.startsWith("/s/")) {
window.open(href, "_blank");
return;
}
// If we're navigating to an internal document link then prepend the
// share route to the URL so that the document is loaded in context
if (shareId && navigateTo.includes("/doc/")) {
if (
shareId &&
navigateTo.includes("/doc/") &&
!navigateTo.includes(shareId)
) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
}
// If we're navigating to a share link from a non-share link then open it in a new tab
if (!shareId && navigateTo.startsWith("/s/")) {
window.open(href, "_blank");
return;
}
if (!isModKey(event) && !event.shiftKey) {
history.push(navigateTo);
} else {
+7 -7
View File
@@ -1,5 +1,4 @@
import * as React from "react";
import useMobile from "./useMobile";
import useWindowSize from "./useWindowSize";
const useMaxHeight = ({
@@ -15,12 +14,11 @@ const useMaxHeight = ({
margin?: number;
}) => {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(10);
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useLayoutEffect(() => {
if (!isMobile && elementRef?.current) {
const mxHeight = (windowHeight / 100) * maxViewportPercentage;
const calcMaxHeight = React.useCallback(() => {
if (elementRef?.current) {
const mxHeight = (windowHeight / 100) * maxViewportPercentage - margin;
setMaxHeight(
Math.min(
@@ -35,9 +33,11 @@ const useMaxHeight = ({
} else {
setMaxHeight(0);
}
}, [elementRef, windowHeight, margin, isMobile, maxViewportPercentage]);
}, [elementRef, windowHeight, margin, maxViewportPercentage]);
return maxHeight;
React.useLayoutEffect(calcMaxHeight, [calcMaxHeight]);
return { maxHeight, calcMaxHeight };
};
export default useMaxHeight;
+1 -1
View File
@@ -139,7 +139,7 @@ const useSettingsConfig = () => {
name: t("Templates"),
path: settingsPath("templates"),
component: Templates,
enabled: can.update,
enabled: can.readTemplate,
group: t("Workspace"),
icon: ShapesIcon,
},
+16 -3
View File
@@ -1,3 +1,4 @@
import capitalize from "lodash/capitalize";
import { observer } from "mobx-react";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import * as React from "react";
@@ -44,6 +45,7 @@ import {
shareDocument,
copyDocument,
searchInDocument,
moveTemplate,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -124,7 +126,11 @@ function DocumentMenu({
}
) => {
await document.restore(options);
toast.success(t("Document restored"));
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
},
[t, document]
);
@@ -228,7 +234,10 @@ function DocumentMenu({
{
type: "button",
title: t("Restore"),
visible: (!!collection && can.restore) || can.unarchive,
visible:
((document.isWorkspaceTemplate || !!collection) &&
can.restore) ||
can.unarchive,
onClick: (ev) => handleRestore(ev),
icon: <RestoreIcon />,
},
@@ -236,7 +245,10 @@ function DocumentMenu({
type: "submenu",
title: t("Restore"),
visible:
!collection && !!can.restore && restoreItems.length !== 0,
!document.isWorkspaceTemplate &&
!collection &&
!!can.restore &&
restoreItems.length !== 0,
style: {
left: -170,
position: "relative",
@@ -290,6 +302,7 @@ function DocumentMenu({
actionToMenuItem(unpublishDocument, context),
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(moveTemplate, context),
actionToMenuItem(pinDocument, context),
actionToMenuItem(createDocumentFromTemplate, context),
{
+33 -4
View File
@@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Header from "~/components/ContextMenu/Header";
import Template from "~/components/ContextMenu/Template";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import TeamLogo from "~/components/TeamLogo";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -28,7 +28,16 @@ function NewTemplateMenu() {
});
}, [collections]);
const items = React.useMemo(
const workspaceItem: MenuItem | null = can.createTemplate
? {
type: "route",
to: newTemplatePath(),
title: t("Save in workspace"),
icon: <TeamLogo model={team} />,
}
: null;
const collectionItems = React.useMemo(
() =>
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
const can = policies.abilities(collection.id);
@@ -47,7 +56,28 @@ function NewTemplateMenu() {
[collections.orderedData, policies]
);
if (!can.createDocument || items.length === 0) {
const collectionItemsWithHeader: MenuItem[] = React.useMemo(
() =>
collectionItems.length
? [
{ type: "heading", title: t("Choose a collection") },
...collectionItems,
]
: [],
[t, collectionItems]
);
const items = workspaceItem
? collectionItemsWithHeader.length
? [
workspaceItem,
{ type: "separator" } as MenuItem,
...collectionItemsWithHeader,
]
: [workspaceItem]
: collectionItemsWithHeader;
if (items.length === 0) {
return null;
}
@@ -61,7 +91,6 @@ function NewTemplateMenu() {
)}
</MenuButton>
<ContextMenu aria-label={t("New template")} {...menu}>
<Header>{t("Choose a collection")}</Header>
<Template {...menu} items={items} />
</ContextMenu>
</>
+49 -33
View File
@@ -6,11 +6,11 @@ import { MenuButton, useMenuState } from "reakit/Menu";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Separator from "~/components/ContextMenu/Separator";
import Template from "~/components/ContextMenu/Template";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { replaceTitleVariables } from "~/utils/date";
type Props = {
@@ -25,36 +25,56 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const templates = documents.templates;
if (!templates.length) {
const templateToMenuItem = React.useCallback(
(tmpl: Document): MenuItem => ({
type: "button",
title: replaceTitleVariables(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;
if (!items.length) {
return null;
}
const templatesInCollection = templates.filter(
(t) => t.collectionId === document.collectionId
);
const otherTemplates = templates.filter(
(t) => t.collectionId !== document.collectionId
);
const renderTemplate = (template: Document) => (
<MenuItem
key={template.id}
onClick={() => onSelectTemplate(template)}
icon={
template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<DocumentIcon />
)
}
{...menu}
>
{replaceTitleVariables(template.titleWithDefault, user)}
</MenuItem>
);
return (
<>
<MenuButton {...menu}>
@@ -65,11 +85,7 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Templates")}>
{templatesInCollection.map(renderTemplate)}
{otherTemplates.length && templatesInCollection.length ? (
<Separator />
) : undefined}
{otherTemplates.map(renderTemplate)}
<Template {...menu} items={items} />
</ContextMenu>
</>
);
+16 -3
View File
@@ -381,6 +381,11 @@ export default class Document extends ParanoidModel {
return this.collection?.pathToDocument(this.id) ?? [];
}
@computed
get isWorkspaceTemplate() {
return this.template && !this.collectionId;
}
get titleWithDefault(): string {
return this.title || i18n.t("Untitled");
}
@@ -490,7 +495,13 @@ export default class Document extends ParanoidModel {
};
@action
templatize = () => this.store.templatize(this.id);
templatize = ({
collectionId,
publish,
}: {
collectionId: string | null;
publish: boolean;
}) => this.store.templatize({ id: this.id, collectionId, publish });
@action
save = async (
@@ -517,8 +528,10 @@ export default class Document extends ParanoidModel {
}
};
move = (collectionId: string, parentDocumentId?: string | undefined) =>
this.store.move(this.id, collectionId, parentDocumentId);
move = (options: {
collectionId?: string | null;
parentDocumentId?: string;
}) => this.store.move({ documentId: this.id, ...options });
duplicate = (options?: {
title?: string;
+17 -10
View File
@@ -30,7 +30,7 @@ import Loading from "./components/Loading";
const EMPTY_OBJECT = {};
type Response = {
document: DocumentModel;
document?: DocumentModel;
team?: PublicTeam;
sharedTree?: NavigationNode | undefined;
};
@@ -124,6 +124,11 @@ function SharedDocumentScene(props: Props) {
React.useEffect(() => {
async function fetchData() {
try {
setResponse((state) => ({
...state,
document: undefined,
}));
const res = await documents.fetchWithSharedTree(documentSlug, {
shareId,
});
@@ -177,21 +182,23 @@ function SharedDocumentScene(props: Props) {
<TeamContext.Provider value={response.team}>
<ThemeProvider theme={theme}>
<Layout
title={response.document.title}
title={response.document?.title}
sidebar={
response.sharedTree?.children.length ? (
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
) : undefined
}
>
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
{response.document && (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
)}
</Layout>
</ThemeProvider>
</TeamContext.Provider>
+1 -1
View File
@@ -25,7 +25,7 @@ export default function Contents({ headings }: Props) {
});
React.useEffect(() => {
let activeId = headings.at(0)?.id;
let activeId = headings[0]?.id;
for (let key = 0; key < headings.length; key++) {
const heading = headings[key];
@@ -177,7 +177,7 @@ function DataLoader({ match, children }: Props) {
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!can.update && isEditRoute) {
if (!can.update && isEditRoute && !document.template) {
history.push(document.url);
return;
}
+6 -9
View File
@@ -507,12 +507,7 @@ class DocumentScene extends React.Component<Props> {
onSave={this.onSave}
headings={this.headings}
/>
<MeasuredContainer
as={Main}
name="document"
fullWidth={document.fullWidth}
tocPosition={tocPos}
>
<Main fullWidth={document.fullWidth} tocPosition={tocPos}>
<React.Suspense
fallback={
<EditorContainer
@@ -542,7 +537,9 @@ class DocumentScene extends React.Component<Props> {
<Contents headings={this.headings} />
</ContentsContainer>
)}
<EditorContainer
<MeasuredContainer
name="document"
as={EditorContainer}
docFullWidth={document.fullWidth}
showContents={showContents}
tocPosition={tocPos}
@@ -595,11 +592,11 @@ class DocumentScene extends React.Component<Props> {
</>
)}
</Editor>
</EditorContainer>
</MeasuredContainer>
</>
)}
</React.Suspense>
</MeasuredContainer>
</Main>
{isShare &&
!parseDomain(window.location.origin).custom &&
!auth.user && (
+6 -3
View File
@@ -116,8 +116,9 @@ function DocumentHeader({
activeDocumentId: document?.id,
});
const { isDeleted, isTemplate } = document;
const can = usePolicy(document);
const { isDeleted, isTemplate } = document;
const isTemplateEditable = can.update && isTemplate;
const canToggleEmbeds = team?.documentEmbeds;
const isShare = !!shareId;
const showContents =
@@ -276,7 +277,7 @@ function DocumentHeader({
<ShareButton document={document} />
</Action>
)}
{(isEditing || isTemplate) && (
{(isEditing || isTemplateEditable) && (
<Action>
<Tooltip
content={t("Save")}
@@ -351,7 +352,9 @@ function DocumentHeader({
hideOnActionDisabled
hideIcon
>
{document.collectionId ? t("Publish") : `${t("Publish")}`}
{document.collectionId || document.isWorkspaceTemplate
? t("Publish")
: `${t("Publish")}`}
</Button>
</Action>
)}
+13 -4
View File
@@ -8,7 +8,11 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { collectionPath, documentPath } from "~/utils/routeHelpers";
import {
collectionPath,
documentPath,
settingsPath,
} from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -21,7 +25,8 @@ function DocumentDelete({ document, onSubmit }: Props) {
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const canArchive = !document.isDraft && !document.isArchived;
const canArchive =
!document.isDraft && !document.isArchived && !document.template;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -50,8 +55,12 @@ function DocumentDelete({ document, onSubmit }: Props) {
}
}
// otherwise, redirect to the collection home
history.push(collectionPath(collection?.path || "/"));
// If template, redirect to the template settings.
// Otherwise redirect to the collection (or) home.
const path = document.template
? settingsPath("templates")
: collectionPath(collection?.path || "/");
history.push(path);
}
onSubmit();
+2 -2
View File
@@ -68,9 +68,9 @@ function DocumentMove({ document }: Props) {
const collectionId = selectedPath.collectionId as string;
if (type === "document") {
await document.move(collectionId, parentDocumentId);
await document.move({ collectionId, parentDocumentId });
} else {
await document.move(collectionId);
await document.move({ collectionId });
}
toast.success(t("Document moved"));
+1 -1
View File
@@ -50,7 +50,7 @@ function DocumentPublish({ document }: Props) {
// Also move it under if selected path corresponds to another doc
if (type === "document") {
await document.move(collectionId, parentDocumentId);
await document.move({ collectionId, parentDocumentId });
}
document.collectionId = collectionId;
+4 -1
View File
@@ -48,7 +48,10 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
setIsSaving(true);
try {
await documents.move(item.id, collection.id);
await documents.move({
documentId: item.id,
collectionId: collection.id,
});
toast.message(t("Document moved"));
onSubmit();
} catch (err) {
+40 -22
View File
@@ -457,7 +457,15 @@ export default class DocumentsStore extends Store<Document> {
};
@action
templatize = async (id: string): Promise<Document | null | undefined> => {
templatize = async ({
id,
collectionId,
publish,
}: {
id: string;
collectionId: string | null;
publish: boolean;
}): Promise<Document | null | undefined> => {
const doc: Document | null | undefined = this.data.get(id);
invariant(doc, "Document should exist");
@@ -467,6 +475,8 @@ export default class DocumentsStore extends Store<Document> {
const res = await client.post("/documents.templatize", {
id,
collectionId,
publish,
});
invariant(res?.data, "Document not available");
this.addPolicies(res.policies);
@@ -500,17 +510,22 @@ export default class DocumentsStore extends Store<Document> {
this.data.get(id) || this.getByUrl(id);
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
if (doc && policy && !options.force) {
if (!options.shareId) {
return {
document: doc,
};
} else if (this.sharedCache.has(options.shareId)) {
return {
document: doc,
...this.sharedCache.get(options.shareId),
};
}
if (doc && policy && !options.shareId && !options.force) {
return {
document: doc,
};
}
if (
doc &&
options.shareId &&
!options.force &&
this.sharedCache.has(options.shareId)
) {
return {
document: doc,
...this.sharedCache.get(options.shareId),
};
}
const res = await client.post("/documents.info", {
@@ -546,12 +561,17 @@ export default class DocumentsStore extends Store<Document> {
};
@action
move = async (
documentId: string,
collectionId: string,
parentDocumentId?: string | null,
index?: number | null
) => {
move = async ({
documentId,
collectionId,
parentDocumentId,
index,
}: {
documentId: string;
collectionId?: string | null;
parentDocumentId?: string | null;
index?: number | null;
}) => {
this.movingDocumentId = documentId;
try {
@@ -789,7 +809,7 @@ export default class DocumentsStore extends Store<Document> {
unstar = (document: Document) => {
const star = this.rootStore.stars.orderedData.find(
(star) => star.documentId === document.id
(s) => s.documentId === document.id
);
return star?.delete();
};
@@ -802,9 +822,7 @@ export default class DocumentsStore extends Store<Document> {
unsubscribe = (userId: string, document: Document) => {
const subscription = this.rootStore.subscriptions.orderedData.find(
(subscription) =>
subscription.documentId === document.id &&
subscription.userId === userId
(s) => s.documentId === document.id && s.userId === userId
);
return subscription?.delete();
+13 -6
View File
@@ -142,6 +142,19 @@ class ApiClient {
throw new AuthorizationError();
}
if (response.status === 502) {
const text = await response.text();
const err = new BadGatewayError(text);
Logger.error("BadGatewayError", err, {
url: urlToFetch,
requestTime: Math.round(timeEnd - timeStart),
responseText: text,
responseHeaders: Object.fromEntries(response.headers.entries()),
});
throw err;
}
// Handle failed responses
const error: {
message?: string;
@@ -193,12 +206,6 @@ class ApiClient {
);
}
if (response.status === 502) {
throw new BadGatewayError(
`Request to ${urlToFetch} failed in ${timeEnd - timeStart}ms.`
);
}
const err = new RequestError(`Error ${response.status}`);
Logger.error("Request failed", err, {
...error,
+2 -2
View File
@@ -18,8 +18,8 @@ export default function download(
const D = document,
a = D.createElement("a"),
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'a' implicitly has an 'any' type.
z = function (a) {
return String(a);
z = function (o) {
return String(o);
},
// @ts-expect-error ts-migrate(2339) FIXME: Property 'MozBlob' does not exist on type 'Window ... Remove this comment to see the full error message
B = self.Blob || self.MozBlob || self.WebKitBlob || z,
+2 -2
View File
@@ -24,8 +24,8 @@ export function initI18n(defaultLanguage = "en_US") {
backend: {
// this must match the path defined in routes. It's the path that the
// frontend UI code will hit to load missing translations.
loadPath: (languages: string[]) =>
`/locales/${unicodeBCP47toCLDR(languages[0])}.json`,
loadPath: (locale: string[]) =>
`/locales/${unicodeBCP47toCLDR(locale[0])}.json`,
},
interpolation: {
escapeValue: false,
+4 -4
View File
@@ -33,18 +33,18 @@ export function detectLanguage() {
* if running in the desktop shell.
*
* @param locale The locale to change to, in CLDR format (en_US)
* @param i18n The i18n instance to use
* @param instance The i18n instance to use
*/
export async function changeLanguage(
locale: string | null | undefined,
i18n: i18n
instance: i18n
) {
// Languages are stored in en_US format in the database, however the
// frontend translation framework (i18next) expects en-US
const localeBCP = locale ? unicodeCLDRtoBCP47(locale) : undefined;
if (localeBCP && i18n.languages?.[0] !== localeBCP) {
await i18n.changeLanguage(localeBCP);
if (localeBCP && instance.languages?.[0] !== localeBCP) {
await instance.changeLanguage(localeBCP);
await Desktop.bridge?.setSpellCheckerLanguages(["en-US", localeBCP]);
}
}
+4 -2
View File
@@ -81,8 +81,10 @@ export function updateDocumentPath(oldUrl: string, document: Document): string {
);
}
export function newTemplatePath(collectionId: string) {
return settingsPath("templates") + `/new?collectionId=${collectionId}`;
export function newTemplatePath(collectionId?: string) {
return collectionId
? settingsPath("templates") + `/new?collectionId=${collectionId}`
: `${settingsPath("templates")}/new`;
}
export function newDocumentPath(
+3 -3
View File
@@ -16,14 +16,14 @@ export const flattenTree = (root: NavigationNode) => {
};
export const ancestors = (node: NavigationNode | null) => {
const ancestors: NavigationNode[] = [];
const nodes: NavigationNode[] = [];
if (node) {
while (node.parent !== null) {
ancestors.unshift(node.parent as NavigationNode);
nodes.unshift(node.parent as NavigationNode);
node = node.parent as NavigationNode;
}
}
return ancestors;
return nodes;
};
export const descendants = (node: NavigationNode, depth = 0) => {
+10 -11
View File
@@ -8,10 +8,10 @@ Outline's frontend is a React application compiled with [Vite](https://vitejs.de
```
app
├── components - React components reusable across scenes
├── embeds - Embed definitions that represent rich interactive embeds in the editor
├── hooks - Reusable React hooks
├── actions - Reusable actions such as navigating, opening, creating entities
├── components - React components reusable across scenes
├── editor - React components specific to the editor
├── hooks - Reusable React hooks
├── menus - Context menus, often appear in multiple places in the UI
├── models - State models using MobX observables
├── routes - Route definitions, note that chunks are async loaded with suspense
@@ -30,15 +30,14 @@ Interested in more documentation on the API routes? Check out the [API documenta
```
server
├── api - All API routes are contained within here
── middlewares - Koa middlewares specific to the API
── auth - Authentication logic
│ └── providers - Authentication providers export passport.js strategies and config
├── commands - We are gradually moving to the command pattern for new write logic
├── routes - All API routes are contained within here
── api - API routes
│ └── auth - Authentication routes
├── commands - Complex commands that perform actions across multiple models
├── config - Database configuration
├── emails - Transactional email templates
│ └── templates - Classes that define each possible email template
├── middlewares - Koa middlewares
├── middlewares - Shared Koa middlewares
├── migrations - Database migrations
├── models - Sequelize models
├── onboarding - Markdown templates for onboarding documents
@@ -60,10 +59,10 @@ small utilities.
```
shared
├── components - Shared React components that are used in both the frontend and backend
├── editor - The text editor, based on Prosemirror
├── i18n - Internationalization configuration
│ └── locales - Language specific translation files
├── styles - Styles, colors and other global aesthetics
── utils - Shared utility methods
└── constants - Shared constants
── utils - Shared utility methods
```
-14
View File
@@ -1,14 +0,0 @@
# Authentication Providers
A new auth provider can be added with the addition of a plugin with a koa router
as the default export in /server/auth/[provider].ts and (optionally) a matching
logo in `/client/Icon.tsx` that will appear on the sign-in button.
Auth providers generally use [Passport](http://www.passportjs.org/) strategies,
although they can use any custom logic if needed. See the `google` auth provider
for the cleanest example of what is required some rules:
- The strategy name _must_ be lowercase
- The strategy _must_ call the `accountProvisioner` command in the verify callback
- The auth file _must_ export a `config` object with `name` and `enabled` keys
- The auth file _must_ have a default export with a koa-router
+13 -15
View File
@@ -47,11 +47,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.609.0",
"@aws-sdk/lib-storage": "3.609.0",
"@aws-sdk/s3-presigned-post": "3.609.0",
"@aws-sdk/s3-request-presigner": "3.609.0",
"@aws-sdk/signature-v4-crt": "^3.609.0",
"@aws-sdk/client-s3": "3.616.0",
"@aws-sdk/lib-storage": "3.616.0",
"@aws-sdk/s3-presigned-post": "3.616.0",
"@aws-sdk/s3-request-presigner": "3.616.0",
"@aws-sdk/signature-v4-crt": "^3.616.0",
"@babel/core": "^7.24.7",
"@babel/plugin-proposal-decorators": "^7.24.7",
"@babel/plugin-transform-class-properties": "^7.24.7",
@@ -104,7 +104,7 @@
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.37.0",
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.11.1",
"datadog-metrics": "^0.11.2",
"date-fns": "^3.6.0",
"dd-trace": "^3.58.0",
"diff": "^5.2.0",
@@ -167,13 +167,13 @@
"passport-oauth2": "^1.8.0",
"passport-slack-oauth2": "^1.2.0",
"patch-package": "^7.0.2",
"pg": "^8.11.5",
"pg": "^8.12.0",
"pg-tsquery": "^8.4.2",
"pluralize": "^8.0.0",
"png-chunks-extract": "^1.0.0",
"polished": "^4.3.1",
"prosemirror-codemark": "^0.4.2",
"prosemirror-commands": "^1.5.2",
"prosemirror-commands": "^1.6.0",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
@@ -181,11 +181,11 @@
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.0",
"prosemirror-model": "^1.22.1",
"prosemirror-schema-list": "^1.3.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.3.7",
"prosemirror-transform": "^1.9.0",
"prosemirror-view": "^1.33.8",
"prosemirror-view": "^1.33.9",
"query-string": "^7.1.3",
"randomstring": "1.3.0",
"rate-limiter-flexible": "^2.4.2",
@@ -298,7 +298,7 @@
"@types/randomstring": "^1.3.0",
"@types/react": "^17.0.34",
"@types/react-avatar-editor": "^13.0.2",
"@types/react-color": "^3.0.10",
"@types/react-color": "^3.0.12",
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
@@ -340,7 +340,7 @@
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.34.3",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.3",
"i18next-parser": "^7.9.0",
@@ -353,7 +353,7 @@
"prettier": "^2.8.8",
"react-refresh": "^0.14.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^0.2.4",
"rollup-plugin-webpack-stats": "^0.4.1",
"terser": "^5.31.1",
"typescript": "^5.4.5",
"vite-plugin-static-copy": "^0.17.0",
@@ -364,9 +364,7 @@
"d3": "^7.0.0",
"debug": "4.3.4",
"node-fetch": "^2.6.12",
"dot-prop": "^5.2.0",
"js-yaml": "^3.14.1",
"jpeg-js": "0.4.4",
"qs": "6.9.7",
"rollup": "^4.5.1"
},
+1 -1
View File
@@ -22,7 +22,7 @@ class Iframely {
env.IFRAMELY_API_KEY
}`
);
return res.json();
return await res.json();
} catch (err) {
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
return;
@@ -284,7 +284,7 @@ function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) {
group !== "comment" ||
team.getPreference(TeamPreference.Commenting)
)
.map(([group, events], i) => (
.map(([group, groupEvents], i) => (
<GroupWrapper key={i} isMobile={isMobile}>
<EventCheckbox
label={t(`All {{ groupName }} events`, {
@@ -293,7 +293,7 @@ function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) {
value={group}
/>
<FieldSet disabled={selectedGroups.includes(group)}>
{events.map((event) => (
{groupEvents.map((event) => (
<EventCheckbox label={event} value={event} key={event} />
))}
</FieldSet>
@@ -72,7 +72,7 @@ import presentWebhook, { WebhookPayload } from "../presenters/webhook";
import presentWebhookSubscription from "../presenters/webhookSubscription";
function assertUnreachable(event: never) {
Logger.warn(`DeliverWebhookTask did not handle ${(event as any).name}`);
Logger.warn(`DeliverWebhookTask did not handle ${(event as Event).name}`);
}
type Props = {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+2 -2
View File
@@ -44,14 +44,14 @@ export default class PersistenceExtension implements Extension {
},
});
let ydoc;
if (document.state) {
const ydoc = new Y.Doc();
ydoc = new Y.Doc();
Logger.info("database", `Document ${documentId} is in database state`);
Y.applyUpdate(ydoc, document.state);
return ydoc;
}
let ydoc;
if (document.content) {
Logger.info(
"database",
+12 -1
View File
@@ -4,15 +4,25 @@ import { AttachmentPreset } from "@shared/types";
import { Attachment, Event, User } from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import FileStorage from "@server/storage/files";
import { RequestInit } from "@server/utils/fetch";
type BaseProps = {
/** The ID of the attachment */
id?: string;
/** The name of the attachment */
name: string;
/** The user who is creating the attachment */
user: User;
/** The source of the attachment */
source?: "import";
/** The preset to use for the attachment */
preset: AttachmentPreset;
/** The IP address of the user creating the attachment, if available. */
ip?: string;
/** The database transaction to use for the creation */
transaction?: Transaction;
/** Options to pass to fetch when downloading the attachment */
fetchOptions?: RequestInit;
};
type UrlProps = BaseProps & {
@@ -34,6 +44,7 @@ export default async function attachmentCreator({
preset,
ip,
transaction,
fetchOptions,
...rest
}: Props): Promise<Attachment | undefined> {
const acl = AttachmentHelper.presetToAcl(preset);
@@ -48,7 +59,7 @@ export default async function attachmentCreator({
if ("url" in rest) {
const { url } = rest;
const res = await FileStorage.storeFromUrl(url, key, acl);
const res = await FileStorage.storeFromUrl(url, key, acl, fetchOptions);
if (!res) {
return;
+6 -15
View File
@@ -99,21 +99,15 @@ export default async function documentCreator({
importId,
sourceMetadata,
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
emoji: templateDocument ? templateDocument.emoji : icon,
icon: templateDocument ? templateDocument.emoji : icon,
icon: templateDocument ? templateDocument.icon : icon,
color: templateDocument ? templateDocument.color : color,
title: TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.title : title,
user
),
text: await TextHelper.replaceImagesWithAttachments(
TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.text : text,
user
),
user,
ip,
transaction
text: TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.text : text,
user
),
content: templateDocument
? ProsemirrorHelper.replaceTemplateVariables(
@@ -148,14 +142,11 @@ export default async function documentCreator({
);
if (publish) {
if (!collectionId) {
if (!collectionId && !template) {
throw new Error("Collection ID is required to publish");
}
await document.publish(user, collectionId, {
silent: true,
transaction,
});
await document.publish(user, collectionId, { silent: true, transaction });
if (document.title) {
await Event.create(
{
@@ -25,7 +25,6 @@ describe("documentDuplicator", () => {
expect(response).toHaveLength(1);
expect(response[0].title).toEqual(original.title);
expect(response[0].text).toEqual(original.text);
expect(response[0].emoji).toEqual(original.emoji);
expect(response[0].icon).toEqual(original.icon);
expect(response[0].color).toEqual(original.color);
expect(response[0].publishedAt).toBeInstanceOf(Date);
@@ -53,7 +52,6 @@ describe("documentDuplicator", () => {
expect(response).toHaveLength(1);
expect(response[0].title).toEqual("New title");
expect(response[0].text).toEqual(original.text);
expect(response[0].emoji).toEqual(original.icon);
expect(response[0].icon).toEqual(original.icon);
expect(response[0].color).toEqual(original.color);
expect(response[0].publishedAt).toBeInstanceOf(Date);
@@ -109,7 +107,6 @@ describe("documentDuplicator", () => {
expect(response).toHaveLength(1);
expect(response[0].title).toEqual(original.title);
expect(response[0].text).toEqual(original.text);
expect(response[0].emoji).toEqual(original.emoji);
expect(response[0].icon).toEqual(original.icon);
expect(response[0].color).toEqual(original.color);
expect(response[0].publishedAt).toBeNull();
+2 -2
View File
@@ -45,7 +45,7 @@ export default async function documentDuplicator({
const duplicated = await documentCreator({
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
icon: document.icon ?? document.emoji,
icon: document.icon,
color: document.color,
template: document.template,
title: title ?? document.title,
@@ -79,7 +79,7 @@ export default async function documentDuplicator({
for (const childDocument of childDocuments) {
const duplicatedChildDocument = await documentCreator({
parentDocumentId: duplicated.id,
icon: childDocument.icon ?? childDocument.emoji,
icon: childDocument.icon,
color: childDocument.color,
title: childDocument.title,
text: childDocument.text,
-5
View File
@@ -1,6 +1,5 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { ValidationError } from "@server/errors";
import { traceFunction } from "@server/logging/tracing";
import {
User,
@@ -58,10 +57,6 @@ async function documentMover({
}
if (document.template) {
if (!document.collectionId) {
throw ValidationError("Templates must be in a collection");
}
document.collectionId = collectionId;
document.parentDocumentId = null;
document.lastModifiedById = user.id;
+1 -2
View File
@@ -69,7 +69,6 @@ export default async function documentUpdater({
document.title = title.trim();
}
if (icon !== undefined) {
document.emoji = icon;
document.icon = icon;
}
if (color !== undefined) {
@@ -106,7 +105,7 @@ export default async function documentUpdater({
ip,
};
if (publish && cId) {
if (publish && (document.template || cId)) {
if (!document.collectionId) {
document.collectionId = cId;
}
+14 -25
View File
@@ -1,6 +1,5 @@
import { Transaction } from "sequelize";
import { Event, Star, User } from "@server/models";
import { sequelize } from "@server/storage/database";
type Props = {
/** The user destroying the star */
@@ -24,31 +23,21 @@ export default async function starDestroyer({
user,
star,
ip,
transaction: t,
transaction,
}: Props): Promise<Star> {
const transaction = t || (await sequelize.transaction());
try {
await star.destroy({ transaction });
await Event.create(
{
name: "stars.delete",
modelId: star.id,
teamId: user.teamId,
actorId: user.id,
userId: star.userId,
documentId: star.documentId,
ip,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
await star.destroy({ transaction });
await Event.create(
{
name: "stars.delete",
modelId: star.id,
teamId: user.teamId,
actorId: user.id,
userId: star.userId,
documentId: star.documentId,
ip,
},
{ transaction }
);
return star;
}
+18 -24
View File
@@ -1,5 +1,5 @@
import { Transaction } from "sequelize";
import { Event, Star, User } from "@server/models";
import { sequelize } from "@server/storage/database";
type Props = {
/** The user updating the star */
@@ -10,6 +10,8 @@ type Props = {
index: string;
/** The IP address of the user creating the star */
ip: string;
/** Optional existing transaction */
transaction?: Transaction;
};
/**
@@ -24,30 +26,22 @@ export default async function starUpdater({
star,
index,
ip,
transaction,
}: Props): Promise<Star> {
const transaction = await sequelize.transaction();
try {
star.index = index;
await star.save({ transaction });
await Event.create(
{
name: "stars.update",
modelId: star.id,
userId: star.userId,
teamId: user.teamId,
actorId: user.id,
documentId: star.documentId,
ip,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
star.index = index;
await star.save({ transaction });
await Event.create(
{
name: "stars.update",
modelId: star.id,
userId: star.userId,
teamId: user.teamId,
actorId: user.id,
documentId: star.documentId,
ip,
},
{ transaction }
);
return star;
}
+7
View File
@@ -348,6 +348,13 @@ export class Environment {
*/
public SMTP_SECURE = this.toBoolean(environment.SMTP_SECURE ?? "true");
/**
* Dropbox app key for embedding Dropbox files
*/
@Public
@IsOptional()
public DROPBOX_APP_KEY = this.toOptionalString(environment.DROPBOX_APP_KEY);
/**
* Sentry DSN for capturing errors and frontend performance.
*/
@@ -0,0 +1,38 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("documents", "emoji", { transaction });
await queryInterface.removeColumn("revisions", "emoji", { transaction });
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"documents",
"emoji",
{
type: Sequelize.STRING,
allowNull: true,
},
{
transaction,
}
);
await queryInterface.addColumn(
"revisions",
"emoji",
{
type: Sequelize.STRING,
allowNull: true,
},
{
transaction,
}
);
});
},
};
@@ -0,0 +1,14 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface) {
await queryInterface.sequelize.query(
`DELETE FROM group_users WHERE "deletedAt" IS NOT NULL`
);
},
async down () {
// No reverting possible
}
};
+10 -1
View File
@@ -16,8 +16,10 @@ import {
Table,
DataType,
IsNumeric,
BeforeCreate,
BeforeUpdate,
} from "sequelize-typescript";
import { ValidationError } from "@server/errors";
import FileStorage from "@server/storage/files";
import { ValidateKey } from "@server/validation";
import Document from "./Document";
@@ -140,12 +142,19 @@ class Attachment extends IdModel<
// hooks
@BeforeUpdate
@BeforeCreate
static async sanitizeKey(model: Attachment) {
model.key = ValidateKey.sanitize(model.key);
return model;
}
@BeforeUpdate
static async preventKeyChange(model: Attachment) {
if (model.changed("key")) {
throw ValidationError("Cannot change the key of an attachment");
}
}
@BeforeDestroy
static async deleteAttachmentFromS3(model: Attachment) {
await FileStorage.deleteFile(model.key);
+9 -15
View File
@@ -255,17 +255,6 @@ class Document extends ParanoidModel<
@Column
editorVersion: string;
/**
* An emoji to use as the document icon,
* This is used as fallback (for backward compat) when icon is not set.
*/
@Length({
max: 50,
msg: `Emoji must be 50 characters or less`,
})
@Column
emoji: string | null;
/** An icon to use as the document icon. */
@Length({
max: 50,
@@ -722,6 +711,13 @@ class Document extends ParanoidModel<
return !!(this.importId && this.sourceMetadata?.trial);
}
/**
* Returns whether this document is a template created at the workspace level.
*/
get isWorkspaceTemplate() {
return this.template && !this.collectionId;
}
/**
* Revert the state of the document to match the passed revision.
*
@@ -735,7 +731,6 @@ class Document extends ParanoidModel<
this.content = revision.content;
this.text = revision.text;
this.title = revision.title;
this.emoji = revision.emoji;
this.icon = revision.icon;
this.color = revision.color;
};
@@ -817,7 +812,7 @@ class Document extends ParanoidModel<
publish = async (
user: User,
collectionId: string,
collectionId: string | null | undefined,
options: SaveOptions
): Promise<this> => {
const { transaction } = options;
@@ -832,7 +827,7 @@ class Document extends ParanoidModel<
this.collectionId = collectionId;
}
if (!this.template) {
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
@@ -1078,7 +1073,6 @@ class Document extends ParanoidModel<
id: this.id,
title: this.title,
url: this.url,
emoji: isNil(this.emoji) ? undefined : this.emoji,
icon: isNil(this.icon) ? undefined : this.icon,
color: isNil(this.color) ? undefined : this.color,
children,
+1 -1
View File
@@ -36,7 +36,7 @@ import Fix from "./decorators/Fix";
],
},
}))
@Table({ tableName: "group_users", modelName: "group_user", paranoid: true })
@Table({ tableName: "group_users", modelName: "group_user" })
@Fix
class GroupUser extends Model<
InferAttributes<GroupUser>,
-12
View File
@@ -71,17 +71,6 @@ class Revision extends IdModel<
@Column(DataType.JSONB)
content: ProsemirrorData;
/**
* An emoji to use as the document icon,
* This is used as fallback (for backward compat) when icon is not set.
*/
@Length({
max: 50,
msg: `Emoji must be 50 characters or less`,
})
@Column
emoji: string | null;
/** An icon to use as the document icon. */
@Length({
max: 50,
@@ -138,7 +127,6 @@ class Revision extends IdModel<
return this.build({
title: document.title,
text: document.text,
emoji: document.emoji,
icon: document.icon,
color: document.color,
content: document.content,
+2 -2
View File
@@ -18,13 +18,13 @@ describe("user model", () => {
buildUser({
name: "www.google.com",
})
).rejects.toThrowError();
).rejects.toThrow();
await expect(
buildUser({
name: "My name https://malicious.com",
})
).rejects.toThrowError();
).rejects.toThrow();
await expect(
buildUser({
+1 -1
View File
@@ -670,7 +670,7 @@ class User extends ParanoidModel<
if (attachment) {
await DeleteAttachmentTask.schedule({
attachmentId: attachment.id,
teamId: model.id,
teamId: model.teamId,
});
}
}
+44 -2
View File
@@ -12,11 +12,53 @@ describe("DocumentHelper", () => {
jest.useRealTimers();
});
describe("replaceInternalUrls", () => {
it("should replace internal urls", async () => {
const document = await buildDocument({
text: `[link](/doc/internal-123)`,
});
const result = await DocumentHelper.toJSON(document, {
internalUrlBase: "/s/share-123",
});
expect(result).toEqual({
content: [
{
content: [
{
marks: [
{
attrs: {
href: "/s/share-123/doc/internal-123",
title: null,
},
type: "link",
},
],
text: "link",
type: "text",
},
],
type: "paragraph",
},
],
type: "doc",
});
});
});
describe("toJSON", () => {
it("should return content directly if no transformation required", async () => {
const document = await buildDocument();
const result = await DocumentHelper.toJSON(document);
expect(result === document.content).toBe(true);
});
});
describe("parseMentions", () => {
it("should not parse normal links as mentions", async () => {
const document = await buildDocument({
text: `# Header
[link not mention](http://google.com)`,
});
const result = DocumentHelper.parseMentions(document);
@@ -26,7 +68,7 @@ describe("DocumentHelper", () => {
it("should return an array of mentions", async () => {
const document = await buildDocument({
text: `# Header
@[Alan Kay](mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64) :wink:
More text
+41 -14
View File
@@ -45,7 +45,12 @@ export class DocumentHelper {
* @param document The document or revision to convert
* @returns The document content as a Prosemirror Node
*/
static toProsemirror(document: Document | Revision | Collection) {
static toProsemirror(
document: Document | Revision | Collection | ProsemirrorData
) {
if ("type" in document && document.type === "doc") {
return Node.fromJSON(schema, document);
}
if ("content" in document && document.content) {
return Node.fromJSON(schema, document.content);
}
@@ -72,17 +77,27 @@ export class DocumentHelper {
document: Document | Revision | Collection,
options?: {
/** The team context */
teamId: string;
teamId?: string;
/** Whether to sign attachment urls, and if so for how many seconds is the signature valid */
signedUrls: number;
signedUrls?: number;
/** Marks to remove from the document */
removeMarks?: string[];
/** The base path to use for internal links (will replace /doc/) */
internalUrlBase?: string;
}
): Promise<ProsemirrorData> {
let doc: Node | null;
let json;
if ("content" in document && document.content) {
// Optimized path for documents with content available and no transformation required.
if (
!options?.removeMarks &&
!options?.signedUrls &&
!options?.internalUrlBase
) {
return document.content;
}
doc = Node.fromJSON(schema, document.content);
} else if ("state" in document && document.state) {
const ydoc = new Y.Doc();
@@ -94,7 +109,7 @@ export class DocumentHelper {
doc = parser.parse(document.text);
}
if (doc && options?.signedUrls) {
if (doc && options?.signedUrls && options?.teamId) {
json = await ProsemirrorHelper.signAttachmentUrls(
doc,
options.teamId,
@@ -104,6 +119,13 @@ export class DocumentHelper {
json = doc?.toJSON() ?? {};
}
if (options?.internalUrlBase) {
json = ProsemirrorHelper.replaceInternalUrls(
json,
options.internalUrlBase
);
}
if (options?.removeMarks) {
json = ProsemirrorHelper.removeMarks(json, options.removeMarks);
}
@@ -122,8 +144,8 @@ export class DocumentHelper {
const node = DocumentHelper.toProsemirror(document);
const textSerializers = Object.fromEntries(
Object.entries(schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
.filter(([, n]) => n.spec.toPlainText)
.map(([name, n]) => [name, n.spec.toPlainText])
);
return textBetween(node, 0, node.content.size, textSerializers);
@@ -135,10 +157,12 @@ export class DocumentHelper {
* @param document The document or revision to convert
* @returns The document title and content as a Markdown string
*/
static toMarkdown(document: Document | Revision | Collection) {
static toMarkdown(
document: Document | Revision | Collection | ProsemirrorData
) {
const text = serializer
.serialize(DocumentHelper.toProsemirror(document))
.replace(/\n\\(\n|$)/g, "\n\n")
.replace(/(^|\n)\\(\n|$)/g, "\n\n")
.replace(/“/g, '"')
.replace(/”/g, '"')
.replace(//g, "'")
@@ -149,14 +173,17 @@ export class DocumentHelper {
return text;
}
const icon = document.icon ?? document.emoji;
const iconType = determineIconType(icon);
if (document instanceof Document || document instanceof Revision) {
const iconType = determineIconType(document.icon);
const title = `${iconType === IconType.Emoji ? icon + " " : ""}${
document.title
}`;
const title = `${iconType === IconType.Emoji ? document.icon + " " : ""}${
document.title
}`;
return `# ${title}\n\n${text}`;
return `# ${title}\n\n${text}`;
}
return text;
}
/**
+43 -2
View File
@@ -15,6 +15,7 @@ import light from "@shared/styles/theme";
import { ProsemirrorData } from "@shared/types";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import { isRTL } from "@shared/utils/rtl";
import { isInternalUrl } from "@shared/utils/urls";
import { schema, parser } from "@server/editor";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
@@ -161,7 +162,9 @@ export class ProsemirrorHelper {
* @param marks The mark types to remove
* @returns The content with marks removed
*/
static removeMarks(data: ProsemirrorData, marks: string[]) {
static removeMarks(doc: Node | ProsemirrorData, marks: string[]) {
const json = "toJSON" in doc ? (doc.toJSON() as ProsemirrorData) : doc;
function removeMarksInner(node: ProsemirrorData) {
if (node.marks) {
node.marks = node.marks.filter((mark) => !marks.includes(mark.type));
@@ -171,7 +174,7 @@ export class ProsemirrorHelper {
}
return node;
}
return removeMarksInner(data);
return removeMarksInner(json);
}
/**
@@ -197,6 +200,44 @@ export class ProsemirrorHelper {
return replace(data);
}
static async replaceInternalUrls(
doc: Node | ProsemirrorData,
basePath: string
) {
const json = "toJSON" in doc ? (doc.toJSON() as ProsemirrorData) : doc;
if (basePath.endsWith("/")) {
throw new Error("internalUrlBase must not end with a slash");
}
function replaceUrl(url: string) {
return url.replace(`/doc/`, `${basePath}/doc/`);
}
function replaceInternalUrlsInner(node: ProsemirrorData) {
if (typeof node.attrs?.href === "string") {
node.attrs.href = replaceUrl(node.attrs.href);
} else if (node.marks) {
node.marks.forEach((mark) => {
if (
typeof mark.attrs?.href === "string" &&
isInternalUrl(mark.attrs?.href)
) {
mark.attrs.href = replaceUrl(mark.attrs.href);
}
});
}
if (node.content) {
node.content.forEach(replaceInternalUrlsInner);
}
return node;
}
return replaceInternalUrlsInner(json);
}
/**
* Returns the document as a plain JSON object with attachment URLs signed.
*
@@ -486,6 +486,25 @@ describe("SearchHelper", () => {
);
expect(totalCount).toBe(1);
});
test("should correctly handle removal of trailing spaces", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
userId: user.id,
});
const document = await buildDocument({
teamId: team.id,
userId: user.id,
collectionId: collection.id,
text: "env: some env",
});
document.title = "change";
await document.save();
const { totalCount } = await SearchHelper.searchForUser(user, "env: ");
expect(totalCount).toBe(1);
});
});
describe("#searchTitlesForUser", () => {
+6 -1
View File
@@ -555,7 +555,12 @@ export default class SearchHelper {
}
return (
queryParser()(quotedSearch ? limitedQuery : `${limitedQuery}*`)
queryParser()(
// Although queryParser trims the query, looks like there's a
// bug for certain cases where it removes other characters in addition to
// spaces. Ref: https://github.com/caub/pg-tsquery/issues/27
quotedSearch ? limitedQuery.trim() : `${limitedQuery.trim()}*`
)
// Remove any trailing join characters
.replace(/&$/, "")
);
+7
View File
@@ -9,6 +9,7 @@ import {
unicodeCLDRtoBCP47,
} from "@shared/utils/date";
import attachmentCreator from "@server/commands/attachmentCreator";
import env from "@server/env";
import { trace } from "@server/logging/tracing";
import { Attachment, User } from "@server/models";
import FileStorage from "@server/storage/files";
@@ -95,6 +96,9 @@ export class TextHelper {
) {
let output = markdown;
const images = parseImages(markdown);
const timeoutPerImage = Math.floor(
Math.min(env.REQUEST_TIMEOUT / images.length, 10000)
);
await Promise.all(
images.map(async (image) => {
@@ -112,6 +116,9 @@ export class TextHelper {
user,
ip,
transaction,
fetchOptions: {
timeout: timeoutPerImage,
},
});
if (attachment) {
+41 -4
View File
@@ -29,6 +29,10 @@ allow(User, "read", Document, (actor, document) =>
DocumentPermission.Admin,
]),
and(!!document?.isDraft, actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
can(actor, "readTemplate", actor.team)
),
can(actor, "readDocument", document?.collection)
)
)
@@ -98,7 +102,14 @@ allow(User, "update", Document, (actor, document) =>
]),
or(
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById)
and(!!document?.isDraft && actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
or(
actor.id === document?.createdById,
can(actor, "updateTemplate", actor.team)
)
)
)
)
)
@@ -118,7 +129,14 @@ allow(User, ["manageUsers", "duplicate"], Document, (actor, document) =>
or(
includesMembership(document, [DocumentPermission.Admin]),
can(actor, "updateDocument", document?.collection),
!!document?.isDraft && actor.id === document?.createdById
!!document?.isDraft && actor.id === document?.createdById,
and(
!!document?.isWorkspaceTemplate,
or(
actor.id === document?.createdById,
can(actor, "updateTemplate", actor.team)
)
)
)
)
);
@@ -128,7 +146,14 @@ allow(User, "move", Document, (actor, document) =>
can(actor, "update", document),
or(
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById)
and(!!document?.isDraft && actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
or(
actor.id === document?.createdById,
can(actor, "updateTemplate", actor.team)
)
)
)
)
);
@@ -166,7 +191,7 @@ allow(User, "delete", Document, (actor, document) =>
or(
can(actor, "unarchive", document),
can(actor, "update", document),
!document?.collection
and(!document?.isWorkspaceTemplate, !document?.collection)
)
)
);
@@ -183,6 +208,10 @@ allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
]),
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
can(actor, "updateTemplate", actor.team)
),
!document?.collection
)
)
@@ -236,6 +265,14 @@ allow(User, "unpublish", Document, (user, document) => {
) {
return false;
}
if (
document.isWorkspaceTemplate &&
(user.id === document.createdById || can(user, "updateTemplate", user.team))
) {
return true;
}
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
+1 -1
View File
@@ -14,6 +14,6 @@ it("should serialize domain policies on Team", async () => {
teamId: team.id,
});
const response = serialize(user, team);
expect(response.createDocument).toEqual(true);
expect(response.createTemplate).toEqual(true);
expect(response.inviteUser).toEqual(true);
});
+68 -4
View File
@@ -1,8 +1,9 @@
import { UserRole } from "@shared/types";
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
import { setSelfHosted } from "@server/test/support";
import { serialize } from "./index";
describe.skip("policies/team", () => {
describe("policies/team", () => {
it("should allow reading only", async () => {
setSelfHosted();
@@ -15,7 +16,7 @@ describe.skip("policies/team", () => {
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.createTemplate).toEqual(true);
expect(abilities.createGroup).toEqual(false);
expect(abilities.createIntegration).toEqual(false);
});
@@ -32,7 +33,7 @@ describe.skip("policies/team", () => {
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.createTemplate).toEqual(true);
expect(abilities.createGroup).toEqual(true);
expect(abilities.createIntegration).toEqual(true);
});
@@ -47,8 +48,71 @@ describe.skip("policies/team", () => {
expect(abilities.createTeam).toEqual(true);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.createTemplate).toEqual(true);
expect(abilities.createGroup).toEqual(true);
expect(abilities.createIntegration).toEqual(true);
});
describe("read template", () => {
const permissions = new Map<UserRole, boolean>([
[UserRole.Admin, true],
[UserRole.Member, true],
[UserRole.Viewer, false],
[UserRole.Guest, true],
]);
for (const [role, permission] of permissions.entries()) {
it(`check permission for ${role}`, async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
role,
});
const abilities = serialize(user, team);
expect(abilities.readTemplate).toEqual(permission);
});
}
});
describe("create template", () => {
const permissions = new Map<UserRole, boolean>([
[UserRole.Admin, true],
[UserRole.Member, true],
[UserRole.Viewer, false],
[UserRole.Guest, false],
]);
for (const [role, permission] of permissions.entries()) {
it(`check permission for ${role}`, async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
role,
});
const abilities = serialize(user, team);
expect(abilities.createTemplate).toEqual(permission);
});
}
});
describe("update template", () => {
const permissions = new Map<UserRole, boolean>([
[UserRole.Admin, true],
[UserRole.Member, false],
[UserRole.Viewer, false],
[UserRole.Guest, false],
]);
for (const [role, permission] of permissions.entries()) {
it(`check permission for ${role}`, async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
role,
});
const abilities = serialize(user, team);
expect(abilities.updateTemplate).toEqual(permission);
});
}
});
});
+31 -1
View File
@@ -1,6 +1,13 @@
import { Team, User } from "@server/models";
import { allow } from "./cancan";
import { and, isCloudHosted, isTeamAdmin, isTeamModel, or } from "./utils";
import {
and,
isCloudHosted,
isTeamAdmin,
isTeamModel,
isTeamMutable,
or,
} from "./utils";
allow(User, "read", Team, isTeamModel);
@@ -32,3 +39,26 @@ allow(User, ["delete", "audit"], Team, (actor, team) =>
isTeamAdmin(actor, team)
)
);
allow(User, "createTemplate", Team, (actor, team) =>
and(
//
!actor.isGuest,
!actor.isViewer,
isTeamModel(actor, team),
isTeamMutable(actor)
)
);
allow(User, "readTemplate", Team, (actor, team) =>
and(!actor.isViewer, isTeamModel(actor, team))
);
allow(User, "updateTemplate", Team, (actor, team) =>
and(
//
actor.isAdmin,
isTeamModel(actor, team),
isTeamMutable(actor)
)
);
+31 -32
View File
@@ -1,13 +1,14 @@
import { traceFunction } from "@server/logging/tracing";
import { Document } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { APIContext } from "@server/types";
import presentUser from "./user";
type Options = {
/** Whether to render the document's public fields. */
isPublic?: boolean;
/** The root share ID when presenting a shared document. */
shareId?: string;
/** Always include the text of the document in the payload. */
includeText?: boolean;
/** Always include the data of the document in the payload. */
@@ -25,30 +26,28 @@ async function presentDocument(
};
const asData = !ctx || Number(ctx?.headers["x-api-version"] ?? 0) >= 3;
const text = options.isPublic
? await TextHelper.attachmentsToSignedUrls(document.text, document.teamId)
: document.text;
const data: Record<string, any> = {
const data = await DocumentHelper.toJSON(
document,
options.isPublic
? {
signedUrls: 60,
teamId: document.teamId,
removeMarks: ["comment"],
internalUrlBase: `/s/${options.shareId}`,
}
: undefined
);
const text = DocumentHelper.toMarkdown(data);
const res: Record<string, any> = {
id: document.id,
url: document.url,
url: document.path,
urlId: document.urlId,
title: document.title,
data:
asData || options.includeData
? await DocumentHelper.toJSON(
document,
options.isPublic
? {
signedUrls: 60,
teamId: document.teamId,
removeMarks: ["comment"],
}
: undefined
)
: undefined,
data: asData || options?.includeData ? data : undefined,
text: !asData || options?.includeText ? text : undefined,
emoji: document.emoji,
icon: document.icon,
color: document.color,
tasks: document.tasks,
@@ -70,22 +69,22 @@ async function presentDocument(
};
if (!!document.views && document.views.length > 0) {
data.lastViewedAt = document.views[0].updatedAt;
res.lastViewedAt = document.views[0].updatedAt;
}
if (!options.isPublic) {
const source = await document.$get("import");
data.isCollectionDeleted = await document.isCollectionDeleted();
data.collectionId = document.collectionId;
data.parentDocumentId = document.parentDocumentId;
data.createdBy = presentUser(document.createdBy);
data.updatedBy = presentUser(document.updatedBy);
data.collaboratorIds = document.collaboratorIds;
data.templateId = document.templateId;
data.template = document.template;
data.insightsEnabled = document.insightsEnabled;
data.sourceMetadata = document.sourceMetadata
res.isCollectionDeleted = await document.isCollectionDeleted();
res.collectionId = document.collectionId;
res.parentDocumentId = document.parentDocumentId;
res.createdBy = presentUser(document.createdBy);
res.updatedBy = presentUser(document.updatedBy);
res.collaboratorIds = document.collaboratorIds;
res.templateId = document.templateId;
res.template = document.template;
res.insightsEnabled = document.insightsEnabled;
res.sourceMetadata = document.sourceMetadata
? {
importedAt: source?.createdAt ?? document.createdAt,
importType: source?.format,
@@ -95,7 +94,7 @@ async function presentDocument(
: undefined;
}
return data;
return res;
}
export default traceFunction({
+1 -1
View File
@@ -13,7 +13,7 @@ async function presentRevision(revision: Revision, diff?: string) {
documentId: revision.documentId,
title: strippedTitle,
data: await DocumentHelper.toJSON(revision),
icon: revision.icon ?? revision.emoji ?? emoji,
icon: revision.icon ?? emoji,
color: revision.color,
html: diff,
createdAt: revision.createdAt,
-1
View File
@@ -79,7 +79,6 @@ export default class ImportJSONTask extends ImportTask {
// TODO: This is kind of temporary, we can import the document
// structure directly in the future.
text: serializer.serialize(Node.fromJSON(schema, node.data)),
emoji: node.icon ?? node.emoji,
icon: node.icon ?? node.emoji,
color: node.color,
createdAt: node.createdAt ? new Date(node.createdAt) : undefined,
@@ -122,7 +122,6 @@ export default class ImportMarkdownZipTask extends ImportTask {
output.documents.push({
id,
title,
emoji: icon,
icon,
text,
collectionId,
-1
View File
@@ -130,7 +130,6 @@ export default class ImportNotionTask extends ImportTask {
output.documents.push({
id,
title,
emoji: icon,
icon,
text,
collectionId,
+19 -22
View File
@@ -2,6 +2,7 @@ import path from "path";
import fs from "fs-extra";
import chunk from "lodash/chunk";
import truncate from "lodash/truncate";
import { InferCreationAttributes } from "sequelize";
import tmp from "tmp";
import {
AttachmentPreset,
@@ -358,20 +359,28 @@ export default abstract class ImportTask extends BaseTask<Props> {
})
: null;
const sharedDefaults: Partial<InferCreationAttributes<Collection>> = {
...options,
id: item.id,
description: truncatedDescription,
color: item.color,
icon: item.icon,
sort: item.sort,
createdById: fileOperation.userId,
permission:
item.permission ?? fileOperation.options?.permission !== undefined
? fileOperation.options?.permission
: CollectionPermission.ReadWrite,
importId: fileOperation.id,
};
// check if collection with name exists
const response = await Collection.findOrCreate({
where: {
teamId: fileOperation.teamId,
name: item.name,
},
defaults: {
...options,
id: item.id,
description: truncatedDescription,
createdById: fileOperation.userId,
permission: CollectionPermission.ReadWrite,
importId: fileOperation.id,
},
defaults: sharedDefaults,
transaction,
});
@@ -385,21 +394,9 @@ export default abstract class ImportTask extends BaseTask<Props> {
const name = `${item.name} (Imported)`;
collection = await Collection.create(
{
...options,
id: item.id,
description: truncatedDescription,
color: item.color,
icon: item.icon,
sort: item.sort,
teamId: fileOperation.teamId,
createdById: fileOperation.userId,
...sharedDefaults,
name,
permission:
item.permission ??
fileOperation.options?.permission !== undefined
? fileOperation.options?.permission
: CollectionPermission.ReadWrite,
importId: fileOperation.id,
teamId: fileOperation.teamId,
},
{ transaction }
);
+3 -15
View File
@@ -1,10 +1,8 @@
import emojiRegex from "emoji-regex";
import isUndefined from "lodash/isUndefined";
import { z } from "zod";
import { CollectionPermission, FileOperationFormat } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { Collection } from "@server/models";
import { zodEnumFromObjectKeys } from "@server/utils/zod";
import { zodIconType } from "@server/utils/zod";
import { ValidateColor, ValidateIndex } from "@server/validation";
import { BaseSchema, ProsemirrorSchema } from "../schema";
@@ -27,12 +25,7 @@ export const CollectionsCreateSchema = BaseSchema.extend({
.nullish()
.transform((val) => (isUndefined(val) ? null : val)),
sharing: z.boolean().default(true),
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.optional(),
icon: zodIconType().optional(),
sort: z
.object({
field: z.union([z.literal("title"), z.literal("index")]),
@@ -171,12 +164,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
name: z.string().optional(),
description: z.string().nullish(),
data: ProsemirrorSchema.nullish(),
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.nullish(),
icon: zodIconType().nullish(),
permission: z.nativeEnum(CollectionPermission).nullish(),
color: z
.string()
+222 -71
View File
@@ -1929,6 +1929,140 @@ describe("#documents.templatize", () => {
expect(res.status).toBe(400);
expect(body.message).toBe("id: Required");
});
it("should require publish", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: "random-id",
},
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.message).toBe("publish: Required");
});
it("should create a published non-workspace template", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
publish: true,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeTruthy();
expect(body.data.collectionId).toEqual(collection.id);
});
it("should create a published workspace template", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
publish: true,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeTruthy();
expect(body.data.collectionId).toBeNull();
});
it("should create a draft non-workspace template", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
publish: false,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeNull();
expect(body.data.collectionId).toEqual(collection.id);
});
it("should create a draft workspace template", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
publish: false,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeNull();
expect(body.data.collectionId).toBeNull();
});
it("should create a template in a different collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const anotherCollection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: anotherCollection.id,
publish: true,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeTruthy();
expect(body.data.collectionId).toEqual(anotherCollection.id);
});
});
describe("#documents.archived", () => {
@@ -2285,23 +2419,6 @@ describe("#documents.move", () => {
expect(body.message).toEqual("id: Required");
});
it("should require collectionId", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("collectionId: Required");
});
it("should fail for invalid index", async () => {
const user = await buildUser();
const collection = await buildCollection({
@@ -2389,6 +2506,56 @@ describe("#documents.move", () => {
expect(res.status).toEqual(403);
});
it("should move a template to workspace", async () => {
const user = await buildAdmin();
const collection = await buildCollection({
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
template: true,
});
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents[0].collectionId).toBeNull();
expect(body.policies[0].abilities.move).toEqual(true);
});
it("should move a workspace template to collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
template: true,
});
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents[0].collectionId).toEqual(collection.id);
expect(body.policies[0].abilities.move).toEqual(true);
});
it("should require authentication", async () => {
const res = await server.post("/api/documents.move");
expect(res.status).toEqual(401);
@@ -2786,33 +2953,6 @@ describe("#documents.create", () => {
expect(body.message).toEqual("parentDocumentId: Invalid uuid");
});
it("should create as a new document with emoji", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
emoji: "🚢",
title: "new document",
text: "hello",
publish: true,
},
});
const body = await res.json();
const newDocument = await Document.findByPk(body.data.id);
expect(res.status).toEqual(200);
expect(newDocument!.parentDocumentId).toBe(null);
expect(newDocument!.collectionId).toBe(collection.id);
expect(newDocument!.emoji).toBe("🚢");
expect(newDocument!.icon).toBe("🚢");
expect(body.policies[0].abilities.update).toEqual(true);
});
it("should create as a new document with icon", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
@@ -2835,7 +2975,6 @@ describe("#documents.create", () => {
expect(res.status).toEqual(200);
expect(newDocument!.parentDocumentId).toBe(null);
expect(newDocument!.collectionId).toBe(collection.id);
expect(newDocument!.emoji).toBe("🚢");
expect(newDocument!.icon).toBe("🚢");
expect(body.policies[0].abilities.update).toEqual(true);
});
@@ -2858,7 +2997,7 @@ describe("#documents.create", () => {
expect(body.data.collectionId).toBeNull();
});
it("should not allow creating a template with a collection", async () => {
it("should allow creating a draft template without a collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/documents.create", {
@@ -2871,10 +3010,10 @@ describe("#documents.create", () => {
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toBe(
"collectionId is required to create a template document"
);
expect(res.status).toEqual(200);
expect(body.data.template).toBe(true);
expect(body.data.publishedAt).toBeNull();
expect(body.data.collectionId).toBeNull();
});
it("should not allow publishing without specifying the collection", async () => {
@@ -3094,6 +3233,39 @@ describe("#documents.update", () => {
expect(body.data.text).toBe("Updated text");
});
it("should successfully publish a draft template without collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const document = await buildDraftDocument({
title: "title",
text: "text",
teamId: team.id,
userId: user.id,
collectionId: null,
template: true,
});
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
id: document.id,
title: "Updated title",
text: "Updated text",
collectionId: collection.id,
publish: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.collectionId).toBe(collection.id);
expect(body.data.title).toBe("Updated title");
expect(body.data.text).toBe("Updated text");
expect(body.data.publishedAt).toBeTruthy();
});
it("should not allow publishing by another collection's user", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
@@ -3142,26 +3314,6 @@ describe("#documents.update", () => {
expect(body.message).toBe("icon: Invalid");
});
it("should successfully update the emoji", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
id: document.id,
emoji: "🚢",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.emoji).toBe("🚢");
expect(body.data.icon).toBe("🚢");
expect(body.data.color).toBeNull;
});
it("should successfully update the icon", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -3201,7 +3353,6 @@ describe("#documents.update", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.icon).toBeNull();
expect(body.data.emoji).toBeNull();
expect(body.data.color).toBeNull();
});
+52 -17
View File
@@ -48,7 +48,8 @@ import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import SearchHelper from "@server/models/helpers/SearchHelper";
import { authorize, cannot } from "@server/policies";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { authorize, can, cannot } from "@server/policies";
import {
presentCollection,
presentDocument,
@@ -129,7 +130,15 @@ router.post(
} // otherwise, filter by all collections the user has access to
} else {
const collectionIds = await user.collectionIds();
where = { ...where, collectionId: collectionIds };
where = {
...where,
collectionId:
template && can(user, "readTemplate", user.team)
? {
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
}
: collectionIds,
};
}
if (parentDocumentId) {
@@ -432,6 +441,7 @@ router.post(
const isPublic = cannot(user, "read", document);
const serializedDocument = await presentDocument(ctx, document, {
isPublic,
shareId,
});
const team = await document.$get("team");
@@ -882,6 +892,7 @@ router.post(
results.map(async (result) => {
const document = await presentDocument(ctx, result.document, {
isPublic,
shareId,
});
return { ...result, document };
})
@@ -915,7 +926,7 @@ router.post(
validate(T.DocumentsTemplatizeSchema),
transaction(),
async (ctx: APIContext<T.DocumentsTemplatizeReq>) => {
const { id } = ctx.input.body;
const { id, collectionId, publish } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
@@ -926,16 +937,24 @@ router.post(
authorize(user, "update", original);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "createDocument", collection);
} else {
authorize(user, "createTemplate", user.team);
}
const document = await Document.create(
{
editorVersion: original.editorVersion,
collectionId: original.collectionId,
teamId: original.teamId,
publishedAt: new Date(),
collectionId,
teamId: user.teamId,
publishedAt: publish ? new Date() : null,
lastModifiedById: user.id,
createdById: user.id,
template: true,
emoji: original.emoji,
icon: original.icon,
color: original.color,
title: original.title,
@@ -1007,7 +1026,7 @@ router.post(
authorize(user, "publish", document);
}
if (!document.collectionId) {
if (!document.collectionId && !document.isWorkspaceTemplate) {
assertPresent(
collectionId,
"collectionId is required to publish a draft without collection"
@@ -1026,6 +1045,8 @@ router.post(
}
);
authorize(user, "createChildDocument", parentDocument, { collection });
} else if (document.isWorkspaceTemplate) {
authorize(user, "createTemplate", user.team);
} else {
authorize(user, "createDocument", collection);
}
@@ -1035,7 +1056,7 @@ router.post(
document,
user,
...input,
icon: input.icon ?? input.emoji ?? null,
icon: input.icon ?? null,
publish,
collectionId,
insightsEnabled,
@@ -1076,6 +1097,8 @@ router.post(
if (collection) {
authorize(user, "updateDocument", collection);
} else if (document.isWorkspaceTemplate) {
authorize(user, "createTemplate", user.team);
}
if (parentDocumentId) {
@@ -1128,10 +1151,16 @@ router.post(
});
authorize(user, "move", document);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "updateDocument", collection);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "updateDocument", collection);
} else if (document.template) {
authorize(user, "updateTemplate", user.team);
} else {
throw InvalidRequestError("collectionId is required to move a document");
}
if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId, {
@@ -1148,7 +1177,7 @@ router.post(
const { documents, collections, collectionChanged } = await documentMover({
user,
document,
collectionId,
collectionId: collectionId ?? null,
parentDocumentId,
index,
ip: ctx.request.ip,
@@ -1376,7 +1405,6 @@ router.post(
const {
title,
text,
emoji,
icon,
color,
publish,
@@ -1427,6 +1455,8 @@ router.post(
transaction,
});
authorize(user, "createDocument", collection);
} else if (!!template && !collectionId) {
authorize(user, "createTemplate", user.team);
}
let templateDocument: Document | null | undefined;
@@ -1441,8 +1471,13 @@ router.post(
const document = await documentCreator({
title,
text,
icon: icon ?? emoji,
text: await TextHelper.replaceImagesWithAttachments(
text,
user,
ctx.request.ip,
transaction
),
icon,
color,
createdAt,
publish,
+17 -38
View File
@@ -1,13 +1,11 @@
import emojiRegex from "emoji-regex";
import formidable from "formidable";
import isEmpty from "lodash/isEmpty";
import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import { DocumentPermission, StatusFilter } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { BaseSchema } from "@server/routes/api/schema";
import { zodEnumFromObjectKeys } from "@server/utils/zod";
import { zodIconType } from "@server/utils/zod";
import { ValidateColor } from "@server/validation";
const DocumentsSortParamsSchema = z.object({
@@ -196,7 +194,12 @@ export const DocumentsDuplicateSchema = BaseSchema.extend({
export type DocumentsDuplicateReq = z.infer<typeof DocumentsDuplicateSchema>;
export const DocumentsTemplatizeSchema = BaseSchema.extend({
body: BaseIdSchema,
body: BaseIdSchema.extend({
/** Id of the collection inside which the template should be created */
collectionId: z.string().nullish(),
/** Whether the new template should be published */
publish: z.boolean(),
}),
});
export type DocumentsTemplatizeReq = z.infer<typeof DocumentsTemplatizeSchema>;
@@ -209,16 +212,8 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
/** Doc text to be updated */
text: z.string().optional(),
/** Emoji displayed alongside doc title */
emoji: z.string().regex(emojiRegex()).nullish(),
/** Icon displayed alongside doc title */
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.nullish(),
icon: zodIconType().nullish(),
/** Icon color */
color: z
@@ -259,7 +254,7 @@ export type DocumentsUpdateReq = z.infer<typeof DocumentsUpdateSchema>;
export const DocumentsMoveSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Id of collection to which the doc is supposed to be moved */
collectionId: z.string().uuid(),
collectionId: z.string().uuid().nullish(),
/** Parent Id, in case if the doc is moved to a new parent */
parentDocumentId: z.string().uuid().nullish(),
@@ -321,16 +316,8 @@ export const DocumentsCreateSchema = BaseSchema.extend({
/** Document text */
text: z.string().default(""),
/** Emoji displayed alongside doc title */
emoji: z.string().regex(emojiRegex()).nullish(),
/** Icon displayed alongside doc title */
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.optional(),
icon: zodIconType().optional(),
/** Icon color */
color: z
@@ -364,21 +351,13 @@ export const DocumentsCreateSchema = BaseSchema.extend({
/** Whether this should be considered a template */
template: z.boolean().optional(),
}),
})
.refine((req) => !(req.body.template && !req.body.collectionId), {
message: "collectionId is required to create a template document",
})
.refine(
(req) =>
!(
req.body.publish &&
!req.body.parentDocumentId &&
!req.body.collectionId
),
{
message: "collectionId or parentDocumentId is required to publish",
}
);
}).refine(
(req) =>
!(req.body.publish && !req.body.parentDocumentId && !req.body.collectionId),
{
message: "collectionId or parentDocumentId is required to publish",
}
);
export type DocumentsCreateReq = z.infer<typeof DocumentsCreateSchema>;
+90 -50
View File
@@ -3,6 +3,7 @@ import { Op, WhereOptions } from "sequelize";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { User, Event, Group, GroupUser } from "@server/models";
import { authorize } from "@server/policies";
@@ -99,27 +100,39 @@ router.post(
rateLimiter(RateLimiterStrategy.TenPerHour),
auth(),
validate(T.GroupsCreateSchema),
transaction(),
async (ctx: APIContext<T.GroupsCreateReq>) => {
const { name } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
authorize(user, "createGroup", user.team);
const g = await Group.create({
name,
teamId: user.teamId,
createdById: user.id,
});
const g = await Group.create(
{
name,
teamId: user.teamId,
createdById: user.id,
},
{ transaction }
);
// reload to get default scope
const group = await Group.findByPk(g.id, { rejectOnEmpty: true });
await Event.createFromContext(ctx, {
name: "groups.create",
modelId: group.id,
data: {
name: group.name,
},
const group = await Group.findByPk(g.id, {
transaction,
rejectOnEmpty: true,
});
await Event.createFromContext(
ctx,
{
name: "groups.create",
modelId: group.id,
data: {
name: group.name,
},
},
{ transaction }
);
ctx.body = {
data: presentGroup(group),
policies: presentPolicies(user, [group]),
@@ -131,24 +144,30 @@ router.post(
"groups.update",
auth(),
validate(T.GroupsUpdateSchema),
transaction(),
async (ctx: APIContext<T.GroupsUpdateReq>) => {
const { id, name } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const group = await Group.findByPk(id);
const group = await Group.findByPk(id, { transaction });
authorize(user, "update", group);
group.name = name;
if (group.changed()) {
await group.save();
await Event.createFromContext(ctx, {
name: "groups.update",
modelId: group.id,
data: {
name,
await group.save({ transaction });
await Event.createFromContext(
ctx,
{
name: "groups.update",
modelId: group.id,
data: {
name,
},
},
});
{ transaction }
);
}
ctx.body = {
@@ -162,21 +181,27 @@ router.post(
"groups.delete",
auth(),
validate(T.GroupsDeleteSchema),
transaction(),
async (ctx: APIContext<T.GroupsDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const group = await Group.findByPk(id);
const group = await Group.findByPk(id, { transaction });
authorize(user, "delete", group);
await group.destroy();
await Event.createFromContext(ctx, {
name: "groups.delete",
modelId: group.id,
data: {
name: group.name,
await group.destroy({ transaction });
await Event.createFromContext(
ctx,
{
name: "groups.delete",
modelId: group.id,
data: {
name: group.name,
},
},
});
{ transaction }
);
ctx.body = {
success: true,
@@ -238,14 +263,16 @@ router.post(
"groups.add_user",
auth(),
validate(T.GroupsAddUserSchema),
transaction(),
async (ctx: APIContext<T.GroupsAddUserReq>) => {
const { id, userId } = ctx.input.body;
const actor = ctx.state.auth.user;
const { transaction } = ctx.state;
const user = await User.findByPk(userId);
const user = await User.findByPk(userId, { transaction });
authorize(actor, "read", user);
let group = await Group.findByPk(id);
let group = await Group.findByPk(id, { transaction });
authorize(actor, "update", group);
let groupUser = await GroupUser.findOne({
@@ -253,6 +280,7 @@ router.post(
groupId: id,
userId,
},
transaction,
});
if (!groupUser) {
@@ -260,6 +288,7 @@ router.post(
through: {
createdById: actor.id,
},
transaction,
});
// reload to get default scope
groupUser = await GroupUser.findOne({
@@ -268,19 +297,24 @@ router.post(
userId,
},
rejectOnEmpty: true,
transaction,
});
// reload to get default scope
group = await Group.findByPk(id, { rejectOnEmpty: true });
group = await Group.findByPk(id, { transaction, rejectOnEmpty: true });
await Event.createFromContext(ctx, {
name: "groups.add_user",
userId,
modelId: group.id,
data: {
name: user.name,
await Event.createFromContext(
ctx,
{
name: "groups.add_user",
userId,
modelId: group.id,
data: {
name: user.name,
},
},
});
{ transaction }
);
}
ctx.body = {
@@ -297,28 +331,34 @@ router.post(
"groups.remove_user",
auth(),
validate(T.GroupsRemoveUserSchema),
transaction(),
async (ctx: APIContext<T.GroupsRemoveUserReq>) => {
const { id, userId } = ctx.input.body;
const actor = ctx.state.auth.user;
const { transaction } = ctx.state;
let group = await Group.findByPk(id);
let group = await Group.findByPk(id, { transaction });
authorize(actor, "update", group);
const user = await User.findByPk(userId);
const user = await User.findByPk(userId, { transaction });
authorize(actor, "read", user);
await group.$remove("user", user);
await Event.createFromContext(ctx, {
name: "groups.remove_user",
userId,
modelId: group.id,
data: {
name: user.name,
await group.$remove("user", user, { transaction });
await Event.createFromContext(
ctx,
{
name: "groups.remove_user",
userId,
modelId: group.id,
data: {
name: user.name,
},
},
});
{ transaction }
);
// reload to get default scope
group = await Group.findByPk(id, { rejectOnEmpty: true });
group = await Group.findByPk(id, { transaction, rejectOnEmpty: true });
ctx.body = {
data: {
@@ -115,11 +115,16 @@ router.post(
"integrations.update",
auth({ role: UserRole.Admin }),
validate(T.IntegrationsUpdateSchema),
transaction(),
async (ctx: APIContext<T.IntegrationsUpdateReq>) => {
const { id, events, settings } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const integration = await Integration.findByPk(id);
const integration = await Integration.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "update", integration);
if (integration.type === IntegrationType.Post) {
@@ -130,7 +135,7 @@ router.post(
integration.settings = settings;
await integration.save();
await integration.save({ transaction });
ctx.body = {
data: presentIntegration(integration),
@@ -152,6 +157,7 @@ router.post(
const integration = await Integration.findByPk(id, {
rejectOnEmpty: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "delete", integration);

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